diff --git a/seq/docs/scripting.md b/seq/docs/scripting.md deleted file mode 100644 index 6d96c94..0000000 --- a/seq/docs/scripting.md +++ /dev/null @@ -1,115 +0,0 @@ -# Scripting - -Steps are programmed using Rhai, a simple scripting language. - -## Basic Syntax - -Create sounds using `sound()` and chain parameters: - -``` -sound("kick").gain(0.8) -``` - -``` -sound("hat").freq(8000).decay(0.1) -``` - -## Context Variables - -These are available in every step script: - -- `step`: Current step index (0-based) -- `beat`: Current beat position -- `bank`: Current bank index -- `pattern`: Current pattern index -- `tempo`: Current BPM -- `phase`: Phase within the bar (0.0 to 1.0) -- `slot`: Slot number playing this pattern - -## Randomness - -- `rand(min, max)`: Random float in range -- `rrand(min, max)`: Random integer in range (inclusive) -- `seed(n)`: Set random seed for reproducibility - -## Variables - -Store and retrieve values across steps: - -- `set("name", value)`: Store a value -- `get("name")`: Retrieve a value - -## Sound Parameters - -### Core - -- `sound(name)`: Create sound command -- `freq(hz)`: Frequency -- `note(midi)`: MIDI note number -- `gain(amp)`: Volume (0.0-1.0) -- `pan(pos)`: Stereo position (-1.0 to 1.0) -- `dur(secs)`: Duration -- `gate(secs)`: Gate time - -### Envelope - -- `attack(secs)`: Attack time -- `decay(secs)`: Decay time -- `sustain(level)`: Sustain level -- `release(secs)`: Release time - -### Filter - -- `lpf(hz)`: Lowpass frequency -- `lpq(q)`: Lowpass resonance -- `hpf(hz)`: Highpass frequency -- `bpf(hz)`: Bandpass frequency - -### Effects - -- `delay(mix)`: Delay amount -- `delaytime(secs)`: Delay time -- `delayfeedback(amt)`: Delay feedback -- `verb(mix)`: Reverb amount -- `verbdecay(secs)`: Reverb decay - -### Modulation - -- `vib(hz)`: Vibrato rate -- `vibmod(amt)`: Vibrato depth -- `fm(hz)`: FM modulator frequency -- `fmh(ratio)`: FM harmonic ratio - -### Sample Playback - -- `speed(ratio)`: Playback speed -- `begin(pos)`: Start position (0.0-1.0) -- `end(pos)`: End position (0.0-1.0) - -## Examples - -Conditional based on step: - -``` -if step % 4 == 0 { - sound("kick").gain(1.0) -} else { - sound("hat").gain(0.5) -} -``` - -Random variation: - -``` -sound("synth") - .freq(rand(200.0, 800.0)) - .gain(rand(0.3, 0.7)) -``` - -Using variables: - -``` -let n = get("counter"); -set("counter", n + 1); -sound("beep").note(60 + (n % 12)) -``` diff --git a/seq/src/app.rs b/seq/src/app.rs index d58ad29..e442227 100644 --- a/seq/src/app.rs +++ b/seq/src/app.rs @@ -549,7 +549,7 @@ impl App { 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!("{} (copy)", name)), + Some(name) if !name.ends_with(" (copy)") => Some(format!("{name} (copy)")), Some(name) => Some(name.clone()), None => Some("(copy)".to_string()), }; @@ -572,7 +572,7 @@ impl App { 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!("{} (copy)", name)), + Some(name) if !name.ends_with(" (copy)") => Some(format!("{name} (copy)")), Some(name) => Some(name.clone()), None => Some("(copy)".to_string()), }; @@ -844,11 +844,13 @@ impl App { 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); @@ -856,6 +858,16 @@ impl App { 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 => { diff --git a/seq/src/commands.rs b/seq/src/commands.rs index aec66d2..f6799bd 100644 --- a/seq/src/commands.rs +++ b/seq/src/commands.rs @@ -116,6 +116,8 @@ pub enum AppCommand { DocPrevTopic, DocScrollDown(usize), DocScrollUp(usize), + DocNextCategory, + DocPrevCategory, // Patterns view PatternsCursorLeft, diff --git a/seq/src/engine/link.rs b/seq/src/engine/link.rs index e2bb59f..d8b2e3a 100644 --- a/seq/src/engine/link.rs +++ b/seq/src/engine/link.rs @@ -1,20 +1,50 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + use rusty_link::{AblLink, SessionState}; pub struct LinkState { link: AblLink, - quantum: f64, + quantum: AtomicU64, } impl LinkState { pub fn new(tempo: f64, quantum: f64) -> Self { let link = AblLink::new(tempo); - Self { link, quantum } + 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() } @@ -29,14 +59,14 @@ impl LinkState { 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) + 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) + state.phase_at_time(time, self.quantum()) } pub fn peers(&self) -> u64 { diff --git a/seq/src/input.rs b/seq/src/input.rs index a34da77..3fee948 100644 --- a/seq/src/input.rs +++ b/seq/src/input.rs @@ -569,6 +569,11 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { AudioFocus::Channels => ctx.app.audio.adjust_channels(-1), AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(-64), 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), }, KeyCode::Right => match ctx.app.audio.focus { AudioFocus::OutputDevice => ctx.app.audio.next_output_device(), @@ -576,6 +581,11 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { AudioFocus::Channels => ctx.app.audio.adjust_channels(1), AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(64), 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), }, KeyCode::Char('R') => ctx.app.audio.trigger_restart(), KeyCode::Char('A') => { @@ -613,8 +623,12 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { fn handle_doc_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { - KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::DocNextTopic), - KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::DocPrevTopic), + 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') => { diff --git a/seq/src/model/forth.rs b/seq/src/model/forth.rs index f8ab3a6..7291d52 100644 --- a/seq/src/model/forth.rs +++ b/seq/src/model/forth.rs @@ -102,7 +102,7 @@ impl Value { } #[derive(Clone, Debug)] -enum Op { +pub enum Op { PushInt(i64), PushFloat(f64), PushStr(String), @@ -120,6 +120,9 @@ enum Op { Mod, Neg, Abs, + Floor, + Ceil, + Round, Min, Max, Eq, @@ -145,7 +148,10 @@ enum Op { Cycle, Choose, Chance, + Coin, Maybe, + Mtof, + Ftom, ListStart, ListEnd, At, @@ -156,6 +162,1331 @@ enum Op { Each, } +pub enum WordCompile { + Simple, + Context(&'static str), + Param, + Alias(&'static str), + Probability(f64), +} + +pub struct Word { + pub name: &'static str, + pub stack: &'static str, + pub desc: &'static str, + pub example: &'static str, + pub compile: WordCompile, +} + +use WordCompile::*; + +pub const WORDS: &[Word] = &[ + // Stack manipulation + Word { + name: "dup", + stack: "(a -- a a)", + desc: "Duplicate top of stack", + example: "3 dup => 3 3", + compile: Simple, + }, + Word { + name: "drop", + stack: "(a --)", + desc: "Remove top of stack", + example: "1 2 drop => 1", + compile: Simple, + }, + Word { + name: "swap", + stack: "(a b -- b a)", + desc: "Exchange top two items", + example: "1 2 swap => 2 1", + compile: Simple, + }, + Word { + name: "over", + stack: "(a b -- a b a)", + desc: "Copy second to top", + example: "1 2 over => 1 2 1", + compile: Simple, + }, + Word { + name: "rot", + stack: "(a b c -- b c a)", + desc: "Rotate top three", + example: "1 2 3 rot => 2 3 1", + compile: Simple, + }, + Word { + name: "nip", + stack: "(a b -- b)", + desc: "Remove second item", + example: "1 2 nip => 2", + compile: Simple, + }, + Word { + name: "tuck", + stack: "(a b -- b a b)", + desc: "Copy top under second", + example: "1 2 tuck => 2 1 2", + compile: Simple, + }, + // Arithmetic + Word { + name: "+", + stack: "(a b -- a+b)", + desc: "Add", + example: "2 3 + => 5", + compile: Simple, + }, + Word { + name: "-", + stack: "(a b -- a-b)", + desc: "Subtract", + example: "5 3 - => 2", + compile: Simple, + }, + Word { + name: "*", + stack: "(a b -- a*b)", + desc: "Multiply", + example: "3 4 * => 12", + compile: Simple, + }, + Word { + name: "/", + stack: "(a b -- a/b)", + desc: "Divide", + example: "10 2 / => 5", + compile: Simple, + }, + Word { + name: "mod", + stack: "(a b -- a%b)", + desc: "Modulo", + example: "7 3 mod => 1", + compile: Simple, + }, + Word { + name: "neg", + stack: "(a -- -a)", + desc: "Negate", + example: "5 neg => -5", + compile: Simple, + }, + Word { + name: "abs", + stack: "(a -- |a|)", + desc: "Absolute value", + example: "-5 abs => 5", + compile: Simple, + }, + Word { + name: "floor", + stack: "(f -- n)", + desc: "Round down to integer", + example: "3.7 floor => 3", + compile: Simple, + }, + Word { + name: "ceil", + stack: "(f -- n)", + desc: "Round up to integer", + example: "3.2 ceil => 4", + compile: Simple, + }, + Word { + name: "round", + stack: "(f -- n)", + desc: "Round to nearest integer", + example: "3.5 round => 4", + compile: Simple, + }, + Word { + name: "min", + stack: "(a b -- min)", + desc: "Minimum of two values", + example: "3 5 min => 3", + compile: Simple, + }, + Word { + name: "max", + stack: "(a b -- max)", + desc: "Maximum of two values", + example: "3 5 max => 5", + compile: Simple, + }, + // Comparison + Word { + name: "=", + stack: "(a b -- bool)", + desc: "Equal", + example: "3 3 = => 1", + compile: Simple, + }, + Word { + name: "<>", + stack: "(a b -- bool)", + desc: "Not equal", + example: "3 4 <> => 1", + compile: Simple, + }, + Word { + name: "<", + stack: "(a b -- bool)", + desc: "Less than", + example: "2 3 < => 1", + compile: Simple, + }, + Word { + name: ">", + stack: "(a b -- bool)", + desc: "Greater than", + example: "3 2 > => 1", + compile: Simple, + }, + Word { + name: "<=", + stack: "(a b -- bool)", + desc: "Less or equal", + example: "3 3 <= => 1", + compile: Simple, + }, + Word { + name: ">=", + stack: "(a b -- bool)", + desc: "Greater or equal", + example: "3 3 >= => 1", + compile: Simple, + }, + // Logic + Word { + name: "and", + stack: "(a b -- bool)", + desc: "Logical and", + example: "1 1 and => 1", + compile: Simple, + }, + Word { + name: "or", + stack: "(a b -- bool)", + desc: "Logical or", + example: "0 1 or => 1", + compile: Simple, + }, + Word { + name: "not", + stack: "(a -- bool)", + desc: "Logical not", + example: "0 not => 1", + compile: Simple, + }, + // Sound + Word { + name: "sound", + stack: "(name --)", + desc: "Begin sound command", + example: "\"kick\" sound", + compile: Simple, + }, + Word { + name: "s", + stack: "(name --)", + desc: "Alias for sound", + example: "\"kick\" s", + compile: Alias("sound"), + }, + Word { + name: "emit", + stack: "(--)", + desc: "Output current sound", + example: "\"kick\" s emit", + compile: Simple, + }, + // Variables + Word { + name: "get", + stack: "(name -- val)", + desc: "Get variable value", + example: "\"x\" get", + compile: Simple, + }, + Word { + name: "set", + stack: "(val name --)", + desc: "Set variable value", + example: "42 \"x\" set", + compile: Simple, + }, + // Randomness + Word { + name: "rand", + stack: "(min max -- f)", + desc: "Random float in range", + example: "0 1 rand => 0.42", + compile: Simple, + }, + Word { + name: "rrand", + stack: "(min max -- n)", + desc: "Random int in range", + example: "1 6 rrand => 4", + compile: Simple, + }, + Word { + name: "seed", + stack: "(n --)", + desc: "Set random seed", + example: "12345 seed", + compile: Simple, + }, + Word { + name: "coin", + stack: "(-- bool)", + desc: "50/50 random boolean", + example: "coin => 0 or 1", + compile: Simple, + }, + Word { + name: "chance", + stack: "(prob -- bool)", + desc: "True with probability", + example: "0.75 chance", + compile: Simple, + }, + Word { + name: "choose", + stack: "(..n n -- val)", + desc: "Random pick from n items", + example: "1 2 3 3 choose", + compile: Simple, + }, + Word { + name: "cycle", + stack: "(..n n -- val)", + desc: "Cycle through n items", + example: "1 2 3 3 cycle", + compile: Simple, + }, + // Probability shortcuts + Word { + name: "always", + stack: "(-- 1)", + desc: "Always true (100%)", + example: "always => 1", + compile: Probability(1.0), + }, + Word { + name: "never", + stack: "(-- 0)", + desc: "Always false (0%)", + example: "never => 0", + compile: Probability(0.0), + }, + Word { + name: "often", + stack: "(-- bool)", + desc: "75% probability", + example: "often", + compile: Probability(0.75), + }, + Word { + name: "sometimes", + stack: "(-- bool)", + desc: "50% probability", + example: "sometimes", + compile: Probability(0.5), + }, + Word { + name: "rarely", + stack: "(-- bool)", + desc: "25% probability", + example: "rarely", + compile: Probability(0.25), + }, + Word { + name: "almostNever", + stack: "(-- bool)", + desc: "10% probability", + example: "almostNever", + compile: Probability(0.1), + }, + Word { + name: "almostAlways", + stack: "(-- bool)", + desc: "90% probability", + example: "almostAlways", + compile: Probability(0.9), + }, + // Context + Word { + name: "step", + stack: "(-- n)", + desc: "Current step index", + example: "step => 0", + compile: Context("step"), + }, + Word { + name: "beat", + stack: "(-- f)", + desc: "Current beat position", + example: "beat => 4.5", + compile: Context("beat"), + }, + Word { + name: "bank", + stack: "(-- n)", + desc: "Current bank index", + example: "bank => 0", + compile: Context("bank"), + }, + Word { + name: "pattern", + stack: "(-- n)", + desc: "Current pattern index", + example: "pattern => 0", + compile: Context("pattern"), + }, + Word { + name: "tempo", + stack: "(-- f)", + desc: "Current BPM", + example: "tempo => 120.0", + compile: Context("tempo"), + }, + Word { + name: "phase", + stack: "(-- f)", + desc: "Phase in bar (0-1)", + example: "phase => 0.25", + compile: Context("phase"), + }, + Word { + name: "slot", + stack: "(-- n)", + desc: "Current slot number", + example: "slot => 0", + compile: Context("slot"), + }, + Word { + name: "runs", + stack: "(-- n)", + desc: "Times this step ran", + example: "runs => 3", + compile: Context("runs"), + }, + Word { + name: "stepdur", + stack: "(-- f)", + desc: "Step duration in seconds", + example: "stepdur => 0.125", + compile: Context("stepdur"), + }, + // Music + Word { + name: "mtof", + stack: "(midi -- hz)", + desc: "MIDI note to frequency", + example: "69 mtof => 440.0", + compile: Simple, + }, + Word { + name: "ftom", + stack: "(hz -- midi)", + desc: "Frequency to MIDI note", + example: "440 ftom => 69.0", + compile: Simple, + }, + // Time + Word { + name: "at", + stack: "(pos --)", + desc: "Emit at position in window", + example: "0.5 at", + compile: Simple, + }, + Word { + name: "window", + stack: "(start end --)", + desc: "Create time window", + example: "0.0 0.5 window", + compile: Simple, + }, + Word { + name: "pop", + stack: "(--)", + desc: "Pop time context", + example: "pop", + compile: Simple, + }, + Word { + name: "div", + stack: "(n --)", + desc: "Subdivide time into n", + example: "4 div", + compile: Simple, + }, + Word { + name: "each", + stack: "(--)", + desc: "Emit at each subdivision", + example: "4 div each", + compile: Simple, + }, + Word { + name: "tempo!", + stack: "(bpm --)", + desc: "Set global tempo", + example: "140 tempo!", + compile: Simple, + }, + // Lists + Word { + name: "[", + stack: "(-- marker)", + desc: "Start list", + example: "[ 1 2 3 ]", + compile: Simple, + }, + Word { + name: "]", + stack: "(marker..n -- n)", + desc: "End list, push count", + example: "[ 1 2 3 ] => 3", + compile: Simple, + }, + // Other + Word { + name: "?", + stack: "(prob --)", + desc: "Maybe (not implemented)", + example: "0.5 ?", + compile: Simple, + }, + // Parameters (synthesis) + Word { + name: "time", + stack: "(f --)", + desc: "Set time offset", + example: "0.1 time", + compile: Param, + }, + Word { + name: "repeat", + stack: "(n --)", + desc: "Set repeat count", + example: "4 repeat", + compile: Param, + }, + Word { + name: "dur", + stack: "(f --)", + desc: "Set duration", + example: "0.5 dur", + compile: Param, + }, + Word { + name: "gate", + stack: "(f --)", + desc: "Set gate time", + example: "0.8 gate", + compile: Param, + }, + Word { + name: "freq", + stack: "(f --)", + desc: "Set frequency (Hz)", + example: "440 freq", + compile: Param, + }, + Word { + name: "detune", + stack: "(f --)", + desc: "Set detune amount", + example: "0.01 detune", + compile: Param, + }, + Word { + name: "speed", + stack: "(f --)", + desc: "Set playback speed", + example: "1.5 speed", + compile: Param, + }, + Word { + name: "glide", + stack: "(f --)", + desc: "Set glide/portamento", + example: "0.1 glide", + compile: Param, + }, + Word { + name: "pw", + stack: "(f --)", + desc: "Set pulse width", + example: "0.5 pw", + compile: Param, + }, + Word { + name: "spread", + stack: "(f --)", + desc: "Set stereo spread", + example: "0.5 spread", + compile: Param, + }, + Word { + name: "mult", + stack: "(f --)", + desc: "Set multiplier", + example: "2 mult", + compile: Param, + }, + Word { + name: "warp", + stack: "(f --)", + desc: "Set warp amount", + example: "0.5 warp", + compile: Param, + }, + Word { + name: "mirror", + stack: "(f --)", + desc: "Set mirror", + example: "1 mirror", + compile: Param, + }, + Word { + name: "harmonics", + stack: "(f --)", + desc: "Set harmonics", + example: "4 harmonics", + compile: Param, + }, + Word { + name: "timbre", + stack: "(f --)", + desc: "Set timbre", + example: "0.5 timbre", + compile: Param, + }, + Word { + name: "morph", + stack: "(f --)", + desc: "Set morph", + example: "0.5 morph", + compile: Param, + }, + Word { + name: "begin", + stack: "(f --)", + desc: "Set sample start (0-1)", + example: "0.25 begin", + compile: Param, + }, + Word { + name: "end", + stack: "(f --)", + desc: "Set sample end (0-1)", + example: "0.75 end", + compile: Param, + }, + Word { + name: "gain", + stack: "(f --)", + desc: "Set volume (0-1)", + example: "0.8 gain", + compile: Param, + }, + Word { + name: "postgain", + stack: "(f --)", + desc: "Set post gain", + example: "1.2 postgain", + compile: Param, + }, + Word { + name: "velocity", + stack: "(f --)", + desc: "Set velocity", + example: "100 velocity", + compile: Param, + }, + Word { + name: "pan", + stack: "(f --)", + desc: "Set pan (-1 to 1)", + example: "0.5 pan", + compile: Param, + }, + Word { + name: "attack", + stack: "(f --)", + desc: "Set attack time", + example: "0.01 attack", + compile: Param, + }, + Word { + name: "decay", + stack: "(f --)", + desc: "Set decay time", + example: "0.1 decay", + compile: Param, + }, + Word { + name: "sustain", + stack: "(f --)", + desc: "Set sustain level", + example: "0.5 sustain", + compile: Param, + }, + Word { + name: "release", + stack: "(f --)", + desc: "Set release time", + example: "0.3 release", + compile: Param, + }, + Word { + name: "lpf", + stack: "(f --)", + desc: "Set lowpass frequency", + example: "2000 lpf", + compile: Param, + }, + Word { + name: "lpq", + stack: "(f --)", + desc: "Set lowpass resonance", + example: "0.5 lpq", + compile: Param, + }, + Word { + name: "lpe", + stack: "(f --)", + desc: "Set lowpass envelope", + example: "0.5 lpe", + compile: Param, + }, + Word { + name: "lpa", + stack: "(f --)", + desc: "Set lowpass attack", + example: "0.01 lpa", + compile: Param, + }, + Word { + name: "lpd", + stack: "(f --)", + desc: "Set lowpass decay", + example: "0.1 lpd", + compile: Param, + }, + Word { + name: "lps", + stack: "(f --)", + desc: "Set lowpass sustain", + example: "0.5 lps", + compile: Param, + }, + Word { + name: "lpr", + stack: "(f --)", + desc: "Set lowpass release", + example: "0.3 lpr", + compile: Param, + }, + Word { + name: "hpf", + stack: "(f --)", + desc: "Set highpass frequency", + example: "100 hpf", + compile: Param, + }, + Word { + name: "hpq", + stack: "(f --)", + desc: "Set highpass resonance", + example: "0.5 hpq", + compile: Param, + }, + Word { + name: "hpe", + stack: "(f --)", + desc: "Set highpass envelope", + example: "0.5 hpe", + compile: Param, + }, + Word { + name: "hpa", + stack: "(f --)", + desc: "Set highpass attack", + example: "0.01 hpa", + compile: Param, + }, + Word { + name: "hpd", + stack: "(f --)", + desc: "Set highpass decay", + example: "0.1 hpd", + compile: Param, + }, + Word { + name: "hps", + stack: "(f --)", + desc: "Set highpass sustain", + example: "0.5 hps", + compile: Param, + }, + Word { + name: "hpr", + stack: "(f --)", + desc: "Set highpass release", + example: "0.3 hpr", + compile: Param, + }, + Word { + name: "bpf", + stack: "(f --)", + desc: "Set bandpass frequency", + example: "1000 bpf", + compile: Param, + }, + Word { + name: "bpq", + stack: "(f --)", + desc: "Set bandpass resonance", + example: "0.5 bpq", + compile: Param, + }, + Word { + name: "bpe", + stack: "(f --)", + desc: "Set bandpass envelope", + example: "0.5 bpe", + compile: Param, + }, + Word { + name: "bpa", + stack: "(f --)", + desc: "Set bandpass attack", + example: "0.01 bpa", + compile: Param, + }, + Word { + name: "bpd", + stack: "(f --)", + desc: "Set bandpass decay", + example: "0.1 bpd", + compile: Param, + }, + Word { + name: "bps", + stack: "(f --)", + desc: "Set bandpass sustain", + example: "0.5 bps", + compile: Param, + }, + Word { + name: "bpr", + stack: "(f --)", + desc: "Set bandpass release", + example: "0.3 bpr", + compile: Param, + }, + Word { + name: "ftype", + stack: "(n --)", + desc: "Set filter type", + example: "1 ftype", + compile: Param, + }, + Word { + name: "penv", + stack: "(f --)", + desc: "Set pitch envelope", + example: "0.5 penv", + compile: Param, + }, + Word { + name: "patt", + stack: "(f --)", + desc: "Set pitch attack", + example: "0.01 patt", + compile: Param, + }, + Word { + name: "pdec", + stack: "(f --)", + desc: "Set pitch decay", + example: "0.1 pdec", + compile: Param, + }, + Word { + name: "psus", + stack: "(f --)", + desc: "Set pitch sustain", + example: "0 psus", + compile: Param, + }, + Word { + name: "prel", + stack: "(f --)", + desc: "Set pitch release", + example: "0.1 prel", + compile: Param, + }, + Word { + name: "vib", + stack: "(f --)", + desc: "Set vibrato rate", + example: "5 vib", + compile: Param, + }, + Word { + name: "vibmod", + stack: "(f --)", + desc: "Set vibrato depth", + example: "0.5 vibmod", + compile: Param, + }, + Word { + name: "vibshape", + stack: "(f --)", + desc: "Set vibrato shape", + example: "0 vibshape", + compile: Param, + }, + Word { + name: "fm", + stack: "(f --)", + desc: "Set FM frequency", + example: "200 fm", + compile: Param, + }, + Word { + name: "fmh", + stack: "(f --)", + desc: "Set FM harmonic ratio", + example: "2 fmh", + compile: Param, + }, + Word { + name: "fmshape", + stack: "(f --)", + desc: "Set FM shape", + example: "0 fmshape", + compile: Param, + }, + Word { + name: "fme", + stack: "(f --)", + desc: "Set FM envelope", + example: "0.5 fme", + compile: Param, + }, + Word { + name: "fma", + stack: "(f --)", + desc: "Set FM attack", + example: "0.01 fma", + compile: Param, + }, + Word { + name: "fmd", + stack: "(f --)", + desc: "Set FM decay", + example: "0.1 fmd", + compile: Param, + }, + Word { + name: "fms", + stack: "(f --)", + desc: "Set FM sustain", + example: "0.5 fms", + compile: Param, + }, + Word { + name: "fmr", + stack: "(f --)", + desc: "Set FM release", + example: "0.1 fmr", + compile: Param, + }, + Word { + name: "am", + stack: "(f --)", + desc: "Set AM frequency", + example: "10 am", + compile: Param, + }, + Word { + name: "amdepth", + stack: "(f --)", + desc: "Set AM depth", + example: "0.5 amdepth", + compile: Param, + }, + Word { + name: "amshape", + stack: "(f --)", + desc: "Set AM shape", + example: "0 amshape", + compile: Param, + }, + Word { + name: "rm", + stack: "(f --)", + desc: "Set RM frequency", + example: "100 rm", + compile: Param, + }, + Word { + name: "rmdepth", + stack: "(f --)", + desc: "Set RM depth", + example: "0.5 rmdepth", + compile: Param, + }, + Word { + name: "rmshape", + stack: "(f --)", + desc: "Set RM shape", + example: "0 rmshape", + compile: Param, + }, + Word { + name: "phaser", + stack: "(f --)", + desc: "Set phaser rate", + example: "1 phaser", + compile: Param, + }, + Word { + name: "phaserdepth", + stack: "(f --)", + desc: "Set phaser depth", + example: "0.5 phaserdepth", + compile: Param, + }, + Word { + name: "phasersweep", + stack: "(f --)", + desc: "Set phaser sweep", + example: "0.5 phasersweep", + compile: Param, + }, + Word { + name: "phasercenter", + stack: "(f --)", + desc: "Set phaser center", + example: "1000 phasercenter", + compile: Param, + }, + Word { + name: "flanger", + stack: "(f --)", + desc: "Set flanger rate", + example: "0.5 flanger", + compile: Param, + }, + Word { + name: "flangerdepth", + stack: "(f --)", + desc: "Set flanger depth", + example: "0.5 flangerdepth", + compile: Param, + }, + Word { + name: "flangerfeedback", + stack: "(f --)", + desc: "Set flanger feedback", + example: "0.5 flangerfeedback", + compile: Param, + }, + Word { + name: "chorus", + stack: "(f --)", + desc: "Set chorus rate", + example: "1 chorus", + compile: Param, + }, + Word { + name: "chorusdepth", + stack: "(f --)", + desc: "Set chorus depth", + example: "0.5 chorusdepth", + compile: Param, + }, + Word { + name: "chorusdelay", + stack: "(f --)", + desc: "Set chorus delay", + example: "0.02 chorusdelay", + compile: Param, + }, + Word { + name: "comb", + stack: "(f --)", + desc: "Set comb filter mix", + example: "0.5 comb", + compile: Param, + }, + Word { + name: "combfreq", + stack: "(f --)", + desc: "Set comb frequency", + example: "200 combfreq", + compile: Param, + }, + Word { + name: "combfeedback", + stack: "(f --)", + desc: "Set comb feedback", + example: "0.5 combfeedback", + compile: Param, + }, + Word { + name: "combdamp", + stack: "(f --)", + desc: "Set comb damping", + example: "0.5 combdamp", + compile: Param, + }, + Word { + name: "coarse", + stack: "(f --)", + desc: "Set coarse tune", + example: "12 coarse", + compile: Param, + }, + Word { + name: "crush", + stack: "(f --)", + desc: "Set bit crush", + example: "8 crush", + compile: Param, + }, + Word { + name: "fold", + stack: "(f --)", + desc: "Set wave fold", + example: "2 fold", + compile: Param, + }, + Word { + name: "wrap", + stack: "(f --)", + desc: "Set wave wrap", + example: "0.5 wrap", + compile: Param, + }, + Word { + name: "distort", + stack: "(f --)", + desc: "Set distortion", + example: "0.5 distort", + compile: Param, + }, + Word { + name: "distortvol", + stack: "(f --)", + desc: "Set distortion volume", + example: "0.8 distortvol", + compile: Param, + }, + Word { + name: "delay", + stack: "(f --)", + desc: "Set delay mix", + example: "0.3 delay", + compile: Param, + }, + Word { + name: "delaytime", + stack: "(f --)", + desc: "Set delay time", + example: "0.25 delaytime", + compile: Param, + }, + Word { + name: "delayfeedback", + stack: "(f --)", + desc: "Set delay feedback", + example: "0.5 delayfeedback", + compile: Param, + }, + Word { + name: "delaytype", + stack: "(n --)", + desc: "Set delay type", + example: "1 delaytype", + compile: Param, + }, + Word { + name: "verb", + stack: "(f --)", + desc: "Set reverb mix", + example: "0.3 verb", + compile: Param, + }, + Word { + name: "verbdecay", + stack: "(f --)", + desc: "Set reverb decay", + example: "2 verbdecay", + compile: Param, + }, + Word { + name: "verbdamp", + stack: "(f --)", + desc: "Set reverb damping", + example: "0.5 verbdamp", + compile: Param, + }, + Word { + name: "verbpredelay", + stack: "(f --)", + desc: "Set reverb predelay", + example: "0.02 verbpredelay", + compile: Param, + }, + Word { + name: "verbdiff", + stack: "(f --)", + desc: "Set reverb diffusion", + example: "0.7 verbdiff", + compile: Param, + }, + Word { + name: "voice", + stack: "(n --)", + desc: "Set voice number", + example: "1 voice", + compile: Param, + }, + Word { + name: "orbit", + stack: "(n --)", + desc: "Set orbit/bus", + example: "0 orbit", + compile: Param, + }, + Word { + name: "note", + stack: "(n --)", + desc: "Set MIDI note", + example: "60 note", + compile: Param, + }, + Word { + name: "size", + stack: "(f --)", + desc: "Set size", + example: "1 size", + compile: Param, + }, + Word { + name: "n", + stack: "(n --)", + desc: "Set sample number", + example: "0 n", + compile: Param, + }, + Word { + name: "cut", + stack: "(n --)", + desc: "Set cut group", + example: "1 cut", + compile: Param, + }, + Word { + name: "reset", + stack: "(n --)", + desc: "Reset parameter", + example: "1 reset", + compile: Param, + }, +]; + +fn simple_op(name: &str) -> Option { + Some(match name { + "dup" => Op::Dup, + "drop" => Op::Drop, + "swap" => Op::Swap, + "over" => Op::Over, + "rot" => Op::Rot, + "nip" => Op::Nip, + "tuck" => Op::Tuck, + "+" => Op::Add, + "-" => Op::Sub, + "*" => Op::Mul, + "/" => Op::Div, + "mod" => Op::Mod, + "neg" => Op::Neg, + "abs" => Op::Abs, + "floor" => Op::Floor, + "ceil" => Op::Ceil, + "round" => Op::Round, + "min" => Op::Min, + "max" => Op::Max, + "=" => Op::Eq, + "<>" => Op::Ne, + "<" => Op::Lt, + ">" => Op::Gt, + "<=" => Op::Le, + ">=" => Op::Ge, + "and" => Op::And, + "or" => Op::Or, + "not" => Op::Not, + "sound" => Op::NewCmd, + "emit" => Op::Emit, + "get" => Op::Get, + "set" => Op::Set, + "rand" => Op::Rand, + "rrand" => Op::Rrand, + "seed" => Op::Seed, + "cycle" => Op::Cycle, + "choose" => Op::Choose, + "chance" => Op::Chance, + "coin" => Op::Coin, + "mtof" => Op::Mtof, + "ftom" => Op::Ftom, + "?" => Op::Maybe, + "at" => Op::At, + "window" => Op::Window, + "pop" => Op::Pop, + "div" => Op::Subdivide, + "each" => Op::Each, + "tempo!" => Op::SetTempo, + "[" => Op::ListStart, + "]" => Op::ListEnd, + _ => return None, + }) +} + +fn compile_word(name: &str, ops: &mut Vec) -> bool { + for word in WORDS { + if word.name == name { + match &word.compile { + Simple => { + if let Some(op) = simple_op(name) { + ops.push(op); + } + } + Context(ctx) => ops.push(Op::GetContext((*ctx).into())), + Param => ops.push(Op::SetParam(name.into())), + Alias(target) => return compile_word(target, ops), + Probability(p) => { + ops.push(Op::PushFloat(*p)); + ops.push(Op::Maybe); + } + } + return true; + } + } + false +} + #[derive(Clone, Debug)] struct TimeContext { start: f64, @@ -227,115 +1558,6 @@ fn tokenize(input: &str) -> Vec { tokens } -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", -]; - fn compile(tokens: &[Token]) -> Result, String> { let mut ops = Vec::new(); let mut i = 0; @@ -347,108 +1569,20 @@ fn compile(tokens: &[Token]) -> Result, String> { Token::Str(s) => ops.push(Op::PushStr(s.clone())), Token::Word(w) => { let word = w.as_str(); - match word { - "dup" => ops.push(Op::Dup), - "drop" => ops.push(Op::Drop), - "swap" => ops.push(Op::Swap), - "over" => ops.push(Op::Over), - "rot" => ops.push(Op::Rot), - "nip" => ops.push(Op::Nip), - "tuck" => ops.push(Op::Tuck), - "+" => ops.push(Op::Add), - "-" => ops.push(Op::Sub), - "*" => ops.push(Op::Mul), - "/" => ops.push(Op::Div), - "mod" => ops.push(Op::Mod), - "neg" => ops.push(Op::Neg), - "abs" => ops.push(Op::Abs), - "min" => ops.push(Op::Min), - "max" => ops.push(Op::Max), - "=" => ops.push(Op::Eq), - "<>" => ops.push(Op::Ne), - "<" => ops.push(Op::Lt), - ">" => ops.push(Op::Gt), - "<=" => ops.push(Op::Le), - ">=" => ops.push(Op::Ge), - "and" => ops.push(Op::And), - "or" => ops.push(Op::Or), - "not" => ops.push(Op::Not), - "sound" | "s" => ops.push(Op::NewCmd), - "emit" => ops.push(Op::Emit), - "get" => ops.push(Op::Get), - "set" => ops.push(Op::Set), - "rand" => ops.push(Op::Rand), - "rrand" => ops.push(Op::Rrand), - "seed" => ops.push(Op::Seed), - "cycle" => ops.push(Op::Cycle), - "choose" => ops.push(Op::Choose), - "chance" => ops.push(Op::Chance), - "?" => ops.push(Op::Maybe), - "always" => { - ops.push(Op::PushFloat(1.0)); - ops.push(Op::Maybe); - } - "never" => { - ops.push(Op::PushFloat(0.0)); - ops.push(Op::Maybe); - } - "often" => { - ops.push(Op::PushFloat(0.75)); - ops.push(Op::Maybe); - } - "sometimes" => { - ops.push(Op::PushFloat(0.5)); - ops.push(Op::Maybe); - } - "rarely" => { - ops.push(Op::PushFloat(0.25)); - ops.push(Op::Maybe); - } - "almostNever" => { - ops.push(Op::PushFloat(0.1)); - ops.push(Op::Maybe); - } - "almostAlways" => { - ops.push(Op::PushFloat(0.9)); - ops.push(Op::Maybe); - } - "at" => ops.push(Op::At), - "window" => ops.push(Op::Window), - "pop" => ops.push(Op::Pop), - "div" => ops.push(Op::Subdivide), - "each" => ops.push(Op::Each), - "tempo!" => ops.push(Op::SetTempo), - "[" => ops.push(Op::ListStart), - "]" => ops.push(Op::ListEnd), - "step" => ops.push(Op::GetContext("step".into())), - "beat" => ops.push(Op::GetContext("beat".into())), - "bank" => ops.push(Op::GetContext("bank".into())), - "pattern" => ops.push(Op::GetContext("pattern".into())), - "tempo" => ops.push(Op::GetContext("tempo".into())), - "phase" => ops.push(Op::GetContext("phase".into())), - "slot" => ops.push(Op::GetContext("slot".into())), - "runs" => ops.push(Op::GetContext("runs".into())), - "stepdur" => ops.push(Op::GetContext("stepdur".into())), - "if" => { - let (then_ops, else_ops, consumed) = compile_if(&tokens[i + 1..])?; - i += consumed; - if else_ops.is_empty() { - ops.push(Op::BranchIfZero(then_ops.len())); - ops.extend(then_ops); - } else { - ops.push(Op::BranchIfZero(then_ops.len() + 1)); - ops.extend(then_ops); - ops.push(Op::Branch(else_ops.len())); - ops.extend(else_ops); - } - } - _ => { - if PARAMS.contains(&word) { - ops.push(Op::SetParam(word.into())); - } else { - return Err(format!("unknown word: {word}")); - } + if word == "if" { + let (then_ops, else_ops, consumed) = compile_if(&tokens[i + 1..])?; + i += consumed; + if else_ops.is_empty() { + ops.push(Op::BranchIfZero(then_ops.len())); + ops.extend(then_ops); + } else { + ops.push(Op::BranchIfZero(then_ops.len() + 1)); + ops.extend(then_ops); + ops.push(Op::Branch(else_ops.len())); + ops.extend(else_ops); } + } else if !compile_word(word, &mut ops) { + return Err(format!("unknown word: {word}")); } } } @@ -602,6 +1736,18 @@ impl Forth { _ => return Err("expected number".into()), } } + Op::Floor => { + let v = stack.pop().ok_or("stack underflow")?.as_float()?; + stack.push(Value::Int(v.floor() as i64)); + } + Op::Ceil => { + let v = stack.pop().ok_or("stack underflow")?.as_float()?; + stack.push(Value::Int(v.ceil() as i64)); + } + Op::Round => { + let v = stack.pop().ok_or("stack underflow")?.as_float()?; + stack.push(Value::Int(v.round() as i64)); + } Op::Min => binary_op(&mut stack, |a, b| a.min(b))?, Op::Max => binary_op(&mut stack, |a, b| a.max(b))?, @@ -749,10 +1895,27 @@ impl Forth { stack.push(Value::Int(if val < prob { 1 } else { 0 })); } + Op::Coin => { + let val: f64 = self.rng.lock().unwrap().gen(); + stack.push(Value::Int(if val < 0.5 { 1 } else { 0 })); + } + Op::Maybe => { return Err("? is not yet implemented with the new param model".into()); } + Op::Mtof => { + let note = stack.pop().ok_or("stack underflow")?.as_float()?; + let freq = 440.0 * 2.0_f64.powf((note - 69.0) / 12.0); + stack.push(Value::Float(freq)); + } + + Op::Ftom => { + let freq = stack.pop().ok_or("stack underflow")?.as_float()?; + let note = 69.0 + 12.0 * (freq / 440.0).log2(); + stack.push(Value::Float(note)); + } + Op::At => { let pos = stack.pop().ok_or("stack underflow")?.as_float()?; let (sound, mut params) = cmd.take().ok_or("no sound set")?; diff --git a/seq/src/model/mod.rs b/seq/src/model/mod.rs index a7c6643..bc10df8 100644 --- a/seq/src/model/mod.rs +++ b/seq/src/model/mod.rs @@ -1,5 +1,5 @@ mod file; -mod forth; +pub mod forth; mod project; mod script; diff --git a/seq/src/state/audio.rs b/seq/src/state/audio.rs index a331847..0ba460a 100644 --- a/seq/src/state/audio.rs +++ b/seq/src/state/audio.rs @@ -34,6 +34,9 @@ pub enum AudioFocus { Channels, BufferSize, SamplePaths, + LinkEnabled, + StartStopSync, + Quantum, } pub struct Metrics { @@ -94,17 +97,23 @@ impl AudioSettings { AudioFocus::InputDevice => AudioFocus::Channels, AudioFocus::Channels => AudioFocus::BufferSize, AudioFocus::BufferSize => AudioFocus::SamplePaths, - AudioFocus::SamplePaths => AudioFocus::OutputDevice, + 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::SamplePaths, + AudioFocus::OutputDevice => AudioFocus::Quantum, AudioFocus::InputDevice => AudioFocus::OutputDevice, AudioFocus::Channels => AudioFocus::InputDevice, AudioFocus::BufferSize => AudioFocus::Channels, AudioFocus::SamplePaths => AudioFocus::BufferSize, + AudioFocus::LinkEnabled => AudioFocus::SamplePaths, + AudioFocus::StartStopSync => AudioFocus::LinkEnabled, + AudioFocus::Quantum => AudioFocus::StartStopSync, }; } diff --git a/seq/src/state/ui.rs b/seq/src/state/ui.rs index d5daae3..e7a2951 100644 --- a/seq/src/state/ui.rs +++ b/seq/src/state/ui.rs @@ -8,6 +8,7 @@ pub struct UiState { pub modal: Modal, pub doc_topic: usize, pub doc_scroll: usize, + pub doc_category: usize, pub show_title: bool, } @@ -19,6 +20,7 @@ impl Default for UiState { modal: Modal::None, doc_topic: 0, doc_scroll: 0, + doc_category: 0, show_title: true, } } diff --git a/seq/src/views/audio_view.rs b/seq/src/views/audio_view.rs index 8cc1ad5..a95497a 100644 --- a/seq/src/views/audio_view.rs +++ b/seq/src/views/audio_view.rs @@ -1,18 +1,23 @@ -use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Gauge, Paragraph}; +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, area: Rect) { - let [config_area, stats_area] = - Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(area); +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_config(frame, app, config_area); - render_stats(frame, app, stats_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 { @@ -23,204 +28,337 @@ fn truncate_name(name: &str, max_len: usize) -> String { } } -fn render_config(frame: &mut Frame, app: &App, area: Rect) { +fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) - .title("Configuration") + .title(" Audio ") .border_style(Style::new().fg(Color::Magenta)); let inner = block.inner(area); frame.render_widget(block, area); - let [output_area, input_area, channels_area, buffer_area, rate_area, samples_area, _, hints_area] = - Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(3), - Constraint::Min(1), - Constraint::Length(1), - ]) - .areas(inner); + 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(4), + 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 dim = Style::new().fg(Color::DarkGray); + let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); - let output_name = truncate_name(app.audio.current_output_device_name(), 25); - let output_style = if app.audio.focus == AudioFocus::OutputDevice { - highlight - } else { - normal - }; - let output_line = Line::from(vec![ - Span::styled("Output ", dim), - Span::styled("< ", output_style), - Span::styled(output_name, output_style), - Span::styled(" >", output_style), - ]); - frame.render_widget(Paragraph::new(output_line), output_area); + 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 input_name = truncate_name(app.audio.current_input_device_name(), 25); - let input_style = if app.audio.focus == AudioFocus::InputDevice { - highlight - } else { - normal - }; - let input_line = Line::from(vec![ - Span::styled("Input ", dim), - Span::styled("< ", input_style), - Span::styled(input_name, input_style), - Span::styled(" >", input_style), - ]); - frame.render_widget(Paragraph::new(input_line), input_area); + let output_focused = app.audio.focus == AudioFocus::OutputDevice; + let input_focused = app.audio.focus == AudioFocus::InputDevice; - let channels_style = if app.audio.focus == AudioFocus::Channels { - highlight - } else { - normal - }; - let channels_line = Line::from(vec![ - Span::styled("Channels ", dim), - Span::styled("< ", channels_style), - Span::styled(format!("{:2}", app.audio.config.channels), channels_style), - Span::styled(" >", channels_style), - ]); - frame.render_widget(Paragraph::new(channels_line), channels_area); + 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 buffer_style = if app.audio.focus == AudioFocus::BufferSize { - highlight - } else { - normal - }; - let buffer_line = Line::from(vec![ - Span::styled("Buffer ", dim), - Span::styled("< ", buffer_style), - Span::styled(format!("{:4}", app.audio.config.buffer_size), buffer_style), - Span::styled(" >", buffer_style), - ]); - frame.render_widget(Paragraph::new(buffer_line), buffer_area); + let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]); + frame.render_widget(table, content_area); +} - let rate_line = Line::from(vec![ - Span::styled("Rate ", dim), - Span::styled(format!("{:.0} Hz", app.audio.config.sample_rate), normal), - ]); - frame.render_widget(Paragraph::new(rate_line), rate_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 samples_style = if app.audio.focus == AudioFocus::SamplePaths { - highlight - } else { - normal - }; + let [header_area, content_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); - let mut sample_lines = vec![Line::from(vec![ - Span::styled("Samples ", dim), - Span::styled( - format!( - "{} paths, {} indexed", - app.audio.config.sample_paths.len(), - app.audio.config.sample_count + 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 rows = vec![ + Row::new(vec![ + Span::styled("Channels", label_style), + render_selector( + &format!("{}", app.audio.config.channels), + channels_focused, + highlight, + normal, ), - samples_style, - ), - ])]; - - for (i, path) in app.audio.config.sample_paths.iter().take(2).enumerate() { - let path_str = path.to_string_lossy(); - let display = truncate_name(&path_str, 35); - sample_lines.push(Line::from(vec![ - Span::styled(" ", dim), + ]), + 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("Rate", label_style), Span::styled( - format!("{}: {}", i + 1, display), - Style::new().fg(Color::DarkGray), + format!("{:.0} Hz", app.audio.config.sample_rate), + value_style, ), + ]), + ]; + + let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]); + frame.render_widget(table, content_area); +} + +fn render_samples(frame: &mut Frame, app: &App, area: Rect) { + let header_style = Style::new() + .fg(Color::Rgb(100, 160, 180)) + .add_modifier(Modifier::BOLD); + + let [header_area, content_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); + + let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let samples_focused = app.audio.focus == AudioFocus::SamplePaths; + + let header_text = format!( + "Samples {} paths · {} indexed", + app.audio.config.sample_paths.len(), + app.audio.config.sample_count + ); + + let header_line = if samples_focused { + Line::from(vec![ + Span::styled("Samples ", header_style), + Span::styled( + format!( + "{} paths · {} indexed", + app.audio.config.sample_paths.len(), + app.audio.config.sample_count + ), + highlight, + ), + ]) + } else { + Line::from(Span::styled(header_text, header_style)) + }; + frame.render_widget(Paragraph::new(header_line), header_area); + + let dim = Style::new().fg(Color::Rgb(80, 85, 95)); + let path_style = Style::new().fg(Color::Rgb(120, 125, 135)); + + let mut lines: Vec = Vec::new(); + for (i, path) in app.audio.config.sample_paths.iter().take(4).enumerate() { + let path_str = path.to_string_lossy(); + let display = truncate_name(&path_str, 45); + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", i + 1), dim), + Span::styled(display, path_style), ])); } - frame.render_widget(Paragraph::new(sample_lines), samples_area); + if lines.is_empty() { + lines.push(Line::from(Span::styled( + " No sample paths configured", + dim, + ))); + } - let hints_line = Line::from(vec![ - Span::styled("[R] Restart ", Style::new().fg(Color::Cyan)), - Span::styled("[A] Add path ", Style::new().fg(Color::DarkGray)), - Span::styled("[D] Refresh", Style::new().fg(Color::DarkGray)), - ]); - frame.render_widget(Paragraph::new(hints_line), hints_area); + frame.render_widget(Paragraph::new(lines), content_area); } -fn render_stats(frame: &mut Frame, app: &App, area: Rect) { +fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let block = Block::default() .borders(Borders::ALL) - .title("Engine Stats") + .title(" Ableton Link ") .border_style(Style::new().fg(Color::Cyan)); let inner = block.inner(area); frame.render_widget(block, area); - let [cpu_area, voices_area, extra_area] = Layout::vertical([ + 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(2), + Constraint::Length(1), + Constraint::Length(5), + Constraint::Length(1), Constraint::Min(1), ]) - .areas(inner); + .areas(padded); - let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0); - let cpu_color = if cpu_pct > 80.0 { - Color::Red - } else if cpu_pct > 50.0 { - Color::Yellow - } else { - Color::Green - }; - - let gauge = Gauge::default() - .block(Block::default().title("CPU")) - .gauge_style(Style::new().fg(cpu_color).bg(Color::DarkGray)) - .percent(cpu_pct as u16) - .label(format!("{cpu_pct:.1}%")); - - frame.render_widget(gauge, cpu_area); - - let voice_color = if app.metrics.active_voices > 24 { - Color::Red - } else if app.metrics.active_voices > 16 { - Color::Yellow - } else { - Color::Cyan - }; - - let voices = Paragraph::new(Line::from(vec![ - Span::raw("Active: "), - Span::styled( - format!("{:3}", app.metrics.active_voices), - Style::new().fg(voice_color).add_modifier(Modifier::BOLD), - ), - Span::raw(" Peak: "), - Span::styled( - format!("{:3}", app.metrics.peak_voices), - Style::new().fg(Color::Yellow), - ), - ])); - - frame.render_widget(voices, voices_area); - - let extra = Paragraph::new(vec![ - Line::from(vec![ - Span::raw("Schedule: "), - Span::styled( - format!("{}", app.metrics.schedule_depth), - Style::new().fg(Color::White), - ), - ]), - Line::from(vec![ - Span::raw("Pool: "), - Span::styled( - format!("{:.1} MB", app.sample_pool_mb), - Style::new().fg(Color::White), - ), - ]), - ]); - - frame.render_widget(extra, extra_area); + render_link_status(frame, link, status_area); + render_link_config(frame, app, link, config_area); + render_link_info(frame, link, info_area); +} + +fn render_link_status(frame: &mut Frame, link: &LinkState, area: Rect) { + let enabled = link.is_enabled(); + let peers = link.peers(); + + let (status_text, status_color) = if !enabled { + ("DISABLED", Color::Rgb(120, 60, 60)) + } else if peers > 0 { + ("CONNECTED", Color::Rgb(60, 120, 60)) + } else { + ("LISTENING", Color::Rgb(120, 120, 60)) + }; + + let status_style = Style::new().fg(status_color).add_modifier(Modifier::BOLD); + + let peer_text = if enabled { + if peers == 0 { + "No peers".to_string() + } else if peers == 1 { + "1 peer".to_string() + } else { + format!("{peers} peers") + } + } else { + String::new() + }; + + let lines = vec![ + Line::from(Span::styled(status_text, status_style)), + Line::from(Span::styled( + peer_text, + Style::new().fg(Color::Rgb(120, 125, 135)), + )), + ]; + + frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); +} + +fn render_link_config(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { + let header_style = Style::new() + .fg(Color::Rgb(100, 160, 180)) + .add_modifier(Modifier::BOLD); + + let [header_area, content_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); + + frame.render_widget( + Paragraph::new("Configuration").style(header_style), + header_area, + ); + + let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let normal = Style::new().fg(Color::White); + let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); + + let enabled_focused = app.audio.focus == AudioFocus::LinkEnabled; + let startstop_focused = app.audio.focus == AudioFocus::StartStopSync; + let quantum_focused = app.audio.focus == AudioFocus::Quantum; + + let enabled_text = if link.is_enabled() { "On" } else { "Off" }; + let startstop_text = if link.is_start_stop_sync_enabled() { + "On" + } else { + "Off" + }; + let quantum_text = format!("{:.0}", link.quantum()); + + let rows = vec![ + Row::new(vec![ + Span::styled("Enabled", label_style), + render_selector(enabled_text, enabled_focused, highlight, normal), + ]), + Row::new(vec![ + Span::styled("Start/Stop", label_style), + render_selector(startstop_text, startstop_focused, highlight, normal), + ]), + Row::new(vec![ + Span::styled("Quantum", label_style), + render_selector(&quantum_text, quantum_focused, highlight, normal), + ]), + ]; + + let table = Table::new(rows, [Constraint::Length(10), Constraint::Fill(1)]); + frame.render_widget(table, content_area); +} + +fn render_link_info(frame: &mut Frame, link: &LinkState, area: Rect) { + let header_style = Style::new() + .fg(Color::Rgb(100, 160, 180)) + .add_modifier(Modifier::BOLD); + + let [header_area, content_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); + + frame.render_widget(Paragraph::new("Session").style(header_style), header_area); + + let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); + let value_style = Style::new().fg(Color::Rgb(180, 180, 190)); + let tempo_style = Style::new() + .fg(Color::Rgb(220, 180, 100)) + .add_modifier(Modifier::BOLD); + + let tempo = link.tempo(); + let beat = link.beat(); + let phase = link.phase(); + + let rows = vec![ + Row::new(vec![ + Span::styled("Tempo", label_style), + Span::styled(format!("{tempo:.1} BPM"), tempo_style), + ]), + Row::new(vec![ + Span::styled("Beat", label_style), + Span::styled(format!("{beat:.2}"), value_style), + ]), + Row::new(vec![ + Span::styled("Phase", label_style), + Span::styled(format!("{phase:.2}"), value_style), + ]), + ]; + + let table = Table::new(rows, [Constraint::Length(10), Constraint::Fill(1)]); + frame.render_widget(table, content_area); +} + +fn render_selector(value: &str, focused: bool, highlight: Style, normal: Style) -> Span<'static> { + let style = if focused { highlight } else { normal }; + if focused { + Span::styled(format!("< {value} >"), style) + } else { + Span::styled(format!(" {value} "), style) + } } diff --git a/seq/src/views/doc_view.rs b/seq/src/views/doc_view.rs index 9856076..5b7bea4 100644 --- a/seq/src/views/doc_view.rs +++ b/seq/src/views/doc_view.rs @@ -6,26 +6,49 @@ use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; use ratatui::Frame; use crate::app::App; +use crate::model::forth::{Word, WordCompile, WORDS}; -const DOCS: &[(&str, &str)] = &[ +const STATIC_DOCS: &[(&str, &str)] = &[ ("Keybindings", include_str!("../../docs/keybindings.md")), - ("Scripting", include_str!("../../docs/scripting.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); - render_content(frame, app, content_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 = DOCS + let items: Vec = TOPICS .iter() .enumerate() - .map(|(i, (name, _))| { + .map(|(i, name)| { let style = if i == app.ui.doc_topic { Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD) } else { @@ -40,8 +63,12 @@ fn render_topics(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(list, area); } -fn render_content(frame: &mut Frame, app: &App, area: Rect) { - let (title, md) = DOCS[app.ui.doc_topic]; +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; @@ -55,10 +82,113 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { .take(visible_height) .collect(); + let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(topic)); + frame.render_widget(para, area); +} + +fn render_forth_reference(frame: &mut Frame, app: &App, area: Rect) { + let [cat_area, words_area] = + Layout::horizontal([Constraint::Length(14), Constraint::Fill(1)]).areas(area); + + render_categories(frame, app, cat_area); + render_words(frame, app, words_area); +} + +fn render_categories(frame: &mut Frame, app: &App, area: Rect) { + let items: Vec = CATEGORIES + .iter() + .enumerate() + .map(|(i, name)| { + let style = if i == app.ui.doc_category { + Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::new().fg(Color::White) + }; + let prefix = if i == app.ui.doc_category { "> " } else { " " }; + ListItem::new(format!("{prefix}{name}")).style(style) + }) + .collect(); + + let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Category")); + frame.render_widget(list, area); +} + +fn render_words(frame: &mut Frame, app: &App, area: Rect) { + let category = CATEGORIES[app.ui.doc_category]; + let words: Vec<&Word> = WORDS + .iter() + .filter(|w| word_category(w.name, &w.compile) == category) + .collect(); + + let word_style = Style::new().fg(Color::Green).add_modifier(Modifier::BOLD); + let stack_style = Style::new().fg(Color::Magenta); + let desc_style = Style::new().fg(Color::White); + let example_style = Style::new().fg(Color::Rgb(150, 150, 150)); + + let mut lines: Vec = Vec::new(); + + for word in &words { + lines.push(RLine::from(vec![ + Span::styled(format!("{:<14}", word.name), word_style), + Span::styled(format!("{:<18}", word.stack), stack_style), + Span::styled(word.desc.to_string(), desc_style), + ])); + lines.push(RLine::from(vec![ + Span::raw(" "), + Span::styled(format!("e.g. {}", word.example), example_style), + ])); + lines.push(RLine::from("")); + } + + let visible_height = area.height.saturating_sub(2) as usize; + let total_lines = lines.len(); + let max_scroll = total_lines.saturating_sub(visible_height); + let scroll = app.ui.doc_scroll.min(max_scroll); + + let visible: Vec = lines + .into_iter() + .skip(scroll) + .take(visible_height) + .collect(); + + let title = format!("{category} ({} words)", words.len()); let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(title)); frame.render_widget(para, area); } +fn word_category(name: &str, compile: &WordCompile) -> &'static str { + const STACK: &[&str] = &["dup", "drop", "swap", "over", "rot", "nip", "tuck"]; + const ARITH: &[&str] = &[ + "+", "-", "*", "/", "mod", "neg", "abs", "floor", "ceil", "round", "min", "max", + ]; + const CMP: &[&str] = &["=", "<>", "<", ">", "<=", ">="]; + const LOGIC: &[&str] = &["and", "or", "not"]; + const SOUND: &[&str] = &["sound", "s", "emit"]; + const VAR: &[&str] = &["get", "set"]; + const RAND: &[&str] = &["rand", "rrand", "seed", "coin", "chance", "choose", "cycle"]; + const MUSIC: &[&str] = &["mtof", "ftom"]; + const TIME: &[&str] = &[ + "at", "window", "pop", "div", "each", "tempo!", "[", "]", "?", + ]; + + match compile { + WordCompile::Simple if STACK.contains(&name) => "Stack", + WordCompile::Simple if ARITH.contains(&name) => "Arithmetic", + WordCompile::Simple if CMP.contains(&name) => "Comparison", + WordCompile::Simple if LOGIC.contains(&name) => "Logic", + WordCompile::Simple if SOUND.contains(&name) => "Sound", + WordCompile::Alias(_) => "Sound", + WordCompile::Simple if VAR.contains(&name) => "Variables", + WordCompile::Simple if RAND.contains(&name) => "Randomness", + WordCompile::Probability(_) => "Probability", + WordCompile::Context(_) => "Context", + WordCompile::Simple if MUSIC.contains(&name) => "Music", + WordCompile::Simple if TIME.contains(&name) => "Time", + WordCompile::Param => "Parameters", + _ => "Other", + } +} + fn parse_markdown(md: &str) -> Vec> { let text = minimad::Text::from(md); let mut lines = Vec::new(); @@ -128,5 +258,9 @@ fn compound_to_span(compound: Compound, base: Style) -> Span<'static> { } pub fn topic_count() -> usize { - DOCS.len() + TOPICS.len() +} + +pub fn category_count() -> usize { + CATEGORIES.len() } diff --git a/seq/src/views/highlight.rs b/seq/src/views/highlight.rs index dae12f4..1161288 100644 --- a/seq/src/views/highlight.rs +++ b/seq/src/views/highlight.rs @@ -190,7 +190,7 @@ pub fn tokenize_line(line: &str) -> Vec { if c == '"' { let mut end = start + 1; - while let Some((i, ch)) = chars.next() { + for (i, ch) in chars.by_ref() { end = i + ch.len_utf8(); if ch == '"' { break; diff --git a/seq/src/views/patterns_view.rs b/seq/src/views/patterns_view.rs index f481129..8aaf54c 100644 --- a/seq/src/views/patterns_view.rs +++ b/seq/src/views/patterns_view.rs @@ -286,7 +286,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a // Column 2: length let length_line = Line::from(vec![ Span::styled("Length: ", bold_style), - Span::styled(format!("{}", length), base_style), + Span::styled(format!("{length}"), base_style), ]); frame.render_widget(Paragraph::new(length_line), length_area); diff --git a/seq/src/views/render.rs b/seq/src/views/render.rs index f1b331a..d007dda 100644 --- a/seq/src/views/render.rs +++ b/seq/src/views/render.rs @@ -41,7 +41,7 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq 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, body_area), + Page::Audio => audio_view::render(frame, app, link, body_area), Page::Doc => doc_view::render(frame, app, body_area), } @@ -95,7 +95,7 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let bank_name = bank .name .as_deref() - .map(|n| format!(" {} ", n)) + .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( @@ -129,7 +129,7 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { 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 {:.0}% V:{} L:{} ", cpu_pct, voices, peers); + 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));