From b23dd85d0f429d3e42c016c6e4af3b4b3778fcdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sun, 15 Feb 2026 19:06:49 +0100 Subject: [PATCH] Feat: improving MIDI --- crates/forth/src/vm.rs | 146 ++++++++++++++++++++++++++---------- docs/tutorial_at.md | 100 ++++++++++++++++++++++++ docs/tutorial_generators.md | 49 +----------- src/engine/sequencer.rs | 29 +++---- src/model/docs.rs | 1 + tests/forth/midi.rs | 77 +++++++++++++++++++ 6 files changed, 304 insertions(+), 98 deletions(-) create mode 100644 docs/tutorial_at.md diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 1bae589..eddc707 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -1231,47 +1231,115 @@ impl Forth { // MIDI operations Op::MidiEmit => { let (_, params) = cmd.snapshot().unwrap_or((None, &[])); - let get_int = |name: &str| -> Option { - params - .iter() - .rev() - .find(|(k, _)| *k == name) - .and_then(|(_, v)| v.as_int().ok()) - }; - let get_float = |name: &str| -> Option { - params - .iter() - .rev() - .find(|(k, _)| *k == name) - .and_then(|(_, v)| v.as_float().ok()) - }; - let chan = get_int("chan") - .map(|c| (c.clamp(1, 16) - 1) as u8) - .unwrap_or(0); - let dev = get_int("dev").map(|d| d.clamp(0, 3) as u8).unwrap_or(0); - if let (Some(cc), Some(val)) = (get_int("ccnum"), get_int("ccout")) { - let cc = cc.clamp(0, 127) as u8; - let val = val.clamp(0, 127) as u8; - outputs.push(format!("/midi/cc/{cc}/{val}/chan/{chan}/dev/{dev}")); - } else if let Some(bend) = get_float("bend") { - let bend_clamped = bend.clamp(-1.0, 1.0); - let bend_14bit = ((bend_clamped + 1.0) * 8191.5) as u16; - outputs.push(format!("/midi/bend/{bend_14bit}/chan/{chan}/dev/{dev}")); - } else if let Some(pressure) = get_int("pressure") { - let pressure = pressure.clamp(0, 127) as u8; - outputs.push(format!("/midi/pressure/{pressure}/chan/{chan}/dev/{dev}")); - } else if let Some(program) = get_int("program") { - let program = program.clamp(0, 127) as u8; - outputs.push(format!("/midi/program/{program}/chan/{chan}/dev/{dev}")); + // Build schedule: (emit_idx, delta_secs) — same logic as Op::Emit + let schedule: Vec<(usize, f64)> = if has_arp_list(cmd) { + let arp_count = compute_arp_count(cmd); + let explicit = !cmd.deltas().is_empty(); + let delta_list = cmd.deltas(); + let count = if explicit { + arp_count.max(delta_list.len()) + } else { + arp_count + }; + (0..count) + .map(|i| { + let delta_secs = if explicit { + let frac = delta_list[i % delta_list.len()] + .as_float() + .unwrap_or(0.0); + ctx.nudge_secs + frac * ctx.step_duration() + } else { + ctx.nudge_secs + + (i as f64 / count as f64) * ctx.step_duration() + }; + (i, delta_secs) + }) + .collect() } else { - let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8; - let velocity = get_int("velocity").unwrap_or(100).clamp(0, 127) as u8; - let dur = get_float("dur").unwrap_or(1.0); - let dur_secs = dur * ctx.step_duration(); - outputs.push(format!( - "/midi/note/{note}/vel/{velocity}/chan/{chan}/dur/{dur_secs}/dev/{dev}" - )); + let poly_count = compute_poly_count(cmd); + let deltas: Vec = if cmd.deltas().is_empty() { + vec![0.0] + } else { + cmd.deltas() + .iter() + .filter_map(|v| v.as_float().ok()) + .collect() + }; + let mut sched = Vec::with_capacity(poly_count * deltas.len()); + for poly_idx in 0..poly_count { + for &frac in &deltas { + sched.push(( + poly_idx, + ctx.nudge_secs + frac * ctx.step_duration(), + )); + } + } + sched + }; + + for (emit_idx, delta_secs) in schedule { + let get_int = |name: &str| -> Option { + params + .iter() + .rev() + .find(|(k, _)| *k == name) + .and_then(|(_, v)| { + resolve_cycling(v, emit_idx).as_int().ok() + }) + }; + let get_float = |name: &str| -> Option { + params + .iter() + .rev() + .find(|(k, _)| *k == name) + .and_then(|(_, v)| { + resolve_cycling(v, emit_idx).as_float().ok() + }) + }; + let chan = get_int("chan") + .map(|c| (c.clamp(1, 16) - 1) as u8) + .unwrap_or(0); + let dev = + get_int("dev").map(|d| d.clamp(0, 3) as u8).unwrap_or(0); + let delta_suffix = if delta_secs > 0.0 { + format!("/delta/{delta_secs}") + } else { + String::new() + }; + + if let (Some(cc), Some(val)) = (get_int("ccnum"), get_int("ccout")) { + let cc = cc.clamp(0, 127) as u8; + let val = val.clamp(0, 127) as u8; + outputs.push(format!( + "/midi/cc/{cc}/{val}/chan/{chan}/dev/{dev}{delta_suffix}" + )); + } else if let Some(bend) = get_float("bend") { + let bend_clamped = bend.clamp(-1.0, 1.0); + let bend_14bit = ((bend_clamped + 1.0) * 8191.5) as u16; + outputs.push(format!( + "/midi/bend/{bend_14bit}/chan/{chan}/dev/{dev}{delta_suffix}" + )); + } else if let Some(pressure) = get_int("pressure") { + let pressure = pressure.clamp(0, 127) as u8; + outputs.push(format!( + "/midi/pressure/{pressure}/chan/{chan}/dev/{dev}{delta_suffix}" + )); + } else if let Some(program) = get_int("program") { + let program = program.clamp(0, 127) as u8; + outputs.push(format!( + "/midi/program/{program}/chan/{chan}/dev/{dev}{delta_suffix}" + )); + } else { + let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8; + let velocity = + get_int("velocity").unwrap_or(100).clamp(0, 127) as u8; + let dur = get_float("dur").unwrap_or(1.0); + let dur_secs = dur * ctx.step_duration(); + outputs.push(format!( + "/midi/note/{note}/vel/{velocity}/chan/{chan}/dur/{dur_secs}/dev/{dev}{delta_suffix}" + )); + } } } Op::MidiClock => { diff --git a/docs/tutorial_at.md b/docs/tutorial_at.md new file mode 100644 index 0000000..8a713fa --- /dev/null +++ b/docs/tutorial_at.md @@ -0,0 +1,100 @@ +# Timing with at + +Every step has a duration. By default, sounds emit at the very start of that duration. `at` changes *when* within the step sounds fire -- giving you sub-step rhythmic control without adding more steps. + +## The Basics + +`at` drains the entire stack and stores the values as timing offsets. Each value is a fraction of the step duration: 0 = start, 0.5 = halfway, 1.0 = next step boundary. + +```forth +0.5 at kick s . ;; kick at the midpoint +``` + +Push multiple values before calling `at` to get multiple emits from a single `.`: + +```forth +0 0.5 at kick s . ;; two kicks: one at start, one at midpoint +0 0.25 0.5 0.75 at hat s . ;; four hats, evenly spaced +``` + +The deltas persist across multiple `.` calls until `clear` or a new `at`: + +```forth +0 0.5 at +kick s . ;; 2 kicks +hat s . ;; 2 hats (same timing) +clear +snare s . ;; 1 snare (deltas cleared) +``` + +## Cross-product: at Without arp + +Without `arp`, deltas multiply with polyphonic voices. If you have 3 notes and 2 deltas, you get 6 emits -- every note at every delta: + +```forth +0 0.5 at +c4 e4 g4 note sine s . ;; 6 emits: 3 notes x 2 deltas +``` + +This is a chord played twice per step. + +## 1:1 Pairing: at With arp + +`arp` changes the behavior. Instead of cross-product, deltas and arp values pair up 1:1. Each delta gets one note from the arpeggio: + +```forth +0 0.33 0.66 at +c4 e4 g4 arp note sine s . ;; c4 at 0, e4 at 0.33, g4 at 0.66 +``` + +If the lists differ in length, the shorter one wraps around: + +```forth +0 0.25 0.5 0.75 at +c4 e4 arp note sine s . ;; c4, e4, c4, e4 at 4 time points +``` + +This is THE key distinction. Without `arp`: every note at every time. With `arp`: one note per time slot. + +## Generating Deltas + +You rarely type deltas by hand. Use generators: + +Evenly spaced via `.,`: + +```forth +0 1 0.25 ., at hat s . ;; 0 0.25 0.5 0.75 1.0 +``` + +Euclidean distribution via `euclid`: + +```forth +3 8 euclid at hat s . ;; 3 hats at positions 0, 3, 5 +``` + +Random timing via `gen`: + +```forth +{ 0.0 1.0 rand } 4 gen at hat s . ;; 4 hats at random positions +``` + +Geometric spacing via `geom..`: + +```forth +0.0 2.0 4 geom.. at hat s . ;; exponentially spaced +``` + +## Gating at + +Wrap `at` expressions in quotations for conditional timing: + +```forth +{ 0 0.25 0.5 0.75 at } 2 every ;; 16th-note hats every other bar +hat s . + +{ 0 0.5 at } 0.5 chance ;; 50% chance of double-hit +kick s . +``` + +When the quotation doesn't execute, no deltas are set -- you get the default single emit at beat start. + diff --git a/docs/tutorial_generators.md b/docs/tutorial_generators.md index 462558c..1b110d2 100644 --- a/docs/tutorial_generators.md +++ b/docs/tutorial_generators.md @@ -27,13 +27,13 @@ Sequences of values drive music: arpeggios, parameter sweeps, rhythmic patterns. 100 0.5 4 geom.. ;; 100 50 25 12.5 ``` -Musical use -- build a harmonic series: +Build a harmonic series: ```forth -110 2 5 geom.. 5 rev note +110 2 5 geom.. 5 rev freq ``` -That gives you 110, 220, 440, 880, 1760 (reversed), ready to feed into `note` or `freq`. +That gives you 110, 220, 440, 880, 1760 (reversed), ready to feed into `freq`. ## Computed Sequences @@ -73,14 +73,6 @@ The distinction: `gen` is for building data. `times` is for doing things. 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: @@ -143,39 +135,4 @@ Or replicate a value for batch processing: 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/src/engine/sequencer.rs b/src/engine/sequencer.rs index 657d852..ba628e8 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -1180,10 +1180,12 @@ fn sequencer_loop( // Route commands: audio direct to doux, MIDI through dispatcher for tsc in output.audio_commands { - if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) { + if let Some((midi_cmd, dur, delta_secs)) = parse_midi_command(&tsc.cmd) { + let target_time_us = + current_time_us + (delta_secs * 1_000_000.0) as SyncTime; let _ = dispatch_tx.send(TimedMidiCommand { command: MidiDispatch::Send(midi_cmd.clone()), - target_time_us: current_time_us, + target_time_us, }); if let ( @@ -1196,7 +1198,7 @@ fn sequencer_loop( Some(dur_secs), ) = (&midi_cmd, dur) { - let off_time_us = current_time_us + (dur_secs * 1_000_000.0) as SyncTime; + let off_time_us = target_time_us + (dur_secs * 1_000_000.0) as SyncTime; let _ = dispatch_tx.send(TimedMidiCommand { command: MidiDispatch::Send(MidiCommand::NoteOff { device: *device, @@ -1229,7 +1231,7 @@ fn sequencer_loop( } } -fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option)> { +fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option, f64)> { if !cmd.starts_with("/midi/") { return None; } @@ -1254,10 +1256,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option)> { }; let device: u8 = find_param("dev").and_then(|s| s.parse().ok()).unwrap_or(0); + let delta: f64 = find_param("delta").and_then(|s| s.parse().ok()).unwrap_or(0.0); match parts[1] { "note" => { - // /midi/note//vel//chan//dur//dev/ let note: u8 = parts.get(2)?.parse().ok()?; let vel: u8 = find_param("vel")?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?; @@ -1270,10 +1272,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option)> { velocity: vel, }, dur, + delta, )) } "cc" => { - // /midi/cc///chan//dev/ let cc: u8 = parts.get(2)?.parse().ok()?; let val: u8 = parts.get(3)?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?; @@ -1285,10 +1287,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option)> { value: val, }, None, + delta, )) } "bend" => { - // /midi/bend//chan//dev/ let value: u16 = parts.get(2)?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?; Some(( @@ -1298,10 +1300,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option)> { value, }, None, + delta, )) } "pressure" => { - // /midi/pressure//chan//dev/ let value: u8 = parts.get(2)?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?; Some(( @@ -1311,10 +1313,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option)> { value, }, None, + delta, )) } "program" => { - // /midi/program//chan//dev/ let program: u8 = parts.get(2)?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?; Some(( @@ -1324,12 +1326,13 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option)> { program, }, None, + delta, )) } - "clock" => Some((MidiCommand::Clock { device }, None)), - "start" => Some((MidiCommand::Start { device }, None)), - "stop" => Some((MidiCommand::Stop { device }, None)), - "continue" => Some((MidiCommand::Continue { device }, None)), + "clock" => Some((MidiCommand::Clock { device }, None, delta)), + "start" => Some((MidiCommand::Start { device }, None, delta)), + "stop" => Some((MidiCommand::Stop { device }, None, delta)), + "continue" => Some((MidiCommand::Continue { device }, None, delta)), _ => None, } } diff --git a/src/model/docs.rs b/src/model/docs.rs index 6eff151..0a34b90 100644 --- a/src/model/docs.rs +++ b/src/model/docs.rs @@ -67,6 +67,7 @@ pub const DOCS: &[DocEntry] = &[ "Generators", include_str!("../../docs/tutorial_generators.md"), ), + Topic("Timing with at", include_str!("../../docs/tutorial_at.md")), ]; pub fn topic_count() -> usize { diff --git a/tests/forth/midi.rs b/tests/forth/midi.rs index 22bd62c..4a9fe99 100644 --- a/tests/forth/midi.rs +++ b/tests/forth/midi.rs @@ -230,6 +230,52 @@ fn test_midi_continue() { assert_eq!(outputs[0], "/midi/continue/dev/0"); } +// at (delta) tests +#[test] +fn test_midi_at_single_delta() { + let outputs = expect_outputs("0.5 at 60 note m.", 1); + assert!(outputs[0].contains("/note/60/")); + assert!(outputs[0].contains("/delta/")); +} + +#[test] +fn test_midi_at_multiple_deltas() { + let outputs = expect_outputs("0 0.5 at 60 note m.", 2); + assert!(outputs[0].contains("/note/60/")); + assert!(outputs[1].contains("/note/60/")); + assert!(outputs[1].contains("/delta/")); +} + +#[test] +fn test_midi_at_with_polyphony() { + // 2 notes × 2 deltas = 4 events + expect_outputs("0 0.5 at 60 64 note m.", 4); +} + +#[test] +fn test_midi_arp_notes() { + let outputs = expect_outputs("c4 e4 g4 arp note m.", 3); + assert!(outputs[0].contains("/note/60/")); + assert!(outputs[1].contains("/note/64/")); + assert!(outputs[2].contains("/note/67/")); +} + +#[test] +fn test_midi_arp_with_at() { + let outputs = expect_outputs("0 0.25 0.5 at c4 e4 g4 arp note m.", 3); + assert!(outputs[0].contains("/note/60/")); + assert!(outputs[1].contains("/note/64/")); + assert!(outputs[2].contains("/note/67/")); +} + +#[test] +fn test_midi_at_cc() { + let outputs = expect_outputs("0 0.5 at 1 ccnum 64 ccout m.", 2); + assert!(outputs[0].contains("/midi/cc/1/64/")); + assert!(outputs[1].contains("/midi/cc/1/64/")); + assert!(outputs[1].contains("/delta/")); +} + // Test message type priority (first matching type wins) #[test] fn test_midi_message_priority_cc_over_note() { @@ -286,3 +332,34 @@ fn test_midi_note_duration_with_speed() { let outputs = f.evaluate("60 note m.", &ctx).unwrap(); assert!(outputs[0].contains("/dur/0.0625")); } + +// Polyphonic MIDI tests +#[test] +fn test_midi_polyphonic_notes() { + let outputs = expect_outputs("60 64 67 note m.", 3); + assert!(outputs[0].contains("/midi/note/60/")); + assert!(outputs[1].contains("/midi/note/64/")); + assert!(outputs[2].contains("/midi/note/67/")); +} + +#[test] +fn test_midi_polyphonic_notes_with_velocity() { + let outputs = expect_outputs("60 64 67 note 100 80 60 velocity m.", 3); + assert!(outputs[0].contains("/note/60/vel/100/")); + assert!(outputs[1].contains("/note/64/vel/80/")); + assert!(outputs[2].contains("/note/67/vel/60/")); +} + +#[test] +fn test_midi_polyphonic_channel() { + let outputs = expect_outputs("60 note 1 2 chan m.", 2); + assert!(outputs[0].contains("/note/60/") && outputs[0].contains("/chan/0")); + assert!(outputs[1].contains("/note/60/") && outputs[1].contains("/chan/1")); +} + +#[test] +fn test_midi_polyphonic_cc() { + let outputs = expect_outputs("1 2 ccnum 64 127 ccout m.", 2); + assert!(outputs[0].contains("/midi/cc/1/64/")); + assert!(outputs[1].contains("/midi/cc/2/127/")); +}