diff --git a/Cargo.toml b/Cargo.toml index 1095a49..8dd311d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,7 @@ cagire-forth = { path = "crates/forth" } cagire-markdown = { path = "crates/markdown" } cagire-project = { path = "crates/project" } cagire-ratatui = { path = "crates/ratatui" } -doux = { git = "https://github.com/Bubobubobubobubo/doux.git", features = ["native"] } +doux = { path = "/Users/bubo/doux", features = ["native"] } rusty_link = "0.4" ratatui = "0.30" crossterm = "0.29" diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 669f3ff..7a959fa 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -80,7 +80,9 @@ pub enum Op { Mtof, Ftom, SetTempo, - Every, + Every(Option), + Bjork(Option), + PBjork(Option), Quotation(Arc<[Op]>, Option), When, Unless, diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 525775a..1bae589 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -838,13 +838,36 @@ impl Forth { stack.push(Value::Int(if result { 1 } else { 0 }, None)); } - Op::Every => { + Op::Every(word_span) => { let n = pop_int(stack)?; + let quot = pop(stack)?; if n <= 0 { return Err("every count must be > 0".into()); } let result = ctx.iter as i64 % n == 0; - stack.push(Value::Int(if result { 1 } else { 0 }, None)); + record_resolved(&trace_cell, *word_span, ResolvedValue::Bool(result)); + if result { + run_quotation(quot, stack, outputs, cmd)?; + } + } + + Op::Bjork(word_span) | Op::PBjork(word_span) => { + let n = pop_int(stack)?; + let k = pop_int(stack)?; + let quot = pop(stack)?; + if n <= 0 || k < 0 { + return Err("bjork: n must be > 0, k must be >= 0".into()); + } + let counter = match &ops[pc] { + Op::Bjork(_) => ctx.runs, + _ => ctx.iter, + }; + let pos = counter % n as usize; + let hit = k >= n || euclidean_hit(k as usize, n as usize, pos); + record_resolved(&trace_cell, *word_span, ResolvedValue::Bool(hit)); + if hit { + run_quotation(quot, stack, outputs, cmd)?; + } } Op::Quotation(quote_ops, body_span) => { @@ -1424,6 +1447,13 @@ fn emit_output( outputs.push(out); } +fn euclidean_hit(k: usize, n: usize, pos: usize) -> bool { + if k == 0 { + return false; + } + ((pos + 1) * k) / n != (pos * k) / n +} + fn euclidean_rhythm(k: usize, n: usize, rotation: usize) -> Vec { if k == 0 || n == 0 { return Vec::new(); diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index 4c31839..e2e1b5b 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -68,7 +68,9 @@ pub(super) fn simple_op(name: &str) -> Option { "choose" => Op::Choose(None), "bounce" => Op::Bounce(None), "wchoose" => Op::WChoose(None), - "every" => Op::Every, + "every" => Op::Every(None), + "bjork" => Op::Bjork(None), + "pbjork" => Op::PBjork(None), "chance" => Op::ChanceExec(None), "prob" => Op::ProbExec(None), "coin" => Op::Coin(None), @@ -192,7 +194,9 @@ fn attach_span(op: &mut Op, span: SourceSpan) { match op { Op::Rand(s) | Op::ExpRand(s) | Op::LogRand(s) | Op::Coin(s) | Op::Choose(s) | Op::WChoose(s) | Op::Cycle(s) | Op::PCycle(s) - | Op::Bounce(s) | Op::ChanceExec(s) | Op::ProbExec(s) => *s = Some(span), + | Op::Bounce(s) | Op::ChanceExec(s) | Op::ProbExec(s) + | Op::Every(s) + | Op::Bjork(s) | Op::PBjork(s) => *s = Some(span), _ => {} } } diff --git a/crates/forth/src/words/effects.rs b/crates/forth/src/words/effects.rs index 17dd6b3..5de0d00 100644 --- a/crates/forth/src/words/effects.rs +++ b/crates/forth/src/words/effects.rs @@ -839,6 +839,36 @@ pub(super) const WORDS: &[Word] = &[ compile: Param, varargs: true, }, + Word { + name: "smear", + aliases: &[], + category: "Mod FX", + stack: "(v.. --)", + desc: "Set smear allpass chain wet/dry mix (0=bypass, 1=full wet)", + example: "0.5 smear", + compile: Param, + varargs: true, + }, + Word { + name: "smearfreq", + aliases: &[], + category: "Mod FX", + stack: "(v.. --)", + desc: "Set smear allpass break frequency in Hz", + example: "800 smearfreq", + compile: Param, + varargs: true, + }, + Word { + name: "smearfb", + aliases: &[], + category: "Mod FX", + stack: "(v.. --)", + desc: "Set smear feedback for resonance (0-0.95)", + example: "0.8 smearfb", + compile: Param, + varargs: true, + }, Word { name: "chorus", aliases: &[], diff --git a/crates/forth/src/words/sequencing.rs b/crates/forth/src/words/sequencing.rs index e9af1f4..9dd57aa 100644 --- a/crates/forth/src/words/sequencing.rs +++ b/crates/forth/src/words/sequencing.rs @@ -198,9 +198,29 @@ pub(super) const WORDS: &[Word] = &[ name: "every", aliases: &[], category: "Time", - stack: "(n -- bool)", - desc: "True every nth iteration", - example: "4 every", + stack: "(quot n --)", + desc: "Execute quotation every nth iteration", + example: "{ 2 distort } 4 every", + compile: Simple, + varargs: false, + }, + Word { + name: "bjork", + aliases: &[], + category: "Time", + stack: "(quot k n --)", + desc: "Execute quotation using Euclidean distribution over step runs", + example: "{ 2 distort } 3 8 bjork", + compile: Simple, + varargs: false, + }, + Word { + name: "pbjork", + aliases: &[], + category: "Time", + stack: "(quot k n --)", + desc: "Execute quotation using Euclidean distribution over pattern iterations", + example: "{ 2 distort } 3 8 pbjork", compile: Simple, varargs: false, }, diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index bebabd1..550568a 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -3,7 +3,7 @@ mod project; pub const MAX_BANKS: usize = 32; pub const MAX_PATTERNS: usize = 32; -pub const MAX_STEPS: usize = 128; +pub const MAX_STEPS: usize = 1024; pub const DEFAULT_LENGTH: usize = 16; pub use file::{load, save, FileError}; diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index cf47924..0ea9132 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -220,6 +220,10 @@ impl Step { pub fn is_default(&self) -> bool { self.active && self.script.is_empty() && self.source.is_none() && self.name.is_none() } + + pub fn has_content(&self) -> bool { + !self.script.is_empty() + } } impl Default for Step { diff --git a/crates/ratatui/src/scope.rs b/crates/ratatui/src/scope.rs index df39f20..c93e349 100644 --- a/crates/ratatui/src/scope.rs +++ b/crates/ratatui/src/scope.rs @@ -9,7 +9,7 @@ thread_local! { static PATTERNS: RefCell> = const { RefCell::new(Vec::new()) }; } -#[allow(dead_code)] +#[derive(Clone, Copy)] pub enum Orientation { Horizontal, Vertical, diff --git a/crates/ratatui/src/theme/catppuccin_latte.rs b/crates/ratatui/src/theme/catppuccin_latte.rs index 201bfd5..4275de9 100644 --- a/crates/ratatui/src/theme/catppuccin_latte.rs +++ b/crates/ratatui/src/theme/catppuccin_latte.rs @@ -63,6 +63,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: yellow, active_bg: Color::Rgb(200, 235, 235), active_fg: teal, + content_bg: Color::Rgb(185, 225, 225), inactive_bg: surface0, inactive_fg: subtext0, active_selected_bg: Color::Rgb(215, 210, 240), diff --git a/crates/ratatui/src/theme/catppuccin_mocha.rs b/crates/ratatui/src/theme/catppuccin_mocha.rs index 51a53a6..ac16681 100644 --- a/crates/ratatui/src/theme/catppuccin_mocha.rs +++ b/crates/ratatui/src/theme/catppuccin_mocha.rs @@ -63,6 +63,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: yellow, active_bg: Color::Rgb(40, 55, 55), active_fg: teal, + content_bg: Color::Rgb(47, 62, 62), inactive_bg: surface0, inactive_fg: subtext0, active_selected_bg: Color::Rgb(70, 60, 80), diff --git a/crates/ratatui/src/theme/dracula.rs b/crates/ratatui/src/theme/dracula.rs index 0930c4e..a3191cf 100644 --- a/crates/ratatui/src/theme/dracula.rs +++ b/crates/ratatui/src/theme/dracula.rs @@ -57,6 +57,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: yellow, active_bg: Color::Rgb(50, 70, 70), active_fg: cyan, + content_bg: Color::Rgb(57, 77, 77), inactive_bg: current_line, inactive_fg: comment, active_selected_bg: Color::Rgb(80, 70, 95), diff --git a/crates/ratatui/src/theme/eden.rs b/crates/ratatui/src/theme/eden.rs index 94caa45..d7297d6 100644 --- a/crates/ratatui/src/theme/eden.rs +++ b/crates/ratatui/src/theme/eden.rs @@ -60,6 +60,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: yellow, active_bg: Color::Rgb(14, 28, 26), active_fg: cyan, + content_bg: Color::Rgb(21, 35, 33), inactive_bg: surface, inactive_fg: fg_dim, active_selected_bg: Color::Rgb(22, 36, 28), diff --git a/crates/ratatui/src/theme/ember.rs b/crates/ratatui/src/theme/ember.rs index 0e54243..581f8e7 100644 --- a/crates/ratatui/src/theme/ember.rs +++ b/crates/ratatui/src/theme/ember.rs @@ -58,6 +58,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: yellow, active_bg: Color::Rgb(20, 30, 30), active_fg: cyan, + content_bg: Color::Rgb(27, 37, 37), inactive_bg: surface, inactive_fg: fg_dim, active_selected_bg: Color::Rgb(40, 30, 35), diff --git a/crates/ratatui/src/theme/fairyfloss.rs b/crates/ratatui/src/theme/fairyfloss.rs index 320833b..ae3b4f5 100644 --- a/crates/ratatui/src/theme/fairyfloss.rs +++ b/crates/ratatui/src/theme/fairyfloss.rs @@ -58,6 +58,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: yellow, active_bg: Color::Rgb(70, 100, 100), active_fg: mint, + content_bg: Color::Rgb(77, 107, 107), inactive_bg: bg_light, inactive_fg: fg_dim, active_selected_bg: Color::Rgb(120, 90, 130), diff --git a/crates/ratatui/src/theme/georges.rs b/crates/ratatui/src/theme/georges.rs index ca3f1b1..e2fd8d8 100644 --- a/crates/ratatui/src/theme/georges.rs +++ b/crates/ratatui/src/theme/georges.rs @@ -62,6 +62,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: yellow, active_bg: Color::Rgb(0, 16, 32), active_fg: lightblue, + content_bg: Color::Rgb(7, 23, 39), inactive_bg: surface, inactive_fg: fg_dim, active_selected_bg: Color::Rgb(10, 20, 36), diff --git a/crates/ratatui/src/theme/gruvbox_dark.rs b/crates/ratatui/src/theme/gruvbox_dark.rs index 27ec11e..7220afa 100644 --- a/crates/ratatui/src/theme/gruvbox_dark.rs +++ b/crates/ratatui/src/theme/gruvbox_dark.rs @@ -59,6 +59,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: yellow, active_bg: Color::Rgb(50, 65, 55), active_fg: aqua, + content_bg: Color::Rgb(57, 72, 62), inactive_bg: bg1, inactive_fg: fg3, active_selected_bg: Color::Rgb(85, 70, 60), diff --git a/crates/ratatui/src/theme/hot_dog_stand.rs b/crates/ratatui/src/theme/hot_dog_stand.rs index 1c9a28b..c518d40 100644 --- a/crates/ratatui/src/theme/hot_dog_stand.rs +++ b/crates/ratatui/src/theme/hot_dog_stand.rs @@ -54,6 +54,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: black, active_bg: Color::Rgb(200, 50, 50), active_fg: yellow, + content_bg: Color::Rgb(210, 60, 60), inactive_bg: dark_red, inactive_fg: gold, active_selected_bg: Color::Rgb(200, 200, 0), diff --git a/crates/ratatui/src/theme/kanagawa.rs b/crates/ratatui/src/theme/kanagawa.rs index 1b3fc17..572fb56 100644 --- a/crates/ratatui/src/theme/kanagawa.rs +++ b/crates/ratatui/src/theme/kanagawa.rs @@ -59,6 +59,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: fg_dim, active_bg: Color::Rgb(45, 55, 70), active_fg: crystal_blue, + content_bg: Color::Rgb(52, 62, 77), inactive_bg: bg_light, inactive_fg: fg_dim, active_selected_bg: Color::Rgb(65, 55, 70), diff --git a/crates/ratatui/src/theme/letz_light.rs b/crates/ratatui/src/theme/letz_light.rs index e00618d..5f63bef 100644 --- a/crates/ratatui/src/theme/letz_light.rs +++ b/crates/ratatui/src/theme/letz_light.rs @@ -59,6 +59,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: Color::Rgb(180, 140, 20), active_bg: Color::Rgb(210, 235, 240), active_fg: function, + content_bg: Color::Rgb(195, 225, 230), inactive_bg: surface, inactive_fg: text_dim, active_selected_bg: Color::Rgb(210, 215, 245), diff --git a/crates/ratatui/src/theme/mod.rs b/crates/ratatui/src/theme/mod.rs index 6237da5..8a7a302 100644 --- a/crates/ratatui/src/theme/mod.rs +++ b/crates/ratatui/src/theme/mod.rs @@ -139,6 +139,7 @@ pub struct TileColors { pub playing_inactive_fg: Color, pub active_bg: Color, pub active_fg: Color, + pub content_bg: Color, pub inactive_bg: Color, pub inactive_fg: Color, pub active_selected_bg: Color, diff --git a/crates/ratatui/src/theme/monochrome_black.rs b/crates/ratatui/src/theme/monochrome_black.rs index a4145c2..fdbce30 100644 --- a/crates/ratatui/src/theme/monochrome_black.rs +++ b/crates/ratatui/src/theme/monochrome_black.rs @@ -56,6 +56,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: medium, active_bg: Color::Rgb(45, 45, 45), active_fg: bright, + content_bg: Color::Rgb(55, 55, 55), inactive_bg: surface, inactive_fg: fg_dim, active_selected_bg: Color::Rgb(80, 80, 80), diff --git a/crates/ratatui/src/theme/monochrome_white.rs b/crates/ratatui/src/theme/monochrome_white.rs index 7808249..c5088a0 100644 --- a/crates/ratatui/src/theme/monochrome_white.rs +++ b/crates/ratatui/src/theme/monochrome_white.rs @@ -56,6 +56,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: medium, active_bg: Color::Rgb(210, 210, 210), active_fg: dark, + content_bg: Color::Rgb(195, 195, 195), inactive_bg: surface, inactive_fg: fg_dim, active_selected_bg: Color::Rgb(170, 170, 170), diff --git a/crates/ratatui/src/theme/monokai.rs b/crates/ratatui/src/theme/monokai.rs index 28a703a..c56db01 100644 --- a/crates/ratatui/src/theme/monokai.rs +++ b/crates/ratatui/src/theme/monokai.rs @@ -57,6 +57,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: yellow, active_bg: Color::Rgb(55, 75, 70), active_fg: blue, + content_bg: Color::Rgb(62, 82, 77), inactive_bg: bg_light, inactive_fg: fg_dim, active_selected_bg: Color::Rgb(85, 65, 80), diff --git a/crates/ratatui/src/theme/nord.rs b/crates/ratatui/src/theme/nord.rs index f941612..3e54e7c 100644 --- a/crates/ratatui/src/theme/nord.rs +++ b/crates/ratatui/src/theme/nord.rs @@ -57,6 +57,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: aurora_yellow, active_bg: Color::Rgb(50, 65, 65), active_fg: frost0, + content_bg: Color::Rgb(57, 72, 72), inactive_bg: polar_night1, inactive_fg: snow_storm0, active_selected_bg: Color::Rgb(75, 75, 95), diff --git a/crates/ratatui/src/theme/pitch_black.rs b/crates/ratatui/src/theme/pitch_black.rs index b7a41dd..8c5b117 100644 --- a/crates/ratatui/src/theme/pitch_black.rs +++ b/crates/ratatui/src/theme/pitch_black.rs @@ -58,6 +58,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: yellow, active_bg: Color::Rgb(15, 40, 40), active_fg: cyan, + content_bg: Color::Rgb(22, 47, 47), inactive_bg: surface, inactive_fg: fg_dim, active_selected_bg: Color::Rgb(45, 40, 55), diff --git a/crates/ratatui/src/theme/rose_pine.rs b/crates/ratatui/src/theme/rose_pine.rs index b05d5e6..a5329ef 100644 --- a/crates/ratatui/src/theme/rose_pine.rs +++ b/crates/ratatui/src/theme/rose_pine.rs @@ -58,6 +58,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: subtle, active_bg: Color::Rgb(35, 50, 60), active_fg: foam, + content_bg: Color::Rgb(42, 57, 67), inactive_bg: bg_light, inactive_fg: fg_dim, active_selected_bg: Color::Rgb(60, 50, 70), diff --git a/crates/ratatui/src/theme/tokyo_night.rs b/crates/ratatui/src/theme/tokyo_night.rs index 2ad7879..e35a274 100644 --- a/crates/ratatui/src/theme/tokyo_night.rs +++ b/crates/ratatui/src/theme/tokyo_night.rs @@ -58,6 +58,7 @@ pub fn theme() -> ThemeColors { playing_inactive_fg: yellow, active_bg: Color::Rgb(45, 60, 75), active_fg: blue, + content_bg: Color::Rgb(52, 67, 82), inactive_bg: bg_light, inactive_fg: fg_dim, active_selected_bg: Color::Rgb(70, 55, 85), diff --git a/crates/ratatui/src/theme/transform.rs b/crates/ratatui/src/theme/transform.rs index f56c6f4..a153d91 100644 --- a/crates/ratatui/src/theme/transform.rs +++ b/crates/ratatui/src/theme/transform.rs @@ -123,6 +123,7 @@ pub fn rotate_theme(theme: ThemeColors, degrees: f32) -> ThemeColors { playing_inactive_fg: rotate_color(theme.tile.playing_inactive_fg, degrees), active_bg: rotate_color(theme.tile.active_bg, degrees), active_fg: rotate_color(theme.tile.active_fg, degrees), + content_bg: rotate_color(theme.tile.content_bg, degrees), inactive_bg: rotate_color(theme.tile.inactive_bg, degrees), inactive_fg: rotate_color(theme.tile.inactive_fg, degrees), active_selected_bg: rotate_color(theme.tile.active_selected_bg, degrees), diff --git a/docs/banks_patterns.md b/docs/banks_patterns.md index 0fe223c..c9e7878 100644 --- a/docs/banks_patterns.md +++ b/docs/banks_patterns.md @@ -12,10 +12,10 @@ Cagire organizes all your patterns and data following a strict hierarchy: Project └── 32 Banks └── 32 Patterns (per bank) - └── 128 Steps (per pattern) + └── 1024 Steps (per pattern) ``` -A single project gives you 32 banks, each holding 32 patterns. You get 1024 patterns in each project, ~131.000 steps. +A single project gives you 32 banks, each holding 32 patterns. You get 1024 patterns in each project, ~1.048.000 steps. ## Patterns @@ -23,7 +23,7 @@ Each pattern is an independent sequence of steps with its own properties: | Property | Description | Default | |----------|-------------|---------| -| Length | Steps before the pattern loops (`1`-`128`) | `16` | +| Length | Steps before the pattern loops (`1`-`1024`) | `16` | | Speed | Playback rate (`1/8x` to `8x`) | `1x` | | Quantization | When the pattern launches | `Bar` | | Sync Mode | Reset or Phase-Lock on re-trigger | `Reset` | diff --git a/docs/how_it_works.md b/docs/how_it_works.md index 41eac8c..a865370 100644 --- a/docs/how_it_works.md +++ b/docs/how_it_works.md @@ -8,9 +8,9 @@ Cagire can run multiple patterns concurrently. Each pattern contains a given num - **32 Banks** - **32 Patterns** per bank -- **128 Steps** per pattern +- **1024 Steps** per pattern -That's over 130,000 possible steps per project. Most of my sessions use 15-20 at best. +That's over 1,000,000 possible steps per project. Most of my sessions use 15-20 at best. ## What does a script look like? diff --git a/docs/navigation.md b/docs/navigation.md index 39a7671..5494690 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -36,4 +36,5 @@ These work on most views: - `Arrow keys` - move or scroll - `Tab` - switch focus between panels - `/` or `Ctrl+f` - search (where available) +- `:` - jump to step number (sequencer view) - `q` - quit (with confirmation) diff --git a/docs/oddities.md b/docs/oddities.md index 03701d3..d6c731b 100644 --- a/docs/oddities.md +++ b/docs/oddities.md @@ -175,6 +175,20 @@ Classic Forth is deterministic. Cagire has built-in randomness: These words take a quotation and execute it probabilistically. +## Periodic Execution + +Execute a quotation on specific iterations: + +```forth +{ "snare" s . } 4 every ;; every 4th pattern iteration +{ "hat" s . } 3 8 bjork ;; Euclidean: 3 hits across 8 step runs +{ "hat" s . } 5 8 pbjork ;; Euclidean: 5 hits across 8 pattern iterations +``` + +`every` checks the pattern iteration count. On iteration 0, 4, 8, 12... the quotation runs. On all other iterations it is skipped. + +`bjork` and `pbjork` use Bjorklund's algorithm to distribute k hits as evenly as possible across n positions. `bjork` counts by step runs, `pbjork` counts by pattern iterations. Classic Euclidean rhythms: tresillo (3,8), cinquillo (5,8), son clave (5,16). + ## Cycling Cagire has built-in support for cycling through values. Push values onto the stack, then select one based on pattern state: diff --git a/docs/tutorial_generators.md b/docs/tutorial_generators.md new file mode 100644 index 0000000..462558c --- /dev/null +++ b/docs/tutorial_generators.md @@ -0,0 +1,181 @@ +# Generators & Sequences + +Sequences of values drive music: arpeggios, parameter sweeps, rhythmic patterns. Cagire has dedicated words for building sequences on the stack, transforming them, and collapsing them to single values. + +## Ranges + +`..` pushes an integer range onto the stack. Both endpoints are inclusive. If start exceeds end, it counts down: + +```forth +1 5 .. ;; 1 2 3 4 5 +5 1 .. ;; 5 4 3 2 1 +0 7 .. ;; 0 1 2 3 4 5 6 7 +``` + +`.,` adds a step parameter. Works with floats: + +```forth +0 1 0.25 ., ;; 0 0.25 0.5 0.75 1 +0 10 2 ., ;; 0 2 4 6 8 10 +1 0 0.5 ., ;; 1 0.5 0 (descending) +``` + +`geom..` builds a geometric sequence. Takes start, ratio, and count: + +```forth +1 2 4 geom.. ;; 1 2 4 8 +100 0.5 4 geom.. ;; 100 50 25 12.5 +``` + +Musical use -- build a harmonic series: + +```forth +110 2 5 geom.. 5 rev note +``` + +That gives you 110, 220, 440, 880, 1760 (reversed), ready to feed into `note` or `freq`. + +## Computed Sequences + +`gen` executes a quotation n times and collects all results. The quotation must push exactly one value per call: + +```forth +{ 1 6 rand } 4 gen ;; 4 random values between 1 and 6 +{ coin } 8 gen ;; 8 random 0s and 1s +``` + +Contrast with `times`, which executes for side effects and does not collect. `times` sets `@i` to the current index: + +```forth +4 { @i } times ;; 0 1 2 3 (pushes @i each iteration) +4 { @i 60 + note sine s . } times ;; plays 4 notes, collects nothing +``` + +The distinction: `gen` is for building data. `times` is for doing things. + +## Euclidean Patterns + +`euclid` distributes k hits evenly across n positions and pushes the hit indices: + +```forth +3 8 euclid ;; 0 3 5 +4 8 euclid ;; 0 2 4 6 +5 8 euclid ;; 0 1 3 5 6 +``` + +`euclidrot` adds a rotation parameter that shifts the pattern: + +```forth +3 8 0 euclidrot ;; 0 3 5 (no rotation) +3 8 1 euclidrot ;; 1 4 6 +3 8 2 euclidrot ;; 1 4 6 +``` + +These give you raw indices as data on the stack. This is different from `bjork` and `pbjork` (covered in the Randomness tutorial), which execute a quotation on matching steps. `euclid` gives you numbers to work with; `bjork` triggers actions. + +Use euclid indices to pick notes from a scale: + +```forth +: pick ( ..vals n i -- val ) rot drop swap ; +c4 d4 e4 g4 a4 ;; pentatonic scale on the stack +3 8 euclid ;; get 3 hit positions +``` + +## Transforming Sequences + +Four words reshape values already on the stack. All take n (the count of items to operate on) from the top: + +`rev` reverses order: + +```forth +1 2 3 4 4 rev ;; 4 3 2 1 +c4 e4 g4 3 rev ;; g4 e4 c4 (descending arpeggio) +``` + +`shuffle` randomizes order: + +```forth +c4 e4 g4 b4 4 shuffle ;; random permutation each time +``` + +`sort` and `rsort` for ascending and descending: + +```forth +3 1 4 1 5 5 sort ;; 1 1 3 4 5 +3 1 4 1 5 5 rsort ;; 5 4 3 1 1 +``` + +## Reducing Sequences + +`sum` and `prod` collapse n values into one: + +```forth +1 2 3 4 4 sum ;; 10 +1 2 3 4 4 prod ;; 24 +``` + +Useful for computing averages or accumulating values: + +```forth +{ 1 6 rand } 4 gen 4 sum ;; sum of 4 dice rolls +``` + +## Replication + +`dupn` (alias `!`) duplicates a value n times: + +```forth +440 3 dupn ;; 440 440 440 +c4 4 dupn ;; c4 c4 c4 c4 +``` + +Build a drone chord -- same note, different octaves: + +```forth +c3 note 0.5 gain sine s . +c3 note 12 + 0.5 gain sine s . +c3 note 24 + 0.3 gain sine s . +``` + +Or replicate a value for batch processing: + +```forth +0.5 4 dupn 4 sum ;; 2.0 +``` + +## Combining Techniques + +An arpeggio that shuffles every time the step plays: + +```forth +c4 e4 g4 b4 4 shuffle +drop drop drop ;; keep only the first note +note sine s . +``` + +Parameter spread across voices -- four sines with geometrically spaced frequencies: + +```forth +220 1.5 4 geom.. +4 { @i 1 + pick note sine s . } times +``` + +Euclidean rhythm driving note selection from a generated sequence: + +```forth +3 8 euclid ;; 3 hit indices +``` + +A chord built from a range, then sorted high to low: + +```forth +60 67 .. 8 rsort +``` + +Rhythmic density control -- generate hits, keep only the loud ones: + +```forth +{ 0.0 1.0 rand } 8 gen +``` + +The generator words produce raw material. The transform words shape it. Together they let you express complex musical ideas in a few words. \ No newline at end of file diff --git a/docs/tutorial_harmony.md b/docs/tutorial_harmony.md new file mode 100644 index 0000000..53abdcc --- /dev/null +++ b/docs/tutorial_harmony.md @@ -0,0 +1,302 @@ +# Notes & Harmony + +Cagire speaks music theory. Notes, intervals, chords, and scales are all first-class words that compile to stack operations on MIDI values. This tutorial covers every pitch-related feature. + +## MIDI Notes + +Write a note name followed by an octave number. It compiles to a MIDI integer: + +```forth +c4 ;; 60 (middle C) +a4 ;; 69 (concert A) +e3 ;; 52 +``` + +Sharps use `s` or `#`. Flats use `b`: + +```forth +fs4 ;; 66 (F sharp 4) +f#4 ;; 66 (same thing) +bb3 ;; 58 (B flat 3) +eb4 ;; 63 +``` + +Octave range is -1 to 9. The formula is `(octave + 1) * 12 + base + modifier`, where C=0, D=2, E=4, F=5, G=7, A=9, B=11. + +Note literals push a single integer onto the stack, just like writing `60` directly. They work everywhere an integer works: + +```forth +c4 note sine s . ;; play middle C as a sine +a4 note 0.5 gain modal s . ;; concert A, quieter +``` + +## Intervals + +An interval duplicates the top of the stack and adds semitones. This lets you build chords by stacking: + +```forth +c4 M3 P5 ;; stack: 60 64 67 (C major triad) +c4 m3 P5 ;; stack: 60 63 67 (C minor triad) +a3 P5 ;; stack: 57 64 (A plus a fifth) +``` + +Simple intervals (within one octave): + +| Interval | Semitones | Name | +|----------|-----------|------| +| `P1` / `unison` | 0 | Perfect unison | +| `m2` | 1 | Minor 2nd | +| `M2` | 2 | Major 2nd | +| `m3` | 3 | Minor 3rd | +| `M3` | 4 | Major 3rd | +| `P4` | 5 | Perfect 4th | +| `aug4` / `dim5` / `tritone` | 6 | Tritone | +| `P5` | 7 | Perfect 5th | +| `m6` | 8 | Minor 6th | +| `M6` | 9 | Major 6th | +| `m7` | 10 | Minor 7th | +| `M7` | 11 | Major 7th | +| `P8` | 12 | Octave | + +Compound intervals (beyond one octave): + +| Interval | Semitones | +|----------|-----------| +| `m9` | 13 | +| `M9` | 14 | +| `m10` | 15 | +| `M10` | 16 | +| `P11` | 17 | +| `aug11` | 18 | +| `P12` | 19 | +| `m13` | 20 | +| `M13` | 21 | +| `m14` | 22 | +| `M14` | 23 | +| `P15` | 24 | + +## Chords + +Chord words take a root note and push all the chord tones. They eat the root and replace it with the full voicing: + +```forth +c4 maj ;; stack: 60 64 67 +c4 min7 ;; stack: 60 63 67 70 +c4 dom9 ;; stack: 60 64 67 70 74 +``` + +**Triads:** + +| Word | Intervals | Example (C4) | +|------|-----------|-------------| +| `maj` | 0 4 7 | 60 64 67 | +| `m` | 0 3 7 | 60 63 67 | +| `dim` | 0 3 6 | 60 63 66 | +| `aug` | 0 4 8 | 60 64 68 | +| `sus2` | 0 2 7 | 60 62 67 | +| `sus4` | 0 5 7 | 60 65 67 | + +**Seventh chords:** + +| Word | Intervals | Example (C4) | +|------|-----------|-------------| +| `maj7` | 0 4 7 11 | 60 64 67 71 | +| `min7` | 0 3 7 10 | 60 63 67 70 | +| `dom7` | 0 4 7 10 | 60 64 67 70 | +| `dim7` | 0 3 6 9 | 60 63 66 69 | +| `m7b5` | 0 3 6 10 | 60 63 66 70 | +| `minmaj7` | 0 3 7 11 | 60 63 67 71 | +| `aug7` | 0 4 8 10 | 60 64 68 70 | + +**Sixth chords:** + +| Word | Intervals | Example (C4) | +|------|-----------|-------------| +| `maj6` | 0 4 7 9 | 60 64 67 69 | +| `min6` | 0 3 7 9 | 60 63 67 69 | + +**Extended chords:** + +| Word | Intervals | Example (C4) | +|------|-----------|-------------| +| `dom9` | 0 4 7 10 14 | 60 64 67 70 74 | +| `maj9` | 0 4 7 11 14 | 60 64 67 71 74 | +| `min9` | 0 3 7 10 14 | 60 63 67 70 74 | +| `dom11` | 0 4 7 10 14 17 | 60 64 67 70 74 77 | +| `min11` | 0 3 7 10 14 17 | 60 63 67 70 74 77 | +| `dom13` | 0 4 7 10 14 21 | 60 64 67 70 74 81 | + +**Add chords:** + +| Word | Intervals | Example (C4) | +|------|-----------|-------------| +| `add9` | 0 4 7 14 | 60 64 67 74 | +| `add11` | 0 4 7 17 | 60 64 67 77 | +| `madd9` | 0 3 7 14 | 60 63 67 74 | + +**Altered dominants:** + +| Word | Intervals | Example (C4) | +|------|-----------|-------------| +| `dom7b9` | 0 4 7 10 13 | 60 64 67 70 73 | +| `dom7s9` | 0 4 7 10 15 | 60 64 67 70 75 | +| `dom7b5` | 0 4 6 10 | 60 64 66 70 | +| `dom7s5` | 0 4 8 10 | 60 64 68 70 | + +Chord tones are varargs -- they eat the entire stack. So a chord word should come right after the root note: + +```forth +c4 maj note sine s . ;; plays all 3 notes as one chord +``` + +## Scales + +Scale words convert a degree index into a MIDI note. The base note is C4 (MIDI 60). Degrees wrap around with octave transposition: + +```forth +0 major ;; 60 (C4 -- degree 0) +4 major ;; 67 (G4 -- degree 4) +7 major ;; 72 (C5 -- degree 7, wraps to next octave) +-1 major ;; 59 (B3 -- negative degrees go down) +``` + +Use scales with `cycle` or `rand` to walk through pitches: + +```forth +0 1 2 3 4 5 6 7 8 cycle minor note sine s . +``` + +**Standard modes:** + +| Word | Pattern (semitones) | +|------|-------------------| +| `major` | 0 2 4 5 7 9 11 | +| `minor` | 0 2 3 5 7 8 10 | +| `dorian` | 0 2 3 5 7 9 10 | +| `phrygian` | 0 1 3 5 7 8 10 | +| `lydian` | 0 2 4 6 7 9 11 | +| `mixolydian` | 0 2 4 5 7 9 10 | +| `aeolian` | 0 2 3 5 7 8 10 | +| `locrian` | 0 1 3 5 6 8 10 | + +**Pentatonic and blues:** + +| Word | Pattern | +|------|---------| +| `pentatonic` | 0 2 4 7 9 | +| `minpent` | 0 3 5 7 10 | +| `blues` | 0 3 5 6 7 10 | + +**Chromatic and whole tone:** + +| Word | Pattern | +|------|---------| +| `chromatic` | 0 1 2 3 4 5 6 7 8 9 10 11 | +| `wholetone` | 0 2 4 6 8 10 | + +**Harmonic and melodic minor:** + +| Word | Pattern | +|------|---------| +| `harmonicminor` | 0 2 3 5 7 8 11 | +| `melodicminor` | 0 2 3 5 7 9 11 | + +**Jazz / Bebop:** + +| Word | Pattern | +|------|---------| +| `bebop` | 0 2 4 5 7 9 10 11 | +| `bebopmaj` | 0 2 4 5 7 8 9 11 | +| `bebopmin` | 0 2 3 5 7 8 9 10 | +| `altered` | 0 1 3 4 6 8 10 | +| `lyddom` | 0 2 4 6 7 9 10 | + +**Symmetric:** + +| Word | Pattern | +|------|---------| +| `halfwhole` | 0 1 3 4 6 7 9 10 | +| `wholehalf` | 0 2 3 5 6 8 9 11 | +| `augmented` | 0 3 4 7 8 11 | +| `tritone` | 0 1 4 6 7 10 | +| `prometheus` | 0 2 4 6 9 10 | + +**Modal variants (from melodic minor):** + +| Word | Pattern | +|------|---------| +| `dorianb2` | 0 1 3 5 7 9 10 | +| `lydianaug` | 0 2 4 6 8 9 11 | +| `mixb6` | 0 2 4 5 7 8 10 | +| `locrian2` | 0 2 3 5 6 8 10 | + +## Octave Shifting + +`oct` transposes a note by octaves: + +```forth +c4 1 oct ;; 72 (C5) +c4 -1 oct ;; 48 (C3) +c4 2 oct ;; 84 (C6) +``` + +Stack effect: `(note shift -- transposed)`. The shift is multiplied by 12 and added to the note. + +## Frequency Conversion + +`mtof` converts a MIDI note to frequency in Hz. `ftom` does the reverse: + +```forth +69 mtof ;; 440.0 (A4) +60 mtof ;; 261.63 (C4) +440 ftom ;; 69.0 +``` + +Useful when a synth parameter expects Hz rather than MIDI: + +```forth +c4 mtof freq sine s . +``` + +## Putting It Together + +A chord progression cycling every pattern iteration: + +```forth +{ c3 maj7 } { f3 maj7 } { g3 dom7 } { c3 maj7 } 4 pcycle +note sine s . +``` + +Arpeggiate a chord across the step's time divisions: + +```forth +c4 min7 arp note 0.5 decay sine s . +``` + +Random notes from a scale: + +```forth +0 7 rand minor note sine s . +``` + +A bass line walking scale degrees: + +```forth +0 2 4 5 7 5 4 2 8 cycle minor note +-2 oct 0.8 gain sine s . +``` + +Chord voicings with random inversion: + +```forth +e3 min9 +{ } { 1 oct } 2 choose +note modal s . +``` + +Stacked intervals for custom voicings: + +```forth +c3 P5 P8 M10 ;; C3, G3, C4, E4 +note sine s . +``` \ No newline at end of file diff --git a/docs/tutorial_randomness.md b/docs/tutorial_randomness.md new file mode 100644 index 0000000..e1bcbcb --- /dev/null +++ b/docs/tutorial_randomness.md @@ -0,0 +1,201 @@ +# Randomness + +Music needs surprise. A pattern that plays identically every time gets boring fast. Cagire has a rich set of words for injecting randomness and controlled variation into your sequences. + +## Random Numbers + +`coin` pushes 0 or 1 with equal probability: + +```forth +coin note sine s . ;; either 0 or 1 as the note +``` + +`rand` takes a range and returns a random value. If both bounds are integers, the result is an integer. If either is a float, you get a float: + +```forth +60 72 rand note sine s . ;; random MIDI note from 60 to 72 +0.3 0.9 rand gain sine s . ;; random gain between 0.3 and 0.9 +``` + +`exprand` and `logrand` give you weighted distributions. `exprand` is biased toward the low end, `logrand` toward the high end: + +```forth +200.0 8000.0 exprand freq sine s . ;; mostly low frequencies +200.0 8000.0 logrand freq sine s . ;; mostly high frequencies +``` + +These are useful for parameters where perception is logarithmic, like frequency and duration. + +## Conditional Execution + +The probability words take a quotation and execute it with some chance. `chance` takes a float from 0.0 to 1.0, `prob` takes a percentage from 0 to 100: + +```forth +{ hat s . } 0.25 chance ;; 25% chance +{ hat s . } 75 prob ;; 75% chance +``` + +Named probability words save you from remembering numbers: + +| Word | Probability | +|------|------------| +| `always` | 100% | +| `almostAlways` | 90% | +| `often` | 75% | +| `sometimes` | 50% | +| `rarely` | 25% | +| `almostNever` | 10% | +| `never` | 0% | + +```forth +{ hat s . } often ;; 75% +{ snare s . } sometimes ;; 50% +{ clap s . } rarely ;; 25% +``` + +`always` and `never` are useful when you want to temporarily mute or unmute a voice without deleting code. Change `sometimes` to `never` to silence it, `always` to bring it back. + +Use `?` and `!?` with `coin` for quick coin-flip decisions: + +```forth +{ hat s . } coin ? ;; execute if coin is 1 +{ rim s . } coin !? ;; execute if coin is 0 +``` + +## Selection + +`choose` picks randomly from n items on the stack: + +```forth +kick snare hat 3 choose s . ;; random drum hit +60 64 67 72 4 choose note sine s . ;; random note from a set +``` + +When a chosen item is a quotation, it gets executed: + +```forth +{ 0.1 decay } { 0.5 decay } { 0.9 decay } 3 choose +sine s . +``` + +`wchoose` lets you assign weights to each option. Push value/weight pairs: + +```forth +kick 0.5 snare 0.3 hat 0.2 3 wchoose s . +``` + +Kick plays 50% of the time, snare 30%, hat 20%. Weights don't need to sum to 1 -- they're normalized automatically. + +`shuffle` randomizes the order of n items on the stack: + +```forth +60 64 67 72 4 shuffle ;; stack now has the same 4 values in random order +``` + +Combined with `note`, this gives you a random permutation of a chord every time the step runs. + +## Cycling + +Cycling steps through values deterministically. No randomness -- pure rotation. + +`cycle` selects based on how many times this step has played (its `runs` count): + +```forth +60 64 67 3 cycle note sine s . ;; 60, 64, 67, 60, 64, 67, ... +``` + +`pcycle` selects based on the pattern iteration count (`iter`): + +```forth +kick snare 2 pcycle s . ;; kick on even iterations, snare on odd +``` + +The difference matters when patterns have different lengths. `cycle` counts per-step, `pcycle` counts per-pattern. + +Quotations work here too: + +```forth +{ c4 note } { e4 note } { g4 note } 3 cycle +sine s . +``` + +`bounce` ping-pongs instead of wrapping around: + +```forth +60 64 67 72 4 bounce note sine s . ;; 60, 64, 67, 72, 67, 64, 60, 64, ... +``` + +## Periodic Execution + +`every` runs a quotation once every n pattern iterations: + +```forth +{ crash s . } 4 every ;; crash cymbal every 4th iteration +``` + +`bjork` and `pbjork` use Bjorklund's algorithm to distribute k hits across n positions as evenly as possible. Classic Euclidean rhythms: + +```forth +{ hat s . } 3 8 bjork ;; tresillo: x..x..x. (by step runs) +{ hat s . } 5 8 pbjork ;; cinquillo: x.xx.xx. (by pattern iterations) +``` + +`bjork` counts by step runs (how many times this particular step has played). `pbjork` counts by pattern iterations. Some classic patterns: + +| k | n | Name | +|---|---|------| +| 3 | 8 | tresillo | +| 5 | 8 | cinquillo | +| 5 | 16 | bossa nova | +| 7 | 16 | samba | + +## Seeding + +By default, every run produces different random values. Use `seed` to make randomness reproducible: + +```forth +42 seed +60 72 rand note sine s . ;; always the same "random" note +``` + +The seed is set at the start of the script. Same seed, same sequence. Useful when you want a specific random pattern to repeat. + +## Combining Words + +The real power comes from mixing techniques. A hi-hat pattern with ghost notes: + +```forth +hat s +{ 0.3 0.6 rand gain } { 0.8 gain } 2 cycle +. +``` + +Full volume on even runs, random quiet on odd runs. + +A bass line that changes every 4 bars: + +```forth +{ c2 note } { e2 note } { g2 note } { a2 note } 4 pcycle +{ 0.5 decay } often +sine s . +``` + +Layered percussion with different densities: + +```forth +{ kick s . } always +{ snare s . } 2 every +{ hat s . } 5 8 bjork +{ rim s . } rarely +``` + +A melodic step with weighted note selection and random timbre: + +```forth +c4 0.4 e4 0.3 g4 0.2 b4 0.1 4 wchoose note +0.3 0.7 rand decay +1.0 4.0 exprand harmonics +modal s . +``` + +The root note plays most often. Higher chord tones are rarer. Decay and harmonics vary continuously. \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 35b51c3..71c16e4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -20,6 +20,7 @@ use crate::page::Page; use crate::services::{clipboard, dict_nav, euclidean, help_nav, pattern_editor}; use crate::settings::Settings; use crate::state::{ + undo::{UndoEntry, UndoHistory, UndoScope}, AudioSettings, CyclicEnum, EditorContext, EditorTarget, FlashKind, LiveKeyState, Metrics, Modal, MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState, ProjectState, SampleTree, StagedChange, StagedPropChange, UiState, @@ -61,6 +62,8 @@ pub struct App { pub copied_patterns: Option>, pub copied_banks: Option>, + pub undo: UndoHistory, + pub audio: AudioSettings, pub options: OptionsState, pub panel: PanelState, @@ -103,6 +106,8 @@ impl App { copied_patterns: None, copied_banks: None, + undo: UndoHistory::default(), + audio: AudioSettings::default(), options: OptionsState::default(), panel: PanelState::default(), @@ -124,6 +129,7 @@ impl App { runtime_highlight: self.ui.runtime_highlight, show_scope: self.audio.config.show_scope, show_spectrum: self.audio.config.show_spectrum, + show_preview: self.audio.config.show_preview, show_completion: self.ui.show_completion, color_scheme: self.ui.color_scheme, layout: self.audio.config.layout, @@ -617,6 +623,7 @@ impl App { link.set_tempo(tempo); self.playback.clear_queues(); + self.undo.clear(); self.variables.store(Arc::new(HashMap::new())); self.dict.lock().clear(); @@ -742,6 +749,46 @@ impl App { } } + pub fn shift_patterns_up(&mut self) { + let bank = self.patterns_nav.bank_cursor; + let patterns = self.patterns_nav.selected_patterns(); + let start = *patterns.first().unwrap(); + let end = *patterns.last().unwrap(); + if let Some(dirty) = clipboard::shift_patterns_up( + &mut self.project_state.project, + bank, + start..=end, + ) { + for (b, p) in &dirty { + self.project_state.mark_dirty(*b, *p); + } + self.patterns_nav.pattern_cursor -= 1; + if let Some(ref mut anchor) = self.patterns_nav.pattern_anchor { + *anchor -= 1; + } + } + } + + pub fn shift_patterns_down(&mut self) { + let bank = self.patterns_nav.bank_cursor; + let patterns = self.patterns_nav.selected_patterns(); + let start = *patterns.first().unwrap(); + let end = *patterns.last().unwrap(); + if let Some(dirty) = clipboard::shift_patterns_down( + &mut self.project_state.project, + bank, + start..=end, + ) { + for (b, p) in &dirty { + self.project_state.mark_dirty(*b, *p); + } + self.patterns_nav.pattern_cursor += 1; + if let Some(ref mut anchor) = self.patterns_nav.pattern_anchor { + *anchor += 1; + } + } + } + pub fn copy_bank(&mut self, bank: usize) { self.copied_banks = Some(vec![clipboard::copy_bank( &self.project_state.project, @@ -986,8 +1033,120 @@ impl App { }; } - pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) { + fn undoable_scope(&self, cmd: &AppCommand) -> Option { match cmd { + // Pattern-level + AppCommand::ToggleSteps + | AppCommand::LengthIncrease + | AppCommand::LengthDecrease + | AppCommand::SpeedIncrease + | AppCommand::SpeedDecrease + | AppCommand::SaveEditorToStep + | AppCommand::CompileCurrentStep + | AppCommand::HardenSteps + | AppCommand::PasteSteps + | AppCommand::LinkPasteSteps + | AppCommand::DuplicateSteps + | AppCommand::CopySteps => { + let (bank, pattern) = self.current_bank_pattern(); + let data = self.project_state.project.pattern_at(bank, pattern).clone(); + Some(UndoScope::Pattern { bank, pattern, data }) + } + AppCommand::SetLength { bank, pattern, .. } + | AppCommand::SetSpeed { bank, pattern, .. } + | AppCommand::DeleteStep { bank, pattern, .. } + | AppCommand::DeleteSteps { bank, pattern, .. } + | AppCommand::ResetPattern { bank, pattern } + | AppCommand::PastePattern { bank, pattern } + | AppCommand::RenamePattern { bank, pattern, .. } => { + let data = self.project_state.project.pattern_at(*bank, *pattern).clone(); + Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data }) + } + AppCommand::RenameStep { bank, pattern, .. } => { + let data = self.project_state.project.pattern_at(*bank, *pattern).clone(); + Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data }) + } + AppCommand::ApplyEuclideanDistribution { bank, pattern, .. } => { + let data = self.project_state.project.pattern_at(*bank, *pattern).clone(); + Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data }) + } + // Bank-level + AppCommand::ResetBank { bank } | AppCommand::PasteBank { bank } => { + let data = self.project_state.project.banks[*bank].clone(); + Some(UndoScope::Bank { bank: *bank, data }) + } + AppCommand::ShiftPatternsUp | AppCommand::ShiftPatternsDown => { + let bank = self.patterns_nav.bank_cursor; + let data = self.project_state.project.banks[bank].clone(); + Some(UndoScope::Bank { bank, data }) + } + AppCommand::PastePatterns { bank, .. } + | AppCommand::ResetPatterns { bank, .. } + | AppCommand::RenameBank { bank, .. } => { + let data = self.project_state.project.banks[*bank].clone(); + Some(UndoScope::Bank { bank: *bank, data }) + } + AppCommand::PasteBanks { .. } | AppCommand::ResetBanks { .. } => None, + _ => None, + } + } + + fn apply_undo_entry(&mut self, entry: UndoEntry) -> UndoEntry { + let cursor = (self.editor_ctx.bank, self.editor_ctx.pattern, self.editor_ctx.step); + let reverse_scope = match entry.scope { + UndoScope::Pattern { bank, pattern, data } => { + let current = self.project_state.project.pattern_at(bank, pattern).clone(); + *self.project_state.project.pattern_at_mut(bank, pattern) = data; + self.project_state.mark_dirty(bank, pattern); + UndoScope::Pattern { bank, pattern, data: current } + } + UndoScope::Bank { bank, data } => { + let current = self.project_state.project.banks[bank].clone(); + let pat_count = current.patterns.len(); + self.project_state.project.banks[bank] = data; + for p in 0..pat_count { + self.project_state.mark_dirty(bank, p); + } + UndoScope::Bank { bank, data: current } + } + }; + self.editor_ctx.bank = entry.cursor.0; + self.editor_ctx.pattern = entry.cursor.1; + self.editor_ctx.step = entry.cursor.2; + self.load_step_to_editor(); + UndoEntry { scope: reverse_scope, cursor } + } + + pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) { + // Handle undo/redo before the undoable snapshot + match cmd { + AppCommand::Undo => { + if let Some(entry) = self.undo.pop_undo() { + let reverse = self.apply_undo_entry(entry); + self.undo.push_redo(reverse); + self.ui.flash("Undo", 100, FlashKind::Info); + } + return; + } + AppCommand::Redo => { + if let Some(entry) = self.undo.pop_redo() { + let reverse = self.apply_undo_entry(entry); + self.undo.undo_stack.push(reverse); + self.ui.flash("Redo", 100, FlashKind::Info); + } + return; + } + _ => {} + } + + if let Some(scope) = self.undoable_scope(&cmd) { + let cursor = (self.editor_ctx.bank, self.editor_ctx.pattern, self.editor_ctx.step); + self.undo.push(UndoEntry { scope, cursor }); + } + + match cmd { + AppCommand::Undo | AppCommand::Redo => unreachable!(), + // Playback AppCommand::TogglePlaying => self.toggle_playing(link), AppCommand::TempoUp => self.tempo_up(link), @@ -1092,6 +1251,10 @@ impl App { self.reset_banks(&banks); } + // Reorder + AppCommand::ShiftPatternsUp => self.shift_patterns_up(), + AppCommand::ShiftPatternsDown => self.shift_patterns_down(), + // Clipboard AppCommand::HardenSteps => self.harden_steps(), AppCommand::CopySteps => self.copy_steps(), @@ -1406,6 +1569,9 @@ impl App { AppCommand::ToggleSpectrum => { self.audio.config.show_spectrum = !self.audio.config.show_spectrum; } + AppCommand::TogglePreview => { + self.audio.config.show_preview = !self.audio.config.show_preview; + } // Metrics AppCommand::ResetPeakVoices => { diff --git a/src/commands.rs b/src/commands.rs index a40b67e..459d303 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -5,6 +5,10 @@ use crate::page::Page; use crate::state::{ColorScheme, DeviceKind, EngineSection, Modal, OptionsFocus, PatternField, SettingKind}; pub enum AppCommand { + // Undo/Redo + Undo, + Redo, + // Playback TogglePlaying, TempoUp, @@ -89,6 +93,10 @@ pub enum AppCommand { banks: Vec, }, + // Reorder + ShiftPatternsUp, + ShiftPatternsDown, + // Clipboard HardenSteps, CopySteps, @@ -239,6 +247,7 @@ pub enum AppCommand { ToggleRefreshRate, ToggleScope, ToggleSpectrum, + TogglePreview, // Metrics ResetPeakVoices, diff --git a/src/init.rs b/src/init.rs index 8ac1593..5aa6934 100644 --- a/src/init.rs +++ b/src/init.rs @@ -81,6 +81,7 @@ pub fn init(args: InitArgs) -> Init { app.ui.runtime_highlight = settings.display.runtime_highlight; app.audio.config.show_scope = settings.display.show_scope; app.audio.config.show_spectrum = settings.display.show_spectrum; + app.audio.config.show_preview = settings.display.show_preview; app.ui.show_completion = settings.display.show_completion; app.ui.color_scheme = settings.display.color_scheme; app.ui.hue_rotation = settings.display.hue_rotation; diff --git a/src/input/main_page.rs b/src/input/main_page.rs index a23313f..c783e88 100644 --- a/src/input/main_page.rs +++ b/src/input/main_page.rs @@ -96,6 +96,12 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool let state = FileBrowserState::new_save(initial); ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state)))); } + KeyCode::Char('z') if ctrl && !shift => { + ctx.dispatch(AppCommand::Undo); + } + KeyCode::Char('Z') if ctrl => { + ctx.dispatch(AppCommand::Redo); + } KeyCode::Char('c') if ctrl => { ctx.dispatch(AppCommand::CopySteps); } @@ -134,6 +140,10 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool let current = format!("{:.1}", ctx.link.tempo()); ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current))); } + KeyCode::Char(':') => { + let current = (ctx.app.editor_ctx.step + 1).to_string(); + ctx.dispatch(AppCommand::OpenModal(Modal::JumpToStep(current))); + } KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease), KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease), KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease), diff --git a/src/input/modal.rs b/src/input/modal.rs index 404b311..bc40a56 100644 --- a/src/input/modal.rs +++ b/src/input/modal.rs @@ -138,6 +138,22 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input KeyCode::Char(c) => input.push(c), _ => {} }, + Modal::JumpToStep(input) => match key.code { + KeyCode::Enter => { + if let Ok(step) = input.parse::() { + if step > 0 { + ctx.dispatch(AppCommand::GoToStep(step - 1)); + } + } + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Char(c) if c.is_ascii_digit() => input.push(c), + _ => {} + }, Modal::SetTempo(input) => match key.code { KeyCode::Enter => { if let Ok(tempo) = input.parse::() { @@ -466,7 +482,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input EuclideanField::Rotation => rotation, }; if let Ok(val) = target.parse::() { - *target = (val + 1).min(128).to_string(); + *target = (val + 1).min(1024).to_string(); } } KeyCode::Char(c) if c.is_ascii_digit() => match field { diff --git a/src/input/mouse.rs b/src/input/mouse.rs index 371a185..a1318ed 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -312,7 +312,8 @@ fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) { // Replay viz/sequencer split let show_scope = ctx.app.audio.config.show_scope; let show_spectrum = ctx.app.audio.config.show_spectrum; - let has_viz = show_scope || show_spectrum; + let show_preview = ctx.app.audio.config.show_preview; + let has_viz = show_scope || show_spectrum || show_preview; let layout = ctx.app.audio.config.layout; let sequencer_area = match layout { diff --git a/src/input/options_page.rs b/src/input/options_page.rs index c1bd4c7..c9b5074 100644 --- a/src/input/options_page.rs +++ b/src/input/options_page.rs @@ -25,6 +25,7 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) { OptionsFocus::ShowScope => ctx.dispatch(AppCommand::ToggleScope), OptionsFocus::ShowSpectrum => ctx.dispatch(AppCommand::ToggleSpectrum), OptionsFocus::ShowCompletion => ctx.dispatch(AppCommand::ToggleCompletion), + OptionsFocus::ShowPreview => ctx.dispatch(AppCommand::TogglePreview), OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), OptionsFocus::StartStopSync => ctx .link diff --git a/src/input/patterns_page.rs b/src/input/patterns_page.rs index 9eb7399..f918c6c 100644 --- a/src/input/patterns_page.rs +++ b/src/input/patterns_page.rs @@ -8,8 +8,15 @@ use crate::state::{ConfirmAction, Modal, PatternsColumn, RenameTarget}; pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let shift = key.modifiers.contains(KeyModifiers::SHIFT); + let alt = key.modifiers.contains(KeyModifiers::ALT); match key.code { + KeyCode::Up if alt && ctx.app.patterns_nav.column == PatternsColumn::Patterns => { + ctx.dispatch(AppCommand::ShiftPatternsUp); + } + KeyCode::Down if alt && ctx.app.patterns_nav.column == PatternsColumn::Patterns => { + ctx.dispatch(AppCommand::ShiftPatternsDown); + } KeyCode::Up if shift => { match ctx.app.patterns_nav.column { PatternsColumn::Banks => { @@ -94,6 +101,12 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp selected: false, })); } + KeyCode::Char('z') if ctrl && !shift => { + ctx.dispatch(AppCommand::Undo); + } + KeyCode::Char('Z') if ctrl => { + ctx.dispatch(AppCommand::Redo); + } KeyCode::Char('c') if ctrl => { let bank = ctx.app.patterns_nav.bank_cursor; match ctx.app.patterns_nav.column { diff --git a/src/model/docs.rs b/src/model/docs.rs index 7e5659f..6eff151 100644 --- a/src/model/docs.rs +++ b/src/model/docs.rs @@ -56,6 +56,17 @@ pub const DOCS: &[DocEntry] = &[ Topic("Introduction", include_str!("../../docs/midi_intro.md")), Topic("MIDI Output", include_str!("../../docs/midi_output.md")), Topic("MIDI Input", include_str!("../../docs/midi_input.md")), + // Tutorials + Section("Tutorials"), + Topic("Randomness", include_str!("../../docs/tutorial_randomness.md")), + Topic( + "Notes & Harmony", + include_str!("../../docs/tutorial_harmony.md"), + ), + Topic( + "Generators", + include_str!("../../docs/tutorial_generators.md"), + ), ]; pub fn topic_count() -> usize { diff --git a/src/services/clipboard.rs b/src/services/clipboard.rs index bfb7cca..bf3c930 100644 --- a/src/services/clipboard.rs +++ b/src/services/clipboard.rs @@ -9,6 +9,34 @@ fn annotate_copy_name(name: &Option) -> Option { } } +pub fn shift_patterns_up( + project: &mut Project, + bank: usize, + range: std::ops::RangeInclusive, +) -> Option> { + if *range.start() == 0 { + return None; + } + let slice_start = range.start() - 1; + let slice_end = *range.end(); + project.banks[bank].patterns[slice_start..=slice_end].rotate_left(1); + Some((slice_start..=slice_end).map(|p| (bank, p)).collect()) +} + +pub fn shift_patterns_down( + project: &mut Project, + bank: usize, + range: std::ops::RangeInclusive, +) -> Option> { + if *range.end() >= crate::model::MAX_PATTERNS - 1 { + return None; + } + let slice_start = *range.start(); + let slice_end = range.end() + 1; + project.banks[bank].patterns[slice_start..=slice_end].rotate_right(1); + Some((slice_start..=slice_end).map(|p| (bank, p)).collect()) +} + pub fn copy_pattern(project: &Project, bank: usize, pattern: usize) -> Pattern { project.banks[bank].patterns[pattern].clone() } diff --git a/src/settings.rs b/src/settings.rs index 39fda44..e690e07 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -40,6 +40,8 @@ pub struct DisplaySettings { pub show_scope: bool, pub show_spectrum: bool, #[serde(default = "default_true")] + pub show_preview: bool, + #[serde(default = "default_true")] pub show_completion: bool, #[serde(default = "default_font")] pub font: String, @@ -83,6 +85,7 @@ impl Default for DisplaySettings { runtime_highlight: false, show_scope: true, show_spectrum: true, + show_preview: true, show_completion: true, font: default_font(), color_scheme: ColorScheme::default(), diff --git a/src/state/audio.rs b/src/state/audio.rs index a3c1fe3..526a7bb 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -83,6 +83,7 @@ pub struct AudioConfig { pub refresh_rate: RefreshRate, pub show_scope: bool, pub show_spectrum: bool, + pub show_preview: bool, pub layout: MainLayout, } @@ -101,6 +102,7 @@ impl Default for AudioConfig { refresh_rate: RefreshRate::default(), show_scope: true, show_spectrum: true, + show_preview: true, layout: MainLayout::default(), } } diff --git a/src/state/mod.rs b/src/state/mod.rs index 3c0bed0..a5d4fe7 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -27,6 +27,7 @@ pub mod patterns_nav; pub mod playback; pub mod project; pub mod sample_browser; +pub mod undo; pub mod ui; pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind}; diff --git a/src/state/modal.rs b/src/state/modal.rs index f134b75..bbc9f83 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -64,6 +64,7 @@ pub enum Modal { input: String, }, SetTempo(String), + JumpToStep(String), AddSamplePath(Box), Editor, Preview, diff --git a/src/state/options.rs b/src/state/options.rs index e9216f3..9e95e7d 100644 --- a/src/state/options.rs +++ b/src/state/options.rs @@ -10,6 +10,7 @@ pub enum OptionsFocus { ShowScope, ShowSpectrum, ShowCompletion, + ShowPreview, LinkEnabled, StartStopSync, Quantum, @@ -32,6 +33,7 @@ impl CyclicEnum for OptionsFocus { Self::ShowScope, Self::ShowSpectrum, Self::ShowCompletion, + Self::ShowPreview, Self::LinkEnabled, Self::StartStopSync, Self::Quantum, @@ -54,17 +56,18 @@ const FOCUS_LINES: &[(OptionsFocus, usize)] = &[ (OptionsFocus::ShowScope, 6), (OptionsFocus::ShowSpectrum, 7), (OptionsFocus::ShowCompletion, 8), - (OptionsFocus::LinkEnabled, 12), - (OptionsFocus::StartStopSync, 13), - (OptionsFocus::Quantum, 14), - (OptionsFocus::MidiOutput0, 24), - (OptionsFocus::MidiOutput1, 25), - (OptionsFocus::MidiOutput2, 26), - (OptionsFocus::MidiOutput3, 27), - (OptionsFocus::MidiInput0, 31), - (OptionsFocus::MidiInput1, 32), - (OptionsFocus::MidiInput2, 33), - (OptionsFocus::MidiInput3, 34), + (OptionsFocus::ShowPreview, 9), + (OptionsFocus::LinkEnabled, 13), + (OptionsFocus::StartStopSync, 14), + (OptionsFocus::Quantum, 15), + (OptionsFocus::MidiOutput0, 25), + (OptionsFocus::MidiOutput1, 26), + (OptionsFocus::MidiOutput2, 27), + (OptionsFocus::MidiOutput3, 28), + (OptionsFocus::MidiInput0, 32), + (OptionsFocus::MidiInput1, 33), + (OptionsFocus::MidiInput2, 34), + (OptionsFocus::MidiInput3, 35), ]; impl OptionsFocus { diff --git a/src/state/undo.rs b/src/state/undo.rs new file mode 100644 index 0000000..af4dc4b --- /dev/null +++ b/src/state/undo.rs @@ -0,0 +1,53 @@ +use crate::model::{Bank, Pattern}; + +const MAX_UNDO: usize = 100; + +pub enum UndoScope { + Pattern { + bank: usize, + pattern: usize, + data: Pattern, + }, + Bank { + bank: usize, + data: Bank, + }, +} + +pub struct UndoEntry { + pub scope: UndoScope, + pub cursor: (usize, usize, usize), +} + +#[derive(Default)] +pub struct UndoHistory { + pub(crate) undo_stack: Vec, + redo_stack: Vec, +} + +impl UndoHistory { + pub fn push(&mut self, entry: UndoEntry) { + self.redo_stack.clear(); + if self.undo_stack.len() >= MAX_UNDO { + self.undo_stack.remove(0); + } + self.undo_stack.push(entry); + } + + pub fn pop_undo(&mut self) -> Option { + self.undo_stack.pop() + } + + pub fn push_redo(&mut self, entry: UndoEntry) { + self.redo_stack.push(entry); + } + + pub fn pop_redo(&mut self) -> Option { + self.redo_stack.pop() + } + + pub fn clear(&mut self) { + self.undo_stack.clear(); + self.redo_stack.clear(); + } +} diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs index 3bde131..eb5e1e9 100644 --- a/src/views/keybindings.rs +++ b/src/views/keybindings.rs @@ -36,6 +36,7 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str bindings.push(("f", "Fill", "Toggle fill mode (hold)")); bindings.push(("r", "Rename", "Rename current step")); bindings.push(("Ctrl+R", "Run", "Run step script immediately")); + bindings.push((":", "Jump", "Jump to step number")); bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm")); bindings.push(("m", "Mute", "Stage mute for current pattern")); bindings.push(("x", "Solo", "Stage solo for current pattern")); diff --git a/src/views/main_view.rs b/src/views/main_view.rs index 038ff70..525d900 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -1,12 +1,18 @@ +use std::collections::HashSet; + 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::SequencerSnapshot; +use crate::model::SourceSpan; use crate::state::MainLayout; use crate::theme; +use crate::views::highlight::highlight_line_with_runtime; +use crate::views::render::{adjust_resolved_for_line, adjust_spans_for_line}; use crate::widgets::{ActivePatterns, Orientation, Scope, Spectrum, VuMeter}; pub fn layout(area: Rect) -> [Rect; 5] { @@ -25,7 +31,8 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: let show_scope = app.audio.config.show_scope; let show_spectrum = app.audio.config.show_spectrum; - let has_viz = show_scope || show_spectrum; + let show_preview = app.audio.config.show_preview; + let has_viz = show_scope || show_spectrum || show_preview; let layout = app.audio.config.layout; let (viz_area, sequencer_area) = match layout { @@ -70,7 +77,7 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: }; if has_viz { - render_viz_area(frame, app, viz_area, layout, show_scope, show_spectrum); + render_viz_area(frame, app, snapshot, viz_area); } render_sequencer(frame, app, snapshot, sequencer_area); @@ -78,43 +85,48 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: render_active_patterns(frame, app, snapshot, patterns_area); } +enum VizPanel { + Scope, + Spectrum, + Preview, +} + fn render_viz_area( frame: &mut Frame, app: &App, + snapshot: &SequencerSnapshot, area: Rect, - layout: MainLayout, - show_scope: bool, - show_spectrum: bool, ) { - let is_vertical_layout = matches!(layout, MainLayout::Left | MainLayout::Right); + let is_vertical_layout = matches!(app.audio.config.layout, MainLayout::Left | MainLayout::Right); + let show_scope = app.audio.config.show_scope; + let show_spectrum = app.audio.config.show_spectrum; + let show_preview = app.audio.config.show_preview; - if show_scope && show_spectrum { - if is_vertical_layout { - let [scope_area, spectrum_area] = Layout::vertical([ - Constraint::Fill(1), - Constraint::Fill(1), - ]) - .areas(area); - render_scope(frame, app, scope_area, Orientation::Vertical); - render_spectrum(frame, app, spectrum_area); - } else { - let [scope_area, spectrum_area] = Layout::horizontal([ - Constraint::Fill(1), - Constraint::Fill(1), - ]) - .areas(area); - render_scope(frame, app, scope_area, Orientation::Horizontal); - render_spectrum(frame, app, spectrum_area); + let mut panels = Vec::new(); + if show_scope { panels.push(VizPanel::Scope); } + if show_spectrum { panels.push(VizPanel::Spectrum); } + if show_preview { panels.push(VizPanel::Preview); } + + let constraints: Vec = panels.iter().map(|_| Constraint::Fill(1)).collect(); + + let areas: Vec = if is_vertical_layout { + Layout::vertical(&constraints).split(area).to_vec() + } else { + Layout::horizontal(&constraints).split(area).to_vec() + }; + + let orientation = if is_vertical_layout { + Orientation::Vertical + } else { + Orientation::Horizontal + }; + + for (panel, panel_area) in panels.iter().zip(areas.iter()) { + match panel { + VizPanel::Scope => render_scope(frame, app, *panel_area, orientation), + VizPanel::Spectrum => render_spectrum(frame, app, *panel_area), + VizPanel::Preview => render_script_preview(frame, app, snapshot, *panel_area), } - } else if show_scope { - let orientation = if is_vertical_layout { - Orientation::Vertical - } else { - Orientation::Horizontal - }; - render_scope(frame, app, area, orientation); - } else if show_spectrum { - render_spectrum(frame, app, area); } } @@ -187,6 +199,7 @@ fn render_tile( let pattern = app.current_edit_pattern(); let step = pattern.step(step_idx); let is_active = step.map(|s| s.active).unwrap_or(false); + let has_content = step.map(|s| s.has_content()).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 in_selection = app.editor_ctx.selection_range() @@ -217,7 +230,10 @@ fn render_tile( let (r, g, b) = link_color.unwrap().1; (Color::Rgb(r, g, b), theme.tile.active_fg) } - (false, true, false, false, _) => (theme.tile.active_bg, theme.tile.active_fg), + (false, true, false, false, _) => { + let bg = if has_content { theme.tile.content_bg } else { theme.tile.active_bg }; + (bg, theme.tile.active_fg) + } (false, false, true, _, _) => (theme.selection.selected, theme.selection.cursor_fg), (false, false, _, _, true) => (theme.selection.in_range, theme.selection.cursor_fg), (false, false, false, _, _) => (theme.tile.inactive_bg, theme.tile.inactive_fg), @@ -234,6 +250,8 @@ fn render_tile( "▶".to_string() } else if let Some(source) = source_idx { format!("→{:02}", source + 1) + } else if has_content { + format!("·{:02}·", step_idx + 1) } else { format!("{:02}", step_idx + 1) }; @@ -314,6 +332,90 @@ fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(spectrum, inner); } +fn render_script_preview( + frame: &mut Frame, + app: &App, + snapshot: &SequencerSnapshot, + area: Rect, +) { + let theme = theme::get(); + let user_words: HashSet = app.dict.lock().keys().cloned().collect(); + + let pattern = app.current_edit_pattern(); + let step_idx = app.editor_ctx.step; + let step = pattern.step(step_idx); + let source_idx = step.and_then(|s| s.source); + let step_name = step.and_then(|s| s.name.as_ref()); + + let title = match (source_idx, step_name) { + (Some(src), Some(name)) => format!(" {:02}: {} -> {:02} ", step_idx + 1, name, src + 1), + (None, Some(name)) => format!(" {:02}: {} ", step_idx + 1, name), + (Some(src), None) => format!(" {:02} -> {:02} ", step_idx + 1, src + 1), + (None, None) => format!(" Step {:02} ", step_idx + 1), + }; + + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::new().fg(theme.ui.border)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let script = pattern.resolve_script(step_idx).unwrap_or(""); + if script.is_empty() { + let empty = Paragraph::new("(empty)") + .alignment(Alignment::Center) + .style(Style::new().fg(theme.ui.text_dim)); + let centered = Rect { + y: inner.y + inner.height / 2, + height: 1, + ..inner + }; + frame.render_widget(empty, centered); + return; + } + + let trace = if app.ui.runtime_highlight && app.playback.playing { + let source = pattern.resolve_source(step_idx); + snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source) + } else { + None + }; + + let resolved_display: Vec<(SourceSpan, String)> = trace + .map(|t| { + t.resolved + .iter() + .map(|(s, v)| (*s, v.display())) + .collect() + }) + .unwrap_or_default(); + + let mut line_start = 0usize; + let lines: Vec = script + .lines() + .take(inner.height as usize) + .map(|line_str| { + let tokens = if let Some(t) = trace { + let exec = adjust_spans_for_line(&t.executed_spans, line_start, line_str.len()); + let sel = adjust_spans_for_line(&t.selected_spans, line_start, line_str.len()); + let res = adjust_resolved_for_line(&resolved_display, line_start, line_str.len()); + highlight_line_with_runtime(line_str, &exec, &sel, &res, &user_words) + } else { + highlight_line_with_runtime(line_str, &[], &[], &[], &user_words) + }; + line_start += line_str.len() + 1; + let spans: Vec = tokens + .into_iter() + .map(|(style, text, _)| Span::styled(text, style)) + .collect(); + Line::from(spans) + }) + .collect(); + + frame.render_widget(Paragraph::new(lines), inner); +} + fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let block = Block::default() diff --git a/src/views/options_view.rs b/src/views/options_view.rs index d647773..5ff23fe 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -161,6 +161,12 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { focus == OptionsFocus::ShowCompletion, &theme, ), + render_option_line( + "Show preview", + if app.audio.config.show_preview { "On" } else { "Off" }, + focus == OptionsFocus::ShowPreview, + &theme, + ), Line::from(""), link_header, render_divider(content_width, &theme), diff --git a/src/views/render.rs b/src/views/render.rs index dd65722..7960bd0 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -38,7 +38,7 @@ fn clip_span(span: SourceSpan, line_start: usize, line_len: usize) -> Option { let (title, hint) = match field { - PatternField::Length => ("Set Length (1-128)", "Enter number"), + PatternField::Length => ("Set Length (1-1024)", "Enter number"), PatternField::Speed => ("Set Speed", "e.g. 1/3, 2/5, 1x, 2x"), }; TextInputModal::new(title, input) @@ -527,6 +527,15 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .border_color(theme.modal.confirm) .render_centered(frame, term) } + Modal::JumpToStep(input) => { + let pattern_len = app.current_edit_pattern().length; + let title = format!("Jump to Step (1-{})", pattern_len); + TextInputModal::new(&title, input) + .hint("Enter step number") + .width(30) + .border_color(theme.modal.confirm) + .render_centered(frame, term) + } Modal::SetTempo(input) => TextInputModal::new("Set Tempo (20-300 BPM)", input) .hint("Enter BPM") .width(30) diff --git a/tests/forth/context.rs b/tests/forth/context.rs index 9023f50..5486464 100644 --- a/tests/forth/context.rs +++ b/tests/forth/context.rs @@ -59,29 +59,29 @@ fn iter() { #[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); + let f = run_ctx("{ 100 } 4 every", &ctx); + assert_eq!(stack_int(&f), 100); } #[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); + let f = run_ctx("{ 100 } 4 every", &ctx); + assert_eq!(stack_int(&f), 100); } #[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); + let f = run_ctx("{ 100 } 4 every", &ctx); + assert!(f.stack().is_empty(), "iter={} should not execute quotation", i); } } #[test] fn every_zero_count() { - expect_error("0 every", "every count must be > 0"); + expect_error("{ 1 } 0 every", "every count must be > 0"); } #[test] @@ -97,3 +97,77 @@ fn context_in_computation() { let f = run_ctx("60 step +", &ctx); assert_eq!(stack_int(&f), 63); } + +// bjork (runs-based) + +#[test] +fn bjork_tresillo() { + // Bresenham(3,8) hits at positions 0, 2, 5 + for runs in 0..8 { + let ctx = ctx_with(|c| c.runs = runs); + let f = run_ctx("{ 100 } 3 8 bjork", &ctx); + let hit = ((runs + 1) * 3) / 8 != (runs * 3) / 8; + if hit { + assert_eq!(stack_int(&f), 100, "runs={} should hit", runs); + } else { + assert!(f.stack().is_empty(), "runs={} should miss", runs); + } + } +} + +#[test] +fn bjork_hit_count() { + // Bjorklund(3,8) should produce exactly 3 hits + let mut hit_count = 0; + for runs in 0..8 { + let ctx = ctx_with(|c| c.runs = runs); + let f = run_ctx("{ 100 } 3 8 bjork", &ctx); + if !f.stack().is_empty() { + hit_count += 1; + } + } + assert_eq!(hit_count, 3); +} + +#[test] +fn bjork_all_hits() { + let ctx = ctx_with(|c| c.runs = 0); + let f = run_ctx("{ 100 } 8 8 bjork", &ctx); + assert_eq!(stack_int(&f), 100); +} + +#[test] +fn bjork_zero_hits() { + let ctx = ctx_with(|c| c.runs = 0); + let f = run_ctx("{ 100 } 0 8 bjork", &ctx); + assert!(f.stack().is_empty()); +} + +#[test] +fn bjork_invalid() { + expect_error("{ 1 } 3 0 bjork", "bjork"); +} + +// pbjork (iter-based) + +#[test] +fn pbjork_cinquillo() { + let mut hit_count = 0; + for iter in 0..8 { + let ctx = ctx_with(|c| c.iter = iter); + let f = run_ctx("{ 100 } 5 8 pbjork", &ctx); + if !f.stack().is_empty() { + hit_count += 1; + } + } + assert_eq!(hit_count, 5); +} + +#[test] +fn pbjork_wraps() { + let ctx0 = ctx_with(|c| c.iter = 0); + let ctx8 = ctx_with(|c| c.iter = 8); + let f0 = run_ctx("{ 100 } 3 8 pbjork", &ctx0); + let f8 = run_ctx("{ 100 } 3 8 pbjork", &ctx8); + assert_eq!(f0.stack().is_empty(), f8.stack().is_empty()); +} diff --git a/tests/forth/quotations.rs b/tests/forth/quotations.rs index dbf7c1e..2287300 100644 --- a/tests/forth/quotations.rs +++ b/tests/forth/quotations.rs @@ -29,14 +29,14 @@ fn when_with_arithmetic() { #[test] fn when_with_every() { - // iter=0, every 2 should be true + // iter=0, every 2 executes quotation let ctx = ctx_with(|c| c.iter = 0); - let f = run_ctx("{ 100 } 2 every ?", &ctx); + let f = run_ctx("{ 100 } 2 every", &ctx); assert_eq!(stack_int(&f), 100); - // iter=1, every 2 should be false + // iter=1, every 2 skips quotation let ctx = ctx_with(|c| c.iter = 1); - let f = run_ctx("50 { 100 } 2 every ?", &ctx); + let f = run_ctx("50 { 100 } 2 every", &ctx); assert_eq!(stack_int(&f), 50); // quotation not executed } @@ -104,13 +104,12 @@ fn unexpected_close_error() { #[test] fn every_with_quotation_integration() { - // Simulating: { 2 distort } 2 every ? - // On even iterations, distort is applied + // { 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 ? ."#, &ctx) + .evaluate(r#""kick" s { 2 distort } 2 every ."#, &ctx) .unwrap(); if iter % 2 == 0 { assert!( @@ -128,6 +127,18 @@ fn every_with_quotation_integration() { } } +// bjork with sound + +#[test] +fn bjork_with_sound() { + let ctx = ctx_with(|c| c.runs = 2); // position 2 is a hit for (3,8) + let f = forth(); + let outputs = f + .evaluate(r#""kick" s { 2 distort } 3 8 bjork ."#, &ctx) + .unwrap(); + assert!(outputs[0].contains("distort/2")); +} + // Unless (!?) tests #[test] @@ -142,28 +153,15 @@ fn unless_true_skips() { 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 + // Using iter mod + ?/!? for if-else behavior (every no longer pushes bool) 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 !? ."#, + r#""kick" s { 2 distort } iter 2 mod 0 = ? { 4 distort } iter 2 mod 0 = !? ."#, &ctx, ) .unwrap(); diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index b6678f4..6afff6b 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -100,7 +100,7 @@ const version = cargo.match(/\[workspace\.package\]\s*\nversion\s*=\s*"([^"]+)"/ - +