From 67322381c34a399b3a0272675399b3e61111b894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Wed, 21 Jan 2026 17:05:30 +0100 Subject: [PATCH] Init --- .gitignore | 4 + Cargo.toml | 29 + docs/keybindings.md | 58 + docs/sequencer.md | 72 + src/app.rs | 949 ++++++++++++ src/commands.rs | 130 ++ src/config.rs | 4 + src/engine/audio.rs | 141 ++ src/engine/link.rs | 89 ++ src/engine/mod.rs | 10 + src/engine/sequencer.rs | 461 ++++++ src/input.rs | 705 +++++++++ src/lib.rs | 2 + src/main.rs | 227 +++ src/model/file.rs | 89 ++ src/model/forth.rs | 2498 ++++++++++++++++++++++++++++++++ src/model/mod.rs | 8 + src/model/project.rs | 210 +++ src/model/script.rs | 28 + src/page.rs | 38 + src/services/mod.rs | 1 + src/services/pattern_editor.rs | 105 ++ src/settings.rs | 72 + src/state/audio.rs | 292 ++++ src/state/editor.rs | 42 + src/state/live_keys.rs | 21 + src/state/mod.rs | 17 + src/state/modal.rs | 42 + src/state/patterns_nav.rs | 53 + src/state/playback.rs | 21 + src/state/project.rs | 41 + src/state/ui.rs | 50 + src/views/audio_view.rs | 381 +++++ src/views/doc_view.rs | 266 ++++ src/views/highlight.rs | 299 ++++ src/views/main_view.rs | 201 +++ src/views/mod.rs | 9 + src/views/patterns_view.rs | 297 ++++ src/views/render.rs | 448 ++++++ src/views/title_view.rs | 51 + src/widgets/confirm.rs | 60 + src/widgets/mod.rs | 11 + src/widgets/modal.rs | 58 + src/widgets/scope.rs | 149 ++ src/widgets/text_input.rs | 82 ++ src/widgets/vu_meter.rs | 81 ++ tests/forth.rs | 35 + tests/forth/arithmetic.rs | 152 ++ tests/forth/comparison.rs | 136 ++ tests/forth/context.rs | 99 ++ tests/forth/control_flow.rs | 64 + tests/forth/errors.rs | 106 ++ tests/forth/harness.rs | 138 ++ tests/forth/quotations.rs | 184 +++ tests/forth/randomness.rs | 118 ++ tests/forth/sound.rs | 125 ++ tests/forth/stack.rs | 94 ++ tests/forth/temporal.rs | 229 +++ tests/forth/variables.rs | 39 + 59 files changed, 10421 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 docs/keybindings.md create mode 100644 docs/sequencer.md create mode 100644 src/app.rs create mode 100644 src/commands.rs create mode 100644 src/config.rs create mode 100644 src/engine/audio.rs create mode 100644 src/engine/link.rs create mode 100644 src/engine/mod.rs create mode 100644 src/engine/sequencer.rs create mode 100644 src/input.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/model/file.rs create mode 100644 src/model/forth.rs create mode 100644 src/model/mod.rs create mode 100644 src/model/project.rs create mode 100644 src/model/script.rs create mode 100644 src/page.rs create mode 100644 src/services/mod.rs create mode 100644 src/services/pattern_editor.rs create mode 100644 src/settings.rs create mode 100644 src/state/audio.rs create mode 100644 src/state/editor.rs create mode 100644 src/state/live_keys.rs create mode 100644 src/state/mod.rs create mode 100644 src/state/modal.rs create mode 100644 src/state/patterns_nav.rs create mode 100644 src/state/playback.rs create mode 100644 src/state/project.rs create mode 100644 src/state/ui.rs create mode 100644 src/views/audio_view.rs create mode 100644 src/views/doc_view.rs create mode 100644 src/views/highlight.rs create mode 100644 src/views/main_view.rs create mode 100644 src/views/mod.rs create mode 100644 src/views/patterns_view.rs create mode 100644 src/views/render.rs create mode 100644 src/views/title_view.rs create mode 100644 src/widgets/confirm.rs create mode 100644 src/widgets/mod.rs create mode 100644 src/widgets/modal.rs create mode 100644 src/widgets/scope.rs create mode 100644 src/widgets/text_input.rs create mode 100644 src/widgets/vu_meter.rs create mode 100644 tests/forth.rs create mode 100644 tests/forth/arithmetic.rs create mode 100644 tests/forth/comparison.rs create mode 100644 tests/forth/context.rs create mode 100644 tests/forth/control_flow.rs create mode 100644 tests/forth/errors.rs create mode 100644 tests/forth/harness.rs create mode 100644 tests/forth/quotations.rs create mode 100644 tests/forth/randomness.rs create mode 100644 tests/forth/sound.rs create mode 100644 tests/forth/stack.rs create mode 100644 tests/forth/temporal.rs create mode 100644 tests/forth/variables.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0245993 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +Cargo.lock +*.prof +.DS_Store diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9404026 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "cagire" +version = "0.1.0" +edition = "2021" + +[lib] +name = "cagire" +path = "src/lib.rs" + +[[bin]] +name = "cagire" +path = "src/main.rs" + +[dependencies] +doux = { git = "https://github.com/Bubobubobubobubo/doux", features = ["native"] } +rusty_link = "0.4" +ratatui = "0.29" +crossterm = "0.28" +cpal = "0.15" +clap = { version = "4", features = ["derive"] } + +rand = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tui-textarea = "0.7" +arboard = "3" +minimad = "0.13" +crossbeam-channel = "0.5" +confy = "2" diff --git a/docs/keybindings.md b/docs/keybindings.md new file mode 100644 index 0000000..24995e0 --- /dev/null +++ b/docs/keybindings.md @@ -0,0 +1,58 @@ +# Keybindings + +## Navigation + +- **Ctrl+Left/Right**: Switch between pages (Main, Audio, Doc) +- **q**: Quit (with confirmation) + +## Main Page - Sequencer Focus + +- **Arrow keys**: Navigate steps in pattern +- **Enter**: Toggle step active/inactive +- **Tab**: Switch focus to editor +- **Space**: Play/pause + +### Pattern Controls + +- **< / >**: Decrease/increase pattern length +- **[ / ]**: Decrease/increase pattern speed +- **p**: Open pattern picker +- **b**: Open bank picker + +### Slots + +- **1-8**: Toggle slot on/off +- **g**: Queue current pattern to first free slot +- **G**: Queue removal of current pattern from its slot + +### Files + +- **s**: Save project +- **l**: Load project +- **Ctrl+C**: Copy step script +- **Ctrl+V**: Paste step script + +### Tempo + +- **+ / =**: Increase tempo +- **-**: Decrease tempo + +## Main Page - Editor Focus + +- **Tab / Esc**: Return to sequencer focus +- **Ctrl+E**: Compile current step script + +## Audio Page + +- **h**: Hush (stop all sounds gracefully) +- **p**: Panic (kill all sounds immediately) +- **r**: Reset peak voice counter +- **t**: Test sound (plays 440Hz sine) +- **Space**: Play/pause + +## Doc Page + +- **j / Down**: Next topic +- **k / Up**: Previous topic +- **PgDn**: Scroll content down +- **PgUp**: Scroll content up diff --git a/docs/sequencer.md b/docs/sequencer.md new file mode 100644 index 0000000..130481d --- /dev/null +++ b/docs/sequencer.md @@ -0,0 +1,72 @@ +# Sequencer + +## Structure + +The sequencer is organized into: + +- **Banks**: 16 banks (B01-B16) +- **Patterns**: 16 patterns per bank (P01-P16) +- **Steps**: Up to 32 steps per pattern +- **Slots**: 8 concurrent playback slots + +## Patterns + +Each pattern has: + +- **Length**: Number of steps (1-32) +- **Speed**: Playback rate relative to tempo +- **Steps**: Each step can have a script + +### Speed Settings + +- 1/4: Quarter speed +- 1/2: Half speed +- 1x: Normal speed +- 2x: Double speed +- 4x: Quadruple speed + +## Slots + +Slots allow multiple patterns to play simultaneously. + +- Press **1-8** to toggle a slot +- Slot changes are quantized to the next bar +- A "?" indicates a slot queued to start +- A "x" indicates a slot queued to stop + +### Workflow + +1. Edit a pattern in the main view +2. Press **g** to queue it to the first free slot +3. It starts playing at the next bar boundary +4. Press **G** to queue its removal + +## Steps + +Steps are the basic unit of the sequencer: + +- Navigate with arrow keys +- Toggle active with Enter +- Each step can contain a Rhai script + +### Active vs Inactive + +- Active steps (highlighted) execute their script +- Inactive steps are skipped during playback +- Toggle with Enter key + +## Playback + +The sequencer uses Ableton Link for timing: + +- Syncs with other Link-enabled apps +- Bar boundaries are used for slot changes +- Phase shows position within the current bar + +## Files + +Projects are saved as JSON files: + +- **s**: Save with dialog +- **l**: Load with dialog +- File extension: `.buboseq` diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..1bc85af --- /dev/null +++ b/src/app.rs @@ -0,0 +1,949 @@ +use rand::rngs::StdRng; +use rand::SeedableRng; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use crossbeam_channel::Sender; + +use crate::commands::AppCommand; +use crate::engine::{ + LinkState, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot, StepSnapshot, +}; +use crate::model::{self, Bank, Pattern, Rng, ScriptEngine, StepContext, Variables}; +use crate::page::Page; +use crate::services::pattern_editor; +use crate::settings::Settings; +use crate::state::{ + AudioSettings, EditorContext, Focus, LiveKeyState, Metrics, Modal, PatternField, PatternsNav, + PlaybackState, ProjectState, UiState, +}; +use crate::views::doc_view; + +pub struct App { + pub project_state: ProjectState, + pub ui: UiState, + pub playback: PlaybackState, + + pub page: Page, + pub editor_ctx: EditorContext, + + pub patterns_nav: PatternsNav, + + pub metrics: Metrics, + pub sample_pool_mb: f32, + pub script_engine: ScriptEngine, + pub variables: Variables, + pub rng: Rng, + pub live_keys: Arc, + pub clipboard: Option, + pub copied_pattern: Option, + pub copied_bank: Option, + + pub audio: AudioSettings, +} + +impl App { + pub fn new() -> Self { + let variables = Arc::new(Mutex::new(HashMap::new())); + let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0))); + let script_engine = ScriptEngine::new(Arc::clone(&variables), Arc::clone(&rng)); + let live_keys = Arc::new(LiveKeyState::new()); + + Self { + project_state: ProjectState::default(), + ui: UiState::default(), + playback: PlaybackState::default(), + + page: Page::default(), + editor_ctx: EditorContext::default(), + + patterns_nav: PatternsNav::default(), + + metrics: Metrics::default(), + sample_pool_mb: 0.0, + variables, + rng, + live_keys, + script_engine, + clipboard: arboard::Clipboard::new().ok(), + copied_pattern: None, + copied_bank: None, + + audio: AudioSettings::default(), + } + } + + pub fn save_settings(&self, link: &LinkState) { + let settings = Settings { + audio: crate::settings::AudioSettings { + output_device: self.audio.config.output_device.clone(), + input_device: self.audio.config.input_device.clone(), + channels: self.audio.config.channels, + buffer_size: self.audio.config.buffer_size, + }, + display: crate::settings::DisplaySettings { + fps: self.audio.config.refresh_rate.to_fps(), + runtime_highlight: self.ui.runtime_highlight, + }, + link: crate::settings::LinkSettings { + enabled: link.is_enabled(), + tempo: link.tempo(), + quantum: link.quantum(), + }, + }; + settings.save(); + } + + fn current_bank_pattern(&self) -> (usize, usize) { + (self.editor_ctx.bank, self.editor_ctx.pattern) + } + + pub fn mark_all_patterns_dirty(&mut self) { + self.project_state.mark_all_dirty(); + } + + pub fn toggle_playing(&mut self) { + self.playback.toggle(); + } + + pub fn tempo_up(&self, link: &LinkState) { + let current = link.tempo(); + link.set_tempo((current + 1.0).min(300.0)); + } + + pub fn tempo_down(&self, link: &LinkState) { + let current = link.tempo(); + link.set_tempo((current - 1.0).max(20.0)); + } + + pub fn toggle_focus(&mut self, link: &LinkState) { + match self.editor_ctx.focus { + Focus::Sequencer => { + self.editor_ctx.focus = Focus::Editor; + self.load_step_to_editor(); + } + Focus::Editor => { + self.save_editor_to_step(); + self.compile_current_step(link); + self.editor_ctx.focus = Focus::Sequencer; + } + } + } + + pub fn current_edit_pattern(&self) -> &Pattern { + let (bank, pattern) = self.current_bank_pattern(); + self.project_state.project.pattern_at(bank, pattern) + } + + pub fn next_step(&mut self) { + let len = self.current_edit_pattern().length; + self.editor_ctx.step = (self.editor_ctx.step + 1) % len; + self.load_step_to_editor(); + } + + pub fn prev_step(&mut self) { + let len = self.current_edit_pattern().length; + self.editor_ctx.step = (self.editor_ctx.step + len - 1) % len; + self.load_step_to_editor(); + } + + pub fn step_up(&mut self) { + let len = self.current_edit_pattern().length; + let num_rows = match len { + 0..=8 => 1, + 9..=16 => 2, + 17..=24 => 3, + _ => 4, + }; + let steps_per_row = len.div_ceil(num_rows); + + if self.editor_ctx.step >= steps_per_row { + self.editor_ctx.step -= steps_per_row; + } else { + self.editor_ctx.step = (self.editor_ctx.step + len - steps_per_row) % len; + } + self.load_step_to_editor(); + } + + pub fn step_down(&mut self) { + let len = self.current_edit_pattern().length; + let num_rows = match len { + 0..=8 => 1, + 9..=16 => 2, + 17..=24 => 3, + _ => 4, + }; + let steps_per_row = len.div_ceil(num_rows); + + self.editor_ctx.step = (self.editor_ctx.step + steps_per_row) % len; + self.load_step_to_editor(); + } + + pub fn toggle_step(&mut self) { + let (bank, pattern) = self.current_bank_pattern(); + let change = pattern_editor::toggle_step( + &mut self.project_state.project, + bank, + pattern, + self.editor_ctx.step, + ); + self.project_state.mark_dirty(change.bank, change.pattern); + } + + pub fn length_increase(&mut self) { + let (bank, pattern) = self.current_bank_pattern(); + let (change, _) = + pattern_editor::increase_length(&mut self.project_state.project, bank, pattern); + self.project_state.mark_dirty(change.bank, change.pattern); + } + + pub fn length_decrease(&mut self) { + let (bank, pattern) = self.current_bank_pattern(); + let (change, new_len) = + pattern_editor::decrease_length(&mut self.project_state.project, bank, pattern); + if self.editor_ctx.step >= new_len { + self.editor_ctx.step = new_len - 1; + self.load_step_to_editor(); + } + self.project_state.mark_dirty(change.bank, change.pattern); + } + + pub fn speed_increase(&mut self) { + let (bank, pattern) = self.current_bank_pattern(); + let change = pattern_editor::increase_speed(&mut self.project_state.project, bank, pattern); + self.project_state.mark_dirty(change.bank, change.pattern); + } + + pub fn speed_decrease(&mut self) { + let (bank, pattern) = self.current_bank_pattern(); + let change = pattern_editor::decrease_speed(&mut self.project_state.project, bank, pattern); + self.project_state.mark_dirty(change.bank, change.pattern); + } + + fn load_step_to_editor(&mut self) { + let (bank, pattern) = self.current_bank_pattern(); + if let Some(script) = pattern_editor::get_step_script( + &self.project_state.project, + bank, + pattern, + self.editor_ctx.step, + ) { + let lines: Vec = if script.is_empty() { + vec![String::new()] + } else { + script.lines().map(String::from).collect() + }; + self.editor_ctx.text = tui_textarea::TextArea::new(lines); + } + } + + pub fn save_editor_to_step(&mut self) { + let text = self.editor_ctx.text.lines().join("\n"); + let (bank, pattern) = self.current_bank_pattern(); + let change = pattern_editor::set_step_script( + &mut self.project_state.project, + bank, + pattern, + self.editor_ctx.step, + text, + ); + self.project_state.mark_dirty(change.bank, change.pattern); + } + + pub fn compile_current_step(&mut self, link: &LinkState) { + let step_idx = self.editor_ctx.step; + let (bank, pattern) = self.current_bank_pattern(); + + let script = + pattern_editor::get_step_script(&self.project_state.project, bank, pattern, step_idx) + .unwrap_or_default(); + + if script.trim().is_empty() { + if let Some(step) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(step_idx) + { + step.command = None; + } + return; + } + + let speed = self + .project_state + .project + .pattern_at(bank, pattern) + .speed + .multiplier(); + let ctx = StepContext { + step: step_idx, + beat: link.beat(), + bank, + pattern, + tempo: link.tempo(), + phase: link.phase(), + slot: 0, + runs: 0, + iter: 0, + speed, + fill: false, + }; + + match self.script_engine.evaluate(&script, &ctx) { + Ok(cmds) => { + if let Some(step) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(step_idx) + { + step.command = if cmds.is_empty() { + None + } else { + Some(cmds.join("\n")) + }; + } + self.ui.flash("Script compiled", 150); + } + Err(e) => { + if let Some(step) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(step_idx) + { + step.command = None; + } + self.ui.set_status(format!("Script error: {e}")); + } + } + } + + pub fn compile_all_steps(&mut self, link: &LinkState) { + let pattern_len = self.current_edit_pattern().length; + let (bank, pattern) = self.current_bank_pattern(); + + for step_idx in 0..pattern_len { + let script = pattern_editor::get_step_script( + &self.project_state.project, + bank, + pattern, + step_idx, + ) + .unwrap_or_default(); + + if script.trim().is_empty() { + if let Some(step) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(step_idx) + { + step.command = None; + } + continue; + } + + let speed = self + .project_state + .project + .pattern_at(bank, pattern) + .speed + .multiplier(); + let ctx = StepContext { + step: step_idx, + beat: 0.0, + bank, + pattern, + tempo: link.tempo(), + phase: 0.0, + slot: 0, + runs: 0, + iter: 0, + speed, + fill: false, + }; + + if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) { + if let Some(step) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(step_idx) + { + step.command = if cmds.is_empty() { + None + } else { + Some(cmds.join("\n")) + }; + } + } + } + } + + pub fn toggle_pattern_playback( + &mut self, + bank: usize, + pattern: usize, + snapshot: &SequencerSnapshot, + ) { + let is_playing = snapshot.is_playing(bank, pattern); + + let pending = self + .playback + .queued_changes + .iter() + .position(|c| c.pattern_id().bank == bank && c.pattern_id().pattern == pattern); + + if let Some(idx) = pending { + self.playback.queued_changes.remove(idx); + self.ui.set_status(format!( + "B{:02}:P{:02} change cancelled", + bank + 1, + pattern + 1 + )); + } else if is_playing { + self.playback + .queued_changes + .push(PatternChange::Stop { bank, pattern }); + self.ui.set_status(format!( + "B{:02}:P{:02} queued to stop", + bank + 1, + pattern + 1 + )); + } else { + self.playback + .queued_changes + .push(PatternChange::Start { bank, pattern }); + self.ui.set_status(format!( + "B{:02}:P{:02} queued to play", + bank + 1, + pattern + 1 + )); + } + } + + pub fn select_edit_pattern(&mut self, pattern: usize) { + self.editor_ctx.pattern = pattern; + self.editor_ctx.step = 0; + self.load_step_to_editor(); + } + + pub fn select_edit_bank(&mut self, bank: usize) { + self.editor_ctx.bank = bank; + self.editor_ctx.pattern = 0; + self.editor_ctx.step = 0; + self.load_step_to_editor(); + } + + pub fn save(&mut self, path: PathBuf, link: &LinkState) { + self.save_editor_to_step(); + self.project_state.project.sample_paths = self.audio.config.sample_paths.clone(); + self.project_state.project.tempo = link.tempo(); + match model::save(&self.project_state.project, &path) { + Ok(()) => { + self.ui.set_status(format!("Saved: {}", path.display())); + self.project_state.file_path = Some(path); + } + Err(e) => { + self.ui.set_status(format!("Save error: {e}")); + } + } + } + + pub fn load(&mut self, path: PathBuf, link: &LinkState) { + match model::load(&path) { + Ok(project) => { + let tempo = project.tempo; + self.project_state.project = project; + self.editor_ctx.step = 0; + self.load_step_to_editor(); + self.compile_all_steps(link); + self.mark_all_patterns_dirty(); + link.set_tempo(tempo); + self.ui.set_status(format!("Loaded: {}", path.display())); + self.project_state.file_path = Some(path); + } + Err(e) => { + self.ui.set_status(format!("Load error: {e}")); + } + } + } + + pub fn copy_step(&mut self) { + let (bank, pattern) = self.current_bank_pattern(); + let step = self.editor_ctx.step; + let script = + pattern_editor::get_step_script(&self.project_state.project, bank, pattern, step); + + if let Some(script) = script { + if let Some(clip) = &mut self.clipboard { + if clip.set_text(&script).is_ok() { + self.editor_ctx.copied_step = Some(crate::state::CopiedStep { + bank, + pattern, + step, + }); + self.ui.set_status("Copied".to_string()); + } + } + } + } + + pub fn delete_step(&mut self, bank: usize, pattern: usize, step: usize) { + let pat = self.project_state.project.pattern_at_mut(bank, pattern); + for s in &mut pat.steps { + if s.source == Some(step) { + s.source = None; + s.script.clear(); + s.command = None; + } + } + + let change = pattern_editor::set_step_script( + &mut self.project_state.project, + bank, + pattern, + step, + String::new(), + ); + if let Some(s) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(step) + { + s.command = None; + s.source = None; + } + self.project_state.mark_dirty(change.bank, change.pattern); + if self.editor_ctx.bank == bank + && self.editor_ctx.pattern == pattern + && self.editor_ctx.step == step + { + self.load_step_to_editor(); + } + self.ui.flash("Step deleted", 150); + } + + pub fn reset_pattern(&mut self, bank: usize, pattern: usize) { + self.project_state.project.banks[bank].patterns[pattern] = Pattern::default(); + self.project_state.mark_dirty(bank, pattern); + if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern { + self.load_step_to_editor(); + } + self.ui.flash("Pattern reset", 150); + } + + pub fn reset_bank(&mut self, bank: usize) { + self.project_state.project.banks[bank] = Bank::default(); + for pattern in 0..self.project_state.project.banks[bank].patterns.len() { + self.project_state.mark_dirty(bank, pattern); + } + if self.editor_ctx.bank == bank { + self.load_step_to_editor(); + } + self.ui.flash("Bank reset", 150); + } + + pub fn copy_pattern(&mut self, bank: usize, pattern: usize) { + let pat = self.project_state.project.banks[bank].patterns[pattern].clone(); + self.copied_pattern = Some(pat); + self.ui.flash("Pattern copied", 150); + } + + pub fn paste_pattern(&mut self, bank: usize, pattern: usize) { + if let Some(src) = &self.copied_pattern { + let mut pat = src.clone(); + pat.name = match &src.name { + Some(name) if !name.ends_with(" (copy)") => Some(format!("{name} (copy)")), + Some(name) => Some(name.clone()), + None => Some("(copy)".to_string()), + }; + self.project_state.project.banks[bank].patterns[pattern] = pat; + self.project_state.mark_dirty(bank, pattern); + if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern { + self.load_step_to_editor(); + } + self.ui.flash("Pattern pasted", 150); + } + } + + pub fn copy_bank(&mut self, bank: usize) { + let b = self.project_state.project.banks[bank].clone(); + self.copied_bank = Some(b); + self.ui.flash("Bank copied", 150); + } + + pub fn paste_bank(&mut self, bank: usize) { + if let Some(src) = &self.copied_bank { + let mut b = src.clone(); + b.name = match &src.name { + Some(name) if !name.ends_with(" (copy)") => Some(format!("{name} (copy)")), + Some(name) => Some(name.clone()), + None => Some("(copy)".to_string()), + }; + self.project_state.project.banks[bank] = b; + for pattern in 0..self.project_state.project.banks[bank].patterns.len() { + self.project_state.mark_dirty(bank, pattern); + } + if self.editor_ctx.bank == bank { + self.load_step_to_editor(); + } + self.ui.flash("Bank pasted", 150); + } + } + + pub fn paste_step(&mut self, link: &LinkState) { + let text = self + .clipboard + .as_mut() + .and_then(|clip| clip.get_text().ok()); + + if let Some(text) = text { + let (bank, pattern) = self.current_bank_pattern(); + let change = pattern_editor::set_step_script( + &mut self.project_state.project, + bank, + pattern, + self.editor_ctx.step, + text, + ); + self.project_state.mark_dirty(change.bank, change.pattern); + self.load_step_to_editor(); + self.compile_current_step(link); + } + } + + pub fn link_paste_step(&mut self) { + let Some(copied) = self.editor_ctx.copied_step else { + self.ui.set_status("Nothing copied".to_string()); + return; + }; + + let (bank, pattern) = self.current_bank_pattern(); + let step = self.editor_ctx.step; + + if copied.bank != bank || copied.pattern != pattern { + self.ui + .set_status("Can only link within same pattern".to_string()); + return; + } + + if copied.step == step { + self.ui.set_status("Cannot link step to itself".to_string()); + return; + } + + let source_step = self + .project_state + .project + .pattern_at(bank, pattern) + .step(copied.step); + if source_step.map(|s| s.source.is_some()).unwrap_or(false) { + self.ui + .set_status("Cannot link to a linked step".to_string()); + return; + } + + if let Some(s) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(step) + { + s.source = Some(copied.step); + s.script.clear(); + s.command = None; + } + self.project_state.mark_dirty(bank, pattern); + self.load_step_to_editor(); + self.ui + .flash(&format!("Linked to step {:02}", copied.step + 1), 150); + } + + pub fn harden_step(&mut self) { + let (bank, pattern) = self.current_bank_pattern(); + let step = self.editor_ctx.step; + + let resolved_script = self + .project_state + .project + .pattern_at(bank, pattern) + .resolve_script(step) + .map(|s| s.to_string()); + + let Some(script) = resolved_script else { + return; + }; + + if let Some(s) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(step) + { + if s.source.is_none() { + self.ui.set_status("Step is not linked".to_string()); + return; + } + s.source = None; + s.script = script; + } + self.project_state.mark_dirty(bank, pattern); + self.load_step_to_editor(); + self.ui.flash("Step hardened", 150); + } + + pub fn open_pattern_modal(&mut self, field: PatternField) { + let current = match field { + PatternField::Length => self.current_edit_pattern().length.to_string(), + PatternField::Speed => self.current_edit_pattern().speed.label().to_string(), + }; + self.ui.modal = Modal::SetPattern { + field, + input: current, + }; + } + + pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) { + match cmd { + // Playback + AppCommand::TogglePlaying => self.toggle_playing(), + AppCommand::TempoUp => self.tempo_up(link), + AppCommand::TempoDown => self.tempo_down(link), + + // Navigation + AppCommand::NextStep => self.next_step(), + AppCommand::PrevStep => self.prev_step(), + AppCommand::StepUp => self.step_up(), + AppCommand::StepDown => self.step_down(), + AppCommand::ToggleFocus => self.toggle_focus(link), + AppCommand::SelectEditBank(bank) => self.select_edit_bank(bank), + AppCommand::SelectEditPattern(pattern) => self.select_edit_pattern(pattern), + + // Pattern editing + AppCommand::ToggleStep => self.toggle_step(), + AppCommand::LengthIncrease => self.length_increase(), + AppCommand::LengthDecrease => self.length_decrease(), + AppCommand::SpeedIncrease => self.speed_increase(), + AppCommand::SpeedDecrease => self.speed_decrease(), + AppCommand::SetLength { + bank, + pattern, + length, + } => { + let (change, new_len) = pattern_editor::set_length( + &mut self.project_state.project, + bank, + pattern, + length, + ); + if self.editor_ctx.bank == bank + && self.editor_ctx.pattern == pattern + && self.editor_ctx.step >= new_len + { + self.editor_ctx.step = new_len - 1; + } + self.project_state.mark_dirty(change.bank, change.pattern); + } + AppCommand::SetSpeed { + bank, + pattern, + speed, + } => { + let change = pattern_editor::set_speed( + &mut self.project_state.project, + bank, + pattern, + speed, + ); + self.project_state.mark_dirty(change.bank, change.pattern); + } + + // Script editing + AppCommand::SaveEditorToStep => self.save_editor_to_step(), + AppCommand::CompileCurrentStep => self.compile_current_step(link), + AppCommand::CompileAllSteps => self.compile_all_steps(link), + AppCommand::DeleteStep { + bank, + pattern, + step, + } => { + self.delete_step(bank, pattern, step); + } + AppCommand::ResetPattern { bank, pattern } => { + self.reset_pattern(bank, pattern); + } + AppCommand::ResetBank { bank } => { + self.reset_bank(bank); + } + AppCommand::CopyPattern { bank, pattern } => { + self.copy_pattern(bank, pattern); + } + AppCommand::PastePattern { bank, pattern } => { + self.paste_pattern(bank, pattern); + } + AppCommand::CopyBank { bank } => { + self.copy_bank(bank); + } + AppCommand::PasteBank { bank } => { + self.paste_bank(bank); + } + + // Clipboard + AppCommand::CopyStep => self.copy_step(), + AppCommand::PasteStep => self.paste_step(link), + AppCommand::LinkPasteStep => self.link_paste_step(), + AppCommand::HardenStep => self.harden_step(), + + // Pattern playback + AppCommand::QueuePatternChange(change) => { + self.playback.queued_changes.push(change); + } + AppCommand::TogglePatternPlayback { bank, pattern } => { + self.toggle_pattern_playback(bank, pattern, snapshot); + } + + // Project + AppCommand::RenameBank { bank, name } => { + self.project_state.project.banks[bank].name = name; + } + AppCommand::RenamePattern { + bank, + pattern, + name, + } => { + self.project_state.project.banks[bank].patterns[pattern].name = name; + } + AppCommand::Save(path) => self.save(path, link), + AppCommand::Load(path) => self.load(path, link), + + // UI + AppCommand::SetStatus(msg) => self.ui.set_status(msg), + AppCommand::ClearStatus => self.ui.clear_status(), + AppCommand::Flash { + message, + duration_ms, + } => self.ui.flash(&message, duration_ms), + AppCommand::OpenModal(modal) => { + if matches!(modal, Modal::Editor) { + // If current step is a shallow copy, navigate to source step + let pattern = &self.project_state.project.banks[self.editor_ctx.bank].patterns + [self.editor_ctx.pattern]; + if let Some(source) = pattern.steps[self.editor_ctx.step].source { + self.editor_ctx.step = source; + } + self.load_step_to_editor(); + } + self.ui.modal = modal; + } + AppCommand::CloseModal => self.ui.modal = Modal::None, + AppCommand::OpenPatternModal(field) => self.open_pattern_modal(field), + + // Page navigation + AppCommand::PageLeft => self.page.left(), + AppCommand::PageRight => self.page.right(), + AppCommand::PageUp => self.page.up(), + AppCommand::PageDown => self.page.down(), + + // Doc navigation + AppCommand::DocNextTopic => { + self.ui.doc_topic = (self.ui.doc_topic + 1) % doc_view::topic_count(); + self.ui.doc_scroll = 0; + self.ui.doc_category = 0; + } + AppCommand::DocPrevTopic => { + let count = doc_view::topic_count(); + self.ui.doc_topic = (self.ui.doc_topic + count - 1) % count; + self.ui.doc_scroll = 0; + self.ui.doc_category = 0; + } + AppCommand::DocScrollDown(n) => { + self.ui.doc_scroll = self.ui.doc_scroll.saturating_add(n); + } + AppCommand::DocScrollUp(n) => { + self.ui.doc_scroll = self.ui.doc_scroll.saturating_sub(n); + } + AppCommand::DocNextCategory => { + let count = doc_view::category_count(); + self.ui.doc_category = (self.ui.doc_category + 1) % count; + self.ui.doc_scroll = 0; + } + AppCommand::DocPrevCategory => { + let count = doc_view::category_count(); + self.ui.doc_category = (self.ui.doc_category + count - 1) % count; + self.ui.doc_scroll = 0; + } + + // Patterns view + AppCommand::PatternsCursorLeft => { + self.patterns_nav.move_left(); + } + AppCommand::PatternsCursorRight => { + self.patterns_nav.move_right(); + } + AppCommand::PatternsCursorUp => { + self.patterns_nav.move_up(); + } + AppCommand::PatternsCursorDown => { + self.patterns_nav.move_down(); + } + AppCommand::PatternsEnter => { + let bank = self.patterns_nav.selected_bank(); + let pattern = self.patterns_nav.selected_pattern(); + self.select_edit_bank(bank); + self.select_edit_pattern(pattern); + self.page.down(); + } + AppCommand::PatternsBack => { + self.page.down(); + } + AppCommand::PatternsTogglePlay => { + let bank = self.patterns_nav.selected_bank(); + let pattern = self.patterns_nav.selected_pattern(); + self.toggle_pattern_playback(bank, pattern, snapshot); + } + } + } + + pub fn flush_queued_changes(&mut self, cmd_tx: &Sender) { + for change in self.playback.queued_changes.drain(..) { + match change { + PatternChange::Start { bank, pattern } => { + let _ = cmd_tx.send(SeqCommand::PatternStart { bank, pattern }); + } + PatternChange::Stop { bank, pattern } => { + let _ = cmd_tx.send(SeqCommand::PatternStop { bank, pattern }); + } + } + } + } + + pub fn flush_dirty_patterns(&mut self, cmd_tx: &Sender) { + for (bank, pattern) in self.project_state.take_dirty() { + let pat = self.project_state.project.pattern_at(bank, pattern); + let snapshot = PatternSnapshot { + speed: pat.speed, + length: pat.length, + steps: pat + .steps + .iter() + .take(pat.length) + .map(|s| StepSnapshot { + active: s.active, + script: s.script.clone(), + source: s.source, + }) + .collect(), + }; + let _ = cmd_tx.send(SeqCommand::PatternUpdate { + bank, + pattern, + data: snapshot, + }); + } + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..521a313 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,130 @@ +use std::path::PathBuf; + +use crate::engine::PatternChange; +use crate::model::PatternSpeed; +use crate::state::{Modal, PatternField}; + +#[allow(dead_code)] +pub enum AppCommand { + // Playback + TogglePlaying, + TempoUp, + TempoDown, + + // Navigation + NextStep, + PrevStep, + StepUp, + StepDown, + ToggleFocus, + SelectEditBank(usize), + SelectEditPattern(usize), + + // Pattern editing + ToggleStep, + LengthIncrease, + LengthDecrease, + SpeedIncrease, + SpeedDecrease, + SetLength { + bank: usize, + pattern: usize, + length: usize, + }, + SetSpeed { + bank: usize, + pattern: usize, + speed: PatternSpeed, + }, + + // Script editing + SaveEditorToStep, + CompileCurrentStep, + CompileAllSteps, + DeleteStep { + bank: usize, + pattern: usize, + step: usize, + }, + ResetPattern { + bank: usize, + pattern: usize, + }, + ResetBank { + bank: usize, + }, + CopyPattern { + bank: usize, + pattern: usize, + }, + PastePattern { + bank: usize, + pattern: usize, + }, + CopyBank { + bank: usize, + }, + PasteBank { + bank: usize, + }, + + // Clipboard + CopyStep, + PasteStep, + LinkPasteStep, + HardenStep, + + // Pattern playback + QueuePatternChange(PatternChange), + TogglePatternPlayback { + bank: usize, + pattern: usize, + }, + + // Project + RenameBank { + bank: usize, + name: Option, + }, + RenamePattern { + bank: usize, + pattern: usize, + name: Option, + }, + Save(PathBuf), + Load(PathBuf), + + // UI + SetStatus(String), + ClearStatus, + Flash { + message: String, + duration_ms: u64, + }, + OpenModal(Modal), + CloseModal, + OpenPatternModal(PatternField), + + // Page navigation + PageLeft, + PageRight, + PageUp, + PageDown, + + // Doc navigation + DocNextTopic, + DocPrevTopic, + DocScrollDown(usize), + DocScrollUp(usize), + DocNextCategory, + DocPrevCategory, + + // Patterns view + PatternsCursorLeft, + PatternsCursorRight, + PatternsCursorUp, + PatternsCursorDown, + PatternsEnter, + PatternsBack, + PatternsTogglePlay, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..5f0fde7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,4 @@ +pub const MAX_BANKS: usize = 16; +pub const MAX_PATTERNS: usize = 16; +pub const MAX_STEPS: usize = 32; +pub const DEFAULT_LENGTH: usize = 16; diff --git a/src/engine/audio.rs b/src/engine/audio.rs new file mode 100644 index 0000000..c800136 --- /dev/null +++ b/src/engine/audio.rs @@ -0,0 +1,141 @@ +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::Stream; +use crossbeam_channel::Receiver; +use doux::{Engine, EngineMetrics}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use super::AudioCommand; + +pub struct ScopeBuffer { + pub samples: [AtomicU32; 64], + peak_left: AtomicU32, + peak_right: AtomicU32, +} + +impl ScopeBuffer { + pub fn new() -> Self { + Self { + samples: std::array::from_fn(|_| AtomicU32::new(0)), + peak_left: AtomicU32::new(0), + peak_right: AtomicU32::new(0), + } + } + + pub fn write(&self, data: &[f32]) { + let mut peak_l: f32 = 0.0; + let mut peak_r: f32 = 0.0; + + for (i, atom) in self.samples.iter().enumerate() { + let idx = i * 2; + let left = data.get(idx).copied().unwrap_or(0.0); + let right = data.get(idx + 1).copied().unwrap_or(0.0); + peak_l = peak_l.max(left.abs()); + peak_r = peak_r.max(right.abs()); + atom.store(left.to_bits(), Ordering::Relaxed); + } + + self.peak_left.store(peak_l.to_bits(), Ordering::Relaxed); + self.peak_right.store(peak_r.to_bits(), Ordering::Relaxed); + } + + pub fn read(&self) -> [f32; 64] { + std::array::from_fn(|i| f32::from_bits(self.samples[i].load(Ordering::Relaxed))) + } + + pub fn peaks(&self) -> (f32, f32) { + let left = f32::from_bits(self.peak_left.load(Ordering::Relaxed)); + let right = f32::from_bits(self.peak_right.load(Ordering::Relaxed)); + (left, right) + } +} + +pub struct AudioStreamConfig { + pub output_device: Option, + pub channels: u16, + pub buffer_size: u32, +} + +pub fn build_stream( + config: &AudioStreamConfig, + audio_rx: Receiver, + scope_buffer: Arc, + metrics: Arc, + initial_samples: Vec, +) -> Result<(Stream, f32), String> { + let host = cpal::default_host(); + + let device = match &config.output_device { + Some(name) => doux::audio::find_output_device(name) + .ok_or_else(|| format!("Device not found: {name}"))?, + None => host + .default_output_device() + .ok_or("No default output device")?, + }; + + let default_config = device.default_output_config().map_err(|e| e.to_string())?; + let sample_rate = default_config.sample_rate().0 as f32; + + let buffer_size = if config.buffer_size > 0 { + cpal::BufferSize::Fixed(config.buffer_size) + } else { + cpal::BufferSize::Default + }; + + let stream_config = cpal::StreamConfig { + channels: config.channels, + sample_rate: default_config.sample_rate(), + buffer_size, + }; + + let sr = sample_rate; + let channels = config.channels as usize; + let metrics_clone = Arc::clone(&metrics); + + let mut engine = Engine::new_with_metrics(sample_rate, channels, Arc::clone(&metrics)); + engine.sample_index = initial_samples; + + let stream = device + .build_output_stream( + &stream_config, + move |data: &mut [f32], _| { + let buffer_samples = data.len() / channels; + let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64; + + while let Ok(cmd) = audio_rx.try_recv() { + match cmd { + AudioCommand::Evaluate(s) => { + engine.evaluate(&s); + } + AudioCommand::Hush => { + engine.hush(); + } + AudioCommand::Panic => { + engine.panic(); + } + AudioCommand::LoadSamples(samples) => { + engine.sample_index.extend(samples); + } + AudioCommand::ResetEngine => { + let old_samples = std::mem::take(&mut engine.sample_index); + engine = + Engine::new_with_metrics(sr, channels, Arc::clone(&metrics_clone)); + engine.sample_index = old_samples; + } + } + } + + engine.metrics.load.set_buffer_time(buffer_time_ns); + engine.process_block(data, &[], &[]); + scope_buffer.write(&engine.output); + }, + |err| eprintln!("stream error: {err}"), + None, + ) + .map_err(|e| format!("Failed to build stream: {e}"))?; + + stream + .play() + .map_err(|e| format!("Failed to play stream: {e}"))?; + Ok((stream, sample_rate)) +} diff --git a/src/engine/link.rs b/src/engine/link.rs new file mode 100644 index 0000000..d8b2e3a --- /dev/null +++ b/src/engine/link.rs @@ -0,0 +1,89 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use rusty_link::{AblLink, SessionState}; + +pub struct LinkState { + link: AblLink, + quantum: AtomicU64, +} + +impl LinkState { + pub fn new(tempo: f64, quantum: f64) -> Self { + let link = AblLink::new(tempo); + Self { + link, + quantum: AtomicU64::new(quantum.to_bits()), + } + } + + pub fn is_enabled(&self) -> bool { + self.link.is_enabled() + } + + pub fn set_enabled(&self, enabled: bool) { + self.link.enable(enabled); + } + + pub fn enable(&self) { + self.link.enable(true); + } + + pub fn is_start_stop_sync_enabled(&self) -> bool { + self.link.is_start_stop_sync_enabled() + } + + pub fn set_start_stop_sync_enabled(&self, enabled: bool) { + self.link.enable_start_stop_sync(enabled); + } + + pub fn quantum(&self) -> f64 { + f64::from_bits(self.quantum.load(Ordering::Relaxed)) + } + + pub fn set_quantum(&self, quantum: f64) { + let clamped = quantum.clamp(1.0, 16.0); + self.quantum.store(clamped.to_bits(), Ordering::Relaxed); + } + + pub fn clock_micros(&self) -> i64 { + self.link.clock_micros() + } + + pub fn tempo(&self) -> f64 { + let mut state = SessionState::new(); + self.link.capture_app_session_state(&mut state); + state.tempo() + } + + pub fn beat(&self) -> f64 { + let mut state = SessionState::new(); + self.link.capture_app_session_state(&mut state); + let time = self.link.clock_micros(); + state.beat_at_time(time, self.quantum()) + } + + pub fn phase(&self) -> f64 { + let mut state = SessionState::new(); + self.link.capture_app_session_state(&mut state); + let time = self.link.clock_micros(); + state.phase_at_time(time, self.quantum()) + } + + pub fn peers(&self) -> u64 { + self.link.num_peers() + } + + pub fn set_tempo(&self, tempo: f64) { + let mut state = SessionState::new(); + self.link.capture_app_session_state(&mut state); + let time = self.link.clock_micros(); + state.set_tempo(tempo, time); + self.link.commit_app_session_state(&state); + } + + pub fn capture_app_state(&self) -> SessionState { + let mut state = SessionState::new(); + self.link.capture_app_session_state(&mut state); + state + } +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs new file mode 100644 index 0000000..7891938 --- /dev/null +++ b/src/engine/mod.rs @@ -0,0 +1,10 @@ +mod audio; +mod link; +mod sequencer; + +pub use audio::{build_stream, AudioStreamConfig, ScopeBuffer}; +pub use link::LinkState; +pub use sequencer::{ + spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot, + StepSnapshot, +}; diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs new file mode 100644 index 0000000..e03bcc3 --- /dev/null +++ b/src/engine/sequencer.rs @@ -0,0 +1,461 @@ +use crossbeam_channel::{bounded, Receiver, Sender, TrySendError}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use super::LinkState; +use crate::config::{MAX_BANKS, MAX_PATTERNS}; +use crate::model::{ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Variables}; +use crate::state::LiveKeyState; + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct PatternId { + pub bank: usize, + pub pattern: usize, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PatternChange { + Start { bank: usize, pattern: usize }, + Stop { bank: usize, pattern: usize }, +} + +impl PatternChange { + pub fn pattern_id(&self) -> PatternId { + match self { + PatternChange::Start { bank, pattern } => PatternId { + bank: *bank, + pattern: *pattern, + }, + PatternChange::Stop { bank, pattern } => PatternId { + bank: *bank, + pattern: *pattern, + }, + } + } +} + +pub enum AudioCommand { + Evaluate(String), + Hush, + Panic, + LoadSamples(Vec), + #[allow(dead_code)] + ResetEngine, +} + +pub enum SeqCommand { + PatternUpdate { + bank: usize, + pattern: usize, + data: PatternSnapshot, + }, + PatternStart { + bank: usize, + pattern: usize, + }, + PatternStop { + bank: usize, + pattern: usize, + }, + Shutdown, +} + +#[derive(Clone)] +pub struct PatternSnapshot { + pub speed: crate::model::PatternSpeed, + pub length: usize, + pub steps: Vec, +} + +#[derive(Clone)] +pub struct StepSnapshot { + pub active: bool, + pub script: String, + pub source: Option, +} + +#[derive(Clone, Copy, Default, Debug)] +pub struct ActivePatternState { + pub bank: usize, + pub pattern: usize, + pub step_index: usize, + pub iter: usize, +} + +#[derive(Clone, Default)] +pub struct SharedSequencerState { + pub active_patterns: Vec, + pub pattern_traces: HashMap>, + pub event_count: usize, +} + +pub struct SequencerSnapshot { + pub active_patterns: Vec, + pub pattern_traces: HashMap>, + pub event_count: usize, +} + +impl SequencerSnapshot { + pub fn is_playing(&self, bank: usize, pattern: usize) -> bool { + self.active_patterns + .iter() + .any(|p| p.bank == bank && p.pattern == pattern) + } + + pub fn get_step(&self, bank: usize, pattern: usize) -> Option { + self.active_patterns + .iter() + .find(|p| p.bank == bank && p.pattern == pattern) + .map(|p| p.step_index) + } + + pub fn get_iter(&self, bank: usize, pattern: usize) -> Option { + self.active_patterns + .iter() + .find(|p| p.bank == bank && p.pattern == pattern) + .map(|p| p.iter) + } + + pub fn get_trace(&self, bank: usize, pattern: usize) -> Option<&Vec> { + self.pattern_traces.get(&PatternId { bank, pattern }) + } +} + +pub struct SequencerHandle { + pub cmd_tx: Sender, + pub audio_tx: Sender, + pub audio_rx: Receiver, + shared_state: Arc>, + thread: JoinHandle<()>, +} + +impl SequencerHandle { + pub fn snapshot(&self) -> SequencerSnapshot { + let state = self.shared_state.lock().unwrap(); + SequencerSnapshot { + active_patterns: state.active_patterns.clone(), + pattern_traces: state.pattern_traces.clone(), + event_count: state.event_count, + } + } + + pub fn shutdown(self) { + let _ = self.cmd_tx.send(SeqCommand::Shutdown); + let _ = self.thread.join(); + } +} + +#[derive(Clone, Copy, Default)] +struct ActivePattern { + bank: usize, + pattern: usize, + step_index: usize, + iter: usize, +} + +struct AudioState { + prev_beat: f64, + active_patterns: HashMap, + pending_starts: Vec, + pending_stops: Vec, +} + +impl AudioState { + fn new() -> Self { + Self { + prev_beat: -1.0, + active_patterns: HashMap::new(), + pending_starts: Vec::new(), + pending_stops: Vec::new(), + } + } +} + +pub fn spawn_sequencer( + link: Arc, + playing: Arc, + variables: Variables, + rng: Rng, + quantum: f64, + live_keys: Arc, +) -> SequencerHandle { + let (cmd_tx, cmd_rx) = bounded::(64); + let (audio_tx, audio_rx) = bounded::(256); + + let shared_state = Arc::new(Mutex::new(SharedSequencerState::default())); + let shared_state_clone = Arc::clone(&shared_state); + let audio_tx_clone = audio_tx.clone(); + + let thread = thread::Builder::new() + .name("sequencer".into()) + .spawn(move || { + sequencer_loop( + cmd_rx, + audio_tx_clone, + link, + playing, + variables, + rng, + quantum, + shared_state_clone, + live_keys, + ); + }) + .expect("Failed to spawn sequencer thread"); + + SequencerHandle { + cmd_tx, + audio_tx, + audio_rx, + shared_state, + thread, + } +} + +struct PatternCache { + patterns: [[Option; MAX_PATTERNS]; MAX_BANKS], +} + +impl PatternCache { + fn new() -> Self { + Self { + patterns: std::array::from_fn(|_| std::array::from_fn(|_| None)), + } + } + + fn get(&self, bank: usize, pattern: usize) -> Option<&PatternSnapshot> { + self.patterns + .get(bank) + .and_then(|b| b.get(pattern)) + .and_then(|p| p.as_ref()) + } + + fn set(&mut self, bank: usize, pattern: usize, data: PatternSnapshot) { + if bank < MAX_BANKS && pattern < MAX_PATTERNS { + self.patterns[bank][pattern] = Some(data); + } + } +} + +impl PatternSnapshot { + fn resolve_source(&self, index: usize) -> usize { + let mut current = index; + for _ in 0..self.steps.len() { + if let Some(step) = self.steps.get(current) { + if let Some(source) = step.source { + current = source; + } else { + return current; + } + } else { + return index; + } + } + index + } + + fn resolve_script(&self, index: usize) -> Option<&str> { + let source_idx = self.resolve_source(index); + self.steps.get(source_idx).map(|s| s.script.as_str()) + } +} + +type StepKey = (usize, usize, usize); + +struct RunsCounter { + counts: HashMap, +} + +impl RunsCounter { + fn new() -> Self { + Self { + counts: HashMap::new(), + } + } + + fn get_and_increment(&mut self, bank: usize, pattern: usize, step: usize) -> usize { + let key = (bank, pattern, step); + let count = self.counts.entry(key).or_insert(0); + let current = *count; + *count += 1; + current + } +} + +fn sequencer_loop( + cmd_rx: Receiver, + audio_tx: Sender, + link: Arc, + playing: Arc, + variables: Variables, + rng: Rng, + quantum: f64, + shared_state: Arc>, + live_keys: Arc, +) { + use std::sync::atomic::Ordering; + + let script_engine = ScriptEngine::new(Arc::clone(&variables), rng); + let mut audio_state = AudioState::new(); + let mut pattern_cache = PatternCache::new(); + let mut runs_counter = RunsCounter::new(); + let mut pattern_traces: HashMap> = HashMap::new(); + let mut event_count: usize = 0; + + loop { + while let Ok(cmd) = cmd_rx.try_recv() { + match cmd { + SeqCommand::PatternUpdate { + bank, + pattern, + data, + } => { + pattern_cache.set(bank, pattern, data); + } + SeqCommand::PatternStart { bank, pattern } => { + let id = PatternId { bank, pattern }; + audio_state.pending_stops.retain(|p| *p != id); + if !audio_state.pending_starts.contains(&id) { + audio_state.pending_starts.push(id); + } + } + SeqCommand::PatternStop { bank, pattern } => { + let id = PatternId { bank, pattern }; + audio_state.pending_starts.retain(|p| *p != id); + if !audio_state.pending_stops.contains(&id) { + audio_state.pending_stops.push(id); + } + } + SeqCommand::Shutdown => { + return; + } + } + } + + if !playing.load(Ordering::Relaxed) { + thread::sleep(Duration::from_micros(500)); + continue; + } + + let state = link.capture_app_state(); + let time = link.clock_micros(); + let beat = state.beat_at_time(time, quantum); + let tempo = state.tempo(); + + let bar = (beat / quantum).floor() as i64; + let prev_bar = (audio_state.prev_beat / quantum).floor() as i64; + if bar != prev_bar && audio_state.prev_beat >= 0.0 { + for id in audio_state.pending_starts.drain(..) { + audio_state.active_patterns.insert( + id, + ActivePattern { + bank: id.bank, + pattern: id.pattern, + step_index: 0, + iter: 0, + }, + ); + } + for id in audio_state.pending_stops.drain(..) { + audio_state.active_patterns.remove(&id); + pattern_traces.remove(&id); + } + } + + let prev_beat = audio_state.prev_beat; + + for (id, active) in audio_state.active_patterns.iter_mut() { + let Some(pattern) = pattern_cache.get(active.bank, active.pattern) else { + continue; + }; + + let speed_mult = pattern.speed.multiplier(); + let beat_int = (beat * 4.0 * speed_mult).floor() as i64; + let prev_beat_int = (prev_beat * 4.0 * speed_mult).floor() as i64; + + if beat_int != prev_beat_int && prev_beat >= 0.0 { + let step_idx = active.step_index % pattern.length; + + if let Some(step) = pattern.steps.get(step_idx) { + let resolved_script = pattern.resolve_script(step_idx); + let has_script = resolved_script + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + if step.active && has_script { + let source_idx = pattern.resolve_source(step_idx); + let runs = + runs_counter.get_and_increment(active.bank, active.pattern, source_idx); + let ctx = StepContext { + step: step_idx, + beat, + bank: active.bank, + pattern: active.pattern, + tempo, + phase: beat % quantum, + slot: 0, + runs, + iter: active.iter, + speed: speed_mult, + fill: live_keys.fill(), + }; + if let Some(script) = resolved_script { + let mut trace = ExecutionTrace::default(); + if let Ok(cmds) = + script_engine.evaluate_with_trace(script, &ctx, &mut trace) + { + pattern_traces + .insert(*id, std::mem::take(&mut trace.selected_spans)); + for cmd in cmds { + match audio_tx.try_send(AudioCommand::Evaluate(cmd)) { + Ok(()) => { + event_count += 1; + } + Err(TrySendError::Full(_)) => {} + Err(TrySendError::Disconnected(_)) => { + return; + } + } + } + if let Some(new_tempo) = { + let mut vars = variables.lock().unwrap(); + vars.remove("__tempo__").and_then(|v| v.as_float().ok()) + } { + link.set_tempo(new_tempo); + } + } + } + } + } + + let next_step = active.step_index + 1; + if next_step >= pattern.length { + active.iter += 1; + } + active.step_index = next_step % pattern.length; + } + } + + { + let mut state = shared_state.lock().unwrap(); + state.active_patterns = audio_state + .active_patterns + .values() + .map(|a| ActivePatternState { + bank: a.bank, + pattern: a.pattern, + step_index: a.step_index, + iter: a.iter, + }) + .collect(); + state.pattern_traces = pattern_traces.clone(); + state.event_count = event_count; + } + + audio_state.prev_beat = beat; + + thread::sleep(Duration::from_micros(500)); + } +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..325ad31 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,705 @@ +use crossbeam_channel::Sender; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::app::App; +use crate::commands::AppCommand; +use crate::engine::{AudioCommand, LinkState, SequencerSnapshot}; +use crate::model::PatternSpeed; +use crate::page::Page; +use crate::state::{AudioFocus, Modal, PatternField}; + +pub enum InputResult { + Continue, + Quit, +} + +pub struct InputContext<'a> { + pub app: &'a mut App, + pub link: &'a LinkState, + pub snapshot: &'a SequencerSnapshot, + pub playing: &'a Arc, + pub audio_tx: &'a Sender, +} + +impl<'a> InputContext<'a> { + fn dispatch(&mut self, cmd: AppCommand) { + self.app.dispatch(cmd, self.link, self.snapshot); + } +} + +pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + if handle_live_keys(ctx, &key) { + return InputResult::Continue; + } + + if key.kind == KeyEventKind::Release { + return InputResult::Continue; + } + + if ctx.app.ui.show_title { + ctx.app.ui.show_title = false; + return InputResult::Continue; + } + + ctx.dispatch(AppCommand::ClearStatus); + + if matches!(ctx.app.ui.modal, Modal::None) { + handle_normal_input(ctx, key) + } else { + handle_modal_input(ctx, key) + } +} + +fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool { + match (key.code, key.kind) { + _ if !matches!(ctx.app.ui.modal, Modal::None) => false, + (KeyCode::Char('f'), KeyEventKind::Press) => { + ctx.app.live_keys.flip_fill(); + true + } + _ => false, + } +} + +fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + match &mut ctx.app.ui.modal { + Modal::ConfirmQuit { selected } => match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => return InputResult::Quit, + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Left | KeyCode::Right => { + *selected = !*selected; + } + KeyCode::Enter => { + if *selected { + return InputResult::Quit; + } else { + ctx.dispatch(AppCommand::CloseModal); + } + } + _ => {} + }, + Modal::ConfirmDeleteStep { + bank, + pattern, + step, + selected: _, + } => { + let (bank, pattern, step) = (*bank, *pattern, *step); + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + ctx.dispatch(AppCommand::DeleteStep { + bank, + pattern, + step, + }); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Left | KeyCode::Right => { + if let Modal::ConfirmDeleteStep { selected, .. } = &mut ctx.app.ui.modal { + *selected = !*selected; + } + } + KeyCode::Enter => { + let do_delete = + if let Modal::ConfirmDeleteStep { selected, .. } = &ctx.app.ui.modal { + *selected + } else { + false + }; + if do_delete { + ctx.dispatch(AppCommand::DeleteStep { + bank, + pattern, + step, + }); + } + ctx.dispatch(AppCommand::CloseModal); + } + _ => {} + } + } + Modal::ConfirmResetPattern { + bank, + pattern, + selected: _, + } => { + let (bank, pattern) = (*bank, *pattern); + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + ctx.dispatch(AppCommand::ResetPattern { bank, pattern }); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Left | KeyCode::Right => { + if let Modal::ConfirmResetPattern { selected, .. } = &mut ctx.app.ui.modal { + *selected = !*selected; + } + } + KeyCode::Enter => { + let do_reset = + if let Modal::ConfirmResetPattern { selected, .. } = &ctx.app.ui.modal { + *selected + } else { + false + }; + if do_reset { + ctx.dispatch(AppCommand::ResetPattern { bank, pattern }); + } + ctx.dispatch(AppCommand::CloseModal); + } + _ => {} + } + } + Modal::ConfirmResetBank { bank, selected: _ } => { + let bank = *bank; + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + ctx.dispatch(AppCommand::ResetBank { bank }); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Left | KeyCode::Right => { + if let Modal::ConfirmResetBank { selected, .. } = &mut ctx.app.ui.modal { + *selected = !*selected; + } + } + KeyCode::Enter => { + let do_reset = + if let Modal::ConfirmResetBank { selected, .. } = &ctx.app.ui.modal { + *selected + } else { + false + }; + if do_reset { + ctx.dispatch(AppCommand::ResetBank { bank }); + } + ctx.dispatch(AppCommand::CloseModal); + } + _ => {} + } + } + Modal::SaveAs(path) => match key.code { + KeyCode::Enter => { + let save_path = PathBuf::from(path.as_str()); + ctx.dispatch(AppCommand::CloseModal); + ctx.dispatch(AppCommand::Save(save_path)); + } + KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), + KeyCode::Backspace => { + path.pop(); + } + KeyCode::Char(c) => path.push(c), + _ => {} + }, + Modal::LoadFrom(path) => match key.code { + KeyCode::Enter => { + let load_path = PathBuf::from(path.as_str()); + ctx.dispatch(AppCommand::CloseModal); + ctx.dispatch(AppCommand::Load(load_path)); + load_project_samples(ctx); + } + KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), + KeyCode::Backspace => { + path.pop(); + } + KeyCode::Char(c) => path.push(c), + _ => {} + }, + Modal::RenameBank { bank, name } => match key.code { + KeyCode::Enter => { + let bank_idx = *bank; + let new_name = if name.trim().is_empty() { + None + } else { + Some(name.clone()) + }; + ctx.dispatch(AppCommand::RenameBank { + bank: bank_idx, + name: new_name, + }); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), + KeyCode::Backspace => { + name.pop(); + } + KeyCode::Char(c) => name.push(c), + _ => {} + }, + Modal::RenamePattern { + bank, + pattern, + name, + } => match key.code { + KeyCode::Enter => { + let (bank_idx, pattern_idx) = (*bank, *pattern); + let new_name = if name.trim().is_empty() { + None + } else { + Some(name.clone()) + }; + ctx.dispatch(AppCommand::RenamePattern { + bank: bank_idx, + pattern: pattern_idx, + name: new_name, + }); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), + KeyCode::Backspace => { + name.pop(); + } + KeyCode::Char(c) => name.push(c), + _ => {} + }, + Modal::SetPattern { field, input } => match key.code { + KeyCode::Enter => { + let field = *field; + let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); + match field { + PatternField::Length => { + if let Ok(len) = input.parse::() { + ctx.dispatch(AppCommand::SetLength { + bank, + pattern, + length: len, + }); + let new_len = ctx + .app + .project_state + .project + .pattern_at(bank, pattern) + .length; + ctx.dispatch(AppCommand::SetStatus(format!("Length set to {new_len}"))); + } else { + ctx.dispatch(AppCommand::SetStatus("Invalid length".to_string())); + } + } + PatternField::Speed => { + if let Some(speed) = PatternSpeed::from_label(input) { + ctx.dispatch(AppCommand::SetSpeed { + bank, + pattern, + speed, + }); + ctx.dispatch(AppCommand::SetStatus(format!( + "Speed set to {}", + speed.label() + ))); + } else { + ctx.dispatch(AppCommand::SetStatus( + "Invalid speed (try 1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x)".to_string(), + )); + } + } + } + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Char(c) => input.push(c), + _ => {} + }, + Modal::SetTempo(input) => match key.code { + KeyCode::Enter => { + if let Ok(tempo) = input.parse::() { + let tempo = tempo.clamp(20.0, 300.0); + ctx.link.set_tempo(tempo); + ctx.dispatch(AppCommand::SetStatus(format!( + "Tempo set to {tempo:.1} BPM" + ))); + } else { + ctx.dispatch(AppCommand::SetStatus("Invalid tempo".to_string())); + } + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => input.push(c), + _ => {} + }, + Modal::AddSamplePath(path) => match key.code { + KeyCode::Enter => { + let sample_path = PathBuf::from(path.as_str()); + if sample_path.is_dir() { + let index = doux::loader::scan_samples_dir(&sample_path); + let count = index.len(); + let _ = ctx.audio_tx.send(AudioCommand::LoadSamples(index)); + ctx.app.audio.config.sample_count += count; + ctx.app.audio.add_sample_path(sample_path); + ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples"))); + } else { + ctx.dispatch(AppCommand::SetStatus("Path is not a directory".to_string())); + } + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), + KeyCode::Backspace => { + path.pop(); + } + KeyCode::Char(c) => path.push(c), + _ => {} + }, + Modal::Editor => { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Esc => { + ctx.dispatch(AppCommand::SaveEditorToStep); + ctx.dispatch(AppCommand::CompileCurrentStep); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Char('e') if ctrl => { + ctx.dispatch(AppCommand::SaveEditorToStep); + ctx.dispatch(AppCommand::CompileCurrentStep); + } + _ => { + ctx.app.editor_ctx.text.input(Event::Key(key)); + } + } + } + Modal::None => unreachable!(), + } + InputResult::Continue +} + +fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + if ctrl { + match key.code { + KeyCode::Left => { + ctx.dispatch(AppCommand::PageLeft); + return InputResult::Continue; + } + KeyCode::Right => { + ctx.dispatch(AppCommand::PageRight); + return InputResult::Continue; + } + KeyCode::Up => { + ctx.dispatch(AppCommand::PageUp); + return InputResult::Continue; + } + KeyCode::Down => { + ctx.dispatch(AppCommand::PageDown); + return InputResult::Continue; + } + _ => {} + } + } + + match ctx.app.page { + Page::Main => handle_main_page(ctx, key, ctrl), + Page::Patterns => handle_patterns_page(ctx, key), + Page::Audio => handle_audio_page(ctx, key), + Page::Doc => handle_doc_page(ctx, key), + } +} + +fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult { + match key.code { + KeyCode::Char('q') => { + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + selected: false, + })); + } + KeyCode::Char(' ') => { + ctx.dispatch(AppCommand::TogglePlaying); + ctx.playing + .store(ctx.app.playback.playing, Ordering::Relaxed); + } + KeyCode::Left => ctx.dispatch(AppCommand::PrevStep), + KeyCode::Right => ctx.dispatch(AppCommand::NextStep), + KeyCode::Up => ctx.dispatch(AppCommand::StepUp), + KeyCode::Down => ctx.dispatch(AppCommand::StepDown), + KeyCode::Enter => ctx.dispatch(AppCommand::OpenModal(Modal::Editor)), + KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleStep), + KeyCode::Char('s') => { + ctx.dispatch(AppCommand::OpenModal(Modal::SaveAs(String::new()))); + } + KeyCode::Char('c') if ctrl => ctx.dispatch(AppCommand::CopyStep), + KeyCode::Char('v') if ctrl => ctx.dispatch(AppCommand::PasteStep), + KeyCode::Char('b') if ctrl => ctx.dispatch(AppCommand::LinkPasteStep), + KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenStep), + KeyCode::Char('l') => { + let default_dir = ctx + .app + .project_state + .file_path + .as_ref() + .and_then(|p| p.parent()) + .map(|p| { + let mut s = p.display().to_string(); + if !s.ends_with('/') { + s.push('/'); + } + s + }) + .unwrap_or_default(); + ctx.dispatch(AppCommand::OpenModal(Modal::LoadFrom(default_dir))); + } + KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp), + KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown), + KeyCode::Char('T') => { + let current = format!("{:.1}", ctx.link.tempo()); + ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current))); + } + KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease), + KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease), + KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease), + KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease), + KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)), + KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)), + KeyCode::Delete | KeyCode::Backspace => { + let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); + let step = ctx.app.editor_ctx.step; + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmDeleteStep { + bank, + pattern, + step, + selected: false, + })); + } + _ => {} + } + InputResult::Continue +} + +fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + use crate::state::PatternsColumn; + + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + match key.code { + KeyCode::Left => ctx.dispatch(AppCommand::PatternsCursorLeft), + KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight), + KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp), + KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown), + KeyCode::Esc => ctx.dispatch(AppCommand::PatternsBack), + KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter), + KeyCode::Char(' ') => ctx.dispatch(AppCommand::PatternsTogglePlay), + KeyCode::Char('q') => { + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + selected: false, + })); + } + KeyCode::Char('c') if ctrl => { + let bank = ctx.app.patterns_nav.bank_cursor; + match ctx.app.patterns_nav.column { + PatternsColumn::Banks => { + ctx.dispatch(AppCommand::CopyBank { bank }); + } + PatternsColumn::Patterns => { + let pattern = ctx.app.patterns_nav.pattern_cursor; + ctx.dispatch(AppCommand::CopyPattern { bank, pattern }); + } + } + } + KeyCode::Char('v') if ctrl => { + let bank = ctx.app.patterns_nav.bank_cursor; + match ctx.app.patterns_nav.column { + PatternsColumn::Banks => { + ctx.dispatch(AppCommand::PasteBank { bank }); + } + PatternsColumn::Patterns => { + let pattern = ctx.app.patterns_nav.pattern_cursor; + ctx.dispatch(AppCommand::PastePattern { bank, pattern }); + } + } + } + KeyCode::Delete | KeyCode::Backspace => { + let bank = ctx.app.patterns_nav.bank_cursor; + match ctx.app.patterns_nav.column { + PatternsColumn::Banks => { + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBank { + bank, + selected: false, + })); + } + PatternsColumn::Patterns => { + let pattern = ctx.app.patterns_nav.pattern_cursor; + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPattern { + bank, + pattern, + selected: false, + })); + } + } + } + KeyCode::Char('r') => { + let bank = ctx.app.patterns_nav.bank_cursor; + match ctx.app.patterns_nav.column { + PatternsColumn::Banks => { + let current_name = ctx.app.project_state.project.banks[bank] + .name + .clone() + .unwrap_or_default(); + ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank { + bank, + name: current_name, + })); + } + PatternsColumn::Patterns => { + let pattern = ctx.app.patterns_nav.pattern_cursor; + let current_name = ctx.app.project_state.project.banks[bank].patterns[pattern] + .name + .clone() + .unwrap_or_default(); + ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern { + bank, + pattern, + name: current_name, + })); + } + } + } + _ => {} + } + InputResult::Continue +} + +fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + match key.code { + KeyCode::Char('q') => { + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + selected: false, + })); + } + KeyCode::Up | KeyCode::Char('k') => ctx.app.audio.prev_focus(), + KeyCode::Down | KeyCode::Char('j') => ctx.app.audio.next_focus(), + KeyCode::Left => { + match ctx.app.audio.focus { + AudioFocus::OutputDevice => ctx.app.audio.prev_output_device(), + AudioFocus::InputDevice => ctx.app.audio.prev_input_device(), + AudioFocus::Channels => ctx.app.audio.adjust_channels(-1), + AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(-64), + AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(), + AudioFocus::RuntimeHighlight => { + ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight + } + AudioFocus::SamplePaths => ctx.app.audio.remove_last_sample_path(), + AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), + AudioFocus::StartStopSync => ctx + .link + .set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()), + AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() - 1.0), + } + if ctx.app.audio.focus != AudioFocus::SamplePaths { + ctx.app.save_settings(ctx.link); + } + } + KeyCode::Right => { + match ctx.app.audio.focus { + AudioFocus::OutputDevice => ctx.app.audio.next_output_device(), + AudioFocus::InputDevice => ctx.app.audio.next_input_device(), + AudioFocus::Channels => ctx.app.audio.adjust_channels(1), + AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(64), + AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(), + AudioFocus::RuntimeHighlight => { + ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight + } + AudioFocus::SamplePaths => {} + AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), + AudioFocus::StartStopSync => ctx + .link + .set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()), + AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() + 1.0), + } + if ctx.app.audio.focus != AudioFocus::SamplePaths { + ctx.app.save_settings(ctx.link); + } + } + KeyCode::Char('R') => ctx.app.audio.trigger_restart(), + KeyCode::Char('A') => { + ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(String::new()))); + } + KeyCode::Char('D') => { + ctx.app.audio.refresh_devices(); + let out_count = ctx.app.audio.output_devices.len(); + let in_count = ctx.app.audio.input_devices.len(); + ctx.dispatch(AppCommand::SetStatus(format!( + "Found {out_count} output, {in_count} input devices" + ))); + } + KeyCode::Char('h') => { + let _ = ctx.audio_tx.send(AudioCommand::Hush); + } + KeyCode::Char('p') => { + let _ = ctx.audio_tx.send(AudioCommand::Panic); + } + KeyCode::Char('r') => ctx.app.metrics.peak_voices = 0, + KeyCode::Char('t') => { + let _ = ctx + .audio_tx + .send(AudioCommand::Evaluate("sin 440 * 0.3".into())); + } + KeyCode::Char(' ') => { + ctx.dispatch(AppCommand::TogglePlaying); + ctx.playing + .store(ctx.app.playback.playing, Ordering::Relaxed); + } + _ => {} + } + InputResult::Continue +} + +fn handle_doc_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + match key.code { + KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::DocScrollDown(1)), + KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::DocScrollUp(1)), + KeyCode::Char('h') | KeyCode::Left => ctx.dispatch(AppCommand::DocPrevCategory), + KeyCode::Char('l') | KeyCode::Right => ctx.dispatch(AppCommand::DocNextCategory), + KeyCode::Tab => ctx.dispatch(AppCommand::DocNextTopic), + KeyCode::BackTab => ctx.dispatch(AppCommand::DocPrevTopic), + KeyCode::PageDown => ctx.dispatch(AppCommand::DocScrollDown(10)), + KeyCode::PageUp => ctx.dispatch(AppCommand::DocScrollUp(10)), + KeyCode::Char('q') => { + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + selected: false, + })); + } + _ => {} + } + InputResult::Continue +} + +fn load_project_samples(ctx: &mut InputContext) { + let paths = ctx.app.project_state.project.sample_paths.clone(); + if paths.is_empty() { + return; + } + + let mut total_count = 0; + for path in &paths { + if path.is_dir() { + let index = doux::loader::scan_samples_dir(path); + let count = index.len(); + total_count += count; + let _ = ctx.audio_tx.send(AudioCommand::LoadSamples(index)); + } + } + + ctx.app.audio.config.sample_paths = paths; + ctx.app.audio.config.sample_count = total_count; + + if total_count > 0 { + ctx.dispatch(AppCommand::SetStatus(format!( + "Loaded {total_count} samples from project" + ))); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6932005 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +mod config; +pub mod model; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..bd19d0d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,227 @@ +mod app; +mod commands; +mod config; +mod engine; +mod input; +mod model; +mod page; +mod services; +mod settings; +mod state; +mod views; +mod widgets; + +use std::io; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use clap::Parser; +use crossterm::event::{ + self, Event, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, + PushKeyboardEnhancementFlags, +}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use crossterm::ExecutableCommand; +use doux::EngineMetrics; +use ratatui::prelude::CrosstermBackend; +use ratatui::Terminal; + +use app::App; +use engine::{build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer}; +use input::{handle_key, InputContext, InputResult}; +use settings::Settings; +use state::audio::RefreshRate; + +#[derive(Parser)] +#[command(name = "seq", about = "A step sequencer with Ableton Link support")] +struct Args { + /// Directory containing audio samples to load (can be specified multiple times) + #[arg(short, long)] + samples: Vec, + + /// Output audio device (name or index) + #[arg(short, long)] + output: Option, + + /// Input audio device (name or index) + #[arg(short, long)] + input: Option, + + /// Number of output channels + #[arg(short, long)] + channels: Option, + + /// Audio buffer size in samples + #[arg(short, long)] + buffer: Option, +} + +fn main() -> io::Result<()> { + let args = Args::parse(); + let settings = Settings::load(); + + let link = Arc::new(LinkState::new(settings.link.tempo, settings.link.quantum)); + if settings.link.enabled { + link.enable(); + } + + let playing = Arc::new(AtomicBool::new(true)); + + let mut app = App::new(); + + app.playback + .queued_changes + .push(engine::PatternChange::Start { + bank: 0, + pattern: 0, + }); + + app.audio.config.output_device = args.output.or(settings.audio.output_device); + app.audio.config.input_device = args.input.or(settings.audio.input_device); + app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels); + app.audio.config.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size); + app.audio.config.sample_paths = args.samples; + app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps); + app.ui.runtime_highlight = settings.display.runtime_highlight; + + let metrics = Arc::new(EngineMetrics::default()); + let scope_buffer = Arc::new(ScopeBuffer::new()); + + let mut initial_samples = Vec::new(); + for path in &app.audio.config.sample_paths { + let index = doux::loader::scan_samples_dir(path); + app.audio.config.sample_count += index.len(); + initial_samples.extend(index); + } + + let sequencer = spawn_sequencer( + Arc::clone(&link), + Arc::clone(&playing), + Arc::clone(&app.variables), + Arc::clone(&app.rng), + settings.link.quantum, + Arc::clone(&app.live_keys), + ); + + let stream_config = AudioStreamConfig { + output_device: app.audio.config.output_device.clone(), + channels: app.audio.config.channels, + buffer_size: app.audio.config.buffer_size, + }; + + let mut _stream = match build_stream( + &stream_config, + sequencer.audio_rx.clone(), + Arc::clone(&scope_buffer), + Arc::clone(&metrics), + initial_samples, + ) { + Ok((s, sample_rate)) => { + app.audio.config.sample_rate = sample_rate; + Some(s) + } + Err(e) => { + app.ui.set_status(format!("Audio failed: {e}")); + app.audio.error = Some(e); + None + } + }; + app.mark_all_patterns_dirty(); + + enable_raw_mode()?; + io::stdout().execute(EnterAlternateScreen)?; + let _ = io::stdout().execute(PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::REPORT_EVENT_TYPES + | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES, + )); + + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend)?; + + loop { + if app.audio.restart_pending { + app.audio.restart_pending = false; + _stream = None; + + let new_config = AudioStreamConfig { + output_device: app.audio.config.output_device.clone(), + channels: app.audio.config.channels, + buffer_size: app.audio.config.buffer_size, + }; + + let mut restart_samples = Vec::new(); + for path in &app.audio.config.sample_paths { + let index = doux::loader::scan_samples_dir(path); + restart_samples.extend(index); + } + app.audio.config.sample_count = restart_samples.len(); + + match build_stream( + &new_config, + sequencer.audio_rx.clone(), + Arc::clone(&scope_buffer), + Arc::clone(&metrics), + restart_samples, + ) { + Ok((new_stream, sr)) => { + _stream = Some(new_stream); + app.audio.config.sample_rate = sr; + app.audio.error = None; + app.ui.set_status("Audio restarted".to_string()); + } + Err(e) => { + app.audio.error = Some(e.clone()); + app.ui.set_status(format!("Audio failed: {e}")); + } + } + } + + app.playback.playing = playing.load(Ordering::Relaxed); + + { + app.metrics.active_voices = metrics.active_voices.load(Ordering::Relaxed) as usize; + app.metrics.peak_voices = app.metrics.peak_voices.max(app.metrics.active_voices); + app.metrics.cpu_load = metrics.load.get_load(); + app.metrics.schedule_depth = metrics.schedule_depth.load(Ordering::Relaxed) as usize; + app.metrics.scope = scope_buffer.read(); + (app.metrics.peak_left, app.metrics.peak_right) = scope_buffer.peaks(); + } + + let seq_snapshot = sequencer.snapshot(); + app.metrics.event_count = seq_snapshot.event_count; + + app.flush_queued_changes(&sequencer.cmd_tx); + app.flush_dirty_patterns(&sequencer.cmd_tx); + + terminal.draw(|frame| views::render(frame, &mut app, &link, &seq_snapshot))?; + + if event::poll(Duration::from_millis(app.audio.config.refresh_rate.millis()))? { + if let Event::Key(key) = event::read()? { + let mut ctx = InputContext { + app: &mut app, + link: &link, + snapshot: &seq_snapshot, + playing: &playing, + audio_tx: &sequencer.audio_tx, + }; + + if let InputResult::Quit = handle_key(&mut ctx, key) { + break; + } + } + } + + } + + let _ = io::stdout().execute(PopKeyboardEnhancementFlags); + disable_raw_mode()?; + io::stdout().execute(LeaveAlternateScreen)?; + + sequencer.shutdown(); + + Ok(()) +} diff --git a/src/model/file.rs b/src/model/file.rs new file mode 100644 index 0000000..91c46cc --- /dev/null +++ b/src/model/file.rs @@ -0,0 +1,89 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::{Bank, Project}; + +const VERSION: u8 = 1; + +#[derive(Serialize, Deserialize)] +struct ProjectFile { + version: u8, + banks: Vec, + #[serde(default)] + sample_paths: Vec, + #[serde(default = "default_tempo")] + tempo: f64, +} + +fn default_tempo() -> f64 { + 120.0 +} + +impl From<&Project> for ProjectFile { + fn from(project: &Project) -> Self { + Self { + version: VERSION, + banks: project.banks.clone(), + sample_paths: project.sample_paths.clone(), + tempo: project.tempo, + } + } +} + +impl From for Project { + fn from(file: ProjectFile) -> Self { + Self { + banks: file.banks, + sample_paths: file.sample_paths, + tempo: file.tempo, + } + } +} + +#[derive(Debug)] +pub enum FileError { + Io(io::Error), + Json(serde_json::Error), + Version(u8), +} + +impl std::fmt::Display for FileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FileError::Io(e) => write!(f, "IO error: {e}"), + FileError::Json(e) => write!(f, "JSON error: {e}"), + FileError::Version(v) => write!(f, "Unsupported version: {v}"), + } + } +} + +impl From for FileError { + fn from(e: io::Error) -> Self { + FileError::Io(e) + } +} + +impl From for FileError { + fn from(e: serde_json::Error) -> Self { + FileError::Json(e) + } +} + +pub fn save(project: &Project, path: &Path) -> Result<(), FileError> { + let file = ProjectFile::from(project); + let json = serde_json::to_string_pretty(&file)?; + fs::write(path, json)?; + Ok(()) +} + +pub fn load(path: &Path) -> Result { + let json = fs::read_to_string(path)?; + let file: ProjectFile = serde_json::from_str(&json)?; + if file.version > VERSION { + return Err(FileError::Version(file.version)); + } + Ok(Project::from(file)) +} diff --git a/src/model/forth.rs b/src/model/forth.rs new file mode 100644 index 0000000..9696737 --- /dev/null +++ b/src/model/forth.rs @@ -0,0 +1,2498 @@ +use rand::rngs::StdRng; +use rand::{Rng as RngTrait, SeedableRng}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SourceSpan { + pub start: usize, + pub end: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct ExecutionTrace { + pub selected_spans: Vec, +} + +pub struct StepContext { + pub step: usize, + pub beat: f64, + pub bank: usize, + pub pattern: usize, + pub tempo: f64, + pub phase: f64, + pub slot: usize, + pub runs: usize, + pub iter: usize, + pub speed: f64, + pub fill: bool, +} + +impl StepContext { + pub fn step_duration(&self) -> f64 { + 60.0 / self.tempo / 4.0 / self.speed + } +} + +pub type Variables = Arc>>; +pub type Rng = Arc>; + +#[derive(Clone, Debug)] +pub enum Value { + Int(i64, Option), + Float(f64, Option), + Str(String, Option), + Marker, + Quotation(Vec), +} + +impl PartialEq for Value { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Value::Int(a, _), Value::Int(b, _)) => a == b, + (Value::Float(a, _), Value::Float(b, _)) => a == b, + (Value::Str(a, _), Value::Str(b, _)) => a == b, + (Value::Marker, Value::Marker) => true, + (Value::Quotation(a), Value::Quotation(b)) => a == b, + _ => false, + } + } +} + +#[derive(Clone, Debug, Default)] +struct CmdRegister { + sound: Option, + params: Vec<(String, String)>, +} + +impl CmdRegister { + fn set_sound(&mut self, name: String) { + self.sound = Some(name); + } + + fn set_param(&mut self, key: String, value: String) { + self.params.push((key, value)); + } + + fn take(&mut self) -> Option<(String, Vec<(String, String)>)> { + let sound = self.sound.take()?; + let params = std::mem::take(&mut self.params); + Some((sound, params)) + } +} + +impl Value { + pub fn as_float(&self) -> Result { + match self { + Value::Float(f, _) => Ok(*f), + Value::Int(i, _) => Ok(*i as f64), + _ => Err("expected number".into()), + } + } + + fn as_int(&self) -> Result { + match self { + Value::Int(i, _) => Ok(*i), + Value::Float(f, _) => Ok(*f as i64), + _ => Err("expected number".into()), + } + } + + fn as_str(&self) -> Result<&str, String> { + match self { + Value::Str(s, _) => Ok(s), + _ => Err("expected string".into()), + } + } + + fn is_truthy(&self) -> bool { + match self { + Value::Int(i, _) => *i != 0, + Value::Float(f, _) => *f != 0.0, + Value::Str(s, _) => !s.is_empty(), + Value::Marker => false, + Value::Quotation(_) => true, + } + } + + fn is_marker(&self) -> bool { + matches!(self, Value::Marker) + } + + fn to_param_string(&self) -> String { + match self { + Value::Int(i, _) => i.to_string(), + Value::Float(f, _) => f.to_string(), + Value::Str(s, _) => s.clone(), + Value::Marker => String::new(), + Value::Quotation(_) => String::new(), + } + } + + fn span(&self) -> Option { + match self { + Value::Int(_, s) | Value::Float(_, s) | Value::Str(_, s) => *s, + Value::Marker | Value::Quotation(_) => None, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Op { + PushInt(i64, Option), + PushFloat(f64, Option), + PushStr(String, Option), + Dup, + Drop, + Swap, + Over, + Rot, + Nip, + Tuck, + Add, + Sub, + Mul, + Div, + Mod, + Neg, + Abs, + Floor, + Ceil, + Round, + Min, + Max, + Eq, + Ne, + Lt, + Gt, + Le, + Ge, + And, + Or, + Not, + BranchIfZero(usize), + Branch(usize), + NewCmd, + SetParam(String), + Emit, + Get, + Set, + GetContext(String), + Rand, + Rrand, + Seed, + Cycle, + Choose, + ChanceExec, + ProbExec, + Coin, + Mtof, + Ftom, + ListStart, + ListEnd, + ListEndCycle, + PCycle, + ListEndPCycle, + At, + Window, + Pop, + Subdivide, + SetTempo, + Each, + Every, + Quotation(Vec), + When, + Unless, + Adsr, + Ad, +} + +pub enum WordCompile { + Simple, + Context(&'static str), + Param, + Alias(&'static str), + Probability(f64), +} + +pub struct Word { + pub name: &'static str, + pub stack: &'static str, + pub desc: &'static str, + pub example: &'static str, + pub compile: WordCompile, +} + +use WordCompile::*; + +pub const WORDS: &[Word] = &[ + // Stack manipulation + Word { + name: "dup", + stack: "(a -- a a)", + desc: "Duplicate top of stack", + example: "3 dup => 3 3", + compile: Simple, + }, + Word { + name: "drop", + stack: "(a --)", + desc: "Remove top of stack", + example: "1 2 drop => 1", + compile: Simple, + }, + Word { + name: "swap", + stack: "(a b -- b a)", + desc: "Exchange top two items", + example: "1 2 swap => 2 1", + compile: Simple, + }, + Word { + name: "over", + stack: "(a b -- a b a)", + desc: "Copy second to top", + example: "1 2 over => 1 2 1", + compile: Simple, + }, + Word { + name: "rot", + stack: "(a b c -- b c a)", + desc: "Rotate top three", + example: "1 2 3 rot => 2 3 1", + compile: Simple, + }, + Word { + name: "nip", + stack: "(a b -- b)", + desc: "Remove second item", + example: "1 2 nip => 2", + compile: Simple, + }, + Word { + name: "tuck", + stack: "(a b -- b a b)", + desc: "Copy top under second", + example: "1 2 tuck => 2 1 2", + compile: Simple, + }, + // Arithmetic + Word { + name: "+", + stack: "(a b -- a+b)", + desc: "Add", + example: "2 3 + => 5", + compile: Simple, + }, + Word { + name: "-", + stack: "(a b -- a-b)", + desc: "Subtract", + example: "5 3 - => 2", + compile: Simple, + }, + Word { + name: "*", + stack: "(a b -- a*b)", + desc: "Multiply", + example: "3 4 * => 12", + compile: Simple, + }, + Word { + name: "/", + stack: "(a b -- a/b)", + desc: "Divide", + example: "10 2 / => 5", + compile: Simple, + }, + Word { + name: "mod", + stack: "(a b -- a%b)", + desc: "Modulo", + example: "7 3 mod => 1", + compile: Simple, + }, + Word { + name: "neg", + stack: "(a -- -a)", + desc: "Negate", + example: "5 neg => -5", + compile: Simple, + }, + Word { + name: "abs", + stack: "(a -- |a|)", + desc: "Absolute value", + example: "-5 abs => 5", + compile: Simple, + }, + Word { + name: "floor", + stack: "(f -- n)", + desc: "Round down to integer", + example: "3.7 floor => 3", + compile: Simple, + }, + Word { + name: "ceil", + stack: "(f -- n)", + desc: "Round up to integer", + example: "3.2 ceil => 4", + compile: Simple, + }, + Word { + name: "round", + stack: "(f -- n)", + desc: "Round to nearest integer", + example: "3.5 round => 4", + compile: Simple, + }, + Word { + name: "min", + stack: "(a b -- min)", + desc: "Minimum of two values", + example: "3 5 min => 3", + compile: Simple, + }, + Word { + name: "max", + stack: "(a b -- max)", + desc: "Maximum of two values", + example: "3 5 max => 5", + compile: Simple, + }, + // Comparison + Word { + name: "=", + stack: "(a b -- bool)", + desc: "Equal", + example: "3 3 = => 1", + compile: Simple, + }, + Word { + name: "<>", + stack: "(a b -- bool)", + desc: "Not equal", + example: "3 4 <> => 1", + compile: Simple, + }, + Word { + name: "lt", + stack: "(a b -- bool)", + desc: "Less than", + example: "2 3 lt => 1", + compile: Simple, + }, + Word { + name: "gt", + stack: "(a b -- bool)", + desc: "Greater than", + example: "3 2 gt => 1", + compile: Simple, + }, + Word { + name: "<=", + stack: "(a b -- bool)", + desc: "Less or equal", + example: "3 3 <= => 1", + compile: Simple, + }, + Word { + name: ">=", + stack: "(a b -- bool)", + desc: "Greater or equal", + example: "3 3 >= => 1", + compile: Simple, + }, + // Logic + Word { + name: "and", + stack: "(a b -- bool)", + desc: "Logical and", + example: "1 1 and => 1", + compile: Simple, + }, + Word { + name: "or", + stack: "(a b -- bool)", + desc: "Logical or", + example: "0 1 or => 1", + compile: Simple, + }, + Word { + name: "not", + stack: "(a -- bool)", + desc: "Logical not", + example: "0 not => 1", + compile: Simple, + }, + // Sound + Word { + name: "sound", + stack: "(name --)", + desc: "Begin sound command", + example: "\"kick\" sound", + compile: Simple, + }, + Word { + name: "s", + stack: "(name --)", + desc: "Alias for sound", + example: "\"kick\" s", + compile: Alias("sound"), + }, + Word { + name: "emit", + stack: "(--)", + desc: "Output current sound", + example: "\"kick\" s emit", + compile: Simple, + }, + // Variables + Word { + name: "get", + stack: "(name -- val)", + desc: "Get variable value", + example: "\"x\" get", + compile: Simple, + }, + Word { + name: "set", + stack: "(val name --)", + desc: "Set variable value", + example: "42 \"x\" set", + compile: Simple, + }, + // Randomness + Word { + name: "rand", + stack: "(min max -- f)", + desc: "Random float in range", + example: "0 1 rand => 0.42", + compile: Simple, + }, + Word { + name: "rrand", + stack: "(min max -- n)", + desc: "Random int in range", + example: "1 6 rrand => 4", + compile: Simple, + }, + Word { + name: "seed", + stack: "(n --)", + desc: "Set random seed", + example: "12345 seed", + compile: Simple, + }, + Word { + name: "coin", + stack: "(-- bool)", + desc: "50/50 random boolean", + example: "coin => 0 or 1", + compile: Simple, + }, + Word { + name: "chance", + stack: "(quot prob --)", + desc: "Execute quotation with probability (0.0-1.0)", + example: "{ 2 distort } 0.75 chance", + compile: Simple, + }, + Word { + name: "prob", + stack: "(quot pct --)", + desc: "Execute quotation with probability (0-100)", + example: "{ 2 distort } 75 prob", + compile: Simple, + }, + Word { + name: "choose", + stack: "(..n n -- val)", + desc: "Random pick from n items", + example: "1 2 3 3 choose", + compile: Simple, + }, + Word { + name: "cycle", + stack: "(..n n -- val)", + desc: "Cycle through n items by step", + example: "1 2 3 3 cycle", + compile: Simple, + }, + Word { + name: "pcycle", + stack: "(..n n -- val)", + desc: "Cycle through n items by pattern", + example: "1 2 3 3 pcycle", + compile: Simple, + }, + Word { + name: "every", + stack: "(n -- bool)", + desc: "True every nth iteration", + example: "4 every", + compile: Simple, + }, + // Probability shortcuts + Word { + name: "always", + stack: "(quot --)", + desc: "Always execute quotation", + example: "{ 2 distort } always", + compile: Probability(1.0), + }, + Word { + name: "never", + stack: "(quot --)", + desc: "Never execute quotation", + example: "{ 2 distort } never", + compile: Probability(0.0), + }, + Word { + name: "often", + stack: "(quot --)", + desc: "Execute quotation 75% of the time", + example: "{ 2 distort } often", + compile: Probability(0.75), + }, + Word { + name: "sometimes", + stack: "(quot --)", + desc: "Execute quotation 50% of the time", + example: "{ 2 distort } sometimes", + compile: Probability(0.5), + }, + Word { + name: "rarely", + stack: "(quot --)", + desc: "Execute quotation 25% of the time", + example: "{ 2 distort } rarely", + compile: Probability(0.25), + }, + Word { + name: "almostNever", + stack: "(quot --)", + desc: "Execute quotation 10% of the time", + example: "{ 2 distort } almostNever", + compile: Probability(0.1), + }, + Word { + name: "almostAlways", + stack: "(quot --)", + desc: "Execute quotation 90% of the time", + example: "{ 2 distort } almostAlways", + compile: Probability(0.9), + }, + // Context + Word { + name: "step", + stack: "(-- n)", + desc: "Current step index", + example: "step => 0", + compile: Context("step"), + }, + Word { + name: "beat", + stack: "(-- f)", + desc: "Current beat position", + example: "beat => 4.5", + compile: Context("beat"), + }, + Word { + name: "bank", + stack: "(str --)", + desc: "Set sample bank suffix", + example: "\"a\" bank", + compile: Param, + }, + Word { + name: "pattern", + stack: "(-- n)", + desc: "Current pattern index", + example: "pattern => 0", + compile: Context("pattern"), + }, + Word { + name: "tempo", + stack: "(-- f)", + desc: "Current BPM", + example: "tempo => 120.0", + compile: Context("tempo"), + }, + Word { + name: "phase", + stack: "(-- f)", + desc: "Phase in bar (0-1)", + example: "phase => 0.25", + compile: Context("phase"), + }, + Word { + name: "slot", + stack: "(-- n)", + desc: "Current slot number", + example: "slot => 0", + compile: Context("slot"), + }, + Word { + name: "runs", + stack: "(-- n)", + desc: "Times this step ran", + example: "runs => 3", + compile: Context("runs"), + }, + Word { + name: "iter", + stack: "(-- n)", + desc: "Pattern iteration count", + example: "iter => 2", + compile: Context("iter"), + }, + Word { + name: "stepdur", + stack: "(-- f)", + desc: "Step duration in seconds", + example: "stepdur => 0.125", + compile: Context("stepdur"), + }, + // Live keys + Word { + name: "fill", + stack: "(-- bool)", + desc: "True when fill is on (f key)", + example: "{ 4 div each } fill ?", + compile: Context("fill"), + }, + // Music + Word { + name: "mtof", + stack: "(midi -- hz)", + desc: "MIDI note to frequency", + example: "69 mtof => 440.0", + compile: Simple, + }, + Word { + name: "ftom", + stack: "(hz -- midi)", + desc: "Frequency to MIDI note", + example: "440 ftom => 69.0", + compile: Simple, + }, + // Time + Word { + name: "at", + stack: "(pos --)", + desc: "Emit at position in window", + example: "0.5 at", + compile: Simple, + }, + Word { + name: "@", + stack: "(pos --)", + desc: "Alias for at", + example: "\"kick\" s 0.5 @", + compile: Alias("at"), + }, + Word { + name: "window", + stack: "(start end --)", + desc: "Create time window", + example: "0.0 0.5 window", + compile: Simple, + }, + Word { + name: "pop", + stack: "(--)", + desc: "Pop time context", + example: "pop", + compile: Simple, + }, + Word { + name: "div", + stack: "(n --)", + desc: "Subdivide time into n", + example: "4 div", + compile: Simple, + }, + Word { + name: "each", + stack: "(--)", + desc: "Emit at each subdivision", + example: "4 div each", + compile: Simple, + }, + Word { + name: "tempo!", + stack: "(bpm --)", + desc: "Set global tempo", + example: "140 tempo!", + compile: Simple, + }, + // Lists + Word { + name: "[", + stack: "(-- marker)", + desc: "Start list", + example: "[ 1 2 3 ]", + compile: Simple, + }, + Word { + name: "]", + stack: "(marker..n -- n)", + desc: "End list, push count", + example: "[ 1 2 3 ] => 3", + compile: Simple, + }, + Word { + name: "<", + stack: "(-- marker)", + desc: "Start cycle list", + example: "< 1 2 3 >", + compile: Alias("["), + }, + Word { + name: ">", + stack: "(marker..n -- val)", + desc: "End cycle list, pick by step", + example: "< 1 2 3 > => cycles through 1, 2, 3", + compile: Simple, + }, + Word { + name: "<<", + stack: "(-- marker)", + desc: "Start pattern cycle list", + example: "<< 1 2 3 >>", + compile: Alias("["), + }, + Word { + name: ">>", + stack: "(marker..n -- val)", + desc: "End pattern cycle list, pick by pattern", + example: "<< 1 2 3 >> => cycles through 1, 2, 3 per pattern", + compile: Simple, + }, + // Quotations + Word { + name: "?", + stack: "(quot bool --)", + desc: "Execute quotation if true", + example: "{ 2 distort } 0.5 chance ?", + compile: Simple, + }, + Word { + name: "!?", + stack: "(quot bool --)", + desc: "Execute quotation if false", + example: "{ 1 distort } 0.5 chance !?", + compile: Simple, + }, + // Parameters (synthesis) + Word { + name: "time", + stack: "(f --)", + desc: "Set time offset", + example: "0.1 time", + compile: Param, + }, + Word { + name: "repeat", + stack: "(n --)", + desc: "Set repeat count", + example: "4 repeat", + compile: Param, + }, + Word { + name: "dur", + stack: "(f --)", + desc: "Set duration", + example: "0.5 dur", + compile: Param, + }, + Word { + name: "gate", + stack: "(f --)", + desc: "Set gate time", + example: "0.8 gate", + compile: Param, + }, + Word { + name: "freq", + stack: "(f --)", + desc: "Set frequency (Hz)", + example: "440 freq", + compile: Param, + }, + Word { + name: "detune", + stack: "(f --)", + desc: "Set detune amount", + example: "0.01 detune", + compile: Param, + }, + Word { + name: "speed", + stack: "(f --)", + desc: "Set playback speed", + example: "1.5 speed", + compile: Param, + }, + Word { + name: "glide", + stack: "(f --)", + desc: "Set glide/portamento", + example: "0.1 glide", + compile: Param, + }, + Word { + name: "pw", + stack: "(f --)", + desc: "Set pulse width", + example: "0.5 pw", + compile: Param, + }, + Word { + name: "spread", + stack: "(f --)", + desc: "Set stereo spread", + example: "0.5 spread", + compile: Param, + }, + Word { + name: "mult", + stack: "(f --)", + desc: "Set multiplier", + example: "2 mult", + compile: Param, + }, + Word { + name: "warp", + stack: "(f --)", + desc: "Set warp amount", + example: "0.5 warp", + compile: Param, + }, + Word { + name: "mirror", + stack: "(f --)", + desc: "Set mirror", + example: "1 mirror", + compile: Param, + }, + Word { + name: "harmonics", + stack: "(f --)", + desc: "Set harmonics", + example: "4 harmonics", + compile: Param, + }, + Word { + name: "timbre", + stack: "(f --)", + desc: "Set timbre", + example: "0.5 timbre", + compile: Param, + }, + Word { + name: "morph", + stack: "(f --)", + desc: "Set morph", + example: "0.5 morph", + compile: Param, + }, + Word { + name: "begin", + stack: "(f --)", + desc: "Set sample start (0-1)", + example: "0.25 begin", + compile: Param, + }, + Word { + name: "end", + stack: "(f --)", + desc: "Set sample end (0-1)", + example: "0.75 end", + compile: Param, + }, + Word { + name: "gain", + stack: "(f --)", + desc: "Set volume (0-1)", + example: "0.8 gain", + compile: Param, + }, + Word { + name: "postgain", + stack: "(f --)", + desc: "Set post gain", + example: "1.2 postgain", + compile: Param, + }, + Word { + name: "velocity", + stack: "(f --)", + desc: "Set velocity", + example: "100 velocity", + compile: Param, + }, + Word { + name: "pan", + stack: "(f --)", + desc: "Set pan (-1 to 1)", + example: "0.5 pan", + compile: Param, + }, + Word { + name: "attack", + stack: "(f --)", + desc: "Set attack time", + example: "0.01 attack", + compile: Param, + }, + Word { + name: "decay", + stack: "(f --)", + desc: "Set decay time", + example: "0.1 decay", + compile: Param, + }, + Word { + name: "sustain", + stack: "(f --)", + desc: "Set sustain level", + example: "0.5 sustain", + compile: Param, + }, + Word { + name: "release", + stack: "(f --)", + desc: "Set release time", + example: "0.3 release", + compile: Param, + }, + Word { + name: "adsr", + stack: "(a d s r --)", + desc: "Set attack, decay, sustain, release", + example: "0.01 0.1 0.5 0.3 adsr", + compile: Simple, + }, + Word { + name: "ad", + stack: "(a d --)", + desc: "Set attack, decay (sustain=0)", + example: "0.01 0.1 ad", + compile: Simple, + }, + Word { + name: "lpf", + stack: "(f --)", + desc: "Set lowpass frequency", + example: "2000 lpf", + compile: Param, + }, + Word { + name: "lpq", + stack: "(f --)", + desc: "Set lowpass resonance", + example: "0.5 lpq", + compile: Param, + }, + Word { + name: "lpe", + stack: "(f --)", + desc: "Set lowpass envelope", + example: "0.5 lpe", + compile: Param, + }, + Word { + name: "lpa", + stack: "(f --)", + desc: "Set lowpass attack", + example: "0.01 lpa", + compile: Param, + }, + Word { + name: "lpd", + stack: "(f --)", + desc: "Set lowpass decay", + example: "0.1 lpd", + compile: Param, + }, + Word { + name: "lps", + stack: "(f --)", + desc: "Set lowpass sustain", + example: "0.5 lps", + compile: Param, + }, + Word { + name: "lpr", + stack: "(f --)", + desc: "Set lowpass release", + example: "0.3 lpr", + compile: Param, + }, + Word { + name: "hpf", + stack: "(f --)", + desc: "Set highpass frequency", + example: "100 hpf", + compile: Param, + }, + Word { + name: "hpq", + stack: "(f --)", + desc: "Set highpass resonance", + example: "0.5 hpq", + compile: Param, + }, + Word { + name: "hpe", + stack: "(f --)", + desc: "Set highpass envelope", + example: "0.5 hpe", + compile: Param, + }, + Word { + name: "hpa", + stack: "(f --)", + desc: "Set highpass attack", + example: "0.01 hpa", + compile: Param, + }, + Word { + name: "hpd", + stack: "(f --)", + desc: "Set highpass decay", + example: "0.1 hpd", + compile: Param, + }, + Word { + name: "hps", + stack: "(f --)", + desc: "Set highpass sustain", + example: "0.5 hps", + compile: Param, + }, + Word { + name: "hpr", + stack: "(f --)", + desc: "Set highpass release", + example: "0.3 hpr", + compile: Param, + }, + Word { + name: "bpf", + stack: "(f --)", + desc: "Set bandpass frequency", + example: "1000 bpf", + compile: Param, + }, + Word { + name: "bpq", + stack: "(f --)", + desc: "Set bandpass resonance", + example: "0.5 bpq", + compile: Param, + }, + Word { + name: "bpe", + stack: "(f --)", + desc: "Set bandpass envelope", + example: "0.5 bpe", + compile: Param, + }, + Word { + name: "bpa", + stack: "(f --)", + desc: "Set bandpass attack", + example: "0.01 bpa", + compile: Param, + }, + Word { + name: "bpd", + stack: "(f --)", + desc: "Set bandpass decay", + example: "0.1 bpd", + compile: Param, + }, + Word { + name: "bps", + stack: "(f --)", + desc: "Set bandpass sustain", + example: "0.5 bps", + compile: Param, + }, + Word { + name: "bpr", + stack: "(f --)", + desc: "Set bandpass release", + example: "0.3 bpr", + compile: Param, + }, + Word { + name: "ftype", + stack: "(n --)", + desc: "Set filter type", + example: "1 ftype", + compile: Param, + }, + Word { + name: "penv", + stack: "(f --)", + desc: "Set pitch envelope", + example: "0.5 penv", + compile: Param, + }, + Word { + name: "patt", + stack: "(f --)", + desc: "Set pitch attack", + example: "0.01 patt", + compile: Param, + }, + Word { + name: "pdec", + stack: "(f --)", + desc: "Set pitch decay", + example: "0.1 pdec", + compile: Param, + }, + Word { + name: "psus", + stack: "(f --)", + desc: "Set pitch sustain", + example: "0 psus", + compile: Param, + }, + Word { + name: "prel", + stack: "(f --)", + desc: "Set pitch release", + example: "0.1 prel", + compile: Param, + }, + Word { + name: "vib", + stack: "(f --)", + desc: "Set vibrato rate", + example: "5 vib", + compile: Param, + }, + Word { + name: "vibmod", + stack: "(f --)", + desc: "Set vibrato depth", + example: "0.5 vibmod", + compile: Param, + }, + Word { + name: "vibshape", + stack: "(f --)", + desc: "Set vibrato shape", + example: "0 vibshape", + compile: Param, + }, + Word { + name: "fm", + stack: "(f --)", + desc: "Set FM frequency", + example: "200 fm", + compile: Param, + }, + Word { + name: "fmh", + stack: "(f --)", + desc: "Set FM harmonic ratio", + example: "2 fmh", + compile: Param, + }, + Word { + name: "fmshape", + stack: "(f --)", + desc: "Set FM shape", + example: "0 fmshape", + compile: Param, + }, + Word { + name: "fme", + stack: "(f --)", + desc: "Set FM envelope", + example: "0.5 fme", + compile: Param, + }, + Word { + name: "fma", + stack: "(f --)", + desc: "Set FM attack", + example: "0.01 fma", + compile: Param, + }, + Word { + name: "fmd", + stack: "(f --)", + desc: "Set FM decay", + example: "0.1 fmd", + compile: Param, + }, + Word { + name: "fms", + stack: "(f --)", + desc: "Set FM sustain", + example: "0.5 fms", + compile: Param, + }, + Word { + name: "fmr", + stack: "(f --)", + desc: "Set FM release", + example: "0.1 fmr", + compile: Param, + }, + Word { + name: "am", + stack: "(f --)", + desc: "Set AM frequency", + example: "10 am", + compile: Param, + }, + Word { + name: "amdepth", + stack: "(f --)", + desc: "Set AM depth", + example: "0.5 amdepth", + compile: Param, + }, + Word { + name: "amshape", + stack: "(f --)", + desc: "Set AM shape", + example: "0 amshape", + compile: Param, + }, + Word { + name: "rm", + stack: "(f --)", + desc: "Set RM frequency", + example: "100 rm", + compile: Param, + }, + Word { + name: "rmdepth", + stack: "(f --)", + desc: "Set RM depth", + example: "0.5 rmdepth", + compile: Param, + }, + Word { + name: "rmshape", + stack: "(f --)", + desc: "Set RM shape", + example: "0 rmshape", + compile: Param, + }, + Word { + name: "phaser", + stack: "(f --)", + desc: "Set phaser rate", + example: "1 phaser", + compile: Param, + }, + Word { + name: "phaserdepth", + stack: "(f --)", + desc: "Set phaser depth", + example: "0.5 phaserdepth", + compile: Param, + }, + Word { + name: "phasersweep", + stack: "(f --)", + desc: "Set phaser sweep", + example: "0.5 phasersweep", + compile: Param, + }, + Word { + name: "phasercenter", + stack: "(f --)", + desc: "Set phaser center", + example: "1000 phasercenter", + compile: Param, + }, + Word { + name: "flanger", + stack: "(f --)", + desc: "Set flanger rate", + example: "0.5 flanger", + compile: Param, + }, + Word { + name: "flangerdepth", + stack: "(f --)", + desc: "Set flanger depth", + example: "0.5 flangerdepth", + compile: Param, + }, + Word { + name: "flangerfeedback", + stack: "(f --)", + desc: "Set flanger feedback", + example: "0.5 flangerfeedback", + compile: Param, + }, + Word { + name: "chorus", + stack: "(f --)", + desc: "Set chorus rate", + example: "1 chorus", + compile: Param, + }, + Word { + name: "chorusdepth", + stack: "(f --)", + desc: "Set chorus depth", + example: "0.5 chorusdepth", + compile: Param, + }, + Word { + name: "chorusdelay", + stack: "(f --)", + desc: "Set chorus delay", + example: "0.02 chorusdelay", + compile: Param, + }, + Word { + name: "comb", + stack: "(f --)", + desc: "Set comb filter mix", + example: "0.5 comb", + compile: Param, + }, + Word { + name: "combfreq", + stack: "(f --)", + desc: "Set comb frequency", + example: "200 combfreq", + compile: Param, + }, + Word { + name: "combfeedback", + stack: "(f --)", + desc: "Set comb feedback", + example: "0.5 combfeedback", + compile: Param, + }, + Word { + name: "combdamp", + stack: "(f --)", + desc: "Set comb damping", + example: "0.5 combdamp", + compile: Param, + }, + Word { + name: "coarse", + stack: "(f --)", + desc: "Set coarse tune", + example: "12 coarse", + compile: Param, + }, + Word { + name: "crush", + stack: "(f --)", + desc: "Set bit crush", + example: "8 crush", + compile: Param, + }, + Word { + name: "fold", + stack: "(f --)", + desc: "Set wave fold", + example: "2 fold", + compile: Param, + }, + Word { + name: "wrap", + stack: "(f --)", + desc: "Set wave wrap", + example: "0.5 wrap", + compile: Param, + }, + Word { + name: "distort", + stack: "(f --)", + desc: "Set distortion", + example: "0.5 distort", + compile: Param, + }, + Word { + name: "distortvol", + stack: "(f --)", + desc: "Set distortion volume", + example: "0.8 distortvol", + compile: Param, + }, + Word { + name: "delay", + stack: "(f --)", + desc: "Set delay mix", + example: "0.3 delay", + compile: Param, + }, + Word { + name: "delaytime", + stack: "(f --)", + desc: "Set delay time", + example: "0.25 delaytime", + compile: Param, + }, + Word { + name: "delayfeedback", + stack: "(f --)", + desc: "Set delay feedback", + example: "0.5 delayfeedback", + compile: Param, + }, + Word { + name: "delaytype", + stack: "(n --)", + desc: "Set delay type", + example: "1 delaytype", + compile: Param, + }, + Word { + name: "verb", + stack: "(f --)", + desc: "Set reverb mix", + example: "0.3 verb", + compile: Param, + }, + Word { + name: "verbdecay", + stack: "(f --)", + desc: "Set reverb decay", + example: "2 verbdecay", + compile: Param, + }, + Word { + name: "verbdamp", + stack: "(f --)", + desc: "Set reverb damping", + example: "0.5 verbdamp", + compile: Param, + }, + Word { + name: "verbpredelay", + stack: "(f --)", + desc: "Set reverb predelay", + example: "0.02 verbpredelay", + compile: Param, + }, + Word { + name: "verbdiff", + stack: "(f --)", + desc: "Set reverb diffusion", + example: "0.7 verbdiff", + compile: Param, + }, + Word { + name: "voice", + stack: "(n --)", + desc: "Set voice number", + example: "1 voice", + compile: Param, + }, + Word { + name: "orbit", + stack: "(n --)", + desc: "Set orbit/bus", + example: "0 orbit", + compile: Param, + }, + Word { + name: "note", + stack: "(n --)", + desc: "Set MIDI note", + example: "60 note", + compile: Param, + }, + Word { + name: "size", + stack: "(f --)", + desc: "Set size", + example: "1 size", + compile: Param, + }, + Word { + name: "n", + stack: "(n --)", + desc: "Set sample number", + example: "0 n", + compile: Param, + }, + Word { + name: "cut", + stack: "(n --)", + desc: "Set cut group", + example: "1 cut", + compile: Param, + }, + Word { + name: "reset", + stack: "(n --)", + desc: "Reset parameter", + example: "1 reset", + compile: Param, + }, +]; + +fn simple_op(name: &str) -> Option { + Some(match name { + "dup" => Op::Dup, + "drop" => Op::Drop, + "swap" => Op::Swap, + "over" => Op::Over, + "rot" => Op::Rot, + "nip" => Op::Nip, + "tuck" => Op::Tuck, + "+" => Op::Add, + "-" => Op::Sub, + "*" => Op::Mul, + "/" => Op::Div, + "mod" => Op::Mod, + "neg" => Op::Neg, + "abs" => Op::Abs, + "floor" => Op::Floor, + "ceil" => Op::Ceil, + "round" => Op::Round, + "min" => Op::Min, + "max" => Op::Max, + "=" => Op::Eq, + "<>" => Op::Ne, + "lt" => Op::Lt, + "gt" => Op::Gt, + "<=" => Op::Le, + ">=" => Op::Ge, + "and" => Op::And, + "or" => Op::Or, + "not" => Op::Not, + "sound" => Op::NewCmd, + "emit" => Op::Emit, + "get" => Op::Get, + "set" => Op::Set, + "rand" => Op::Rand, + "rrand" => Op::Rrand, + "seed" => Op::Seed, + "cycle" => Op::Cycle, + "pcycle" => Op::PCycle, + "choose" => Op::Choose, + "every" => Op::Every, + "chance" => Op::ChanceExec, + "prob" => Op::ProbExec, + "coin" => Op::Coin, + "mtof" => Op::Mtof, + "ftom" => Op::Ftom, + "?" => Op::When, + "!?" => Op::Unless, + "at" => Op::At, + "window" => Op::Window, + "pop" => Op::Pop, + "div" => Op::Subdivide, + "each" => Op::Each, + "tempo!" => Op::SetTempo, + "[" => Op::ListStart, + "]" => Op::ListEnd, + ">" => Op::ListEndCycle, + ">>" => Op::ListEndPCycle, + "adsr" => Op::Adsr, + "ad" => Op::Ad, + _ => return None, + }) +} + +fn compile_word(name: &str, ops: &mut Vec) -> bool { + for word in WORDS { + if word.name == name { + match &word.compile { + Simple => { + if let Some(op) = simple_op(name) { + ops.push(op); + } + } + Context(ctx) => ops.push(Op::GetContext((*ctx).into())), + Param => ops.push(Op::SetParam(name.into())), + Alias(target) => return compile_word(target, ops), + Probability(p) => { + ops.push(Op::PushFloat(*p, None)); + ops.push(Op::ChanceExec); + } + } + return true; + } + } + false +} + +#[derive(Clone, Debug)] +struct TimeContext { + start: f64, + duration: f64, + subdivisions: Option>, +} + +#[derive(Clone, Debug)] +enum Token { + Int(i64, SourceSpan), + Float(f64, SourceSpan), + Str(String, SourceSpan), + Word(String, SourceSpan), + QuoteStart(SourceSpan), + QuoteEnd(SourceSpan), +} + +fn tokenize(input: &str) -> Vec { + let mut tokens = Vec::new(); + let mut chars = input.char_indices().peekable(); + + while let Some(&(pos, c)) = chars.peek() { + if c.is_whitespace() { + chars.next(); + continue; + } + + if c == '"' { + let start = pos; + chars.next(); + let mut s = String::new(); + let mut end = start + 1; + while let Some(&(i, ch)) = chars.peek() { + end = i + ch.len_utf8(); + chars.next(); + if ch == '"' { + break; + } + s.push(ch); + } + tokens.push(Token::Str(s, SourceSpan { start, end })); + continue; + } + + if c == '(' { + while let Some(&(_, ch)) = chars.peek() { + chars.next(); + if ch == ')' { + break; + } + } + continue; + } + + if c == '{' { + let start = pos; + chars.next(); + tokens.push(Token::QuoteStart(SourceSpan { + start, + end: start + 1, + })); + continue; + } + + if c == '}' { + let start = pos; + chars.next(); + tokens.push(Token::QuoteEnd(SourceSpan { + start, + end: start + 1, + })); + continue; + } + + let start = pos; + let mut word = String::new(); + let mut end = start; + while let Some(&(i, ch)) = chars.peek() { + if ch.is_whitespace() || ch == '{' || ch == '}' { + break; + } + end = i + ch.len_utf8(); + word.push(ch); + chars.next(); + } + + let span = SourceSpan { start, end }; + if let Ok(i) = word.parse::() { + tokens.push(Token::Int(i, span)); + } else if let Ok(f) = word.parse::() { + tokens.push(Token::Float(f, span)); + } else { + tokens.push(Token::Word(word, span)); + } + } + + tokens +} + +fn compile(tokens: &[Token]) -> Result, String> { + let mut ops = Vec::new(); + let mut i = 0; + + while i < tokens.len() { + match &tokens[i] { + Token::Int(n, span) => ops.push(Op::PushInt(*n, Some(*span))), + Token::Float(f, span) => ops.push(Op::PushFloat(*f, Some(*span))), + Token::Str(s, span) => ops.push(Op::PushStr(s.clone(), Some(*span))), + Token::QuoteStart(_) => { + let (quote_ops, consumed) = compile_quotation(&tokens[i + 1..])?; + i += consumed; + ops.push(Op::Quotation(quote_ops)); + } + Token::QuoteEnd(_) => { + return Err("unexpected }".into()); + } + Token::Word(w, _) => { + let word = w.as_str(); + if word == "if" { + let (then_ops, else_ops, consumed) = compile_if(&tokens[i + 1..])?; + i += consumed; + if else_ops.is_empty() { + ops.push(Op::BranchIfZero(then_ops.len())); + ops.extend(then_ops); + } else { + ops.push(Op::BranchIfZero(then_ops.len() + 1)); + ops.extend(then_ops); + ops.push(Op::Branch(else_ops.len())); + ops.extend(else_ops); + } + } else if !compile_word(word, &mut ops) { + return Err(format!("unknown word: {word}")); + } + } + } + i += 1; + } + + Ok(ops) +} + +fn compile_quotation(tokens: &[Token]) -> Result<(Vec, usize), String> { + let mut depth = 1; + let mut end_pos = None; + + for (i, tok) in tokens.iter().enumerate() { + match tok { + Token::QuoteStart(_) => depth += 1, + Token::QuoteEnd(_) => { + depth -= 1; + if depth == 0 { + end_pos = Some(i); + break; + } + } + _ => {} + } + } + + let end_pos = end_pos.ok_or("missing }")?; + let quote_ops = compile(&tokens[..end_pos])?; + Ok((quote_ops, end_pos + 1)) +} + +fn compile_if(tokens: &[Token]) -> Result<(Vec, Vec, usize), String> { + let mut depth = 1; + let mut else_pos = None; + let mut then_pos = None; + + for (i, tok) in tokens.iter().enumerate() { + if let Token::Word(w, _) = tok { + match w.as_str() { + "if" => depth += 1, + "else" if depth == 1 => else_pos = Some(i), + "then" => { + depth -= 1; + if depth == 0 { + then_pos = Some(i); + break; + } + } + _ => {} + } + } + } + + let then_pos = then_pos.ok_or("missing 'then'")?; + + let (then_ops, else_ops) = if let Some(ep) = else_pos { + let then_ops = compile(&tokens[..ep])?; + let else_ops = compile(&tokens[ep + 1..then_pos])?; + (then_ops, else_ops) + } else { + let then_ops = compile(&tokens[..then_pos])?; + (then_ops, Vec::new()) + }; + + Ok((then_ops, else_ops, then_pos + 1)) +} + +pub type Stack = Arc>>; + +pub struct Forth { + stack: Stack, + vars: Variables, + rng: Rng, +} + +impl Forth { + pub fn new(vars: Variables, rng: Rng) -> Self { + Self { + stack: Arc::new(Mutex::new(Vec::new())), + vars, + rng, + } + } + + pub fn stack(&self) -> Vec { + self.stack.lock().unwrap().clone() + } + + pub fn clear_stack(&self) { + self.stack.lock().unwrap().clear(); + } + + pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result, String> { + self.evaluate_impl(script, ctx, None) + } + + pub fn evaluate_with_trace( + &self, + script: &str, + ctx: &StepContext, + trace: &mut ExecutionTrace, + ) -> Result, String> { + self.evaluate_impl(script, ctx, Some(trace)) + } + + fn evaluate_impl( + &self, + script: &str, + ctx: &StepContext, + trace: Option<&mut ExecutionTrace>, + ) -> Result, String> { + if script.trim().is_empty() { + return Err("empty script".into()); + } + + let tokens = tokenize(script); + let ops = compile(&tokens)?; + self.execute(&ops, ctx, trace) + } + + fn execute( + &self, + ops: &[Op], + ctx: &StepContext, + trace: Option<&mut ExecutionTrace>, + ) -> Result, String> { + let mut stack = self.stack.lock().unwrap(); + let mut outputs: Vec = Vec::new(); + let mut time_stack: Vec = vec![TimeContext { + start: 0.0, + duration: ctx.step_duration(), + subdivisions: None, + }]; + let mut cmd = CmdRegister::default(); + + self.execute_ops( + ops, + ctx, + &mut stack, + &mut outputs, + &mut time_stack, + &mut cmd, + trace, + )?; + + if outputs.is_empty() { + if let Some((sound, params)) = cmd.take() { + let mut pairs = vec![("sound".into(), sound)]; + pairs.extend(params); + pairs.push(("dur".into(), ctx.step_duration().to_string())); + pairs.push(("delaytime".into(), ctx.step_duration().to_string())); + outputs.push(format_cmd(&pairs)); + } + } + + Ok(outputs) + } + + fn execute_ops( + &self, + ops: &[Op], + ctx: &StepContext, + stack: &mut Vec, + outputs: &mut Vec, + time_stack: &mut Vec, + cmd: &mut CmdRegister, + trace: Option<&mut ExecutionTrace>, + ) -> Result<(), String> { + let mut pc = 0; + let trace_cell = std::cell::RefCell::new(trace); + + while pc < ops.len() { + match &ops[pc] { + Op::PushInt(n, span) => stack.push(Value::Int(*n, *span)), + Op::PushFloat(f, span) => stack.push(Value::Float(*f, *span)), + Op::PushStr(s, span) => stack.push(Value::Str(s.clone(), *span)), + + Op::Dup => { + let v = stack.last().ok_or("stack underflow")?.clone(); + stack.push(v); + } + Op::Drop => { + stack.pop().ok_or("stack underflow")?; + } + Op::Swap => { + let len = stack.len(); + if len < 2 { + return Err("stack underflow".into()); + } + stack.swap(len - 1, len - 2); + } + Op::Over => { + let len = stack.len(); + if len < 2 { + return Err("stack underflow".into()); + } + let v = stack[len - 2].clone(); + stack.push(v); + } + Op::Rot => { + let len = stack.len(); + if len < 3 { + return Err("stack underflow".into()); + } + let v = stack.remove(len - 3); + stack.push(v); + } + Op::Nip => { + let len = stack.len(); + if len < 2 { + return Err("stack underflow".into()); + } + stack.remove(len - 2); + } + Op::Tuck => { + let len = stack.len(); + if len < 2 { + return Err("stack underflow".into()); + } + let v = stack[len - 1].clone(); + stack.insert(len - 2, v); + } + + Op::Add => binary_op(stack, |a, b| a + b)?, + Op::Sub => binary_op(stack, |a, b| a - b)?, + Op::Mul => binary_op(stack, |a, b| a * b)?, + Op::Div => binary_op(stack, |a, b| a / b)?, + Op::Mod => { + let b = stack.pop().ok_or("stack underflow")?.as_int()?; + let a = stack.pop().ok_or("stack underflow")?.as_int()?; + stack.push(Value::Int(a % b, None)); + } + Op::Neg => { + let v = stack.pop().ok_or("stack underflow")?; + match v { + Value::Int(i, s) => stack.push(Value::Int(-i, s)), + Value::Float(f, s) => stack.push(Value::Float(-f, s)), + _ => return Err("expected number".into()), + } + } + Op::Abs => { + let v = stack.pop().ok_or("stack underflow")?; + match v { + Value::Int(i, s) => stack.push(Value::Int(i.abs(), s)), + Value::Float(f, s) => stack.push(Value::Float(f.abs(), s)), + _ => return Err("expected number".into()), + } + } + Op::Floor => { + let v = stack.pop().ok_or("stack underflow")?.as_float()?; + stack.push(Value::Int(v.floor() as i64, None)); + } + Op::Ceil => { + let v = stack.pop().ok_or("stack underflow")?.as_float()?; + stack.push(Value::Int(v.ceil() as i64, None)); + } + Op::Round => { + let v = stack.pop().ok_or("stack underflow")?.as_float()?; + stack.push(Value::Int(v.round() as i64, None)); + } + Op::Min => binary_op(stack, |a, b| a.min(b))?, + Op::Max => binary_op(stack, |a, b| a.max(b))?, + + Op::Eq => cmp_op(stack, |a, b| (a - b).abs() < f64::EPSILON)?, + Op::Ne => cmp_op(stack, |a, b| (a - b).abs() >= f64::EPSILON)?, + Op::Lt => cmp_op(stack, |a, b| a < b)?, + Op::Gt => cmp_op(stack, |a, b| a > b)?, + Op::Le => cmp_op(stack, |a, b| a <= b)?, + Op::Ge => cmp_op(stack, |a, b| a >= b)?, + + Op::And => { + let b = stack.pop().ok_or("stack underflow")?.is_truthy(); + let a = stack.pop().ok_or("stack underflow")?.is_truthy(); + stack.push(Value::Int(if a && b { 1 } else { 0 }, None)); + } + Op::Or => { + let b = stack.pop().ok_or("stack underflow")?.is_truthy(); + let a = stack.pop().ok_or("stack underflow")?.is_truthy(); + stack.push(Value::Int(if a || b { 1 } else { 0 }, None)); + } + Op::Not => { + let v = stack.pop().ok_or("stack underflow")?.is_truthy(); + stack.push(Value::Int(if v { 0 } else { 1 }, None)); + } + + Op::BranchIfZero(offset) => { + let v = stack.pop().ok_or("stack underflow")?; + if !v.is_truthy() { + pc += offset; + } + } + Op::Branch(offset) => { + pc += offset; + } + + Op::NewCmd => { + let name = stack.pop().ok_or("stack underflow")?; + let name = name.as_str()?; + cmd.set_sound(name.to_string()); + } + Op::SetParam(param) => { + let val = stack.pop().ok_or("stack underflow")?; + cmd.set_param(param.clone(), val.to_param_string()); + } + Op::Emit => { + let (sound, mut params) = cmd.take().ok_or("no sound set")?; + let mut pairs = vec![("sound".into(), sound)]; + pairs.append(&mut params); + let time_ctx = time_stack.last().ok_or("time stack underflow")?; + if time_ctx.start > 0.0 { + pairs.push(("delta".into(), time_ctx.start.to_string())); + } + if !pairs.iter().any(|(k, _)| k == "dur") { + pairs.push(("dur".into(), ctx.step_duration().to_string())); + } + let stepdur = ctx.step_duration(); + if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") { + let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0); + pairs[idx].1 = (ratio * stepdur).to_string(); + } else { + pairs.push(("delaytime".into(), stepdur.to_string())); + } + outputs.push(format_cmd(&pairs)); + } + + Op::Get => { + let name = stack.pop().ok_or("stack underflow")?; + let name = name.as_str()?; + let vars = self.vars.lock().unwrap(); + let val = vars.get(name).cloned().unwrap_or(Value::Int(0, None)); + stack.push(val); + } + Op::Set => { + let name = stack.pop().ok_or("stack underflow")?; + let name = name.as_str()?.to_string(); + let val = stack.pop().ok_or("stack underflow")?; + self.vars.lock().unwrap().insert(name, val); + } + + Op::GetContext(name) => { + let val = match name.as_str() { + "step" => Value::Int(ctx.step as i64, None), + "beat" => Value::Float(ctx.beat, None), + "pattern" => Value::Int(ctx.pattern as i64, None), + "tempo" => Value::Float(ctx.tempo, None), + "phase" => Value::Float(ctx.phase, None), + "slot" => Value::Int(ctx.slot as i64, None), + "runs" => Value::Int(ctx.runs as i64, None), + "iter" => Value::Int(ctx.iter as i64, None), + "speed" => Value::Float(ctx.speed, None), + "stepdur" => Value::Float(ctx.step_duration(), None), + "fill" => Value::Int(if ctx.fill { 1 } else { 0 }, None), + _ => Value::Int(0, None), + }; + stack.push(val); + } + + Op::Rand => { + let max = stack.pop().ok_or("stack underflow")?.as_float()?; + let min = stack.pop().ok_or("stack underflow")?.as_float()?; + let val = self.rng.lock().unwrap().gen_range(min..max); + stack.push(Value::Float(val, None)); + } + Op::Rrand => { + let max = stack.pop().ok_or("stack underflow")?.as_int()?; + let min = stack.pop().ok_or("stack underflow")?.as_int()?; + let val = self.rng.lock().unwrap().gen_range(min..=max); + stack.push(Value::Int(val, None)); + } + Op::Seed => { + let s = stack.pop().ok_or("stack underflow")?.as_int()?; + *self.rng.lock().unwrap() = StdRng::seed_from_u64(s as u64); + } + + Op::Cycle => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count == 0 { + return Err("cycle count must be > 0".into()); + } + if stack.len() < count { + return Err("stack underflow".into()); + } + let start = stack.len() - count; + let values: Vec = stack.drain(start..).collect(); + let idx = ctx.runs % count; + let selected = values[idx].clone(); + if let Some(span) = selected.span() { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } + } + stack.push(selected); + } + + Op::PCycle => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count == 0 { + return Err("pcycle count must be > 0".into()); + } + if stack.len() < count { + return Err("stack underflow".into()); + } + let start = stack.len() - count; + let values: Vec = stack.drain(start..).collect(); + let idx = ctx.iter % count; + let selected = values[idx].clone(); + if let Some(span) = selected.span() { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } + } + stack.push(selected); + } + + Op::Choose => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count == 0 { + return Err("choose count must be > 0".into()); + } + if stack.len() < count { + return Err("stack underflow".into()); + } + let start = stack.len() - count; + let values: Vec = stack.drain(start..).collect(); + let idx = self.rng.lock().unwrap().gen_range(0..count); + let selected = values[idx].clone(); + if let Some(span) = selected.span() { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } + } + stack.push(selected); + } + + Op::ChanceExec => { + let prob = stack.pop().ok_or("stack underflow")?.as_float()?; + let quot = stack.pop().ok_or("stack underflow")?; + let val: f64 = self.rng.lock().unwrap().gen(); + if val < prob { + match quot { + Value::Quotation(quot_ops) => { + self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, None)?; + } + _ => return Err("expected quotation".into()), + } + } + } + + Op::ProbExec => { + let pct = stack.pop().ok_or("stack underflow")?.as_float()?; + let quot = stack.pop().ok_or("stack underflow")?; + let val: f64 = self.rng.lock().unwrap().gen(); + if val < pct / 100.0 { + match quot { + Value::Quotation(quot_ops) => { + self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, None)?; + } + _ => return Err("expected quotation".into()), + } + } + } + + Op::Coin => { + let val: f64 = self.rng.lock().unwrap().gen(); + stack.push(Value::Int(if val < 0.5 { 1 } else { 0 }, None)); + } + + Op::Every => { + let n = stack.pop().ok_or("stack underflow")?.as_int()?; + if n <= 0 { + return Err("every count must be > 0".into()); + } + let result = ctx.iter as i64 % n == 0; + stack.push(Value::Int(if result { 1 } else { 0 }, None)); + } + + Op::Quotation(quote_ops) => { + stack.push(Value::Quotation(quote_ops.clone())); + } + + Op::When => { + let cond = stack.pop().ok_or("stack underflow")?; + let quot = stack.pop().ok_or("stack underflow")?; + if cond.is_truthy() { + match quot { + Value::Quotation(quot_ops) => { + self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, None)?; + } + _ => return Err("expected quotation".into()), + } + } + } + + Op::Unless => { + let cond = stack.pop().ok_or("stack underflow")?; + let quot = stack.pop().ok_or("stack underflow")?; + if !cond.is_truthy() { + match quot { + Value::Quotation(quot_ops) => { + self.execute_ops("_ops, ctx, stack, outputs, time_stack, cmd, None)?; + } + _ => return Err("expected quotation".into()), + } + } + } + + Op::Mtof => { + let note = stack.pop().ok_or("stack underflow")?.as_float()?; + let freq = 440.0 * 2.0_f64.powf((note - 69.0) / 12.0); + stack.push(Value::Float(freq, None)); + } + + Op::Ftom => { + let freq = stack.pop().ok_or("stack underflow")?.as_float()?; + let note = 69.0 + 12.0 * (freq / 440.0).log2(); + stack.push(Value::Float(note, None)); + } + + Op::At => { + let pos = stack.pop().ok_or("stack underflow")?.as_float()?; + let (sound, mut params) = cmd.take().ok_or("no sound set")?; + let mut pairs = vec![("sound".into(), sound)]; + pairs.append(&mut params); + let time_ctx = time_stack.last().ok_or("time stack underflow")?; + let absolute_time = time_ctx.start + time_ctx.duration * pos; + if absolute_time > 0.0 { + pairs.push(("delta".into(), absolute_time.to_string())); + } + if !pairs.iter().any(|(k, _)| k == "dur") { + pairs.push(("dur".into(), ctx.step_duration().to_string())); + } + let stepdur = ctx.step_duration(); + if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") { + let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0); + pairs[idx].1 = (ratio * stepdur).to_string(); + } else { + pairs.push(("delaytime".into(), stepdur.to_string())); + } + outputs.push(format_cmd(&pairs)); + } + + Op::Window => { + let end = stack.pop().ok_or("stack underflow")?.as_float()?; + let start_pos = stack.pop().ok_or("stack underflow")?.as_float()?; + let parent = time_stack.last().ok_or("time stack underflow")?; + let new_start = parent.start + parent.duration * start_pos; + let new_duration = parent.duration * (end - start_pos); + time_stack.push(TimeContext { + start: new_start, + duration: new_duration, + subdivisions: None, + }); + } + + Op::Pop => { + if time_stack.len() <= 1 { + return Err("cannot pop root time context".into()); + } + time_stack.pop(); + } + + Op::Subdivide => { + let n = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if n == 0 { + return Err("subdivide count must be > 0".into()); + } + let time_ctx = time_stack.last_mut().ok_or("time stack underflow")?; + let sub_duration = time_ctx.duration / n as f64; + let mut subs = Vec::with_capacity(n); + for i in 0..n { + subs.push((time_ctx.start + sub_duration * i as f64, sub_duration)); + } + time_ctx.subdivisions = Some(subs); + } + + Op::Each => { + let (sound, params) = cmd.take().ok_or("no sound set")?; + let time_ctx = time_stack.last().ok_or("time stack underflow")?; + let subs = time_ctx + .subdivisions + .as_ref() + .ok_or("each requires subdivide first")?; + for (sub_start, _sub_dur) in subs { + let mut pairs = vec![("sound".into(), sound.clone())]; + pairs.extend(params.iter().cloned()); + if *sub_start > 0.0 { + pairs.push(("delta".into(), sub_start.to_string())); + } + if !pairs.iter().any(|(k, _)| k == "dur") { + pairs.push(("dur".into(), ctx.step_duration().to_string())); + } + let stepdur = ctx.step_duration(); + if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") { + let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0); + pairs[idx].1 = (ratio * stepdur).to_string(); + } else { + pairs.push(("delaytime".into(), stepdur.to_string())); + } + outputs.push(format_cmd(&pairs)); + } + } + + Op::SetTempo => { + let tempo = stack.pop().ok_or("stack underflow")?.as_float()?; + let clamped = tempo.clamp(20.0, 300.0); + self.vars + .lock() + .unwrap() + .insert("__tempo__".to_string(), Value::Float(clamped, None)); + } + + Op::ListStart => { + stack.push(Value::Marker); + } + + Op::ListEnd => { + let mut count = 0; + let mut values = Vec::new(); + while let Some(v) = stack.pop() { + if v.is_marker() { + break; + } + values.push(v); + count += 1; + } + values.reverse(); + for v in values { + stack.push(v); + } + stack.push(Value::Int(count, None)); + } + + Op::ListEndCycle => { + let mut values = Vec::new(); + while let Some(v) = stack.pop() { + if v.is_marker() { + break; + } + values.push(v); + } + if values.is_empty() { + return Err("empty cycle list".into()); + } + values.reverse(); + let idx = ctx.runs % values.len(); + let selected = values[idx].clone(); + if let Some(span) = selected.span() { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } + } + stack.push(selected); + } + + Op::ListEndPCycle => { + let mut values = Vec::new(); + while let Some(v) = stack.pop() { + if v.is_marker() { + break; + } + values.push(v); + } + if values.is_empty() { + return Err("empty pattern cycle list".into()); + } + values.reverse(); + let idx = ctx.iter % values.len(); + let selected = values[idx].clone(); + if let Some(span) = selected.span() { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } + } + stack.push(selected); + } + + Op::Adsr => { + let r = stack.pop().ok_or("stack underflow")?; + let s = stack.pop().ok_or("stack underflow")?; + let d = stack.pop().ok_or("stack underflow")?; + let a = stack.pop().ok_or("stack underflow")?; + cmd.set_param("attack".into(), a.to_param_string()); + cmd.set_param("decay".into(), d.to_param_string()); + cmd.set_param("sustain".into(), s.to_param_string()); + cmd.set_param("release".into(), r.to_param_string()); + } + + Op::Ad => { + let d = stack.pop().ok_or("stack underflow")?; + let a = stack.pop().ok_or("stack underflow")?; + cmd.set_param("attack".into(), a.to_param_string()); + cmd.set_param("decay".into(), d.to_param_string()); + cmd.set_param("sustain".into(), "0".into()); + } + } + pc += 1; + } + + Ok(()) + } +} + +fn binary_op(stack: &mut Vec, f: F) -> Result<(), String> +where + F: Fn(f64, f64) -> f64, +{ + let b = stack.pop().ok_or("stack underflow")?.as_float()?; + let a = stack.pop().ok_or("stack underflow")?.as_float()?; + let result = f(a, b); + if result.fract() == 0.0 && result.abs() < i64::MAX as f64 { + stack.push(Value::Int(result as i64, None)); + } else { + stack.push(Value::Float(result, None)); + } + Ok(()) +} + +fn cmp_op(stack: &mut Vec, f: F) -> Result<(), String> +where + F: Fn(f64, f64) -> bool, +{ + let b = stack.pop().ok_or("stack underflow")?.as_float()?; + let a = stack.pop().ok_or("stack underflow")?.as_float()?; + stack.push(Value::Int(if f(a, b) { 1 } else { 0 }, None)); + Ok(()) +} + +fn format_cmd(pairs: &[(String, String)]) -> String { + let parts: Vec = pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect(); + format!("/{}", parts.join("/")) +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..c311afb --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,8 @@ +mod file; +pub mod forth; +mod project; +mod script; + +pub use file::{load, save}; +pub use project::{Bank, Pattern, PatternSpeed, Project}; +pub use script::{ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Variables}; diff --git a/src/model/project.rs b/src/model/project.rs new file mode 100644 index 0000000..d603bc7 --- /dev/null +++ b/src/model/project.rs @@ -0,0 +1,210 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::config::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS}; + +#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq)] +pub enum PatternSpeed { + Eighth, // 1/8x + Quarter, // 1/4x + Half, // 1/2x + #[default] + Normal, // 1x + Double, // 2x + Quad, // 4x + Octo, // 8x +} + +impl PatternSpeed { + pub fn multiplier(&self) -> f64 { + match self { + Self::Eighth => 0.125, + Self::Quarter => 0.25, + Self::Half => 0.5, + Self::Normal => 1.0, + Self::Double => 2.0, + Self::Quad => 4.0, + Self::Octo => 8.0, + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::Eighth => "1/8x", + Self::Quarter => "1/4x", + Self::Half => "1/2x", + Self::Normal => "1x", + Self::Double => "2x", + Self::Quad => "4x", + Self::Octo => "8x", + } + } + + pub fn next(&self) -> Self { + match self { + Self::Eighth => Self::Quarter, + Self::Quarter => Self::Half, + Self::Half => Self::Normal, + Self::Normal => Self::Double, + Self::Double => Self::Quad, + Self::Quad => Self::Octo, + Self::Octo => Self::Octo, + } + } + + pub fn prev(&self) -> Self { + match self { + Self::Eighth => Self::Eighth, + Self::Quarter => Self::Eighth, + Self::Half => Self::Quarter, + Self::Normal => Self::Half, + Self::Double => Self::Normal, + Self::Quad => Self::Double, + Self::Octo => Self::Quad, + } + } + + pub fn from_label(s: &str) -> Option { + match s.trim() { + "1/8x" | "1/8" | "0.125x" => Some(Self::Eighth), + "1/4x" | "1/4" | "0.25x" => Some(Self::Quarter), + "1/2x" | "1/2" | "0.5x" => Some(Self::Half), + "1x" | "1" => Some(Self::Normal), + "2x" | "2" => Some(Self::Double), + "4x" | "4" => Some(Self::Quad), + "8x" | "8" => Some(Self::Octo), + _ => None, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Step { + pub active: bool, + pub script: String, + #[serde(skip)] + pub command: Option, + #[serde(default)] + pub source: Option, +} + +impl Default for Step { + fn default() -> Self { + Self { + active: true, + script: String::new(), + command: None, + source: None, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Pattern { + pub steps: Vec, + pub length: usize, + #[serde(default)] + pub speed: PatternSpeed, + #[serde(default)] + pub name: Option, +} + +impl Default for Pattern { + fn default() -> Self { + Self { + steps: (0..MAX_STEPS).map(|_| Step::default()).collect(), + length: DEFAULT_LENGTH, + speed: PatternSpeed::default(), + name: None, + } + } +} + +impl Pattern { + pub fn step(&self, index: usize) -> Option<&Step> { + self.steps.get(index) + } + + pub fn step_mut(&mut self, index: usize) -> Option<&mut Step> { + self.steps.get_mut(index) + } + + pub fn set_length(&mut self, length: usize) { + let length = length.clamp(2, MAX_STEPS); + while self.steps.len() < length { + self.steps.push(Step::default()); + } + self.length = length; + } + + pub fn resolve_source(&self, index: usize) -> usize { + let mut current = index; + for _ in 0..self.steps.len() { + if let Some(step) = self.steps.get(current) { + if let Some(source) = step.source { + current = source; + } else { + return current; + } + } else { + return index; + } + } + index + } + + pub fn resolve_script(&self, index: usize) -> Option<&str> { + let source_idx = self.resolve_source(index); + self.steps.get(source_idx).map(|s| s.script.as_str()) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Bank { + pub patterns: Vec, + #[serde(default)] + pub name: Option, +} + +impl Default for Bank { + fn default() -> Self { + Self { + patterns: (0..MAX_PATTERNS).map(|_| Pattern::default()).collect(), + name: None, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Project { + pub banks: Vec, + #[serde(default)] + pub sample_paths: Vec, + #[serde(default = "default_tempo")] + pub tempo: f64, +} + +fn default_tempo() -> f64 { + 120.0 +} + +impl Default for Project { + fn default() -> Self { + Self { + banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(), + sample_paths: Vec::new(), + tempo: default_tempo(), + } + } +} + +impl Project { + pub fn pattern_at(&self, bank: usize, pattern: usize) -> &Pattern { + &self.banks[bank].patterns[pattern] + } + + pub fn pattern_at_mut(&mut self, bank: usize, pattern: usize) -> &mut Pattern { + &mut self.banks[bank].patterns[pattern] + } +} diff --git a/src/model/script.rs b/src/model/script.rs new file mode 100644 index 0000000..d0f6bdd --- /dev/null +++ b/src/model/script.rs @@ -0,0 +1,28 @@ +use super::forth::Forth; + +pub use super::forth::{ExecutionTrace, Rng, SourceSpan, StepContext, Variables}; + +pub struct ScriptEngine { + forth: Forth, +} + +impl ScriptEngine { + pub fn new(vars: Variables, rng: Rng) -> Self { + Self { + forth: Forth::new(vars, rng), + } + } + + pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result, String> { + self.forth.evaluate(script, ctx) + } + + pub fn evaluate_with_trace( + &self, + script: &str, + ctx: &StepContext, + trace: &mut ExecutionTrace, + ) -> Result, String> { + self.forth.evaluate_with_trace(script, ctx, trace) + } +} diff --git a/src/page.rs b/src/page.rs new file mode 100644 index 0000000..3a34029 --- /dev/null +++ b/src/page.rs @@ -0,0 +1,38 @@ +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum Page { + #[default] + Main, + Patterns, + Audio, + Doc, +} + +impl Page { + pub fn left(&mut self) { + *self = match self { + Page::Main | Page::Patterns => Page::Doc, + Page::Audio => Page::Main, + Page::Doc => Page::Audio, + } + } + + pub fn right(&mut self) { + *self = match self { + Page::Main | Page::Patterns => Page::Audio, + Page::Audio => Page::Doc, + Page::Doc => Page::Main, + } + } + + pub fn up(&mut self) { + if *self == Page::Main { + *self = Page::Patterns; + } + } + + pub fn down(&mut self) { + if *self == Page::Patterns { + *self = Page::Main; + } + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..f4feb56 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1 @@ +pub mod pattern_editor; diff --git a/src/services/pattern_editor.rs b/src/services/pattern_editor.rs new file mode 100644 index 0000000..6143b5a --- /dev/null +++ b/src/services/pattern_editor.rs @@ -0,0 +1,105 @@ +use crate::model::{PatternSpeed, Project}; + +#[derive(Debug, Clone, Copy)] +pub struct PatternChange { + pub bank: usize, + pub pattern: usize, +} + +impl PatternChange { + pub fn new(bank: usize, pattern: usize) -> Self { + Self { bank, pattern } + } +} + +pub fn toggle_step( + project: &mut Project, + bank: usize, + pattern: usize, + step: usize, +) -> PatternChange { + if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(step) { + s.active = !s.active; + } + PatternChange::new(bank, pattern) +} + +pub fn set_length( + project: &mut Project, + bank: usize, + pattern: usize, + length: usize, +) -> (PatternChange, usize) { + project.pattern_at_mut(bank, pattern).set_length(length); + let actual = project.pattern_at(bank, pattern).length; + (PatternChange::new(bank, pattern), actual) +} + +pub fn get_length(project: &Project, bank: usize, pattern: usize) -> usize { + project.pattern_at(bank, pattern).length +} + +pub fn increase_length( + project: &mut Project, + bank: usize, + pattern: usize, +) -> (PatternChange, usize) { + let current = get_length(project, bank, pattern); + set_length(project, bank, pattern, current + 1) +} + +pub fn decrease_length( + project: &mut Project, + bank: usize, + pattern: usize, +) -> (PatternChange, usize) { + let current = get_length(project, bank, pattern); + set_length(project, bank, pattern, current.saturating_sub(1)) +} + +pub fn set_speed( + project: &mut Project, + bank: usize, + pattern: usize, + speed: PatternSpeed, +) -> PatternChange { + project.pattern_at_mut(bank, pattern).speed = speed; + PatternChange::new(bank, pattern) +} + +pub fn increase_speed(project: &mut Project, bank: usize, pattern: usize) -> PatternChange { + let pat = project.pattern_at_mut(bank, pattern); + pat.speed = pat.speed.next(); + PatternChange::new(bank, pattern) +} + +pub fn decrease_speed(project: &mut Project, bank: usize, pattern: usize) -> PatternChange { + let pat = project.pattern_at_mut(bank, pattern); + pat.speed = pat.speed.prev(); + PatternChange::new(bank, pattern) +} + +pub fn set_step_script( + project: &mut Project, + bank: usize, + pattern: usize, + step: usize, + script: String, +) -> PatternChange { + if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(step) { + s.script = script; + } + PatternChange::new(bank, pattern) +} + +pub fn get_step_script( + project: &Project, + bank: usize, + pattern: usize, + step: usize, +) -> Option { + project + .pattern_at(bank, pattern) + .step(step) + .map(|s| s.script.clone()) +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..5ad77b4 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; + +const APP_NAME: &str = "cagire"; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Settings { + pub audio: AudioSettings, + pub display: DisplaySettings, + pub link: LinkSettings, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AudioSettings { + pub output_device: Option, + pub input_device: Option, + pub channels: u16, + pub buffer_size: u32, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DisplaySettings { + pub fps: u32, + pub runtime_highlight: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LinkSettings { + pub enabled: bool, + pub tempo: f64, + pub quantum: f64, +} + +impl Default for AudioSettings { + fn default() -> Self { + Self { + output_device: None, + input_device: None, + channels: 2, + buffer_size: 512, + } + } +} + +impl Default for DisplaySettings { + fn default() -> Self { + Self { + fps: 60, + runtime_highlight: false, + } + } +} + +impl Default for LinkSettings { + fn default() -> Self { + Self { + enabled: true, + tempo: 120.0, + quantum: 4.0, + } + } +} + +impl Settings { + pub fn load() -> Self { + confy::load(APP_NAME, None).unwrap_or_default() + } + + pub fn save(&self) { + let _ = confy::store(APP_NAME, None, self); + } +} + diff --git a/src/state/audio.rs b/src/state/audio.rs new file mode 100644 index 0000000..9b0dee1 --- /dev/null +++ b/src/state/audio.rs @@ -0,0 +1,292 @@ +use doux::audio::AudioDeviceInfo; +use std::path::PathBuf; + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum RefreshRate { + #[default] + Fps60, + Fps30, +} + +impl RefreshRate { + pub fn from_fps(fps: u32) -> Self { + if fps >= 60 { + RefreshRate::Fps60 + } else { + RefreshRate::Fps30 + } + } + + pub fn toggle(self) -> Self { + match self { + RefreshRate::Fps60 => RefreshRate::Fps30, + RefreshRate::Fps30 => RefreshRate::Fps60, + } + } + + pub fn millis(self) -> u64 { + match self { + RefreshRate::Fps60 => 16, + RefreshRate::Fps30 => 33, + } + } + + pub fn label(self) -> &'static str { + match self { + RefreshRate::Fps60 => "60", + RefreshRate::Fps30 => "30", + } + } + + pub fn to_fps(self) -> u32 { + match self { + RefreshRate::Fps60 => 60, + RefreshRate::Fps30 => 30, + } + } +} + +#[derive(Clone)] +pub struct AudioConfig { + pub output_device: Option, + pub input_device: Option, + pub channels: u16, + pub buffer_size: u32, + pub sample_rate: f32, + pub sample_paths: Vec, + pub sample_count: usize, + pub refresh_rate: RefreshRate, +} + +impl Default for AudioConfig { + fn default() -> Self { + Self { + output_device: None, + input_device: None, + channels: 2, + buffer_size: 512, + sample_rate: 44100.0, + sample_paths: Vec::new(), + sample_count: 0, + refresh_rate: RefreshRate::default(), + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum AudioFocus { + #[default] + OutputDevice, + InputDevice, + Channels, + BufferSize, + RefreshRate, + RuntimeHighlight, + SamplePaths, + LinkEnabled, + StartStopSync, + Quantum, +} + +pub struct Metrics { + pub event_count: usize, + pub active_voices: usize, + pub peak_voices: usize, + pub cpu_load: f32, + pub schedule_depth: usize, + pub scope: [f32; 64], + pub peak_left: f32, + pub peak_right: f32, +} + +impl Default for Metrics { + fn default() -> Self { + Self { + event_count: 0, + active_voices: 0, + peak_voices: 0, + cpu_load: 0.0, + schedule_depth: 0, + scope: [0.0; 64], + peak_left: 0.0, + peak_right: 0.0, + } + } +} + +pub struct AudioSettings { + pub config: AudioConfig, + pub focus: AudioFocus, + pub output_devices: Vec, + pub input_devices: Vec, + pub restart_pending: bool, + pub error: Option, +} + +impl Default for AudioSettings { + fn default() -> Self { + Self { + config: AudioConfig::default(), + focus: AudioFocus::default(), + output_devices: doux::audio::list_output_devices(), + input_devices: doux::audio::list_input_devices(), + restart_pending: false, + error: None, + } + } +} + +impl AudioSettings { + pub fn refresh_devices(&mut self) { + self.output_devices = doux::audio::list_output_devices(); + self.input_devices = doux::audio::list_input_devices(); + } + + pub fn next_focus(&mut self) { + self.focus = match self.focus { + AudioFocus::OutputDevice => AudioFocus::InputDevice, + AudioFocus::InputDevice => AudioFocus::Channels, + AudioFocus::Channels => AudioFocus::BufferSize, + AudioFocus::BufferSize => AudioFocus::RefreshRate, + AudioFocus::RefreshRate => AudioFocus::RuntimeHighlight, + AudioFocus::RuntimeHighlight => AudioFocus::SamplePaths, + AudioFocus::SamplePaths => AudioFocus::LinkEnabled, + AudioFocus::LinkEnabled => AudioFocus::StartStopSync, + AudioFocus::StartStopSync => AudioFocus::Quantum, + AudioFocus::Quantum => AudioFocus::OutputDevice, + }; + } + + pub fn prev_focus(&mut self) { + self.focus = match self.focus { + AudioFocus::OutputDevice => AudioFocus::Quantum, + AudioFocus::InputDevice => AudioFocus::OutputDevice, + AudioFocus::Channels => AudioFocus::InputDevice, + AudioFocus::BufferSize => AudioFocus::Channels, + AudioFocus::RefreshRate => AudioFocus::BufferSize, + AudioFocus::RuntimeHighlight => AudioFocus::RefreshRate, + AudioFocus::SamplePaths => AudioFocus::RuntimeHighlight, + AudioFocus::LinkEnabled => AudioFocus::SamplePaths, + AudioFocus::StartStopSync => AudioFocus::LinkEnabled, + AudioFocus::Quantum => AudioFocus::StartStopSync, + }; + } + + pub fn next_output_device(&mut self) { + if self.output_devices.is_empty() { + return; + } + let current_idx = self.current_output_device_index(); + let next_idx = (current_idx + 1) % self.output_devices.len(); + self.config.output_device = Some(self.output_devices[next_idx].name.clone()); + } + + pub fn prev_output_device(&mut self) { + if self.output_devices.is_empty() { + return; + } + let current_idx = self.current_output_device_index(); + let prev_idx = (current_idx + self.output_devices.len() - 1) % self.output_devices.len(); + self.config.output_device = Some(self.output_devices[prev_idx].name.clone()); + } + + fn current_output_device_index(&self) -> usize { + match &self.config.output_device { + Some(name) => self + .output_devices + .iter() + .position(|d| &d.name == name) + .unwrap_or(0), + None => self + .output_devices + .iter() + .position(|d| d.is_default) + .unwrap_or(0), + } + } + + pub fn next_input_device(&mut self) { + if self.input_devices.is_empty() { + return; + } + let current_idx = self.current_input_device_index(); + let next_idx = (current_idx + 1) % self.input_devices.len(); + self.config.input_device = Some(self.input_devices[next_idx].name.clone()); + } + + pub fn prev_input_device(&mut self) { + if self.input_devices.is_empty() { + return; + } + let current_idx = self.current_input_device_index(); + let prev_idx = (current_idx + self.input_devices.len() - 1) % self.input_devices.len(); + self.config.input_device = Some(self.input_devices[prev_idx].name.clone()); + } + + fn current_input_device_index(&self) -> usize { + match &self.config.input_device { + Some(name) => self + .input_devices + .iter() + .position(|d| &d.name == name) + .unwrap_or(0), + None => self + .input_devices + .iter() + .position(|d| d.is_default) + .unwrap_or(0), + } + } + + pub fn adjust_channels(&mut self, delta: i16) { + let new_val = (self.config.channels as i16 + delta).clamp(1, 64) as u16; + self.config.channels = new_val; + } + + pub fn adjust_buffer_size(&mut self, delta: i32) { + let new_val = (self.config.buffer_size as i32 + delta).clamp(64, 4096) as u32; + self.config.buffer_size = new_val; + } + + pub fn toggle_refresh_rate(&mut self) { + self.config.refresh_rate = self.config.refresh_rate.toggle(); + } + + pub fn current_output_device_name(&self) -> &str { + match &self.config.output_device { + Some(name) => name, + None => self + .output_devices + .iter() + .find(|d| d.is_default) + .map(|d| d.name.as_str()) + .unwrap_or("Default"), + } + } + + pub fn current_input_device_name(&self) -> &str { + match &self.config.input_device { + Some(name) => name, + None => self + .input_devices + .iter() + .find(|d| d.is_default) + .map(|d| d.name.as_str()) + .unwrap_or("None"), + } + } + + pub fn add_sample_path(&mut self, path: PathBuf) { + if !self.config.sample_paths.contains(&path) { + self.config.sample_paths.push(path); + } + } + + pub fn remove_last_sample_path(&mut self) { + self.config.sample_paths.pop(); + } + + pub fn trigger_restart(&mut self) { + self.restart_pending = true; + } +} diff --git a/src/state/editor.rs b/src/state/editor.rs new file mode 100644 index 0000000..4b833b3 --- /dev/null +++ b/src/state/editor.rs @@ -0,0 +1,42 @@ +use tui_textarea::TextArea; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Focus { + Sequencer, + Editor, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PatternField { + Length, + Speed, +} + +pub struct EditorContext { + pub bank: usize, + pub pattern: usize, + pub step: usize, + pub focus: Focus, + pub text: TextArea<'static>, + pub copied_step: Option, +} + +#[derive(Clone, Copy)] +pub struct CopiedStep { + pub bank: usize, + pub pattern: usize, + pub step: usize, +} + +impl Default for EditorContext { + fn default() -> Self { + Self { + bank: 0, + pattern: 0, + step: 0, + focus: Focus::Sequencer, + text: TextArea::default(), + copied_step: None, + } + } +} diff --git a/src/state/live_keys.rs b/src/state/live_keys.rs new file mode 100644 index 0000000..c125178 --- /dev/null +++ b/src/state/live_keys.rs @@ -0,0 +1,21 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +#[derive(Default)] +pub struct LiveKeyState { + fill: AtomicBool, +} + +impl LiveKeyState { + pub fn new() -> Self { + Self::default() + } + + pub fn fill(&self) -> bool { + self.fill.load(Ordering::Relaxed) + } + + pub fn flip_fill(&self) { + let current = self.fill.load(Ordering::Relaxed); + self.fill.store(!current, Ordering::Relaxed); + } +} diff --git a/src/state/mod.rs b/src/state/mod.rs new file mode 100644 index 0000000..92b6491 --- /dev/null +++ b/src/state/mod.rs @@ -0,0 +1,17 @@ +pub mod audio; +pub mod editor; +pub mod live_keys; +pub mod modal; +pub mod patterns_nav; +pub mod playback; +pub mod project; +pub mod ui; + +pub use audio::{AudioFocus, AudioSettings, Metrics}; +pub use editor::{CopiedStep, EditorContext, Focus, PatternField}; +pub use live_keys::LiveKeyState; +pub use modal::Modal; +pub use patterns_nav::{PatternsColumn, PatternsNav}; +pub use playback::PlaybackState; +pub use project::ProjectState; +pub use ui::UiState; diff --git a/src/state/modal.rs b/src/state/modal.rs new file mode 100644 index 0000000..c5ebb8a --- /dev/null +++ b/src/state/modal.rs @@ -0,0 +1,42 @@ +use crate::state::editor::PatternField; + +#[derive(Clone, PartialEq, Eq)] +pub enum Modal { + None, + ConfirmQuit { + selected: bool, + }, + ConfirmDeleteStep { + bank: usize, + pattern: usize, + step: usize, + selected: bool, + }, + ConfirmResetPattern { + bank: usize, + pattern: usize, + selected: bool, + }, + ConfirmResetBank { + bank: usize, + selected: bool, + }, + SaveAs(String), + LoadFrom(String), + RenameBank { + bank: usize, + name: String, + }, + RenamePattern { + bank: usize, + pattern: usize, + name: String, + }, + SetPattern { + field: PatternField, + input: String, + }, + SetTempo(String), + AddSamplePath(String), + Editor, +} diff --git a/src/state/patterns_nav.rs b/src/state/patterns_nav.rs new file mode 100644 index 0000000..d2676cb --- /dev/null +++ b/src/state/patterns_nav.rs @@ -0,0 +1,53 @@ +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum PatternsColumn { + #[default] + Banks, + Patterns, +} + +#[derive(Clone, Copy, Default)] +pub struct PatternsNav { + pub column: PatternsColumn, + pub bank_cursor: usize, + pub pattern_cursor: usize, +} + +impl PatternsNav { + pub fn move_left(&mut self) { + self.column = PatternsColumn::Banks; + } + + pub fn move_right(&mut self) { + self.column = PatternsColumn::Patterns; + } + + pub fn move_up(&mut self) { + match self.column { + PatternsColumn::Banks => { + self.bank_cursor = (self.bank_cursor + 15) % 16; + } + PatternsColumn::Patterns => { + self.pattern_cursor = (self.pattern_cursor + 15) % 16; + } + } + } + + pub fn move_down(&mut self) { + match self.column { + PatternsColumn::Banks => { + self.bank_cursor = (self.bank_cursor + 1) % 16; + } + PatternsColumn::Patterns => { + self.pattern_cursor = (self.pattern_cursor + 1) % 16; + } + } + } + + pub fn selected_bank(&self) -> usize { + self.bank_cursor + } + + pub fn selected_pattern(&self) -> usize { + self.pattern_cursor + } +} diff --git a/src/state/playback.rs b/src/state/playback.rs new file mode 100644 index 0000000..4d6eca1 --- /dev/null +++ b/src/state/playback.rs @@ -0,0 +1,21 @@ +use crate::engine::PatternChange; + +pub struct PlaybackState { + pub playing: bool, + pub queued_changes: Vec, +} + +impl Default for PlaybackState { + fn default() -> Self { + Self { + playing: true, + queued_changes: Vec::new(), + } + } +} + +impl PlaybackState { + pub fn toggle(&mut self) { + self.playing = !self.playing; + } +} diff --git a/src/state/project.rs b/src/state/project.rs new file mode 100644 index 0000000..087f1e8 --- /dev/null +++ b/src/state/project.rs @@ -0,0 +1,41 @@ +use std::collections::HashSet; +use std::path::PathBuf; + +use crate::config::{MAX_BANKS, MAX_PATTERNS}; +use crate::model::Project; + +pub struct ProjectState { + pub project: Project, + pub file_path: Option, + pub dirty_patterns: HashSet<(usize, usize)>, +} + +impl Default for ProjectState { + fn default() -> Self { + let mut state = Self { + project: Project::default(), + file_path: None, + dirty_patterns: HashSet::new(), + }; + state.mark_all_dirty(); + state + } +} + +impl ProjectState { + pub fn mark_dirty(&mut self, bank: usize, pattern: usize) { + self.dirty_patterns.insert((bank, pattern)); + } + + pub fn mark_all_dirty(&mut self) { + for bank in 0..MAX_BANKS { + for pattern in 0..MAX_PATTERNS { + self.dirty_patterns.insert((bank, pattern)); + } + } + } + + pub fn take_dirty(&mut self) -> HashSet<(usize, usize)> { + std::mem::take(&mut self.dirty_patterns) + } +} diff --git a/src/state/ui.rs b/src/state/ui.rs new file mode 100644 index 0000000..b013e48 --- /dev/null +++ b/src/state/ui.rs @@ -0,0 +1,50 @@ +use std::time::{Duration, Instant}; + +use crate::state::Modal; + +pub struct UiState { + pub status_message: Option, + pub flash_until: Option, + pub modal: Modal, + pub doc_topic: usize, + pub doc_scroll: usize, + pub doc_category: usize, + pub show_title: bool, + pub runtime_highlight: bool, +} + +impl Default for UiState { + fn default() -> Self { + Self { + status_message: None, + flash_until: None, + modal: Modal::None, + doc_topic: 0, + doc_scroll: 0, + doc_category: 0, + show_title: true, + runtime_highlight: false, + } + } +} + +impl UiState { + pub fn flash(&mut self, msg: &str, duration_ms: u64) { + self.status_message = Some(msg.to_string()); + self.flash_until = Some(Instant::now() + Duration::from_millis(duration_ms)); + } + + pub fn set_status(&mut self, msg: String) { + self.status_message = Some(msg); + } + + pub fn clear_status(&mut self) { + self.status_message = None; + } + + pub fn is_flashing(&self) -> bool { + self.flash_until + .map(|t| Instant::now() < t) + .unwrap_or(false) + } +} diff --git a/src/views/audio_view.rs b/src/views/audio_view.rs new file mode 100644 index 0000000..884f498 --- /dev/null +++ b/src/views/audio_view.rs @@ -0,0 +1,381 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; +use ratatui::Frame; + +use crate::app::App; +use crate::engine::LinkState; +use crate::state::AudioFocus; + +pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { + let [left_col, _, right_col] = Layout::horizontal([ + Constraint::Percentage(52), + Constraint::Length(2), + Constraint::Percentage(48), + ]) + .areas(area); + + render_audio_section(frame, app, left_col); + render_link_section(frame, app, link, right_col); +} + +fn truncate_name(name: &str, max_len: usize) -> String { + if name.len() > max_len { + format!("{}...", &name[..max_len.saturating_sub(3)]) + } else { + name.to_string() + } +} + +fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Audio ") + .border_style(Style::new().fg(Color::Magenta)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let padded = Rect { + x: inner.x + 1, + y: inner.y + 1, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(1), + }; + + let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([ + Constraint::Length(4), + Constraint::Length(1), + Constraint::Length(6), + Constraint::Length(1), + Constraint::Min(3), + ]) + .areas(padded); + + render_devices(frame, app, devices_area); + render_settings(frame, app, settings_area); + render_samples(frame, app, samples_area); +} + +fn render_devices(frame: &mut Frame, app: &App, area: Rect) { + let header_style = Style::new() + .fg(Color::Rgb(100, 160, 180)) + .add_modifier(Modifier::BOLD); + + let [header_area, content_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); + + frame.render_widget(Paragraph::new("Devices").style(header_style), header_area); + + let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let normal = Style::new().fg(Color::White); + let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); + + let output_name = truncate_name(app.audio.current_output_device_name(), 35); + let input_name = truncate_name(app.audio.current_input_device_name(), 35); + + let output_focused = app.audio.focus == AudioFocus::OutputDevice; + let input_focused = app.audio.focus == AudioFocus::InputDevice; + + let rows = vec![ + Row::new(vec![ + Span::styled("Output", label_style), + render_selector(&output_name, output_focused, highlight, normal), + ]), + Row::new(vec![ + Span::styled("Input", label_style), + render_selector(&input_name, input_focused, highlight, normal), + ]), + ]; + + let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]); + frame.render_widget(table, content_area); +} + +fn render_settings(frame: &mut Frame, app: &App, area: Rect) { + let header_style = Style::new() + .fg(Color::Rgb(100, 160, 180)) + .add_modifier(Modifier::BOLD); + + let [header_area, content_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); + + frame.render_widget(Paragraph::new("Settings").style(header_style), header_area); + + let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let normal = Style::new().fg(Color::White); + let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); + let value_style = Style::new().fg(Color::Rgb(180, 180, 190)); + + let channels_focused = app.audio.focus == AudioFocus::Channels; + let buffer_focused = app.audio.focus == AudioFocus::BufferSize; + let fps_focused = app.audio.focus == AudioFocus::RefreshRate; + let highlight_focused = app.audio.focus == AudioFocus::RuntimeHighlight; + + let highlight_text = if app.ui.runtime_highlight { "On" } else { "Off" }; + + let rows = vec![ + Row::new(vec![ + Span::styled("Channels", label_style), + render_selector( + &format!("{}", app.audio.config.channels), + channels_focused, + highlight, + normal, + ), + ]), + Row::new(vec![ + Span::styled("Buffer", label_style), + render_selector( + &format!("{}", app.audio.config.buffer_size), + buffer_focused, + highlight, + normal, + ), + ]), + Row::new(vec![ + Span::styled("FPS", label_style), + render_selector( + app.audio.config.refresh_rate.label(), + fps_focused, + highlight, + normal, + ), + ]), + Row::new(vec![ + Span::styled("Highlight", label_style), + render_selector(highlight_text, highlight_focused, highlight, normal), + ]), + Row::new(vec![ + Span::styled("Rate", label_style), + Span::styled( + format!("{:.0} Hz", app.audio.config.sample_rate), + value_style, + ), + ]), + ]; + + let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]); + frame.render_widget(table, content_area); +} + +fn render_samples(frame: &mut Frame, app: &App, area: Rect) { + let header_style = Style::new() + .fg(Color::Rgb(100, 160, 180)) + .add_modifier(Modifier::BOLD); + + let [header_area, content_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); + + let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let samples_focused = app.audio.focus == AudioFocus::SamplePaths; + + let header_text = format!( + "Samples {} paths · {} indexed", + app.audio.config.sample_paths.len(), + app.audio.config.sample_count + ); + + let header_line = if samples_focused { + Line::from(vec![ + Span::styled("Samples ", header_style), + Span::styled( + format!( + "{} paths · {} indexed", + app.audio.config.sample_paths.len(), + app.audio.config.sample_count + ), + highlight, + ), + ]) + } else { + Line::from(Span::styled(header_text, header_style)) + }; + frame.render_widget(Paragraph::new(header_line), header_area); + + let dim = Style::new().fg(Color::Rgb(80, 85, 95)); + let path_style = Style::new().fg(Color::Rgb(120, 125, 135)); + + let mut lines: Vec = Vec::new(); + for (i, path) in app.audio.config.sample_paths.iter().take(4).enumerate() { + let path_str = path.to_string_lossy(); + let display = truncate_name(&path_str, 45); + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", i + 1), dim), + Span::styled(display, path_style), + ])); + } + + if lines.is_empty() { + lines.push(Line::from(Span::styled( + " No sample paths configured", + dim, + ))); + } + + frame.render_widget(Paragraph::new(lines), content_area); +} + +fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Ableton Link ") + .border_style(Style::new().fg(Color::Cyan)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let padded = Rect { + x: inner.x + 1, + y: inner.y + 1, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(1), + }; + + let [status_area, _, config_area, _, info_area] = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(5), + Constraint::Length(1), + Constraint::Min(1), + ]) + .areas(padded); + + render_link_status(frame, link, status_area); + render_link_config(frame, app, link, config_area); + render_link_info(frame, link, info_area); +} + +fn render_link_status(frame: &mut Frame, link: &LinkState, area: Rect) { + let enabled = link.is_enabled(); + let peers = link.peers(); + + let (status_text, status_color) = if !enabled { + ("DISABLED", Color::Rgb(120, 60, 60)) + } else if peers > 0 { + ("CONNECTED", Color::Rgb(60, 120, 60)) + } else { + ("LISTENING", Color::Rgb(120, 120, 60)) + }; + + let status_style = Style::new().fg(status_color).add_modifier(Modifier::BOLD); + + let peer_text = if enabled { + if peers == 0 { + "No peers".to_string() + } else if peers == 1 { + "1 peer".to_string() + } else { + format!("{peers} peers") + } + } else { + String::new() + }; + + let lines = vec![ + Line::from(Span::styled(status_text, status_style)), + Line::from(Span::styled( + peer_text, + Style::new().fg(Color::Rgb(120, 125, 135)), + )), + ]; + + frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); +} + +fn render_link_config(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { + let header_style = Style::new() + .fg(Color::Rgb(100, 160, 180)) + .add_modifier(Modifier::BOLD); + + let [header_area, content_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); + + frame.render_widget( + Paragraph::new("Configuration").style(header_style), + header_area, + ); + + let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let normal = Style::new().fg(Color::White); + let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); + + let enabled_focused = app.audio.focus == AudioFocus::LinkEnabled; + let startstop_focused = app.audio.focus == AudioFocus::StartStopSync; + let quantum_focused = app.audio.focus == AudioFocus::Quantum; + + let enabled_text = if link.is_enabled() { "On" } else { "Off" }; + let startstop_text = if link.is_start_stop_sync_enabled() { + "On" + } else { + "Off" + }; + let quantum_text = format!("{:.0}", link.quantum()); + + let rows = vec![ + Row::new(vec![ + Span::styled("Enabled", label_style), + render_selector(enabled_text, enabled_focused, highlight, normal), + ]), + Row::new(vec![ + Span::styled("Start/Stop", label_style), + render_selector(startstop_text, startstop_focused, highlight, normal), + ]), + Row::new(vec![ + Span::styled("Quantum", label_style), + render_selector(&quantum_text, quantum_focused, highlight, normal), + ]), + ]; + + let table = Table::new(rows, [Constraint::Length(10), Constraint::Fill(1)]); + frame.render_widget(table, content_area); +} + +fn render_link_info(frame: &mut Frame, link: &LinkState, area: Rect) { + let header_style = Style::new() + .fg(Color::Rgb(100, 160, 180)) + .add_modifier(Modifier::BOLD); + + let [header_area, content_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); + + frame.render_widget(Paragraph::new("Session").style(header_style), header_area); + + let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); + let value_style = Style::new().fg(Color::Rgb(180, 180, 190)); + let tempo_style = Style::new() + .fg(Color::Rgb(220, 180, 100)) + .add_modifier(Modifier::BOLD); + + let tempo = link.tempo(); + let beat = link.beat(); + let phase = link.phase(); + + let rows = vec![ + Row::new(vec![ + Span::styled("Tempo", label_style), + Span::styled(format!("{tempo:.1} BPM"), tempo_style), + ]), + Row::new(vec![ + Span::styled("Beat", label_style), + Span::styled(format!("{beat:.2}"), value_style), + ]), + Row::new(vec![ + Span::styled("Phase", label_style), + Span::styled(format!("{phase:.2}"), value_style), + ]), + ]; + + let table = Table::new(rows, [Constraint::Length(10), Constraint::Fill(1)]); + frame.render_widget(table, content_area); +} + +fn render_selector(value: &str, focused: bool, highlight: Style, normal: Style) -> Span<'static> { + let style = if focused { highlight } else { normal }; + if focused { + Span::styled(format!("< {value} >"), style) + } else { + Span::styled(format!(" {value} "), style) + } +} diff --git a/src/views/doc_view.rs b/src/views/doc_view.rs new file mode 100644 index 0000000..5b7bea4 --- /dev/null +++ b/src/views/doc_view.rs @@ -0,0 +1,266 @@ +use minimad::{Composite, CompositeStyle, Compound, Line}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line as RLine, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; +use ratatui::Frame; + +use crate::app::App; +use crate::model::forth::{Word, WordCompile, WORDS}; + +const STATIC_DOCS: &[(&str, &str)] = &[ + ("Keybindings", include_str!("../../docs/keybindings.md")), + ("Sequencer", include_str!("../../docs/sequencer.md")), +]; + +const TOPICS: &[&str] = &["Keybindings", "Forth Reference", "Sequencer"]; + +const CATEGORIES: &[&str] = &[ + "Stack", + "Arithmetic", + "Comparison", + "Logic", + "Sound", + "Variables", + "Randomness", + "Probability", + "Context", + "Music", + "Time", + "Parameters", +]; + +pub fn render(frame: &mut Frame, app: &App, area: Rect) { + let [topics_area, content_area] = + Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(area); + + render_topics(frame, app, topics_area); + + let topic = TOPICS[app.ui.doc_topic]; + if topic == "Forth Reference" { + render_forth_reference(frame, app, content_area); + } else { + render_markdown_content(frame, app, content_area, topic); + } +} + +fn render_topics(frame: &mut Frame, app: &App, area: Rect) { + let items: Vec = TOPICS + .iter() + .enumerate() + .map(|(i, name)| { + let style = if i == app.ui.doc_topic { + Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::new().fg(Color::White) + }; + let prefix = if i == app.ui.doc_topic { "> " } else { " " }; + ListItem::new(format!("{prefix}{name}")).style(style) + }) + .collect(); + + let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Topics")); + frame.render_widget(list, area); +} + +fn render_markdown_content(frame: &mut Frame, app: &App, area: Rect, topic: &str) { + let md = STATIC_DOCS + .iter() + .find(|(name, _)| *name == topic) + .map(|(_, content)| *content) + .unwrap_or(""); + let lines = parse_markdown(md); + + let visible_height = area.height.saturating_sub(2) as usize; + let total_lines = lines.len(); + let max_scroll = total_lines.saturating_sub(visible_height); + let scroll = app.ui.doc_scroll.min(max_scroll); + + let visible: Vec = lines + .into_iter() + .skip(scroll) + .take(visible_height) + .collect(); + + let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(topic)); + frame.render_widget(para, area); +} + +fn render_forth_reference(frame: &mut Frame, app: &App, area: Rect) { + let [cat_area, words_area] = + Layout::horizontal([Constraint::Length(14), Constraint::Fill(1)]).areas(area); + + render_categories(frame, app, cat_area); + render_words(frame, app, words_area); +} + +fn render_categories(frame: &mut Frame, app: &App, area: Rect) { + let items: Vec = CATEGORIES + .iter() + .enumerate() + .map(|(i, name)| { + let style = if i == app.ui.doc_category { + Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::new().fg(Color::White) + }; + let prefix = if i == app.ui.doc_category { "> " } else { " " }; + ListItem::new(format!("{prefix}{name}")).style(style) + }) + .collect(); + + let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Category")); + frame.render_widget(list, area); +} + +fn render_words(frame: &mut Frame, app: &App, area: Rect) { + let category = CATEGORIES[app.ui.doc_category]; + let words: Vec<&Word> = WORDS + .iter() + .filter(|w| word_category(w.name, &w.compile) == category) + .collect(); + + let word_style = Style::new().fg(Color::Green).add_modifier(Modifier::BOLD); + let stack_style = Style::new().fg(Color::Magenta); + let desc_style = Style::new().fg(Color::White); + let example_style = Style::new().fg(Color::Rgb(150, 150, 150)); + + let mut lines: Vec = Vec::new(); + + for word in &words { + lines.push(RLine::from(vec![ + Span::styled(format!("{:<14}", word.name), word_style), + Span::styled(format!("{:<18}", word.stack), stack_style), + Span::styled(word.desc.to_string(), desc_style), + ])); + lines.push(RLine::from(vec![ + Span::raw(" "), + Span::styled(format!("e.g. {}", word.example), example_style), + ])); + lines.push(RLine::from("")); + } + + let visible_height = area.height.saturating_sub(2) as usize; + let total_lines = lines.len(); + let max_scroll = total_lines.saturating_sub(visible_height); + let scroll = app.ui.doc_scroll.min(max_scroll); + + let visible: Vec = lines + .into_iter() + .skip(scroll) + .take(visible_height) + .collect(); + + let title = format!("{category} ({} words)", words.len()); + let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(title)); + frame.render_widget(para, area); +} + +fn word_category(name: &str, compile: &WordCompile) -> &'static str { + const STACK: &[&str] = &["dup", "drop", "swap", "over", "rot", "nip", "tuck"]; + const ARITH: &[&str] = &[ + "+", "-", "*", "/", "mod", "neg", "abs", "floor", "ceil", "round", "min", "max", + ]; + const CMP: &[&str] = &["=", "<>", "<", ">", "<=", ">="]; + const LOGIC: &[&str] = &["and", "or", "not"]; + const SOUND: &[&str] = &["sound", "s", "emit"]; + const VAR: &[&str] = &["get", "set"]; + const RAND: &[&str] = &["rand", "rrand", "seed", "coin", "chance", "choose", "cycle"]; + const MUSIC: &[&str] = &["mtof", "ftom"]; + const TIME: &[&str] = &[ + "at", "window", "pop", "div", "each", "tempo!", "[", "]", "?", + ]; + + match compile { + WordCompile::Simple if STACK.contains(&name) => "Stack", + WordCompile::Simple if ARITH.contains(&name) => "Arithmetic", + WordCompile::Simple if CMP.contains(&name) => "Comparison", + WordCompile::Simple if LOGIC.contains(&name) => "Logic", + WordCompile::Simple if SOUND.contains(&name) => "Sound", + WordCompile::Alias(_) => "Sound", + WordCompile::Simple if VAR.contains(&name) => "Variables", + WordCompile::Simple if RAND.contains(&name) => "Randomness", + WordCompile::Probability(_) => "Probability", + WordCompile::Context(_) => "Context", + WordCompile::Simple if MUSIC.contains(&name) => "Music", + WordCompile::Simple if TIME.contains(&name) => "Time", + WordCompile::Param => "Parameters", + _ => "Other", + } +} + +fn parse_markdown(md: &str) -> Vec> { + let text = minimad::Text::from(md); + let mut lines = Vec::new(); + + for line in text.lines { + match line { + Line::Normal(composite) => { + lines.push(composite_to_line(composite)); + } + Line::TableRow(_) | Line::HorizontalRule | Line::CodeFence(_) | Line::TableRule(_) => { + lines.push(RLine::from("")); + } + } + } + + lines +} + +fn composite_to_line(composite: Composite) -> RLine<'static> { + let base_style = match composite.style { + CompositeStyle::Header(1) => Style::new() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + CompositeStyle::Header(2) => Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD), + CompositeStyle::Header(_) => Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD), + CompositeStyle::ListItem(_) => Style::new().fg(Color::White), + CompositeStyle::Quote => Style::new().fg(Color::Rgb(150, 150, 150)), + CompositeStyle::Code => Style::new().fg(Color::Green), + CompositeStyle::Paragraph => Style::new().fg(Color::White), + }; + + let prefix = match composite.style { + CompositeStyle::ListItem(_) => " • ", + CompositeStyle::Quote => " │ ", + _ => "", + }; + + let mut spans: Vec> = Vec::new(); + if !prefix.is_empty() { + spans.push(Span::styled(prefix.to_string(), base_style)); + } + + for compound in composite.compounds { + spans.push(compound_to_span(compound, base_style)); + } + + RLine::from(spans) +} + +fn compound_to_span(compound: Compound, base: Style) -> Span<'static> { + let mut style = base; + + if compound.bold { + style = style.add_modifier(Modifier::BOLD); + } + if compound.italic { + style = style.add_modifier(Modifier::ITALIC); + } + if compound.code { + style = Style::new().fg(Color::Green); + } + if compound.strikeout { + style = style.add_modifier(Modifier::CROSSED_OUT); + } + + Span::styled(compound.src.to_string(), style) +} + +pub fn topic_count() -> usize { + TOPICS.len() +} + +pub fn category_count() -> usize { + CATEGORIES.len() +} diff --git a/src/views/highlight.rs b/src/views/highlight.rs new file mode 100644 index 0000000..54ecc29 --- /dev/null +++ b/src/views/highlight.rs @@ -0,0 +1,299 @@ +use ratatui::style::{Color, Modifier, Style}; + +use crate::model::SourceSpan; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TokenKind { + Number, + String, + Comment, + Keyword, + StackOp, + Operator, + Sound, + Param, + Context, + Default, +} + +impl TokenKind { + pub fn style(self) -> Style { + match self { + TokenKind::Number => Style::default().fg(Color::Rgb(255, 180, 100)), + TokenKind::String => Style::default().fg(Color::Rgb(150, 220, 150)), + TokenKind::Comment => Style::default().fg(Color::Rgb(100, 100, 100)), + TokenKind::Keyword => Style::default().fg(Color::Rgb(220, 120, 220)), + TokenKind::StackOp => Style::default().fg(Color::Rgb(120, 180, 220)), + TokenKind::Operator => Style::default().fg(Color::Rgb(200, 200, 130)), + TokenKind::Sound => Style::default().fg(Color::Rgb(100, 220, 200)), + TokenKind::Param => Style::default().fg(Color::Rgb(180, 150, 220)), + TokenKind::Context => Style::default().fg(Color::Rgb(220, 180, 120)), + TokenKind::Default => Style::default().fg(Color::Rgb(200, 200, 200)), + } + } +} + +pub struct Token { + pub start: usize, + pub end: usize, + pub kind: TokenKind, +} + +const STACK_OPS: &[&str] = &["dup", "drop", "swap", "over", "rot", "nip", "tuck"]; +const OPERATORS: &[&str] = &[ + "+", "-", "*", "/", "mod", "neg", "abs", "min", "max", "=", "<>", "<", ">", "<=", ">=", "and", + "or", "not", +]; +const KEYWORDS: &[&str] = &[ + "if", "else", "then", "emit", "get", "set", "rand", "rrand", "seed", "cycle", "choose", + "chance", "[", "]", +]; +const SOUND: &[&str] = &["sound", "s"]; +const CONTEXT: &[&str] = &[ + "step", "beat", "bank", "pattern", "tempo", "phase", "slot", "runs", +]; +const PARAMS: &[&str] = &[ + "time", + "repeat", + "dur", + "gate", + "freq", + "detune", + "speed", + "glide", + "pw", + "spread", + "mult", + "warp", + "mirror", + "harmonics", + "timbre", + "morph", + "begin", + "end", + "gain", + "postgain", + "velocity", + "pan", + "attack", + "decay", + "sustain", + "release", + "lpf", + "lpq", + "lpe", + "lpa", + "lpd", + "lps", + "lpr", + "hpf", + "hpq", + "hpe", + "hpa", + "hpd", + "hps", + "hpr", + "bpf", + "bpq", + "bpe", + "bpa", + "bpd", + "bps", + "bpr", + "ftype", + "penv", + "patt", + "pdec", + "psus", + "prel", + "vib", + "vibmod", + "vibshape", + "fm", + "fmh", + "fmshape", + "fme", + "fma", + "fmd", + "fms", + "fmr", + "am", + "amdepth", + "amshape", + "rm", + "rmdepth", + "rmshape", + "phaser", + "phaserdepth", + "phasersweep", + "phasercenter", + "flanger", + "flangerdepth", + "flangerfeedback", + "chorus", + "chorusdepth", + "chorusdelay", + "comb", + "combfreq", + "combfeedback", + "combdamp", + "coarse", + "crush", + "fold", + "wrap", + "distort", + "distortvol", + "delay", + "delaytime", + "delayfeedback", + "delaytype", + "verb", + "verbdecay", + "verbdamp", + "verbpredelay", + "verbdiff", + "voice", + "orbit", + "note", + "size", + "n", + "cut", + "reset", +]; + +pub fn tokenize_line(line: &str) -> Vec { + let mut tokens = Vec::new(); + let mut chars = line.char_indices().peekable(); + + while let Some((start, c)) = chars.next() { + if c.is_whitespace() { + continue; + } + + if c == '(' { + let end = line.len(); + let comment_end = line[start..] + .find(')') + .map(|i| start + i + 1) + .unwrap_or(end); + tokens.push(Token { + start, + end: comment_end, + kind: TokenKind::Comment, + }); + while let Some((i, _)) = chars.peek() { + if *i >= comment_end { + break; + } + chars.next(); + } + continue; + } + + if c == '"' { + let mut end = start + 1; + for (i, ch) in chars.by_ref() { + end = i + ch.len_utf8(); + if ch == '"' { + break; + } + } + tokens.push(Token { + start, + end, + kind: TokenKind::String, + }); + continue; + } + + let mut end = start + c.len_utf8(); + while let Some((i, ch)) = chars.peek() { + if ch.is_whitespace() { + break; + } + end = *i + ch.len_utf8(); + chars.next(); + } + + let word = &line[start..end]; + let kind = classify_word(word); + tokens.push(Token { start, end, kind }); + } + + tokens +} + +fn classify_word(word: &str) -> TokenKind { + if word.parse::().is_ok() || word.parse::().is_ok() { + return TokenKind::Number; + } + + if STACK_OPS.contains(&word) { + return TokenKind::StackOp; + } + + if OPERATORS.contains(&word) { + return TokenKind::Operator; + } + + if KEYWORDS.contains(&word) { + return TokenKind::Keyword; + } + + if SOUND.contains(&word) { + return TokenKind::Sound; + } + + if CONTEXT.contains(&word) { + return TokenKind::Context; + } + + if PARAMS.contains(&word) { + return TokenKind::Param; + } + + TokenKind::Default +} + +pub fn highlight_line(line: &str) -> Vec<(Style, String)> { + highlight_line_with_runtime(line, &[]) +} + +pub fn highlight_line_with_runtime(line: &str, runtime_spans: &[SourceSpan]) -> Vec<(Style, String)> { + let tokens = tokenize_line(line); + let mut result = Vec::new(); + let mut last_end = 0; + + let runtime_bg = Color::Rgb(80, 60, 20); + + for token in tokens { + if token.start > last_end { + result.push(( + TokenKind::Default.style(), + line[last_end..token.start].to_string(), + )); + } + + let is_runtime = runtime_spans + .iter() + .any(|span| overlaps(token.start, token.end, span.start, span.end)); + + let mut style = token.kind.style(); + if is_runtime { + style = style.bg(runtime_bg).add_modifier(Modifier::BOLD); + } + + result.push((style, line[token.start..token.end].to_string())); + last_end = token.end; + } + + if last_end < line.len() { + result.push((TokenKind::Default.style(), line[last_end..].to_string())); + } + + result +} + +fn overlaps(a_start: usize, a_end: usize, b_start: usize, b_end: usize) -> bool { + a_start < b_end && b_start < a_end +} diff --git a/src/views/main_view.rs b/src/views/main_view.rs new file mode 100644 index 0000000..f2ce946 --- /dev/null +++ b/src/views/main_view.rs @@ -0,0 +1,201 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::app::App; +use crate::engine::SequencerSnapshot; +use crate::views::highlight::{highlight_line, highlight_line_with_runtime}; +use crate::widgets::{Orientation, Scope, VuMeter}; + +pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) { + let [left_area, _spacer, vu_area] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(2), + Constraint::Length(8), + ]) + .areas(area); + + let [scope_area, sequencer_area, preview_area] = Layout::vertical([ + Constraint::Length(8), + Constraint::Fill(1), + Constraint::Length(2), + ]) + .areas(left_area); + + render_scope(frame, app, scope_area); + render_sequencer(frame, app, snapshot, sequencer_area); + render_step_preview(frame, app, snapshot, preview_area); + render_vu_meter(frame, app, vu_area); +} + +fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { + if area.width < 50 { + let msg = Paragraph::new("Terminal too narrow") + .alignment(Alignment::Center) + .style(Style::new().fg(Color::Rgb(120, 125, 135))); + frame.render_widget(msg, area); + return; + } + + let pattern = app.current_edit_pattern(); + let length = pattern.length; + let num_rows = match length { + 0..=8 => 1, + 9..=16 => 2, + 17..=24 => 3, + _ => 4, + }; + let steps_per_row = length.div_ceil(num_rows); + + let spacing = num_rows.saturating_sub(1) as u16; + let row_height = area.height.saturating_sub(spacing) / num_rows as u16; + + let row_constraints: Vec = (0..num_rows * 2 - 1) + .map(|i| { + if i % 2 == 0 { + Constraint::Length(row_height) + } else { + Constraint::Length(1) + } + }) + .collect(); + let rows = Layout::vertical(row_constraints).split(area); + + for row_idx in 0..num_rows { + let row_area = rows[row_idx * 2]; + let start_step = row_idx * steps_per_row; + let end_step = (start_step + steps_per_row).min(length); + let cols_in_row = end_step - start_step; + + let col_constraints: Vec = (0..cols_in_row * 2 - 1) + .map(|i| { + if i % 2 == 0 { + Constraint::Fill(1) + } else if i == cols_in_row - 1 { + Constraint::Length(2) + } else { + Constraint::Length(1) + } + }) + .collect(); + let cols = Layout::horizontal(col_constraints).split(row_area); + + for col_idx in 0..cols_in_row { + let step_idx = start_step + col_idx; + if step_idx < length { + render_tile(frame, cols[col_idx * 2], app, snapshot, step_idx); + } + } + } +} + +fn render_tile( + frame: &mut Frame, + area: Rect, + app: &App, + snapshot: &SequencerSnapshot, + step_idx: usize, +) { + let pattern = app.current_edit_pattern(); + let step = pattern.step(step_idx); + let is_active = step.map(|s| s.active).unwrap_or(false); + let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false); + let is_selected = step_idx == app.editor_ctx.step; + + let is_playing = if app.playback.playing { + snapshot.get_step(app.editor_ctx.bank, app.editor_ctx.pattern) == Some(step_idx) + } else { + false + }; + + let (bg, fg) = match (is_playing, is_active, is_selected, is_linked) { + (true, true, _, _) => (Color::Rgb(195, 85, 65), Color::White), + (true, false, _, _) => (Color::Rgb(180, 120, 45), Color::Black), + (false, true, true, true) => (Color::Rgb(180, 140, 220), Color::Black), + (false, true, true, false) => (Color::Rgb(0, 220, 180), Color::Black), + (false, true, false, true) => (Color::Rgb(90, 70, 120), Color::White), + (false, true, false, false) => (Color::Rgb(45, 106, 95), Color::White), + (false, false, true, _) => (Color::Rgb(80, 180, 255), Color::Black), + (false, false, false, _) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)), + }; + + let symbol = if is_playing { + "▶".to_string() + } else if let Some(source) = step.and_then(|s| s.source) { + format!("→{:02}", source + 1) + } else { + format!("{:02}", step_idx + 1) + }; + + let tile = Paragraph::new(symbol) + .alignment(Alignment::Center) + .style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD)); + + frame.render_widget(tile, area); +} + +fn render_scope(frame: &mut Frame, app: &App, area: Rect) { + let scope = Scope::new(&app.metrics.scope) + .orientation(Orientation::Horizontal) + .color(Color::Green); + frame.render_widget(scope, area); +} + +fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) { + let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right); + frame.render_widget(vu, area); +} + +fn render_step_preview(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { + let pattern = app.current_edit_pattern(); + let step_idx = app.editor_ctx.step; + let step = pattern.step(step_idx); + + let [title_area, content_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); + + let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false); + let source_idx = step.and_then(|s| s.source); + + let title = if let Some(src) = source_idx { + format!(" Step {:02} → {:02} ", step_idx + 1, src + 1) + } else { + format!(" Step {:02} ", step_idx + 1) + }; + let title_color = if is_linked { + Color::Rgb(180, 140, 220) + } else { + Color::Rgb(120, 125, 135) + }; + let title_p = Paragraph::new(title).style(Style::new().fg(title_color)); + frame.render_widget(title_p, title_area); + + let script = pattern.resolve_script(step_idx).unwrap_or(""); + if script.is_empty() { + let empty = Paragraph::new(" (empty)").style(Style::new().fg(Color::Rgb(80, 85, 95))); + frame.render_widget(empty, content_area); + return; + } + + let runtime_spans = if app.ui.runtime_highlight && app.playback.playing { + snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern) + } else { + None + }; + + let spans: Vec<_> = if let Some(traces) = runtime_spans { + highlight_line_with_runtime(script, traces) + } else { + highlight_line(script) + } + .into_iter() + .map(|(style, text)| ratatui::text::Span::styled(text, style)) + .collect(); + let mut line_spans = vec![ratatui::text::Span::raw(" ")]; + line_spans.extend(spans); + let line = Line::from(line_spans); + let paragraph = Paragraph::new(line); + frame.render_widget(paragraph, content_area); +} diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 0000000..702d23a --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,9 @@ +pub mod audio_view; +pub mod doc_view; +pub mod highlight; +pub mod main_view; +pub mod patterns_view; +mod render; +pub mod title_view; + +pub use render::render; diff --git a/src/views/patterns_view.rs b/src/views/patterns_view.rs new file mode 100644 index 0000000..965e066 --- /dev/null +++ b/src/views/patterns_view.rs @@ -0,0 +1,297 @@ +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; +use ratatui::Frame; + +use crate::app::App; +use crate::engine::SequencerSnapshot; +use crate::state::PatternsColumn; + +pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { + let [banks_area, gap, patterns_area] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .areas(area); + + render_banks(frame, app, snapshot, banks_area); + // gap is just empty space + let _ = gap; + render_patterns(frame, app, snapshot, patterns_area); +} + +fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { + let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks); + + let [title_area, inner] = + Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area); + + let title_color = if is_focused { + Color::Rgb(100, 160, 180) + } else { + Color::Rgb(70, 75, 85) + }; + let title = Paragraph::new("Banks") + .style(Style::new().fg(title_color)) + .alignment(ratatui::layout::Alignment::Center); + frame.render_widget(title, title_area); + + let banks_with_playback: Vec = snapshot + .active_patterns + .iter() + .map(|p| p.bank) + .collect(); + + let banks_with_queued: Vec = app + .playback + .queued_changes + .iter() + .filter_map(|c| match c { + crate::engine::PatternChange::Start { bank, .. } => Some(*bank), + _ => None, + }) + .collect(); + + let row_height = (inner.height / 16).max(1); + let total_needed = row_height * 16; + let top_padding = if inner.height > total_needed { + (inner.height - total_needed) / 2 + } else { + 0 + }; + + for idx in 0..16 { + let y = inner.y + top_padding + (idx as u16) * row_height; + if y >= inner.y + inner.height { + break; + } + + let row_area = Rect { + x: inner.x, + y, + width: inner.width, + height: row_height.min(inner.y + inner.height - y), + }; + + let is_cursor = is_focused && idx == app.patterns_nav.bank_cursor; + let is_selected = idx == app.patterns_nav.bank_cursor; + let is_edit = idx == app.editor_ctx.bank; + let is_playing = banks_with_playback.contains(&idx); + let is_queued = banks_with_queued.contains(&idx); + + let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued) { + (true, _, _) => (Color::Cyan, Color::Black, ""), + (false, true, _) => (Color::Rgb(45, 80, 45), Color::Green, "> "), + (false, false, true) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "), + (false, false, false) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""), + (false, false, false) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""), + (false, false, false) => (Color::Reset, Color::Rgb(120, 125, 135), ""), + }; + + let name = app.project_state.project.banks[idx] + .name + .as_deref() + .unwrap_or(""); + let label = if name.is_empty() { + format!("{}{:02}", prefix, idx + 1) + } else { + format!("{}{:02} {}", prefix, idx + 1, name) + }; + + let style = Style::new().bg(bg).fg(fg); + let style = if is_playing || is_queued { + style.add_modifier(Modifier::BOLD) + } else { + style + }; + + // Fill the entire row with background color + let bg_block = Block::default().style(Style::new().bg(bg)); + frame.render_widget(bg_block, row_area); + + let text_y = if row_height > 1 { + row_area.y + (row_height - 1) / 2 + } else { + row_area.y + }; + let text_area = Rect { + x: row_area.x, + y: text_y, + width: row_area.width, + height: 1, + }; + let para = Paragraph::new(label).style(style); + frame.render_widget(para, text_area); + } +} + +fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { + use crate::model::PatternSpeed; + + let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns); + + let [title_area, inner] = + Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area); + + let title_color = if is_focused { + Color::Rgb(100, 160, 180) + } else { + Color::Rgb(70, 75, 85) + }; + + let bank = app.patterns_nav.bank_cursor; + let bank_name = app.project_state.project.banks[bank].name.as_deref(); + let title_text = match bank_name { + Some(name) => format!("Patterns ({name})"), + None => format!("Patterns (Bank {:02})", bank + 1), + }; + let title = Paragraph::new(title_text) + .style(Style::new().fg(title_color)) + .alignment(ratatui::layout::Alignment::Center); + frame.render_widget(title, title_area); + + let playing_patterns: Vec = snapshot + .active_patterns + .iter() + .filter(|p| p.bank == bank) + .map(|p| p.pattern) + .collect(); + + let queued_to_play: Vec = app + .playback + .queued_changes + .iter() + .filter_map(|c| match c { + crate::engine::PatternChange::Start { + bank: b, pattern, .. + } if *b == bank => Some(*pattern), + _ => None, + }) + .collect(); + + let queued_to_stop: Vec = app + .playback + .queued_changes + .iter() + .filter_map(|c| match c { + crate::engine::PatternChange::Stop { + bank: b, + pattern, + } if *b == bank => Some(*pattern), + _ => None, + }) + .collect(); + + let edit_pattern = if app.editor_ctx.bank == bank { + Some(app.editor_ctx.pattern) + } else { + None + }; + + let row_height = (inner.height / 16).max(1); + let total_needed = row_height * 16; + let top_padding = if inner.height > total_needed { + (inner.height - total_needed) / 2 + } else { + 0 + }; + + for idx in 0..16 { + let y = inner.y + top_padding + (idx as u16) * row_height; + if y >= inner.y + inner.height { + break; + } + + let row_area = Rect { + x: inner.x, + y, + width: inner.width, + height: row_height.min(inner.y + inner.height - y), + }; + + let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor; + let is_selected = idx == app.patterns_nav.pattern_cursor; + let is_edit = edit_pattern == Some(idx); + let is_playing = playing_patterns.contains(&idx); + let is_queued_play = queued_to_play.contains(&idx); + let is_queued_stop = queued_to_stop.contains(&idx); + + let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued_play, is_queued_stop) { + (true, _, _, _) => (Color::Cyan, Color::Black, ""), + (false, true, _, true) => (Color::Rgb(120, 90, 30), Color::Yellow, "x "), + (false, true, _, false) => (Color::Rgb(45, 80, 45), Color::Green, "> "), + (false, false, true, _) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "), + (false, false, false, _) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""), + (false, false, false, _) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""), + (false, false, false, _) => (Color::Reset, Color::Rgb(120, 125, 135), ""), + }; + + let pattern = &app.project_state.project.banks[bank].patterns[idx]; + let name = pattern.name.as_deref().unwrap_or(""); + let length = pattern.length; + let speed = pattern.speed; + + let base_style = Style::new().bg(bg).fg(fg); + let bold_style = base_style.add_modifier(Modifier::BOLD); + + // Fill the entire row with background color + let bg_block = Block::default().style(Style::new().bg(bg)); + frame.render_widget(bg_block, row_area); + + let text_y = if row_height > 1 { + row_area.y + (row_height - 1) / 2 + } else { + row_area.y + }; + + // Split row into columns: [index+name] [length] [speed] + let speed_width: u16 = 14; // "Speed: 1/4x " + let length_width: u16 = 13; // "Length: 16 " + let name_width = row_area + .width + .saturating_sub(speed_width + length_width + 2); + + let [name_area, length_area, speed_area] = Layout::horizontal([ + Constraint::Length(name_width), + Constraint::Length(length_width), + Constraint::Length(speed_width), + ]) + .areas(Rect { + x: row_area.x, + y: text_y, + width: row_area.width, + height: 1, + }); + + // Column 1: prefix + index + name (left-aligned) + let name_text = if name.is_empty() { + format!("{}{:02}", prefix, idx + 1) + } else { + format!("{}{:02} {}", prefix, idx + 1, name) + }; + let name_style = if is_playing || is_queued_play { + bold_style + } else { + base_style + }; + frame.render_widget(Paragraph::new(name_text).style(name_style), name_area); + + // Column 2: length + let length_line = Line::from(vec![ + Span::styled("Length: ", bold_style), + Span::styled(format!("{length}"), base_style), + ]); + frame.render_widget(Paragraph::new(length_line), length_area); + + // Column 3: speed (only if non-default) + if speed != PatternSpeed::Normal { + let speed_line = Line::from(vec![ + Span::styled("Speed: ", bold_style), + Span::styled(speed.label(), base_style), + ]); + frame.render_widget(Paragraph::new(speed_line), speed_area); + } + } +} diff --git a/src/views/render.rs b/src/views/render.rs new file mode 100644 index 0000000..60276fc --- /dev/null +++ b/src/views/render.rs @@ -0,0 +1,448 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::app::App; +use crate::engine::{LinkState, SequencerSnapshot}; +use crate::page::Page; +use crate::state::{Modal, PatternField}; +use crate::views::highlight; +use crate::widgets::{ConfirmModal, ModalFrame, TextInputModal}; + +use super::{audio_view, doc_view, main_view, patterns_view, title_view}; + +pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &SequencerSnapshot) { + let term = frame.area(); + + if app.ui.show_title { + title_view::render(frame, term); + return; + } + + let padded = Rect { + x: term.x + 1, + y: term.y + 1, + width: term.width.saturating_sub(2), + height: term.height.saturating_sub(2), + }; + + let [header_area, _padding, body_area, footer_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(3), + ]) + .areas(padded); + + render_header(frame, app, link, snapshot, header_area); + + match app.page { + Page::Main => main_view::render(frame, app, snapshot, body_area), + Page::Patterns => patterns_view::render(frame, app, snapshot, body_area), + Page::Audio => audio_view::render(frame, app, link, body_area), + Page::Doc => doc_view::render(frame, app, body_area), + } + + render_footer(frame, app, footer_area); + render_modal(frame, app, snapshot, term); +} + +fn render_header( + frame: &mut Frame, + app: &App, + link: &LinkState, + snapshot: &SequencerSnapshot, + area: Rect, +) { + use crate::model::PatternSpeed; + + let bank = &app.project_state.project.banks[app.editor_ctx.bank]; + let pattern = &bank.patterns[app.editor_ctx.pattern]; + + // Layout: [Transport] [Live] [Tempo] [Bank] [Pattern] [Stats] + let [transport_area, live_area, tempo_area, bank_area, pattern_area, stats_area] = + Layout::horizontal([ + Constraint::Min(12), + Constraint::Length(9), + Constraint::Min(14), + Constraint::Fill(1), + Constraint::Fill(2), + Constraint::Min(20), + ]) + .areas(area); + + // Transport block + let (transport_bg, transport_text) = if app.playback.playing { + (Color::Rgb(30, 80, 30), " ▶ PLAYING ") + } else { + (Color::Rgb(80, 30, 30), " ■ STOPPED ") + }; + let transport_style = Style::new().bg(transport_bg).fg(Color::White); + frame.render_widget( + Paragraph::new(transport_text) + .style(transport_style) + .alignment(Alignment::Center), + transport_area, + ); + + // Fill indicator + let fill = app.live_keys.fill(); + let fill_style = if fill { + Style::new().bg(Color::Rgb(30, 30, 35)).fg(Color::Rgb(100, 220, 100)) + } else { + Style::new().bg(Color::Rgb(30, 30, 35)).fg(Color::Rgb(60, 60, 70)) + }; + frame.render_widget( + Paragraph::new(if fill { "F" } else { "·" }) + .style(fill_style) + .alignment(Alignment::Center), + live_area, + ); + + // Tempo block + let tempo_style = Style::new() + .bg(Color::Rgb(60, 30, 60)) + .fg(Color::White) + .add_modifier(Modifier::BOLD); + frame.render_widget( + Paragraph::new(format!(" {:.1} BPM ", link.tempo())) + .style(tempo_style) + .alignment(Alignment::Center), + tempo_area, + ); + + // Bank block + let bank_name = bank + .name + .as_deref() + .map(|n| format!(" {n} ")) + .unwrap_or_else(|| format!(" Bank {:02} ", app.editor_ctx.bank + 1)); + let bank_style = Style::new().bg(Color::Rgb(30, 60, 70)).fg(Color::White); + frame.render_widget( + Paragraph::new(bank_name) + .style(bank_style) + .alignment(Alignment::Center), + bank_area, + ); + + // Pattern block (name + length + speed + iter) + let default_pattern_name = format!("Pattern {:02}", app.editor_ctx.pattern + 1); + let pattern_name = pattern.name.as_deref().unwrap_or(&default_pattern_name); + let speed_info = if pattern.speed != PatternSpeed::Normal { + format!(" · {}", pattern.speed.label()) + } else { + String::new() + }; + let iter_info = snapshot + .get_iter(app.editor_ctx.bank, app.editor_ctx.pattern) + .map(|iter| format!(" · #{}", iter + 1)) + .unwrap_or_default(); + let pattern_text = format!( + " {} · {} steps{}{} ", + pattern_name, pattern.length, speed_info, iter_info + ); + let pattern_style = Style::new().bg(Color::Rgb(30, 50, 50)).fg(Color::White); + frame.render_widget( + Paragraph::new(pattern_text) + .style(pattern_style) + .alignment(Alignment::Center), + pattern_area, + ); + + // Stats block + let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0); + let peers = link.peers(); + let voices = app.metrics.active_voices; + let stats_text = format!(" CPU {cpu_pct:.0}% V:{voices} L:{peers} "); + let stats_style = Style::new() + .bg(Color::Rgb(35, 35, 40)) + .fg(Color::Rgb(150, 150, 160)); + frame.render_widget( + Paragraph::new(stats_text) + .style(stats_style) + .alignment(Alignment::Right), + stats_area, + ); +} + +fn render_footer(frame: &mut Frame, app: &App, area: Rect) { + let block = Block::default().borders(Borders::ALL); + let inner = block.inner(area); + let available_width = inner.width as usize; + + let page_indicator = match app.page { + Page::Main => "[MAIN]", + Page::Patterns => "[PATTERNS]", + Page::Audio => "[AUDIO]", + Page::Doc => "[DOC]", + }; + + let content = if let Some(ref msg) = app.ui.status_message { + Line::from(vec![ + Span::styled( + page_indicator.to_string(), + Style::new().fg(Color::White).add_modifier(Modifier::DIM), + ), + Span::raw(" "), + Span::styled(msg.clone(), Style::new().fg(Color::Yellow)), + ]) + } else { + let bindings: Vec<(&str, &str)> = match app.page { + Page::Main => vec![ + ("←→↑↓", "nav"), + ("t", "toggle"), + ("Enter", "edit"), + ("<>", "len"), + ("[]", "spd"), + ("f", "fill"), + ], + Page::Patterns => vec![ + ("←→↑↓", "nav"), + ("Enter", "select"), + ("Space", "play"), + ("Esc", "back"), + ], + Page::Audio => vec![ + ("q", "quit"), + ("h", "hush"), + ("p", "panic"), + ("r", "reset"), + ("t", "test"), + ("C-←→", "page"), + ], + Page::Doc => vec![("j/k", "topic"), ("PgUp/Dn", "scroll"), ("C-←→", "page")], + }; + + let page_width = page_indicator.chars().count(); + let bindings_content_width: usize = bindings + .iter() + .map(|(k, a)| k.chars().count() + 1 + a.chars().count()) + .sum(); + + let n = bindings.len(); + let total_content = page_width + bindings_content_width; + let total_gaps = available_width.saturating_sub(total_content); + let gap_count = n + 1; + let base_gap = total_gaps / gap_count; + let extra = total_gaps % gap_count; + + let mut spans = vec![ + Span::styled( + page_indicator.to_string(), + Style::new().fg(Color::White).add_modifier(Modifier::DIM), + ), + Span::raw(" ".repeat(base_gap + if extra > 0 { 1 } else { 0 })), + ]; + + for (i, (key, action)) in bindings.into_iter().enumerate() { + spans.push(Span::styled( + key.to_string(), + Style::new().fg(Color::Yellow), + )); + spans.push(Span::styled( + format!(" {action}"), + Style::new().fg(Color::Rgb(120, 125, 135)), + )); + + if i < n - 1 { + let gap = base_gap + if i + 1 < extra { 1 } else { 0 }; + spans.push(Span::raw(" ".repeat(gap))); + } + } + + Line::from(spans) + }; + + let footer = Paragraph::new(content).block(block); + frame.render_widget(footer, area); +} + +fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term: Rect) { + match &app.ui.modal { + Modal::None => {} + Modal::ConfirmQuit { selected } => { + ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term); + } + Modal::ConfirmDeleteStep { step, selected, .. } => { + ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected) + .render_centered(frame, term); + } + Modal::ConfirmResetPattern { + pattern, selected, .. + } => { + ConfirmModal::new( + "Confirm", + &format!("Reset pattern {}?", pattern + 1), + *selected, + ) + .render_centered(frame, term); + } + Modal::ConfirmResetBank { bank, selected } => { + ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected) + .render_centered(frame, term); + } + Modal::SaveAs(path) => { + TextInputModal::new("Save As (Enter to confirm, Esc to cancel)", path) + .width(60) + .border_color(Color::Green) + .render_centered(frame, term); + } + Modal::LoadFrom(path) => { + TextInputModal::new("Load From (Enter to confirm, Esc to cancel)", path) + .width(60) + .border_color(Color::Blue) + .render_centered(frame, term); + } + Modal::RenameBank { bank, name } => { + TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name) + .width(40) + .border_color(Color::Magenta) + .render_centered(frame, term); + } + Modal::RenamePattern { + bank, + pattern, + name, + } => { + TextInputModal::new( + &format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1), + name, + ) + .width(40) + .border_color(Color::Magenta) + .render_centered(frame, term); + } + Modal::SetPattern { field, input } => { + let (title, hint) = match field { + PatternField::Length => ("Set Length (2-32)", "Enter number"), + PatternField::Speed => ("Set Speed", "1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x"), + }; + TextInputModal::new(title, input) + .hint(hint) + .width(45) + .border_color(Color::Yellow) + .render_centered(frame, term); + } + Modal::SetTempo(input) => { + TextInputModal::new("Set Tempo (20-300 BPM)", input) + .hint("Enter BPM") + .width(30) + .border_color(Color::Magenta) + .render_centered(frame, term); + } + Modal::AddSamplePath(path) => { + TextInputModal::new("Add Sample Path", path) + .hint("Enter directory path containing samples") + .width(60) + .border_color(Color::Magenta) + .render_centered(frame, term); + } + Modal::Editor => { + let width = (term.width * 80 / 100).max(40); + let height = (term.height * 60 / 100).max(10); + let step_num = app.editor_ctx.step + 1; + + let border_color = if app.ui.is_flashing() { + Color::Green + } else { + Color::Rgb(100, 160, 180) + }; + + let inner = ModalFrame::new(&format!("Step {step_num:02} Script")) + .width(width) + .height(height) + .border_color(border_color) + .render_centered(frame, term); + + let (cursor_row, cursor_col) = app.editor_ctx.text.cursor(); + + let runtime_spans = if app.ui.runtime_highlight && app.playback.playing { + snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern) + } else { + None + }; + + let text_lines = app.editor_ctx.text.lines(); + let mut line_offsets: Vec = Vec::with_capacity(text_lines.len()); + let mut offset = 0; + for line in text_lines.iter() { + line_offsets.push(offset); + offset += line.len() + 1; + } + + let lines: Vec = text_lines + .iter() + .enumerate() + .map(|(row, line)| { + let mut spans: Vec = Vec::new(); + + let line_start = line_offsets[row]; + let line_end = line_start + line.len(); + let adjusted_spans: Vec = runtime_spans + .map(|rs| { + rs.iter() + .filter_map(|s| { + if s.start < line_end && s.end > line_start { + Some(crate::model::SourceSpan { + start: s.start.saturating_sub(line_start), + end: s.end.saturating_sub(line_start).min(line.len()), + }) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default(); + + let tokens = highlight::highlight_line_with_runtime(line, &adjusted_spans); + + if row == cursor_row { + let mut col = 0; + for (style, text) in tokens { + let text_len = text.chars().count(); + if cursor_col >= col && cursor_col < col + text_len { + let before = + text.chars().take(cursor_col - col).collect::(); + let cursor_char = text.chars().nth(cursor_col - col).unwrap_or(' '); + let after = + text.chars().skip(cursor_col - col + 1).collect::(); + + if !before.is_empty() { + spans.push(Span::styled(before, style)); + } + spans.push(Span::styled( + cursor_char.to_string(), + Style::default().bg(Color::White).fg(Color::Black), + )); + if !after.is_empty() { + spans.push(Span::styled(after, style)); + } + } else { + spans.push(Span::styled(text, style)); + } + col += text_len; + } + if cursor_col >= col { + spans.push(Span::styled( + " ", + Style::default().bg(Color::White).fg(Color::Black), + )); + } + } else { + for (style, text) in tokens { + spans.push(Span::styled(text, style)); + } + } + + Line::from(spans) + }) + .collect(); + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, inner); + } + } +} diff --git a/src/views/title_view.rs b/src/views/title_view.rs new file mode 100644 index 0000000..843b3c5 --- /dev/null +++ b/src/views/title_view.rs @@ -0,0 +1,51 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +pub fn render(frame: &mut Frame, area: Rect) { + let title_style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD); + let subtitle_style = Style::new().fg(Color::White); + let dim_style = Style::new() + .fg(Color::Rgb(120, 125, 135)) + .add_modifier(Modifier::DIM); + let link_style = Style::new().fg(Color::Rgb(100, 160, 180)); + + let lines = vec![ + Line::from(""), + Line::from(""), + Line::from(Span::styled("seq", title_style)), + Line::from(""), + Line::from(Span::styled("A Forth Music Sequencer", subtitle_style)), + Line::from(""), + Line::from(""), + Line::from(Span::styled("by BuboBubo", dim_style)), + Line::from(Span::styled("Raphael Maurice Forment", dim_style)), + Line::from(""), + Line::from(Span::styled("https://raphaelforment.fr", link_style)), + Line::from(""), + Line::from(""), + Line::from(Span::styled("AGPL-3.0", dim_style)), + Line::from(""), + Line::from(""), + Line::from(""), + Line::from(Span::styled( + "Press any key to continue", + Style::new().fg(Color::DarkGray), + )), + ]; + + let text_height = lines.len() as u16; + let vertical_padding = area.height.saturating_sub(text_height) / 2; + + let [_, center_area, _] = Layout::vertical([ + Constraint::Length(vertical_padding), + Constraint::Length(text_height), + Constraint::Fill(1), + ]) + .areas(area); + + let paragraph = Paragraph::new(lines).alignment(Alignment::Center); + frame.render_widget(paragraph, center_area); +} diff --git a/src/widgets/confirm.rs b/src/widgets/confirm.rs new file mode 100644 index 0000000..1332426 --- /dev/null +++ b/src/widgets/confirm.rs @@ -0,0 +1,60 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::ModalFrame; + +pub struct ConfirmModal<'a> { + title: &'a str, + message: &'a str, + selected: bool, +} + +impl<'a> ConfirmModal<'a> { + pub fn new(title: &'a str, message: &'a str, selected: bool) -> Self { + Self { + title, + message, + selected, + } + } + + pub fn render_centered(self, frame: &mut Frame, term: Rect) { + let inner = ModalFrame::new(self.title) + .width(30) + .height(5) + .border_color(Color::Yellow) + .render_centered(frame, term); + + let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner); + + frame.render_widget( + Paragraph::new(self.message).alignment(Alignment::Center), + rows[0], + ); + + let yes_style = if self.selected { + Style::new().fg(Color::Black).bg(Color::Yellow) + } else { + Style::default() + }; + let no_style = if !self.selected { + Style::new().fg(Color::Black).bg(Color::Yellow) + } else { + Style::default() + }; + + let buttons = Line::from(vec![ + Span::styled(" Yes ", yes_style), + Span::raw(" "), + Span::styled(" No ", no_style), + ]); + + frame.render_widget( + Paragraph::new(buttons).alignment(Alignment::Center), + rows[1], + ); + } +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..977a199 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,11 @@ +mod confirm; +mod modal; +mod scope; +mod text_input; +mod vu_meter; + +pub use confirm::ConfirmModal; +pub use modal::ModalFrame; +pub use scope::{Orientation, Scope}; +pub use text_input::TextInputModal; +pub use vu_meter::VuMeter; diff --git a/src/widgets/modal.rs b/src/widgets/modal.rs new file mode 100644 index 0000000..f3443fa --- /dev/null +++ b/src/widgets/modal.rs @@ -0,0 +1,58 @@ +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Borders, Clear}; +use ratatui::Frame; + +pub struct ModalFrame<'a> { + title: &'a str, + width: u16, + height: u16, + border_color: Color, +} + +impl<'a> ModalFrame<'a> { + pub fn new(title: &'a str) -> Self { + Self { + title, + width: 40, + height: 5, + border_color: Color::White, + } + } + + pub fn width(mut self, w: u16) -> Self { + self.width = w; + self + } + + pub fn height(mut self, h: u16) -> Self { + self.height = h; + self + } + + pub fn border_color(mut self, c: Color) -> Self { + self.border_color = c; + self + } + + pub fn render_centered(&self, frame: &mut Frame, term: Rect) -> Rect { + let width = self.width.min(term.width.saturating_sub(4)); + let height = self.height.min(term.height.saturating_sub(4)); + + let x = term.x + (term.width.saturating_sub(width)) / 2; + let y = term.y + (term.height.saturating_sub(height)) / 2; + let area = Rect::new(x, y, width, height); + + frame.render_widget(Clear, area); + + let block = Block::default() + .borders(Borders::ALL) + .title(self.title) + .border_style(Style::new().fg(self.border_color)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + inner + } +} diff --git a/src/widgets/scope.rs b/src/widgets/scope.rs new file mode 100644 index 0000000..93140ea --- /dev/null +++ b/src/widgets/scope.rs @@ -0,0 +1,149 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::widgets::Widget; + +#[allow(dead_code)] +pub enum Orientation { + Horizontal, + Vertical, +} + +pub struct Scope<'a> { + data: &'a [f32], + orientation: Orientation, + color: Color, + gain: f32, +} + +impl<'a> Scope<'a> { + pub fn new(data: &'a [f32]) -> Self { + Self { + data, + orientation: Orientation::Horizontal, + color: Color::Green, + gain: 1.0, + } + } + + pub fn orientation(mut self, o: Orientation) -> Self { + self.orientation = o; + self + } + + pub fn color(mut self, c: Color) -> Self { + self.color = c; + self + } +} + +impl Widget for Scope<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 || self.data.is_empty() { + return; + } + + match self.orientation { + Orientation::Horizontal => { + render_horizontal(self.data, area, buf, self.color, self.gain) + } + Orientation::Vertical => render_vertical(self.data, area, buf, self.color, self.gain), + } + } +} + +fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) { + let width = area.width as usize; + let height = area.height as usize; + let fine_width = width * 2; + let fine_height = height * 4; + + let mut patterns = vec![0u8; width * height]; + + for fine_x in 0..fine_width { + let sample_idx = (fine_x * data.len()) / fine_width; + let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0); + + let fine_y = ((1.0 - sample) * 0.5 * (fine_height - 1) as f32).round() as usize; + let fine_y = fine_y.min(fine_height - 1); + + let char_x = fine_x / 2; + let char_y = fine_y / 4; + let dot_x = fine_x % 2; + let dot_y = fine_y % 4; + + let bit = match (dot_x, dot_y) { + (0, 0) => 0x01, + (0, 1) => 0x02, + (0, 2) => 0x04, + (0, 3) => 0x40, + (1, 0) => 0x08, + (1, 1) => 0x10, + (1, 2) => 0x20, + (1, 3) => 0x80, + _ => unreachable!(), + }; + + patterns[char_y * width + char_x] |= bit; + } + + for cy in 0..height { + for cx in 0..width { + let pattern = patterns[cy * width + cx]; + if pattern != 0 { + let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' '); + buf[(area.x + cx as u16, area.y + cy as u16)] + .set_char(ch) + .set_fg(color); + } + } + } +} + +fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) { + let width = area.width as usize; + let height = area.height as usize; + let fine_width = width * 2; + let fine_height = height * 4; + + let mut patterns = vec![0u8; width * height]; + + for fine_y in 0..fine_height { + let sample_idx = (fine_y * data.len()) / fine_height; + let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0); + + let fine_x = ((sample + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize; + let fine_x = fine_x.min(fine_width - 1); + + let char_x = fine_x / 2; + let char_y = fine_y / 4; + let dot_x = fine_x % 2; + let dot_y = fine_y % 4; + + let bit = match (dot_x, dot_y) { + (0, 0) => 0x01, + (0, 1) => 0x02, + (0, 2) => 0x04, + (0, 3) => 0x40, + (1, 0) => 0x08, + (1, 1) => 0x10, + (1, 2) => 0x20, + (1, 3) => 0x80, + _ => unreachable!(), + }; + + patterns[char_y * width + char_x] |= bit; + } + + for cy in 0..height { + for cx in 0..width { + let pattern = patterns[cy * width + cx]; + if pattern != 0 { + let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' '); + buf[(area.x + cx as u16, area.y + cy as u16)] + .set_char(ch) + .set_fg(color); + } + } + } +} diff --git a/src/widgets/text_input.rs b/src/widgets/text_input.rs new file mode 100644 index 0000000..e39e6f3 --- /dev/null +++ b/src/widgets/text_input.rs @@ -0,0 +1,82 @@ +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::ModalFrame; + +pub struct TextInputModal<'a> { + title: &'a str, + input: &'a str, + hint: Option<&'a str>, + border_color: Color, + width: u16, +} + +impl<'a> TextInputModal<'a> { + pub fn new(title: &'a str, input: &'a str) -> Self { + Self { + title, + input, + hint: None, + border_color: Color::White, + width: 50, + } + } + + pub fn hint(mut self, h: &'a str) -> Self { + self.hint = Some(h); + self + } + + pub fn border_color(mut self, c: Color) -> Self { + self.border_color = c; + self + } + + pub fn width(mut self, w: u16) -> Self { + self.width = w; + self + } + + pub fn render_centered(self, frame: &mut Frame, term: Rect) { + let height = if self.hint.is_some() { 6 } else { 5 }; + + let inner = ModalFrame::new(self.title) + .width(self.width) + .height(height) + .border_color(self.border_color) + .render_centered(frame, term); + + if self.hint.is_some() { + let rows = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw("> "), + Span::styled(self.input, Style::new().fg(Color::Cyan)), + Span::styled("█", Style::new().fg(Color::White)), + ])), + rows[0], + ); + + if let Some(hint) = self.hint { + frame.render_widget( + Paragraph::new(Span::styled(hint, Style::new().fg(Color::DarkGray))), + rows[1], + ); + } + } else { + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw("> "), + Span::styled(self.input, Style::new().fg(Color::Cyan)), + Span::styled("█", Style::new().fg(Color::White)), + ])), + inner, + ); + } + } +} diff --git a/src/widgets/vu_meter.rs b/src/widgets/vu_meter.rs new file mode 100644 index 0000000..c5deb64 --- /dev/null +++ b/src/widgets/vu_meter.rs @@ -0,0 +1,81 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::widgets::Widget; + +const DB_MIN: f32 = -48.0; +const DB_MAX: f32 = 3.0; +const DB_RANGE: f32 = DB_MAX - DB_MIN; + +pub struct VuMeter { + left: f32, + right: f32, +} + +impl VuMeter { + pub fn new(left: f32, right: f32) -> Self { + Self { left, right } + } + + fn amplitude_to_db(amp: f32) -> f32 { + if amp <= 0.0 { + DB_MIN + } else { + (20.0 * amp.log10()).clamp(DB_MIN, DB_MAX) + } + } + + fn db_to_normalized(db: f32) -> f32 { + (db - DB_MIN) / DB_RANGE + } + + fn row_to_color(row_position: f32) -> Color { + if row_position > 0.9 { + Color::Red + } else if row_position > 0.75 { + Color::Yellow + } else { + Color::Green + } + } +} + +impl Widget for VuMeter { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width < 3 || area.height == 0 { + return; + } + + let height = area.height as usize; + let half_width = area.width / 2; + let gap = 1u16; + + let left_db = Self::amplitude_to_db(self.left); + let right_db = Self::amplitude_to_db(self.right); + let left_norm = Self::db_to_normalized(left_db); + let right_norm = Self::db_to_normalized(right_db); + + let left_rows = (left_norm * height as f32).round() as usize; + let right_rows = (right_norm * height as f32).round() as usize; + + for row in 0..height { + let y = area.y + area.height - 1 - row as u16; + let row_position = (row as f32 + 0.5) / height as f32; + let color = Self::row_to_color(row_position); + + for col in 0..half_width.saturating_sub(gap) { + let x = area.x + col; + if row < left_rows { + buf[(x, y)].set_char(' ').set_bg(color); + } + } + + for col in 0..half_width.saturating_sub(gap) { + let x = area.x + half_width + gap + col; + if x < area.x + area.width && row < right_rows { + buf[(x, y)].set_char(' ').set_bg(color); + } + } + } + } +} diff --git a/tests/forth.rs b/tests/forth.rs new file mode 100644 index 0000000..954b996 --- /dev/null +++ b/tests/forth.rs @@ -0,0 +1,35 @@ +#[path = "forth/harness.rs"] +mod harness; + +#[path = "forth/arithmetic.rs"] +mod arithmetic; + +#[path = "forth/comparison.rs"] +mod comparison; + +#[path = "forth/context.rs"] +mod context; + +#[path = "forth/control_flow.rs"] +mod control_flow; + +#[path = "forth/errors.rs"] +mod errors; + +#[path = "forth/randomness.rs"] +mod randomness; + +#[path = "forth/sound.rs"] +mod sound; + +#[path = "forth/stack.rs"] +mod stack; + +#[path = "forth/temporal.rs"] +mod temporal; + +#[path = "forth/variables.rs"] +mod variables; + +#[path = "forth/quotations.rs"] +mod quotations; diff --git a/tests/forth/arithmetic.rs b/tests/forth/arithmetic.rs new file mode 100644 index 0000000..a1b122c --- /dev/null +++ b/tests/forth/arithmetic.rs @@ -0,0 +1,152 @@ +use super::harness::*; + +#[test] +fn add_integers() { + expect_int("2 3 +", 5); +} + +#[test] +fn add_floats() { + expect_int("2.5 3.5 +", 6); +} + +#[test] +fn add_mixed() { + expect_float("2 3.5 +", 5.5); +} + +#[test] +fn sub() { + expect_int("10 3 -", 7); +} + +#[test] +fn sub_negative() { + expect_int("3 10 -", -7); +} + +#[test] +fn mul() { + expect_int("4 5 *", 20); +} + +#[test] +fn mul_floats() { + expect_int("2.5 4 *", 10); +} + +#[test] +fn div() { + expect_int("10 2 /", 5); +} + +#[test] +fn div_float_result() { + expect_float("7 2 /", 3.5); +} + +#[test] +fn modulo() { + expect_int("7 3 mod", 1); +} + +#[test] +fn modulo_exact() { + expect_int("9 3 mod", 0); +} + +#[test] +fn neg_int() { + expect_int("5 neg", -5); +} + +#[test] +fn neg_float() { + expect_float("3.5 neg", -3.5); +} + +#[test] +fn neg_double() { + expect_int("-5 neg", 5); +} + +#[test] +fn abs_positive() { + expect_int("5 abs", 5); +} + +#[test] +fn abs_negative() { + expect_int("-5 abs", 5); +} + +#[test] +fn abs_float() { + expect_float("-3.5 abs", 3.5); +} + +#[test] +fn floor() { + expect_int("3.7 floor", 3); +} + +#[test] +fn floor_negative() { + expect_int("-3.2 floor", -4); +} + +#[test] +fn ceil() { + expect_int("3.2 ceil", 4); +} + +#[test] +fn ceil_negative() { + expect_int("-3.7 ceil", -3); +} + +#[test] +fn round_down() { + expect_int("3.4 round", 3); +} + +#[test] +fn round_up() { + expect_int("3.6 round", 4); +} + +#[test] +fn round_half() { + expect_int("3.5 round", 4); +} + +#[test] +fn min() { + expect_int("3 5 min", 3); +} + +#[test] +fn min_reverse() { + expect_int("5 3 min", 3); +} + +#[test] +fn max() { + expect_int("3 5 max", 5); +} + +#[test] +fn max_reverse() { + expect_int("5 3 max", 5); +} + +#[test] +fn chain() { + // (2 + 3) * 4 - 1 = 19 + expect_int("2 3 + 4 * 1 -", 19); +} + +#[test] +fn underflow() { + expect_error("1 +", "stack underflow"); +} diff --git a/tests/forth/comparison.rs b/tests/forth/comparison.rs new file mode 100644 index 0000000..749ad5a --- /dev/null +++ b/tests/forth/comparison.rs @@ -0,0 +1,136 @@ +use super::harness::*; + +#[test] +fn eq_true() { + expect_int("3 3 =", 1); +} + +#[test] +fn eq_false() { + expect_int("3 4 =", 0); +} + +#[test] +fn eq_mixed_types() { + expect_int("3.0 3 =", 1); +} + +#[test] +fn ne_true() { + expect_int("3 4 <>", 1); +} + +#[test] +fn ne_false() { + expect_int("3 3 <>", 0); +} + +#[test] +fn lt_true() { + expect_int("2 3 lt", 1); +} + +#[test] +fn lt_equal() { + expect_int("3 3 lt", 0); +} + +#[test] +fn lt_false() { + expect_int("4 3 lt", 0); +} + +#[test] +fn gt_true() { + expect_int("4 3 gt", 1); +} + +#[test] +fn gt_equal() { + expect_int("3 3 gt", 0); +} + +#[test] +fn gt_false() { + expect_int("2 3 gt", 0); +} + +#[test] +fn le_less() { + expect_int("2 3 <=", 1); +} + +#[test] +fn le_equal() { + expect_int("3 3 <=", 1); +} + +#[test] +fn le_greater() { + expect_int("4 3 <=", 0); +} + +#[test] +fn ge_greater() { + expect_int("4 3 >=", 1); +} + +#[test] +fn ge_equal() { + expect_int("3 3 >=", 1); +} + +#[test] +fn ge_less() { + expect_int("2 3 >=", 0); +} + +#[test] +fn and_tt() { + expect_int("1 1 and", 1); +} + +#[test] +fn and_tf() { + expect_int("1 0 and", 0); +} + +#[test] +fn and_ff() { + expect_int("0 0 and", 0); +} + +#[test] +fn or_tt() { + expect_int("1 1 or", 1); +} + +#[test] +fn or_tf() { + expect_int("1 0 or", 1); +} + +#[test] +fn or_ff() { + expect_int("0 0 or", 0); +} + +#[test] +fn not_true() { + expect_int("1 not", 0); +} + +#[test] +fn not_false() { + expect_int("0 not", 1); +} + +#[test] +fn truthy_nonzero() { + expect_int("5 not", 0); +} + +#[test] +fn truthy_negative() { + expect_int("-1 not", 0); +} diff --git a/tests/forth/context.rs b/tests/forth/context.rs new file mode 100644 index 0000000..9023f50 --- /dev/null +++ b/tests/forth/context.rs @@ -0,0 +1,99 @@ +use super::harness::*; + +#[test] +fn step() { + let ctx = ctx_with(|c| c.step = 7); + let f = run_ctx("step", &ctx); + assert_eq!(stack_int(&f), 7); +} + +#[test] +fn beat() { + let ctx = ctx_with(|c| c.beat = 4.5); + let f = run_ctx("beat", &ctx); + assert!((stack_float(&f) - 4.5).abs() < 1e-9); +} + +#[test] +fn pattern() { + let ctx = ctx_with(|c| c.pattern = 3); + let f = run_ctx("pattern", &ctx); + assert_eq!(stack_int(&f), 3); +} + +#[test] +fn tempo() { + let ctx = ctx_with(|c| c.tempo = 140.0); + let f = run_ctx("tempo", &ctx); + assert!((stack_float(&f) - 140.0).abs() < 1e-9); +} + +#[test] +fn phase() { + let ctx = ctx_with(|c| c.phase = 0.25); + let f = run_ctx("phase", &ctx); + assert!((stack_float(&f) - 0.25).abs() < 1e-9); +} + +#[test] +fn slot() { + let ctx = ctx_with(|c| c.slot = 5); + let f = run_ctx("slot", &ctx); + assert_eq!(stack_int(&f), 5); +} + +#[test] +fn runs() { + let ctx = ctx_with(|c| c.runs = 10); + let f = run_ctx("runs", &ctx); + assert_eq!(stack_int(&f), 10); +} + +#[test] +fn iter() { + let ctx = ctx_with(|c| c.iter = 5); + let f = run_ctx("iter", &ctx); + assert_eq!(stack_int(&f), 5); +} + +#[test] +fn every_true_on_zero() { + let ctx = ctx_with(|c| c.iter = 0); + let f = run_ctx("4 every", &ctx); + assert_eq!(stack_int(&f), 1); +} + +#[test] +fn every_true_on_multiple() { + let ctx = ctx_with(|c| c.iter = 8); + let f = run_ctx("4 every", &ctx); + assert_eq!(stack_int(&f), 1); +} + +#[test] +fn every_false_between() { + for i in 1..4 { + let ctx = ctx_with(|c| c.iter = i); + let f = run_ctx("4 every", &ctx); + assert_eq!(stack_int(&f), 0, "iter={} should be false", i); + } +} + +#[test] +fn every_zero_count() { + expect_error("0 every", "every count must be > 0"); +} + +#[test] +fn stepdur() { + // stepdur = 60.0 / tempo / 4.0 / speed = 60 / 120 / 4 / 1 = 0.125 + let f = run("stepdur"); + assert!((stack_float(&f) - 0.125).abs() < 1e-9); +} + +#[test] +fn context_in_computation() { + let ctx = ctx_with(|c| c.step = 3); + let f = run_ctx("60 step +", &ctx); + assert_eq!(stack_int(&f), 63); +} diff --git a/tests/forth/control_flow.rs b/tests/forth/control_flow.rs new file mode 100644 index 0000000..dedf9fe --- /dev/null +++ b/tests/forth/control_flow.rs @@ -0,0 +1,64 @@ +use super::harness::*; + +#[test] +fn if_then_true() { + expect_int("1 if 42 then", 42); +} + +#[test] +fn if_then_false() { + let f = run("0 if 42 then"); + assert!(f.stack().is_empty()); +} + +#[test] +fn if_then_with_base() { + expect_int("100 0 if 50 + then", 100); +} + +#[test] +fn if_else_true() { + expect_int("1 if 42 else 99 then", 42); +} + +#[test] +fn if_else_false() { + expect_int("0 if 42 else 99 then", 99); +} + +#[test] +fn nested_tt() { + expect_int("1 if 1 if 100 else 200 then else 300 then", 100); +} + +#[test] +fn nested_tf() { + expect_int("1 if 0 if 100 else 200 then else 300 then", 200); +} + +#[test] +fn nested_f() { + expect_int("0 if 1 if 100 else 200 then else 300 then", 300); +} + +#[test] +fn if_with_computation() { + expect_int("3 2 gt if 42 else 99 then", 42); +} + +#[test] +fn missing_then() { + expect_error("1 if 42", "missing 'then'"); +} + +#[test] +fn deeply_nested() { + expect_int("1 if 1 if 1 if 42 then then then", 42); +} + +#[test] +fn chained_conditionals() { + // First if leaves nothing, second if runs + let f = run("0 if 1 then 1 if 42 then"); + assert_eq!(stack_int(&f), 42); +} diff --git a/tests/forth/errors.rs b/tests/forth/errors.rs new file mode 100644 index 0000000..60f82af --- /dev/null +++ b/tests/forth/errors.rs @@ -0,0 +1,106 @@ +use super::harness::*; + +#[test] +fn empty_script() { + expect_error("", "empty script"); +} + +#[test] +fn whitespace_only() { + expect_error(" \n\t ", "empty script"); +} + +#[test] +fn unknown_word() { + expect_error("foobar", "unknown word"); +} + +#[test] +fn string_not_number() { + expect_error(r#""hello" neg"#, "expected number"); +} + +#[test] +fn get_expects_string() { + expect_error("42 get", "expected string"); +} + +#[test] +fn set_expects_string() { + expect_error("1 2 set", "expected string"); +} + +#[test] +fn comment_ignored() { + expect_int("1 (this is a comment) 2 +", 3); +} + +#[test] +fn multiline_comment() { + expect_int("1 (multi\nline\ncomment) 2 +", 3); +} + +#[test] +fn negative_literal() { + expect_int("-5", -5); +} + +#[test] +fn float_literal() { + let f = run("3.14159"); + let val = stack_float(&f); + assert!((val - 3.14159).abs() < 1e-9); +} + +#[test] +fn string_with_spaces() { + let f = run(r#""hello world" "x" set "x" get"#); + match stack_top(&f) { + seq::model::forth::Value::Str(s, _) => assert_eq!(s, "hello world"), + other => panic!("expected string, got {:?}", other), + } +} + +#[test] +fn list_count() { + // [ 1 2 3 ] => stack: 1 2 3 3 (items + count on top) + let f = run("[ 1 2 3 ]"); + assert_eq!(stack_int(&f), 3); +} + +#[test] +fn list_empty() { + expect_int("[ ]", 0); +} + +#[test] +fn list_preserves_values() { + // [ 10 20 ] => stack: 10 20 2 + // drop => 10 20 + // + => 30 + expect_int("[ 10 20 ] drop +", 30); +} + +#[test] +fn conditional_based_on_step() { + let ctx0 = ctx_with(|c| c.step = 0); + let ctx1 = ctx_with(|c| c.step = 1); + + let f0 = run_ctx("step 2 mod 0 = if 100 else 200 then", &ctx0); + let f1 = run_ctx("step 2 mod 0 = if 100 else 200 then", &ctx1); + + assert_eq!(stack_int(&f0), 100); + assert_eq!(stack_int(&f1), 200); +} + +#[test] +fn accumulator() { + let f = forth(); + let ctx = default_ctx(); + f.evaluate(r#"0 "acc" set"#, &ctx).unwrap(); + for _ in 0..5 { + f.clear_stack(); + f.evaluate(r#""acc" get 1 + dup "acc" set"#, &ctx).unwrap(); + } + assert_eq!(stack_int(&f), 5); +} diff --git a/tests/forth/harness.rs b/tests/forth/harness.rs new file mode 100644 index 0000000..5f053be --- /dev/null +++ b/tests/forth/harness.rs @@ -0,0 +1,138 @@ +use rand::rngs::StdRng; +use rand::SeedableRng; +use seq::model::forth::{Forth, Rng, StepContext, Value, Variables}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +pub fn default_ctx() -> StepContext { + StepContext { + step: 0, + beat: 0.0, + bank: 0, + pattern: 0, + tempo: 120.0, + phase: 0.0, + slot: 0, + runs: 0, + iter: 0, + speed: 1.0, + fill: false, + } +} + +pub fn ctx_with(f: impl FnOnce(&mut StepContext)) -> StepContext { + let mut ctx = default_ctx(); + f(&mut ctx); + ctx +} + +pub fn new_vars() -> Variables { + Arc::new(Mutex::new(HashMap::new())) +} + +pub fn seeded_rng(seed: u64) -> Rng { + Arc::new(Mutex::new(StdRng::seed_from_u64(seed))) +} + +pub fn forth() -> Forth { + Forth::new(new_vars(), seeded_rng(42)) +} + +pub fn forth_seeded(seed: u64) -> Forth { + Forth::new(new_vars(), seeded_rng(seed)) +} + +pub fn run(script: &str) -> Forth { + let f = forth(); + f.evaluate(script, &default_ctx()).unwrap(); + f +} + +pub fn run_ctx(script: &str, ctx: &StepContext) -> Forth { + let f = forth(); + f.evaluate(script, ctx).unwrap(); + f +} + +pub fn stack_top(f: &Forth) -> Value { + f.stack().pop().expect("stack empty") +} + +pub fn stack_int(f: &Forth) -> i64 { + match stack_top(f) { + Value::Int(i, _) => i, + other => panic!("expected Int, got {:?}", other), + } +} + +pub fn stack_float(f: &Forth) -> f64 { + match stack_top(f) { + Value::Float(x, _) => x, + Value::Int(i, _) => i as f64, + other => panic!("expected number, got {:?}", other), + } +} + +pub fn expect_stack(script: &str, expected: &[Value]) { + let f = run(script); + let stack = f.stack(); + assert_eq!(stack, expected, "script: {}", script); +} + +pub fn expect_int(script: &str, expected: i64) { + expect_stack(script, &[Value::Int(expected, None)]); +} + +pub fn expect_float(script: &str, expected: f64) { + let f = run(script); + let stack = f.stack(); + assert_eq!(stack.len(), 1, "expected single value on stack"); + let val = stack_float(&f); + assert!( + (val - expected).abs() < 1e-9, + "expected {}, got {}", + expected, + val + ); +} + +pub fn expect_floats_close(script: &str, expected: f64, epsilon: f64) { + let f = run(script); + let val = stack_float(&f); + assert!( + (val - expected).abs() < epsilon, + "expected ~{}, got {}", + expected, + val + ); +} + +pub fn expect_error(script: &str, expected_substr: &str) { + let f = forth(); + let result = f.evaluate(script, &default_ctx()); + assert!(result.is_err(), "expected error for '{}'", script); + let err = result.unwrap_err(); + assert!( + err.contains(expected_substr), + "error '{}' does not contain '{}'", + err, + expected_substr + ); +} + +pub fn expect_outputs(script: &str, count: usize) -> Vec { + let f = forth(); + let outputs = f.evaluate(script, &default_ctx()).unwrap(); + assert_eq!(outputs.len(), count, "expected {} outputs", count); + outputs +} + +pub fn expect_output_contains(script: &str, substr: &str) { + let outputs = expect_outputs(script, 1); + assert!( + outputs[0].contains(substr), + "output '{}' does not contain '{}'", + outputs[0], + substr + ); +} diff --git a/tests/forth/quotations.rs b/tests/forth/quotations.rs new file mode 100644 index 0000000..b046904 --- /dev/null +++ b/tests/forth/quotations.rs @@ -0,0 +1,184 @@ +use super::harness::*; + +#[test] +fn quotation_on_stack() { + // Quotation should be pushable to stack + let f = forth(); + let result = f.evaluate("{ 1 2 + }", &default_ctx()); + assert!(result.is_ok()); +} + +#[test] +fn when_true_executes() { + let f = run("{ 42 } 1 ?"); + assert_eq!(stack_int(&f), 42); +} + +#[test] +fn when_false_skips() { + let f = run("99 { 42 } 0 ?"); + // Stack should still have 99, quotation not executed + assert_eq!(stack_int(&f), 99); +} + +#[test] +fn when_with_arithmetic() { + let f = run("10 { 5 + } 1 ?"); + assert_eq!(stack_int(&f), 15); +} + +#[test] +fn when_with_every() { + // iter=0, every 2 should be true + let ctx = ctx_with(|c| c.iter = 0); + let f = run_ctx("{ 100 } 2 every ?", &ctx); + assert_eq!(stack_int(&f), 100); + + // iter=1, every 2 should be false + let ctx = ctx_with(|c| c.iter = 1); + let f = run_ctx("50 { 100 } 2 every ?", &ctx); + assert_eq!(stack_int(&f), 50); // quotation not executed +} + +#[test] +fn when_with_chance_deterministic() { + // 1.0 chance always executes quotation + let f = run("{ 42 } 1.0 chance"); + assert_eq!(stack_int(&f), 42); + + // 0.0 chance never executes quotation + let f = run("99 { 42 } 0.0 chance"); + assert_eq!(stack_int(&f), 99); +} + +#[test] +fn nested_quotations() { + let f = run("{ { 42 } 1 ? } 1 ?"); + assert_eq!(stack_int(&f), 42); +} + +#[test] +fn quotation_with_param() { + let outputs = expect_outputs(r#""kick" s { 2 distort } 1 ? emit"#, 1); + assert!(outputs[0].contains("distort/2")); +} + +#[test] +fn quotation_skips_param() { + let outputs = expect_outputs(r#""kick" s { 2 distort } 0 ? emit"#, 1); + assert!(!outputs[0].contains("distort")); +} + +#[test] +fn quotation_with_emit() { + // When true, emit should fire + let outputs = expect_outputs(r#""kick" s { emit } 1 ?"#, 1); + assert!(outputs[0].contains("kick")); +} + +#[test] +fn quotation_skips_emit() { + // When false, emit should not fire + let f = forth(); + let outputs = f + .evaluate(r#""kick" s { emit } 0 ?"#, &default_ctx()) + .unwrap(); + // Should have 1 output from implicit emit at end (since cmd is set but not emitted) + assert_eq!(outputs.len(), 1); +} + +#[test] +fn missing_quotation_error() { + expect_error("42 1 ?", "expected quotation"); +} + +#[test] +fn unclosed_quotation_error() { + expect_error("{ 1 2", "missing }"); +} + +#[test] +fn unexpected_close_error() { + expect_error("1 2 }", "unexpected }"); +} + +#[test] +fn every_with_quotation_integration() { + // Simulating: { 2 distort } 2 every ? + // On even iterations, distort is applied + for iter in 0..4 { + let ctx = ctx_with(|c| c.iter = iter); + let f = forth(); + let outputs = f + .evaluate(r#""kick" s { 2 distort } 2 every ? emit"#, &ctx) + .unwrap(); + if iter % 2 == 0 { + assert!( + outputs[0].contains("distort/2"), + "iter {} should have distort", + iter + ); + } else { + assert!( + !outputs[0].contains("distort"), + "iter {} should not have distort", + iter + ); + } + } +} + +// Unless (!?) tests + +#[test] +fn unless_false_executes() { + let f = run("{ 42 } 0 !?"); + assert_eq!(stack_int(&f), 42); +} + +#[test] +fn unless_true_skips() { + let f = run("99 { 42 } 1 !?"); + assert_eq!(stack_int(&f), 99); +} + +#[test] +fn unless_with_every() { + // iter=0, every 2 is true, so unless skips + let ctx = ctx_with(|c| c.iter = 0); + let f = run_ctx("50 { 100 } 2 every !?", &ctx); + assert_eq!(stack_int(&f), 50); + + // iter=1, every 2 is false, so unless executes + let ctx = ctx_with(|c| c.iter = 1); + let f = run_ctx("{ 100 } 2 every !?", &ctx); + assert_eq!(stack_int(&f), 100); +} + +#[test] +fn when_and_unless_complementary() { + // Using both ? and !? for if-else like behavior + for iter in 0..4 { + let ctx = ctx_with(|c| c.iter = iter); + let f = forth(); + let outputs = f + .evaluate( + r#""kick" s { 2 distort } 2 every ? { 4 distort } 2 every !? emit"#, + &ctx, + ) + .unwrap(); + if iter % 2 == 0 { + assert!( + outputs[0].contains("distort/2"), + "iter {} should have distort/2", + iter + ); + } else { + assert!( + outputs[0].contains("distort/4"), + "iter {} should have distort/4", + iter + ); + } + } +} diff --git a/tests/forth/randomness.rs b/tests/forth/randomness.rs new file mode 100644 index 0000000..d854d87 --- /dev/null +++ b/tests/forth/randomness.rs @@ -0,0 +1,118 @@ +use super::harness::*; + +#[test] +fn rand_in_range() { + let f = forth_seeded(12345); + f.evaluate("0 10 rand", &default_ctx()).unwrap(); + let val = stack_float(&f); + assert!(val >= 0.0 && val < 10.0, "rand {} not in [0, 10)", val); +} + +#[test] +fn rand_deterministic() { + let f1 = forth_seeded(99); + let f2 = forth_seeded(99); + f1.evaluate("0 100 rand", &default_ctx()).unwrap(); + f2.evaluate("0 100 rand", &default_ctx()).unwrap(); + assert_eq!(f1.stack(), f2.stack()); +} + +#[test] +fn rrand_inclusive() { + let f = forth_seeded(42); + for _ in 0..20 { + f.clear_stack(); + f.evaluate("1 3 rrand", &default_ctx()).unwrap(); + let val = stack_int(&f); + assert!(val >= 1 && val <= 3, "rrand {} not in [1, 3]", val); + } +} + +#[test] +fn seed_resets() { + let f1 = forth_seeded(1); + f1.evaluate("42 seed 0 100 rand", &default_ctx()).unwrap(); + let f2 = forth_seeded(999); + f2.evaluate("42 seed 0 100 rand", &default_ctx()).unwrap(); + assert_eq!(f1.stack(), f2.stack()); +} + +#[test] +fn coin_binary() { + let f = forth_seeded(42); + f.evaluate("coin", &default_ctx()).unwrap(); + let val = stack_int(&f); + assert!(val == 0 || val == 1); +} + +#[test] +fn chance_zero() { + // 0.0 probability should never execute the quotation + let f = run("99 { 42 } 0.0 chance"); + assert_eq!(stack_int(&f), 99); // quotation not executed, 99 still on stack +} + +#[test] +fn chance_one() { + // 1.0 probability should always execute the quotation + let f = run("{ 42 } 1.0 chance"); + assert_eq!(stack_int(&f), 42); +} + +#[test] +fn choose_from_list() { + let f = forth_seeded(42); + f.evaluate("10 20 30 3 choose", &default_ctx()).unwrap(); + let val = stack_int(&f); + assert!(val == 10 || val == 20 || val == 30); +} + +#[test] +fn choose_underflow() { + expect_error("1 2 5 choose", "stack underflow"); +} + +#[test] +fn cycle_deterministic() { + for runs in 0..6 { + let ctx = ctx_with(|c| c.runs = runs); + let f = run_ctx("10 20 30 3 cycle", &ctx); + let expected = [10, 20, 30][runs % 3]; + assert_eq!(stack_int(&f), expected, "cycle at runs={}", runs); + } +} + +#[test] +fn cycle_zero_count() { + expect_error("1 2 3 0 cycle", "cycle count must be > 0"); +} + +#[test] +fn mtof_a4() { + expect_float("69 mtof", 440.0); +} + +#[test] +fn mtof_octave() { + expect_float("81 mtof", 880.0); +} + +#[test] +fn mtof_c4() { + expect_floats_close("60 mtof", 261.6255653, 0.001); +} + +#[test] +fn ftom_440() { + expect_float("440 ftom", 69.0); +} + +#[test] +fn ftom_880() { + expect_float("880 ftom", 81.0); +} + +#[test] +fn mtof_ftom_roundtrip() { + expect_float("60 mtof ftom", 60.0); +} diff --git a/tests/forth/sound.rs b/tests/forth/sound.rs new file mode 100644 index 0000000..a48ea60 --- /dev/null +++ b/tests/forth/sound.rs @@ -0,0 +1,125 @@ +use super::harness::*; + +#[test] +fn basic_emit() { + let outputs = expect_outputs(r#""kick" sound emit"#, 1); + assert!(outputs[0].contains("sound/kick")); +} + +#[test] +fn alias_s() { + let outputs = expect_outputs(r#""snare" s emit"#, 1); + assert!(outputs[0].contains("sound/snare")); +} + +#[test] +fn with_params() { + let outputs = expect_outputs(r#""kick" s 440 freq 0.5 gain emit"#, 1); + assert!(outputs[0].contains("sound/kick")); + assert!(outputs[0].contains("freq/440")); + assert!(outputs[0].contains("gain/0.5")); +} + +#[test] +fn auto_dur() { + let outputs = expect_outputs(r#""kick" s emit"#, 1); + assert!(outputs[0].contains("dur/")); +} + +#[test] +fn auto_delaytime() { + let outputs = expect_outputs(r#""kick" s emit"#, 1); + assert!(outputs[0].contains("delaytime/")); +} + +#[test] +fn emit_no_sound() { + expect_error("emit", "no sound set"); +} + +#[test] +fn implicit_emit() { + let outputs = expect_outputs(r#""kick" s 440 freq"#, 1); + assert!(outputs[0].contains("sound/kick")); + assert!(outputs[0].contains("freq/440")); +} + +#[test] +fn multiple_emits() { + let outputs = expect_outputs(r#""kick" s emit "snare" s emit"#, 2); + assert!(outputs[0].contains("sound/kick")); + assert!(outputs[1].contains("sound/snare")); +} + +#[test] +fn subdivide_each() { + let outputs = expect_outputs(r#""kick" s 4 div each"#, 4); +} + +#[test] +fn window_pop() { + let outputs = expect_outputs( + r#"0.0 0.5 window "kick" s emit pop 0.5 1.0 window "snare" s emit"#, + 2, + ); + assert!(outputs[0].contains("sound/kick")); + assert!(outputs[1].contains("sound/snare")); +} + +#[test] +fn pop_root_fails() { + expect_error("pop", "cannot pop root time context"); +} + +#[test] +fn subdivide_zero() { + expect_error(r#""kick" s 0 div each"#, "subdivide count must be > 0"); +} + +#[test] +fn each_without_div() { + expect_error(r#""kick" s each"#, "each requires subdivide first"); +} + +#[test] +fn envelope_params() { + let outputs = expect_outputs( + r#""synth" s 0.01 attack 0.1 decay 0.7 sustain 0.3 release emit"#, + 1, + ); + assert!(outputs[0].contains("attack/0.01")); + assert!(outputs[0].contains("decay/0.1")); + assert!(outputs[0].contains("sustain/0.7")); + assert!(outputs[0].contains("release/0.3")); +} + +#[test] +fn filter_params() { + let outputs = expect_outputs(r#""synth" s 2000 lpf 0.5 lpq emit"#, 1); + assert!(outputs[0].contains("lpf/2000")); + assert!(outputs[0].contains("lpq/0.5")); +} + +#[test] +fn adsr_sets_all_envelope_params() { + let outputs = expect_outputs(r#""synth" s 0.01 0.1 0.5 0.3 adsr emit"#, 1); + assert!(outputs[0].contains("attack/0.01")); + assert!(outputs[0].contains("decay/0.1")); + assert!(outputs[0].contains("sustain/0.5")); + assert!(outputs[0].contains("release/0.3")); +} + +#[test] +fn ad_sets_attack_decay_sustain_zero() { + let outputs = expect_outputs(r#""synth" s 0.01 0.1 ad emit"#, 1); + assert!(outputs[0].contains("attack/0.01")); + assert!(outputs[0].contains("decay/0.1")); + assert!(outputs[0].contains("sustain/0")); +} + +#[test] +fn bank_param() { + let outputs = expect_outputs(r#""loop" s "a" bank emit"#, 1); + assert!(outputs[0].contains("sound/loop")); + assert!(outputs[0].contains("bank/a")); +} diff --git a/tests/forth/stack.rs b/tests/forth/stack.rs new file mode 100644 index 0000000..f21b396 --- /dev/null +++ b/tests/forth/stack.rs @@ -0,0 +1,94 @@ +use super::harness::*; +use seq::model::forth::Value; + +fn int(n: i64) -> Value { + Value::Int(n, None) +} + +#[test] +fn dup() { + expect_stack("3 dup", &[int(3), int(3)]); +} + +#[test] +fn dup_underflow() { + expect_error("dup", "stack underflow"); +} + +#[test] +fn drop() { + expect_stack("1 2 drop", &[int(1)]); +} + +#[test] +fn drop_underflow() { + expect_error("drop", "stack underflow"); +} + +#[test] +fn swap() { + expect_stack("1 2 swap", &[int(2), int(1)]); +} + +#[test] +fn swap_underflow() { + expect_error("1 swap", "stack underflow"); +} + +#[test] +fn over() { + expect_stack("1 2 over", &[int(1), int(2), int(1)]); +} + +#[test] +fn over_underflow() { + expect_error("1 over", "stack underflow"); +} + +#[test] +fn rot() { + expect_stack("1 2 3 rot", &[int(2), int(3), int(1)]); +} + +#[test] +fn rot_underflow() { + expect_error("1 2 rot", "stack underflow"); +} + +#[test] +fn nip() { + expect_stack("1 2 nip", &[int(2)]); +} + +#[test] +fn nip_underflow() { + expect_error("1 nip", "stack underflow"); +} + +#[test] +fn tuck() { + expect_stack("1 2 tuck", &[int(2), int(1), int(2)]); +} + +#[test] +fn tuck_underflow() { + expect_error("1 tuck", "stack underflow"); +} + +#[test] +fn stack_persists() { + let f = forth(); + let ctx = default_ctx(); + f.evaluate("1 2 3", &ctx).unwrap(); + assert_eq!(f.stack(), vec![int(1), int(2), int(3)]); + f.evaluate("4 5", &ctx).unwrap(); + assert_eq!(f.stack(), vec![int(1), int(2), int(3), int(4), int(5)]); +} + +#[test] +fn clear_stack() { + let f = forth(); + f.evaluate("1 2 3", &default_ctx()).unwrap(); + f.clear_stack(); + assert!(f.stack().is_empty()); +} diff --git a/tests/forth/temporal.rs b/tests/forth/temporal.rs new file mode 100644 index 0000000..be9d480 --- /dev/null +++ b/tests/forth/temporal.rs @@ -0,0 +1,229 @@ +use super::harness::*; +use std::collections::HashMap; + +fn parse_params(output: &str) -> HashMap { + let mut params = HashMap::new(); + let parts: Vec<&str> = output.trim_start_matches('/').split('/').collect(); + let mut i = 0; + while i + 1 < parts.len() { + if let Ok(v) = parts[i + 1].parse::() { + params.insert(parts[i].to_string(), v); + } + i += 2; + } + params +} + +fn get_deltas(outputs: &[String]) -> Vec { + outputs + .iter() + .map(|o| parse_params(o).get("delta").copied().unwrap_or(0.0)) + .collect() +} + +const EPSILON: f64 = 1e-9; + +fn approx_eq(a: f64, b: f64) -> bool { + (a - b).abs() < EPSILON +} + +// At 120 BPM, speed 1.0: stepdur = 60/120/4/1 = 0.125s + +#[test] +fn stepdur_baseline() { + let f = run("stepdur"); + assert!(approx_eq(stack_float(&f), 0.125)); +} + +#[test] +fn emit_no_delta() { + let outputs = expect_outputs(r#""kick" s emit"#, 1); + let deltas = get_deltas(&outputs); + assert!( + approx_eq(deltas[0], 0.0), + "emit at start should have delta 0" + ); +} + +#[test] +fn at_half() { + // at 0.5 in root window (0..0.125) => delta = 0.5 * 0.125 = 0.0625 + let outputs = expect_outputs(r#""kick" s 0.5 at"#, 1); + let deltas = get_deltas(&outputs); + assert!( + approx_eq(deltas[0], 0.0625), + "at 0.5 should be delta 0.0625, got {}", + deltas[0] + ); +} + +#[test] +fn at_quarter() { + let outputs = expect_outputs(r#""kick" s 0.25 at"#, 1); + let deltas = get_deltas(&outputs); + assert!( + approx_eq(deltas[0], 0.03125), + "at 0.25 should be delta 0.03125, got {}", + deltas[0] + ); +} + +#[test] +fn at_zero() { + let outputs = expect_outputs(r#""kick" s 0.0 at"#, 1); + let deltas = get_deltas(&outputs); + assert!(approx_eq(deltas[0], 0.0), "at 0.0 should be delta 0"); +} + +#[test] +fn div_2_each() { + // 2 subdivisions: deltas at 0 and 0.0625 (half of 0.125) + let outputs = expect_outputs(r#""kick" s 2 div each"#, 2); + let deltas = get_deltas(&outputs); + assert!(approx_eq(deltas[0], 0.0), "first subdivision at 0"); + assert!( + approx_eq(deltas[1], 0.0625), + "second subdivision at 0.0625, got {}", + deltas[1] + ); +} + +#[test] +fn div_4_each() { + // 4 subdivisions: 0, 0.03125, 0.0625, 0.09375 + let outputs = expect_outputs(r#""kick" s 4 div each"#, 4); + let deltas = get_deltas(&outputs); + let expected = [0.0, 0.03125, 0.0625, 0.09375]; + for (i, (got, exp)) in deltas.iter().zip(expected.iter()).enumerate() { + assert!( + approx_eq(*got, *exp), + "subdivision {}: expected {}, got {}", + i, + exp, + got + ); + } +} + +#[test] +fn div_3_each() { + // 3 subdivisions: 0, 0.125/3, 2*0.125/3 + let outputs = expect_outputs(r#""kick" s 3 div each"#, 3); + let deltas = get_deltas(&outputs); + let step = 0.125 / 3.0; + assert!(approx_eq(deltas[0], 0.0)); + assert!(approx_eq(deltas[1], step), "got {}", deltas[1]); + assert!(approx_eq(deltas[2], 2.0 * step), "got {}", deltas[2]); +} + +#[test] +fn window_full() { + // window 0.0 1.0 is the full step, same as root + let outputs = expect_outputs(r#"0.0 1.0 window "kick" s 0.5 at"#, 1); + let deltas = get_deltas(&outputs); + assert!(approx_eq(deltas[0], 0.0625), "full window at 0.5 = 0.0625"); +} + +#[test] +fn window_first_half() { + // window 0.0 0.5 restricts to first half (0..0.0625) + // at 0.5 within that = 0.25 of full step = 0.03125 + let outputs = expect_outputs(r#"0.0 0.5 window "kick" s 0.5 at"#, 1); + let deltas = get_deltas(&outputs); + assert!( + approx_eq(deltas[0], 0.03125), + "first-half window at 0.5 = 0.03125, got {}", + deltas[0] + ); +} + +#[test] +fn window_second_half() { + // window 0.5 1.0 restricts to second half (0.0625..0.125) + // at 0.0 within that = start of second half = 0.0625 + let outputs = expect_outputs(r#"0.5 1.0 window "kick" s 0.0 at"#, 1); + let deltas = get_deltas(&outputs); + assert!( + approx_eq(deltas[0], 0.0625), + "second-half window at 0.0 = 0.0625, got {}", + deltas[0] + ); +} + +#[test] +fn window_second_half_middle() { + // window 0.5 1.0, at 0.5 within that = 0.75 of full step = 0.09375 + let outputs = expect_outputs(r#"0.5 1.0 window "kick" s 0.5 at"#, 1); + let deltas = get_deltas(&outputs); + assert!(approx_eq(deltas[0], 0.09375), "got {}", deltas[0]); +} + +#[test] +fn nested_windows() { + // window 0.0 0.5, then window 0.5 1.0 within that + // outer: 0..0.0625, inner: 0.5..1.0 of that = 0.03125..0.0625 + // at 0.0 in inner = 0.03125 + let outputs = expect_outputs(r#"0.0 0.5 window 0.5 1.0 window "kick" s 0.0 at"#, 1); + let deltas = get_deltas(&outputs); + assert!( + approx_eq(deltas[0], 0.03125), + "nested window at 0.0 = 0.03125, got {}", + deltas[0] + ); +} + +#[test] +fn window_pop_sequence() { + // First in window 0.0 0.5 at 0.0 -> delta 0 + // Pop, then in window 0.5 1.0 at 0.0 -> delta 0.0625 + let outputs = expect_outputs( + r#"0.0 0.5 window "kick" s 0.0 at pop 0.5 1.0 window "snare" s 0.0 at"#, + 2, + ); + let deltas = get_deltas(&outputs); + assert!(approx_eq(deltas[0], 0.0), "first window start"); + assert!( + approx_eq(deltas[1], 0.0625), + "second window start, got {}", + deltas[1] + ); +} + +#[test] +fn div_in_window() { + // window 0.0 0.5 (duration 0.0625), then div 2 each + // subdivisions at 0 and 0.03125 + let outputs = expect_outputs(r#"0.0 0.5 window "kick" s 2 div each"#, 2); + let deltas = get_deltas(&outputs); + assert!(approx_eq(deltas[0], 0.0)); + assert!(approx_eq(deltas[1], 0.03125), "got {}", deltas[1]); +} + +#[test] +fn tempo_affects_stepdur() { + // At 60 BPM: stepdur = 60/60/4/1 = 0.25 + let ctx = ctx_with(|c| c.tempo = 60.0); + let f = forth(); + f.evaluate("stepdur", &ctx).unwrap(); + assert!(approx_eq(stack_float(&f), 0.25)); +} + +#[test] +fn speed_affects_stepdur() { + // At 120 BPM, speed 2.0: stepdur = 60/120/4/2 = 0.0625 + let ctx = ctx_with(|c| c.speed = 2.0); + let f = forth(); + f.evaluate("stepdur", &ctx).unwrap(); + assert!(approx_eq(stack_float(&f), 0.0625)); +} + +#[test] +fn div_each_at_different_tempo() { + // At 60 BPM: stepdur = 0.25, so div 2 each => 0, 0.125 + let ctx = ctx_with(|c| c.tempo = 60.0); + let f = forth(); + let outputs = f.evaluate(r#""kick" s 2 div each"#, &ctx).unwrap(); + let deltas = get_deltas(&outputs); + assert!(approx_eq(deltas[0], 0.0)); + assert!(approx_eq(deltas[1], 0.125), "got {}", deltas[1]); +} diff --git a/tests/forth/variables.rs b/tests/forth/variables.rs new file mode 100644 index 0000000..3c26f1e --- /dev/null +++ b/tests/forth/variables.rs @@ -0,0 +1,39 @@ +use super::harness::*; + +#[test] +fn set_get() { + expect_int(r#"42 "x" set "x" get"#, 42); +} + +#[test] +fn get_nonexistent() { + expect_int(r#""novar" get"#, 0); +} + +#[test] +fn persistence_across_evals() { + let f = forth(); + let ctx = default_ctx(); + f.evaluate(r#"10 "counter" set"#, &ctx).unwrap(); + f.clear_stack(); + f.evaluate(r#""counter" get 1 +"#, &ctx).unwrap(); + assert_eq!(stack_int(&f), 11); +} + +#[test] +fn overwrite() { + expect_int(r#"1 "x" set 99 "x" set "x" get"#, 99); +} + +#[test] +fn multiple_vars() { + let f = run(r#"10 "a" set 20 "b" set "a" get "b" get +"#); + assert_eq!(stack_int(&f), 30); +} + +#[test] +fn float_var() { + let f = run(r#"3.14 "pi" set "pi" get"#); + let val = stack_float(&f); + assert!((val - 3.14).abs() < 1e-9); +}