Skip to main content

rust_synth/tui/
app.rs

1//! Ratatui event loop + key bindings + Life↔Audio coupling.
2//!
3//! Every beat boundary the loop does three things in order:
4//!   1. **Audio → Life**: seed cells in each unmuted track's row based on
5//!      its current amplitude; Heartbeat injects a glider.
6//!   2. **Life step**: Conway B3/S23, one generation.
7//!   3. **Life → Audio** (auto-evolve): every `evolve_period` beats, mutate
8//!      the unmuted track whose row has the lowest live-cell count
9//!      (fitness = row density).
10//!
11//! The user can disable coupling (`L`) or auto-evolve (`O`) at any time.
12
13use anyhow::Result;
14use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent};
15use crossterm::execute;
16use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
17use ratatui::backend::CrosstermBackend;
18use ratatui::layout::{Constraint, Direction, Layout};
19use ratatui::style::{Color, Modifier, Style};
20use ratatui::widgets::{Block, Borders, Paragraph};
21use ratatui::Terminal;
22use std::io;
23use std::path::PathBuf;
24use std::time::{Duration, Instant};
25
26use crate::audio::engine::EngineHandle;
27use crate::audio::preset::PresetKind;
28use crate::audio::track::{Track, TrackParams};
29use crate::math::genetic::{crossover, mutate, Genome};
30use crate::math::harmony::{golden_pentatonic, rand_f32, rand_u32};
31use crate::math::life::Life;
32use crate::math::rhythm;
33use crate::{persistence, recording};
34use std::sync::atomic::Ordering;
35
36const LIFE_ROWS: usize = 8;
37const LIFE_COLS: usize = 22;
38/// Beats between auto-evolve pulses. Shorter = more audible drift.
39const DEFAULT_EVOLVE_PERIOD: u32 = 8;
40/// Mutation strength when auto-evolve fires a weakest-row rewrite.
41const AUTO_EVOLVE_STRENGTH: f32 = 0.55;
42const STATUS_TTL: Duration = Duration::from_secs(4);
43
44#[derive(Clone, Copy, PartialEq, Eq, Debug)]
45pub enum Focus {
46    Tracks,
47    Params,
48}
49
50pub struct AppState {
51    pub focus: Focus,
52    pub selected_track: usize,
53    pub selected_param: usize,
54    pub should_quit: bool,
55    pub rng_seed: u64,
56
57    // ── Life + evolution ──
58    pub life: Life,
59    pub last_beat_index: i64,
60    pub last_evolve_beat: i64,
61    pub evolve_period: u32,
62    pub coupling: bool,
63    pub auto_evolve: bool,
64
65    // ── Status message shown briefly after save / load / record ──
66    pub status: Option<(Instant, String)>,
67    pub presets_dir: PathBuf,
68    pub recordings_dir: PathBuf,
69}
70
71impl AppState {
72    pub fn new() -> Self {
73        let mut life = Life::random(LIFE_ROWS, LIFE_COLS, 0xBEEF_F00D, 0.22);
74        life.inject_glider(0, 0);
75        life.inject_glider(4, 10);
76        Self {
77            focus: Focus::Tracks,
78            selected_track: 0,
79            selected_param: 0,
80            should_quit: false,
81            rng_seed: 0x00C0_FFEE_DEAD_BEEF,
82            life,
83            last_beat_index: -1,
84            last_evolve_beat: 0,
85            evolve_period: DEFAULT_EVOLVE_PERIOD,
86            coupling: true,
87            auto_evolve: true,
88            status: None,
89            presets_dir: PathBuf::from("presets"),
90            recordings_dir: PathBuf::from("recordings"),
91        }
92    }
93
94    fn set_status(&mut self, text: impl Into<String>) {
95        self.status = Some((Instant::now(), text.into()));
96    }
97
98    fn current_status(&self) -> Option<&str> {
99        match &self.status {
100            Some((at, text)) if at.elapsed() < STATUS_TTL => Some(text),
101            _ => None,
102        }
103    }
104}
105
106impl Default for AppState {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112pub fn run(engine: &EngineHandle) -> Result<()> {
113    enable_raw_mode()?;
114    let mut stdout = io::stdout();
115    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
116    let backend = CrosstermBackend::new(stdout);
117    let mut terminal = Terminal::new(backend)?;
118
119    let res = run_loop(&mut terminal, engine);
120
121    disable_raw_mode()?;
122    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
123    terminal.show_cursor()?;
124
125    res
126}
127
128fn run_loop<B: ratatui::backend::Backend>(
129    terminal: &mut Terminal<B>,
130    engine: &EngineHandle,
131) -> Result<()> {
132    let mut app = AppState::new();
133    let tick = Duration::from_millis(33);
134    let mut last = Instant::now();
135
136    loop {
137        advance_beat_sync(&mut app, engine);
138        // Recompute Euclidean pattern bits from hits + rotation so slider
139        // tweaks or auto-evolve mutations are reflected at the next
140        // audio-thread read (next sample, ~20 µs).
141        recompute_patterns(engine);
142        terminal.draw(|f| ui(f, engine, &app))?;
143
144        let timeout = tick.saturating_sub(last.elapsed());
145        if event::poll(timeout)? {
146            if let Event::Key(key) = event::read()? {
147                handle_key(key, engine, &mut app);
148            }
149        }
150        if last.elapsed() >= tick {
151            last = Instant::now();
152        }
153        if app.should_quit {
154            return Ok(());
155        }
156    }
157}
158
159// ─── Beat-synchronous Life step + coupling ──────────────────────────────
160
161fn advance_beat_sync(app: &mut AppState, engine: &EngineHandle) {
162    let t = engine.phase_clock.value();
163    let bpm = engine.global.bpm.value();
164    let cur_beat = (t * bpm / 60.0).floor() as i64;
165
166    if cur_beat <= app.last_beat_index {
167        return;
168    }
169    let steps = (cur_beat - app.last_beat_index).min(4) as usize;
170    for _ in 0..steps {
171        if app.coupling {
172            seed_from_audio(app, engine, cur_beat);
173        }
174        app.life.step();
175    }
176
177    // ── Continuous Life → Audio coupling ──
178    // After stepping, push each row's density to its track's `life_mod`
179    // so the audible gate modulates in real time.
180    if app.coupling {
181        push_density_to_tracks(app, engine);
182    } else {
183        reset_life_mods(engine);
184    }
185
186    if app.auto_evolve && cur_beat - app.last_evolve_beat >= app.evolve_period as i64 {
187        if let Some((name, before, after)) = evolve_weakest(app, engine) {
188            app.set_status(format!(
189                "evolved {name}: freq {before:.0}→{after:.0} Hz"
190            ));
191        }
192        app.last_evolve_beat = cur_beat;
193    }
194
195    app.last_beat_index = cur_beat;
196}
197
198/// Map each row's live-cell ratio to its track's `life_mod` Shared.
199/// The gate formula in preset.rs multiplies by `0.4 + 0.9 · life_mod`,
200/// so dense rows get louder, empty rows fade to 40 %.
201fn push_density_to_tracks(app: &AppState, engine: &EngineHandle) {
202    let tracks = engine.tracks.lock();
203    for (i, track) in tracks.iter().enumerate() {
204        if i >= app.life.rows {
205            break;
206        }
207        let alive = app.life.row_alive_count(i);
208        let ratio = alive as f32 / app.life.cols as f32;
209        // A few alive cells already lift the row → square-root shaping
210        // so small densities produce audible lift.
211        let shaped = ratio.sqrt();
212        track.params.life_mod.set_value(shaped);
213    }
214}
215
216/// Coupling off → freeze life_mod at 1.0 so tracks play at nominal gain.
217fn reset_life_mods(engine: &EngineHandle) {
218    let tracks = engine.tracks.lock();
219    for t in tracks.iter() {
220        t.params.life_mod.set_value(1.0);
221    }
222}
223
224/// Translate hits + rotation sliders to a fresh 16-step bitmask.
225/// Cheap (a dozen shifts + ORs per track), safe to run every frame.
226fn recompute_patterns(engine: &EngineHandle) {
227    let tracks = engine.tracks.lock();
228    for track in tracks.iter() {
229        let hits = track
230            .params
231            .pattern_hits
232            .value()
233            .round()
234            .clamp(0.0, rhythm::STEPS as f32) as u32;
235        let rotation = track
236            .params
237            .pattern_rotation
238            .value()
239            .round()
240            .clamp(0.0, (rhythm::STEPS - 1) as f32) as u32;
241        let bits = rhythm::euclidean_bits(hits, rotation);
242        track.params.pattern_bits.store(bits, Ordering::Relaxed);
243    }
244}
245
246/// Seed Life cells from current audio state. One row per track; column
247/// follows beat phase so trails scroll across the grid.
248fn seed_from_audio(app: &mut AppState, engine: &EngineHandle, cur_beat: i64) {
249    let col = cur_beat.rem_euclid(app.life.cols as i64) as usize;
250    let tracks = engine.tracks.lock();
251    for (i, track) in tracks.iter().enumerate() {
252        if i >= app.life.rows {
253            break;
254        }
255        let p = &track.params;
256        if p.mute.value() > 0.5 {
257            continue;
258        }
259        let gain = p.gain.value();
260        // One cell per beat; extra for loud/heartbeat tracks so they seed
261        // gliders naturally.
262        app.life.set(i, col, true);
263        if gain > 0.45 {
264            app.life.set(i, (col + 1) % app.life.cols, true);
265        }
266        if matches!(track.kind, PresetKind::Heartbeat) {
267            // Inject a glider in this row around the current column.
268            let r0 = i.saturating_sub(1).min(app.life.rows.saturating_sub(3));
269            let c0 = (col + 2) % app.life.cols;
270            for (dr, dc) in [(0, 1), (1, 2), (2, 0), (2, 1), (2, 2)] {
271                let r = (r0 + dr).min(app.life.rows - 1);
272                let c = (c0 + dc) % app.life.cols;
273                app.life.set(r, c, true);
274            }
275        }
276    }
277}
278
279/// Natural selection — find the unmuted track with the lowest row
280/// density and mutate it. Returns (name, freq_before, freq_after) so the
281/// caller can show a status line — makes the "evolve" event visible.
282fn evolve_weakest(app: &mut AppState, engine: &EngineHandle) -> Option<(String, f32, f32)> {
283    let tracks = engine.tracks.lock();
284    let mut weakest: Option<(usize, usize)> = None;
285    for (i, t) in tracks.iter().enumerate() {
286        if i >= app.life.rows {
287            break;
288        }
289        if t.params.mute.value() > 0.5 {
290            continue;
291        }
292        let count = app.life.row_alive_count(i);
293        weakest = match weakest {
294            None => Some((i, count)),
295            Some((_, c)) if count < c => Some((i, count)),
296            s => s,
297        };
298    }
299    let (idx, _) = weakest?;
300    let name = tracks[idx].name.clone();
301    let before = tracks[idx].params.freq.value();
302    let genome = genome_of(&tracks[idx].params);
303    mutate(&genome, &mut app.rng_seed, AUTO_EVOLVE_STRENGTH);
304    let after = tracks[idx].params.freq.value();
305    Some((name, before, after))
306}
307
308fn genome_of(p: &TrackParams) -> Genome<'_> {
309    Genome {
310        freq: &p.freq,
311        cutoff: &p.cutoff,
312        resonance: &p.resonance,
313        reverb_mix: &p.reverb_mix,
314        pulse_depth: &p.pulse_depth,
315        pattern_hits: &p.pattern_hits,
316        pattern_rotation: &p.pattern_rotation,
317        character: &p.character,
318    }
319}
320
321// ─── UI ─────────────────────────────────────────────────────────────────
322
323fn ui(f: &mut ratatui::Frame, engine: &EngineHandle, app: &AppState) {
324    let area = f.area();
325
326    let rows = Layout::default()
327        .direction(Direction::Vertical)
328        .constraints([
329            Constraint::Length(3),      // header
330            Constraint::Length(10),     // tempo + life
331            Constraint::Length(7),      // pattern sequencer (1 row data + title/hint)
332            Constraint::Length(12),     // scope + trajectory
333            Constraint::Min(10),        // tracks + params + formula
334            Constraint::Length(3),      // help
335        ])
336        .split(area);
337
338    let rec_text = if engine.recorder.is_recording() {
339        format!(" REC ● {:>5.1}s", engine.recorder.elapsed_seconds())
340    } else {
341        "".to_string()
342    };
343    let status_text = app.current_status().map(|s| format!(" · {s}")).unwrap_or_default();
344    let brightness = engine.global.brightness.value();
345    let shelf_db = crate::audio::preset::shelf_gain_db(
346        crate::audio::preset::brightness_to_shelf_gain(brightness as f64),
347    );
348    let lp_cutoff = crate::audio::preset::brightness_to_lp_cutoff(brightness as f64);
349    let header_text = format!(
350        " rust-synth · mstr {:>3.0}%  brt {:>3.0}% ({:>+5.1}dB shelf +LP@{:>5.0}Hz)  peak L{:>4.2} R{:>4.2}  couple {} evolve {} gen {}{}{}",
351        engine.global.master_gain.value() * 100.0,
352        brightness * 100.0,
353        shelf_db,
354        lp_cutoff,
355        engine.peak_l.value(),
356        engine.peak_r.value(),
357        on_off(app.coupling),
358        on_off(app.auto_evolve),
359        app.life.generation,
360        rec_text,
361        status_text,
362    );
363    let header_style = if engine.recorder.is_recording() {
364        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
365    } else {
366        Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
367    };
368    let header = Paragraph::new(header_text)
369        .style(header_style)
370        .block(Block::default().borders(Borders::ALL).title(" rust-synth "));
371    f.render_widget(header, rows[0]);
372
373    let top = Layout::default()
374        .direction(Direction::Horizontal)
375        .constraints([Constraint::Percentage(32), Constraint::Percentage(68)])
376        .split(rows[1]);
377    super::beats::render(f, top[0], engine);
378    super::life::render(f, top[1], engine, app);
379
380    super::pattern::render(f, rows[2], engine, app);
381
382    let mid = Layout::default()
383        .direction(Direction::Horizontal)
384        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
385        .split(rows[3]);
386    super::waveform::render(f, mid[0], engine);
387    super::waveshape::render(f, mid[1], engine, app);
388
389    let body = Layout::default()
390        .direction(Direction::Horizontal)
391        .constraints([
392            Constraint::Percentage(32),
393            Constraint::Percentage(36),
394            Constraint::Percentage(32),
395        ])
396        .split(rows[4]);
397    super::tracks::render(f, body[0], engine, app);
398    super::params::render(f, body[1], engine, app);
399    super::formula::render(f, body[2], engine, app);
400
401    let help = Paragraph::new(match app.focus {
402        Focus::Tracks => " ↑↓trk·Enter→p · a add · d kill · m mute · t/T kind · r rand · e/E mut · x cross · h/H hits · p/P rot · S/s super · w/l save/load · c REC · ,/. bpm · {/} brt · q quit ",
403        Focus::Params => " ↑↓param · ←→adj · Esc←tracks · t/T kind · e/E mut · h/H hits · p/P rot · S/s super · w/l save/load · c REC · ,/. bpm · {/} brt · q quit ",
404    })
405    .block(Block::default().borders(Borders::ALL))
406    .style(Style::default().fg(Color::Gray));
407    f.render_widget(help, rows[5]);
408}
409
410fn on_off(b: bool) -> &'static str {
411    if b { "ON " } else { "off" }
412}
413
414fn short_path(p: &std::path::Path) -> String {
415    p.file_name()
416        .map(|s| s.to_string_lossy().into_owned())
417        .unwrap_or_else(|| p.display().to_string())
418}
419
420// ─── Key handling ───────────────────────────────────────────────────────
421
422fn handle_key(key: KeyEvent, engine: &EngineHandle, app: &mut AppState) {
423    // Global keys.
424    match key.code {
425        KeyCode::Char('q') => {
426            app.should_quit = true;
427            return;
428        }
429        KeyCode::Char(',') => {
430            bpm_nudge(engine, -1.0);
431            return;
432        }
433        KeyCode::Char('.') => {
434            bpm_nudge(engine, 1.0);
435            return;
436        }
437        KeyCode::Char('<') => {
438            bpm_nudge(engine, -5.0);
439            return;
440        }
441        KeyCode::Char('>') => {
442            bpm_nudge(engine, 5.0);
443            return;
444        }
445        KeyCode::Char('[') => {
446            master_nudge(engine, -0.05);
447            return;
448        }
449        KeyCode::Char(']') => {
450            master_nudge(engine, 0.05);
451            return;
452        }
453        KeyCode::Char('{') => {
454            brightness_nudge(engine, -0.05);
455            return;
456        }
457        KeyCode::Char('}') => {
458            brightness_nudge(engine, 0.05);
459            return;
460        }
461        KeyCode::Char('L') => {
462            app.coupling = !app.coupling;
463            return;
464        }
465        KeyCode::Char('O') => {
466            app.auto_evolve = !app.auto_evolve;
467            return;
468        }
469        KeyCode::Char('e') => {
470            mutate_selected(app, engine, 0.3);
471            return;
472        }
473        KeyCode::Char('E') => {
474            mutate_all_active(app, engine, 0.25);
475            return;
476        }
477        KeyCode::Char('x') => {
478            crossover_with_neighbor(app, engine);
479            return;
480        }
481        KeyCode::Char('R') => {
482            // Re-seed Life from scratch with a new random + glider.
483            app.life = Life::random(LIFE_ROWS, LIFE_COLS, app.rng_seed, 0.22);
484            app.life.inject_glider(0, 4);
485            return;
486        }
487        KeyCode::Char('S') => {
488            // Supermassive ON full for the selected track.
489            let tracks = engine.tracks.lock();
490            if let Some(track) = tracks.get(app.selected_track) {
491                track.params.supermass.set_value(1.0);
492            }
493            return;
494        }
495        KeyCode::Char('s') => {
496            // Supermassive OFF for the selected track.
497            let tracks = engine.tracks.lock();
498            if let Some(track) = tracks.get(app.selected_track) {
499                track.params.supermass.set_value(0.0);
500            }
501            return;
502        }
503        KeyCode::Char('h') => {
504            pattern_hits_nudge(engine, app, -1.0);
505            return;
506        }
507        KeyCode::Char('H') => {
508            pattern_hits_nudge(engine, app, 1.0);
509            return;
510        }
511        KeyCode::Char('p') => {
512            pattern_rot_nudge(engine, app, -1.0);
513            return;
514        }
515        KeyCode::Char('P') => {
516            pattern_rot_nudge(engine, app, 1.0);
517            return;
518        }
519        KeyCode::Char('t') => {
520            cycle_kind(engine, app, true);
521            return;
522        }
523        KeyCode::Char('T') => {
524            cycle_kind(engine, app, false);
525            return;
526        }
527        KeyCode::Char('w') => {
528            match persistence::save(&app.presets_dir, engine) {
529                Ok(path) => app.set_status(format!("saved preset → {}", short_path(&path))),
530                Err(e) => app.set_status(format!("save failed: {e}")),
531            }
532            return;
533        }
534        KeyCode::Char('l') => {
535            match persistence::load_latest(&app.presets_dir, engine) {
536                Ok(Some((path, n))) => {
537                    app.set_status(format!("loaded {} ({} slots) ← {}", n, n, short_path(&path)));
538                }
539                Ok(None) => app.set_status("no presets/ folder yet — press w first".to_string()),
540                Err(e) => app.set_status(format!("load failed: {e}")),
541            }
542            return;
543        }
544        KeyCode::Char('c') => {
545            if engine.recorder.is_recording() {
546                match engine.recorder.stop_and_encode(&app.recordings_dir) {
547                    Ok(path) => app.set_status(format!(
548                        "rec → {} (encoding in bg, up to {}m cap)",
549                        short_path(&path),
550                        recording::MAX_MINUTES
551                    )),
552                    Err(e) => app.set_status(format!("stop failed: {e}")),
553                }
554            } else {
555                engine.recorder.start();
556                app.set_status(format!("recording started (cap {}m)", recording::MAX_MINUTES));
557            }
558            return;
559        }
560        _ => {}
561    }
562
563    match app.focus {
564        Focus::Tracks => handle_tracks_key(key, engine, app),
565        Focus::Params => handle_params_key(key, engine, app),
566    }
567}
568
569fn handle_tracks_key(key: KeyEvent, engine: &EngineHandle, app: &mut AppState) {
570    let tracks = engine.tracks.lock();
571    let n = tracks.len();
572    match key.code {
573        KeyCode::Up => {
574            if app.selected_track > 0 {
575                app.selected_track -= 1;
576            }
577        }
578        KeyCode::Down => {
579            if app.selected_track + 1 < n {
580                app.selected_track += 1;
581            }
582        }
583        KeyCode::Enter | KeyCode::Right | KeyCode::Tab => {
584            app.focus = Focus::Params;
585        }
586        KeyCode::Char('m') => toggle_mute(&tracks[app.selected_track]),
587        KeyCode::Char('a') => {
588            drop(tracks);
589            activate_next(engine, app);
590        }
591        KeyCode::Char('d') => {
592            let p = &tracks[app.selected_track].params;
593            p.mute.set_value(1.0);
594            p.gain.set_value(0.3);
595        }
596        KeyCode::Char('r') => {
597            let p = &tracks[app.selected_track].params;
598            randomize_track(p, &mut app.rng_seed);
599        }
600        _ => {}
601    }
602}
603
604fn handle_params_key(key: KeyEvent, engine: &EngineHandle, app: &mut AppState) {
605    let tracks = engine.tracks.lock();
606    let Some(track) = tracks.get(app.selected_track) else {
607        return;
608    };
609    let n_params = 13;
610
611    match key.code {
612        KeyCode::Esc | KeyCode::Tab | KeyCode::BackTab => app.focus = Focus::Tracks,
613        KeyCode::Up => {
614            if app.selected_param > 0 {
615                app.selected_param -= 1;
616            }
617        }
618        KeyCode::Down => {
619            if app.selected_param + 1 < n_params {
620                app.selected_param += 1;
621            }
622        }
623        KeyCode::Left => adjust(track, app, -1.0),
624        KeyCode::Right => adjust(track, app, 1.0),
625        KeyCode::Char('m') => toggle_mute(track),
626        KeyCode::Char('r') => randomize_track(&track.params, &mut app.rng_seed),
627        _ => {}
628    }
629}
630
631fn toggle_mute(track: &Track) {
632    let p = &track.params;
633    let v = if p.mute.value() > 0.5 { 0.0 } else { 1.0 };
634    p.mute.set_value(v);
635}
636
637fn master_nudge(engine: &EngineHandle, delta: f32) {
638    let v = (engine.global.master_gain.value() + delta).clamp(0.0, 1.5);
639    engine.global.master_gain.set_value(v);
640}
641
642fn bpm_nudge(engine: &EngineHandle, delta: f32) {
643    let v = (engine.global.bpm.value() + delta).clamp(20.0, 200.0);
644    engine.global.bpm.set_value(v);
645}
646
647fn brightness_nudge(engine: &EngineHandle, delta: f32) {
648    let v = (engine.global.brightness.value() + delta).clamp(0.0, 1.0);
649    engine.global.brightness.set_value(v);
650}
651
652fn pattern_hits_nudge(engine: &EngineHandle, app: &AppState, delta: f32) {
653    let tracks = engine.tracks.lock();
654    if let Some(track) = tracks.get(app.selected_track) {
655        let v = (track.params.pattern_hits.value() + delta).clamp(0.0, rhythm::STEPS as f32);
656        track.params.pattern_hits.set_value(v);
657    }
658}
659
660fn pattern_rot_nudge(engine: &EngineHandle, app: &AppState, delta: f32) {
661    let tracks = engine.tracks.lock();
662    if let Some(track) = tracks.get(app.selected_track) {
663        let steps = rhythm::STEPS as f32;
664        let v = (track.params.pattern_rotation.value() + delta).rem_euclid(steps);
665        track.params.pattern_rotation.set_value(v);
666    }
667}
668
669/// Cycle the selected track's preset kind and rebuild the master graph
670/// so the new voice takes effect immediately. Sets a status line so the
671/// change is visible.
672fn cycle_kind(engine: &EngineHandle, app: &mut AppState, forward: bool) {
673    let new_kind = {
674        let mut tracks = engine.tracks.lock();
675        let Some(track) = tracks.get_mut(app.selected_track) else {
676            return;
677        };
678        let nk = if forward {
679            track.kind.next()
680        } else {
681            track.kind.prev()
682        };
683        track.kind = nk;
684        nk
685    };
686    engine.rebuild_graph();
687    app.set_status(format!(
688        "kind → {} (slot {})",
689        new_kind.label(),
690        app.selected_track
691    ));
692}
693
694fn adjust(track: &Track, app: &AppState, sign: f32) {
695    let p = &track.params;
696    match app.selected_param {
697        0 => p.gain.set_value((p.gain.value() + 0.05 * sign).clamp(0.0, 1.0)),
698        1 => {
699            let factor = if sign > 0.0 { 1.12 } else { 1.0 / 1.12 };
700            let v = (p.cutoff.value() * factor).clamp(40.0, 12000.0);
701            p.cutoff.set_value(v);
702        }
703        // UI resonance range capped at 0.70 — above this the Moog
704        // self-oscillates into a sine-wave whistle at cutoff. Hard safety.
705        2 => p.resonance.set_value((p.resonance.value() + 0.05 * sign).clamp(0.0, 0.70)),
706        3 => p.detune.set_value((p.detune.value() + 2.0 * sign).clamp(-50.0, 50.0)),
707        4 => {
708            let semitone = 2f32.powf(1.0 / 12.0);
709            let factor = if sign > 0.0 { semitone } else { 1.0 / semitone };
710            let v = (p.freq.value() * factor).clamp(20.0, 880.0);
711            p.freq.set_value(v);
712        }
713        5 => p.reverb_mix.set_value((p.reverb_mix.value() + 0.05 * sign).clamp(0.0, 1.0)),
714        6 => p.supermass.set_value((p.supermass.value() + 0.1 * sign).clamp(0.0, 1.0)),
715        7 => p.pulse_depth.set_value((p.pulse_depth.value() + 0.05 * sign).clamp(0.0, 1.0)),
716        // LFO rate — exponential ×1.18 per tap (smooth from 0.01 Hz → 20 Hz).
717        8 => {
718            let factor = if sign > 0.0 { 1.18 } else { 1.0 / 1.18 };
719            let v = (p.lfo_rate.value() * factor).clamp(0.01, 20.0);
720            p.lfo_rate.set_value(v);
721        }
722        9 => p.lfo_depth.set_value((p.lfo_depth.value() + 0.05 * sign).clamp(0.0, 1.0)),
723        10 => {
724            // Cycle through targets 0..LFO_TARGETS, wrapping both directions.
725            let n = crate::audio::preset::LFO_TARGETS as i32;
726            let cur = p.lfo_target.value().round() as i32;
727            let next = (cur + sign as i32).rem_euclid(n);
728            p.lfo_target.set_value(next as f32);
729        }
730        11 => p.character.set_value((p.character.value() + 0.05 * sign).clamp(0.0, 1.0)),
731        12 => p.arp.set_value((p.arp.value() + 0.05 * sign).clamp(0.0, 1.0)),
732        _ => {}
733    }
734}
735
736/// `a` activates either the currently-selected slot (if muted) or the
737/// first muted slot after it. Either way the cursor lands on the slot
738/// that just came alive, and a status line confirms which voice fired.
739fn activate_next(engine: &EngineHandle, app: &mut AppState) {
740    let tracks = engine.tracks.lock();
741
742    let root = tracks
743        .iter()
744        .find(|t| t.params.mute.value() < 0.5)
745        .map(|t| t.params.freq.value())
746        .unwrap_or(55.0);
747    let scale = golden_pentatonic(root);
748
749    // Prefer the selected slot if muted; otherwise first muted after it,
750    // wrapping back to the start.
751    let n = tracks.len();
752    let sel = app.selected_track;
753    let target = (0..n)
754        .map(|k| (sel + k) % n)
755        .find(|&i| tracks[i].params.mute.value() > 0.5);
756    let Some(idx) = target else {
757        drop(tracks);
758        app.set_status("no dormant slots — press d to kill one first".to_string());
759        return;
760    };
761
762    let track = &tracks[idx];
763    let p = &track.params;
764    let note = scale[rand_u32(&mut app.rng_seed, scale.len() as u32) as usize];
765    p.freq.set_value(note);
766    p.mute.set_value(0.0);
767    p.gain.set_value(0.28 + 0.15 * rand_f32(&mut app.rng_seed).abs());
768    p.cutoff.set_value(600.0 + 2500.0 * rand_f32(&mut app.rng_seed).abs());
769    p.resonance.set_value(0.15 + 0.30 * rand_f32(&mut app.rng_seed).abs());
770    p.reverb_mix.set_value(0.45 + 0.45 * rand_f32(&mut app.rng_seed).abs());
771    if matches!(track.kind, PresetKind::Heartbeat | PresetKind::BassPulse) {
772        p.pulse_depth.set_value(0.0);
773    } else {
774        p.pulse_depth.set_value(0.2 * rand_f32(&mut app.rng_seed).abs());
775    }
776    let kind_label = track.kind.label();
777    drop(tracks);
778
779    app.selected_track = idx;
780    app.set_status(format!(
781        "activated slot {idx}: {kind_label} @ {note:.0} Hz"
782    ));
783}
784
785fn randomize_track(p: &TrackParams, seed: &mut u64) {
786    let root = p.freq.value();
787    let scale = golden_pentatonic(root);
788    let note = scale[(rand_u32(seed, scale.len() as u32)) as usize];
789    p.freq.set_value(note);
790    p.cutoff.set_value(500.0 + 3000.0 * rand_f32(seed).abs());
791    p.resonance.set_value(0.1 + 0.4 * rand_f32(seed).abs());
792    p.reverb_mix.set_value(0.3 + 0.6 * rand_f32(seed).abs());
793    p.pulse_depth.set_value(0.2 * rand_f32(seed).abs());
794    // Re-roll the formula shape too — each preset interprets character
795    // as a different radical parameter (partial stretch, FM ratio,
796    // kick pitch drop, etc.). Full [0, 1] range for maximum variety.
797    p.character.set_value(rand_f32(seed).abs());
798}
799
800fn mutate_selected(app: &mut AppState, engine: &EngineHandle, strength: f32) {
801    let tracks = engine.tracks.lock();
802    if let Some(track) = tracks.get(app.selected_track) {
803        let genome = genome_of(&track.params);
804        mutate(&genome, &mut app.rng_seed, strength);
805    }
806}
807
808fn mutate_all_active(app: &mut AppState, engine: &EngineHandle, strength: f32) {
809    let tracks = engine.tracks.lock();
810    for t in tracks.iter() {
811        if t.params.mute.value() < 0.5 {
812            let genome = genome_of(&t.params);
813            mutate(&genome, &mut app.rng_seed, strength);
814        }
815    }
816}
817
818fn crossover_with_neighbor(app: &mut AppState, engine: &EngineHandle) {
819    let tracks = engine.tracks.lock();
820    if tracks.len() < 2 {
821        return;
822    }
823    let me = app.selected_track;
824    let other = (me + 1) % tracks.len();
825    let a = genome_of(&tracks[me].params);
826    let b = genome_of(&tracks[other].params);
827    crossover(&a, &b, &mut app.rng_seed);
828}