Init
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
*.prof
|
||||
.DS_Store
|
||||
29
Cargo.toml
Normal file
29
Cargo.toml
Normal 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
58
docs/keybindings.md
Normal 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
72
docs/sequencer.md
Normal 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
949
src/app.rs
Normal 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
130
src/commands.rs
Normal 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
4
src/config.rs
Normal 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
141
src/engine/audio.rs
Normal 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
89
src/engine/link.rs
Normal 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
10
src/engine/mod.rs
Normal 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
461
src/engine/sequencer.rs
Normal 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
705
src/input.rs
Normal 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
2
src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod config;
|
||||
pub mod model;
|
||||
227
src/main.rs
Normal file
227
src/main.rs
Normal 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
89
src/model/file.rs
Normal 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
2498
src/model/forth.rs
Normal file
File diff suppressed because it is too large
Load Diff
8
src/model/mod.rs
Normal file
8
src/model/mod.rs
Normal 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
210
src/model/project.rs
Normal 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
28
src/model/script.rs
Normal 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
38
src/page.rs
Normal 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
1
src/services/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod pattern_editor;
|
||||
105
src/services/pattern_editor.rs
Normal file
105
src/services/pattern_editor.rs
Normal 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
72
src/settings.rs
Normal 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
292
src/state/audio.rs
Normal 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
42
src/state/editor.rs
Normal 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
21
src/state/live_keys.rs
Normal 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
17
src/state/mod.rs
Normal 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
42
src/state/modal.rs
Normal 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
53
src/state/patterns_nav.rs
Normal 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
21
src/state/playback.rs
Normal 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
41
src/state/project.rs
Normal 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
50
src/state/ui.rs
Normal 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
381
src/views/audio_view.rs
Normal 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
266
src/views/doc_view.rs
Normal 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
299
src/views/highlight.rs
Normal 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
201
src/views/main_view.rs
Normal 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
9
src/views/mod.rs
Normal 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
297
src/views/patterns_view.rs
Normal 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
448
src/views/render.rs
Normal 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
51
src/views/title_view.rs
Normal 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
60
src/widgets/confirm.rs
Normal 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
11
src/widgets/mod.rs
Normal 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
58
src/widgets/modal.rs
Normal 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
149
src/widgets/scope.rs
Normal 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
82
src/widgets/text_input.rs
Normal 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
81
src/widgets/vu_meter.rs
Normal 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
35
tests/forth.rs
Normal 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
152
tests/forth/arithmetic.rs
Normal 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
136
tests/forth/comparison.rs
Normal 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
99
tests/forth/context.rs
Normal 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);
|
||||
}
|
||||
64
tests/forth/control_flow.rs
Normal file
64
tests/forth/control_flow.rs
Normal 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
106
tests/forth/errors.rs
Normal 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
138
tests/forth/harness.rs
Normal 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
184
tests/forth/quotations.rs
Normal 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
118
tests/forth/randomness.rs
Normal 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
125
tests/forth/sound.rs
Normal 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
94
tests/forth/stack.rs
Normal 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
229
tests/forth/temporal.rs
Normal 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
39
tests/forth/variables.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user