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