This commit is contained in:
2026-01-21 17:05:30 +01:00
commit 67322381c3
59 changed files with 10421 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
Cargo.lock
*.prof
.DS_Store

29
Cargo.toml Normal file
View File

@@ -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"

58
docs/keybindings.md Normal file
View File

@@ -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

72
docs/sequencer.md Normal file
View File

@@ -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`

949
src/app.rs Normal file
View File

@@ -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<LiveKeyState>,
pub clipboard: Option<arboard::Clipboard>,
pub copied_pattern: Option<Pattern>,
pub copied_bank: Option<Bank>,
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<String> = 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<SeqCommand>) {
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<SeqCommand>) {
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,
});
}
}
}

130
src/commands.rs Normal file
View File

@@ -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<String>,
},
RenamePattern {
bank: usize,
pattern: usize,
name: Option<String>,
},
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,
}

4
src/config.rs Normal file
View File

@@ -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;

141
src/engine/audio.rs Normal file
View File

@@ -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<String>,
pub channels: u16,
pub buffer_size: u32,
}
pub fn build_stream(
config: &AudioStreamConfig,
audio_rx: Receiver<AudioCommand>,
scope_buffer: Arc<ScopeBuffer>,
metrics: Arc<EngineMetrics>,
initial_samples: Vec<doux::sample::SampleEntry>,
) -> 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))
}

89
src/engine/link.rs Normal file
View File

@@ -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
}
}

10
src/engine/mod.rs Normal file
View File

@@ -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,
};

461
src/engine/sequencer.rs Normal file
View File

@@ -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<doux::sample::SampleEntry>),
#[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<StepSnapshot>,
}
#[derive(Clone)]
pub struct StepSnapshot {
pub active: bool,
pub script: String,
pub source: Option<usize>,
}
#[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<ActivePatternState>,
pub pattern_traces: HashMap<PatternId, Vec<SourceSpan>>,
pub event_count: usize,
}
pub struct SequencerSnapshot {
pub active_patterns: Vec<ActivePatternState>,
pub pattern_traces: HashMap<PatternId, Vec<SourceSpan>>,
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<usize> {
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<usize> {
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<SourceSpan>> {
self.pattern_traces.get(&PatternId { bank, pattern })
}
}
pub struct SequencerHandle {
pub cmd_tx: Sender<SeqCommand>,
pub audio_tx: Sender<AudioCommand>,
pub audio_rx: Receiver<AudioCommand>,
shared_state: Arc<Mutex<SharedSequencerState>>,
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<PatternId, ActivePattern>,
pending_starts: Vec<PatternId>,
pending_stops: Vec<PatternId>,
}
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<LinkState>,
playing: Arc<std::sync::atomic::AtomicBool>,
variables: Variables,
rng: Rng,
quantum: f64,
live_keys: Arc<LiveKeyState>,
) -> SequencerHandle {
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
let (audio_tx, audio_rx) = bounded::<AudioCommand>(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<PatternSnapshot>; 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<StepKey, usize>,
}
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<SeqCommand>,
audio_tx: Sender<AudioCommand>,
link: Arc<LinkState>,
playing: Arc<std::sync::atomic::AtomicBool>,
variables: Variables,
rng: Rng,
quantum: f64,
shared_state: Arc<Mutex<SharedSequencerState>>,
live_keys: Arc<LiveKeyState>,
) {
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<PatternId, Vec<SourceSpan>> = 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));
}
}

705
src/input.rs Normal file
View File

@@ -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<AtomicBool>,
pub audio_tx: &'a Sender<AudioCommand>,
}
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::<usize>() {
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::<f64>() {
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"
)));
}
}

2
src/lib.rs Normal file
View File

@@ -0,0 +1,2 @@
mod config;
pub mod model;

227
src/main.rs Normal file
View File

@@ -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<PathBuf>,
/// Output audio device (name or index)
#[arg(short, long)]
output: Option<String>,
/// Input audio device (name or index)
#[arg(short, long)]
input: Option<String>,
/// Number of output channels
#[arg(short, long)]
channels: Option<u16>,
/// Audio buffer size in samples
#[arg(short, long)]
buffer: Option<u32>,
}
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(())
}

89
src/model/file.rs Normal file
View File

@@ -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<Bank>,
#[serde(default)]
sample_paths: Vec<PathBuf>,
#[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<ProjectFile> 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<io::Error> for FileError {
fn from(e: io::Error) -> Self {
FileError::Io(e)
}
}
impl From<serde_json::Error> 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<Project, FileError> {
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))
}

2498
src/model/forth.rs Normal file

File diff suppressed because it is too large Load Diff

8
src/model/mod.rs Normal file
View File

@@ -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};

210
src/model/project.rs Normal file
View File

@@ -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<Self> {
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<String>,
#[serde(default)]
pub source: Option<usize>,
}
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<Step>,
pub length: usize,
#[serde(default)]
pub speed: PatternSpeed,
#[serde(default)]
pub name: Option<String>,
}
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<Pattern>,
#[serde(default)]
pub name: Option<String>,
}
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<Bank>,
#[serde(default)]
pub sample_paths: Vec<PathBuf>,
#[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]
}
}

28
src/model/script.rs Normal file
View File

@@ -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<Vec<String>, String> {
self.forth.evaluate(script, ctx)
}
pub fn evaluate_with_trace(
&self,
script: &str,
ctx: &StepContext,
trace: &mut ExecutionTrace,
) -> Result<Vec<String>, String> {
self.forth.evaluate_with_trace(script, ctx, trace)
}
}

38
src/page.rs Normal file
View File

@@ -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;
}
}
}

1
src/services/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod pattern_editor;

View File

@@ -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<String> {
project
.pattern_at(bank, pattern)
.step(step)
.map(|s| s.script.clone())
}

72
src/settings.rs Normal file
View File

@@ -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<String>,
pub input_device: Option<String>,
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);
}
}

292
src/state/audio.rs Normal file
View File

@@ -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<String>,
pub input_device: Option<String>,
pub channels: u16,
pub buffer_size: u32,
pub sample_rate: f32,
pub sample_paths: Vec<PathBuf>,
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<AudioDeviceInfo>,
pub input_devices: Vec<AudioDeviceInfo>,
pub restart_pending: bool,
pub error: Option<String>,
}
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;
}
}

42
src/state/editor.rs Normal file
View File

@@ -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<CopiedStep>,
}
#[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,
}
}
}

21
src/state/live_keys.rs Normal file
View File

@@ -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);
}
}

17
src/state/mod.rs Normal file
View File

@@ -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;

42
src/state/modal.rs Normal file
View File

@@ -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,
}

53
src/state/patterns_nav.rs Normal file
View File

@@ -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
}
}

21
src/state/playback.rs Normal file
View File

@@ -0,0 +1,21 @@
use crate::engine::PatternChange;
pub struct PlaybackState {
pub playing: bool,
pub queued_changes: Vec<PatternChange>,
}
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;
}
}

41
src/state/project.rs Normal file
View File

@@ -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<PathBuf>,
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)
}
}

50
src/state/ui.rs Normal file
View File

@@ -0,0 +1,50 @@
use std::time::{Duration, Instant};
use crate::state::Modal;
pub struct UiState {
pub status_message: Option<String>,
pub flash_until: Option<Instant>,
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)
}
}

381
src/views/audio_view.rs Normal file
View File

@@ -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<Line> = 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)
}
}

266
src/views/doc_view.rs Normal file
View File

@@ -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<ListItem> = 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<RLine> = 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<ListItem> = 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<RLine> = 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<RLine> = 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<RLine<'static>> {
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<Span<'static>> = 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()
}

299
src/views/highlight.rs Normal file
View File

@@ -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<Token> {
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::<f64>().is_ok() || word.parse::<i64>().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
}

201
src/views/main_view.rs Normal file
View File

@@ -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<Constraint> = (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<Constraint> = (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);
}

9
src/views/mod.rs Normal file
View File

@@ -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;

297
src/views/patterns_view.rs Normal file
View File

@@ -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<usize> = snapshot
.active_patterns
.iter()
.map(|p| p.bank)
.collect();
let banks_with_queued: Vec<usize> = 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<usize> = snapshot
.active_patterns
.iter()
.filter(|p| p.bank == bank)
.map(|p| p.pattern)
.collect();
let queued_to_play: Vec<usize> = 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<usize> = 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);
}
}
}

448
src/views/render.rs Normal file
View File

@@ -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<usize> = 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<Line> = text_lines
.iter()
.enumerate()
.map(|(row, line)| {
let mut spans: Vec<Span> = Vec::new();
let line_start = line_offsets[row];
let line_end = line_start + line.len();
let adjusted_spans: Vec<crate::model::SourceSpan> = 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::<String>();
let cursor_char = text.chars().nth(cursor_col - col).unwrap_or(' ');
let after =
text.chars().skip(cursor_col - col + 1).collect::<String>();
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);
}
}
}

51
src/views/title_view.rs Normal file
View File

@@ -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);
}

60
src/widgets/confirm.rs Normal file
View File

@@ -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],
);
}
}

11
src/widgets/mod.rs Normal file
View File

@@ -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;

58
src/widgets/modal.rs Normal file
View File

@@ -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
}
}

149
src/widgets/scope.rs Normal file
View File

@@ -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);
}
}
}
}

82
src/widgets/text_input.rs Normal file
View File

@@ -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,
);
}
}
}

81
src/widgets/vu_meter.rs Normal file
View File

@@ -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);
}
}
}
}
}

35
tests/forth.rs Normal file
View File

@@ -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;

152
tests/forth/arithmetic.rs Normal file
View File

@@ -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");
}

136
tests/forth/comparison.rs Normal file
View File

@@ -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);
}

99
tests/forth/context.rs Normal file
View File

@@ -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);
}

View File

@@ -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);
}

106
tests/forth/errors.rs Normal file
View File

@@ -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);
}

138
tests/forth/harness.rs Normal file
View File

@@ -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<String> {
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
);
}

184
tests/forth/quotations.rs Normal file
View File

@@ -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
);
}
}
}

118
tests/forth/randomness.rs Normal file
View File

@@ -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);
}

125
tests/forth/sound.rs Normal file
View File

@@ -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"));
}

94
tests/forth/stack.rs Normal file
View File

@@ -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());
}

229
tests/forth/temporal.rs Normal file
View File

@@ -0,0 +1,229 @@
use super::harness::*;
use std::collections::HashMap;
fn parse_params(output: &str) -> HashMap<String, f64> {
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::<f64>() {
params.insert(parts[i].to_string(), v);
}
i += 2;
}
params
}
fn get_deltas(outputs: &[String]) -> Vec<f64> {
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]);
}

39
tests/forth/variables.rs Normal file
View File

@@ -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);
}