1use 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;
39const DEFAULT_EVOLVE_PERIOD: u32 = 8;
41const 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 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 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_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
162fn 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 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
201fn 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 let shaped = ratio.sqrt();
215 track.params.life_mod.set_value(shaped);
216 }
217}
218
219fn 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
227fn 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
249fn 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 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 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
282fn 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
324fn 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), Constraint::Length(10), Constraint::Length(3), Constraint::Length(10), Constraint::Min(14), Constraint::Length(3), ])
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 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
431fn handle_key(key: KeyEvent, engine: &EngineHandle, app: &mut AppState) {
434 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 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 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 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
701fn 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 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 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 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
768fn 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 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 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}