Feat: improving MIDI

This commit is contained in:
2026-02-15 19:06:49 +01:00
parent 670ae0b6b6
commit 23c7abb145
6 changed files with 304 additions and 98 deletions

View File

@@ -1231,47 +1231,115 @@ impl Forth {
// MIDI operations // MIDI operations
Op::MidiEmit => { Op::MidiEmit => {
let (_, params) = cmd.snapshot().unwrap_or((None, &[])); let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let get_int = |name: &str| -> Option<i64> {
params
.iter()
.rev()
.find(|(k, _)| *k == name)
.and_then(|(_, v)| v.as_int().ok())
};
let get_float = |name: &str| -> Option<f64> {
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")) { // Build schedule: (emit_idx, delta_secs) — same logic as Op::Emit
let cc = cc.clamp(0, 127) as u8; let schedule: Vec<(usize, f64)> = if has_arp_list(cmd) {
let val = val.clamp(0, 127) as u8; let arp_count = compute_arp_count(cmd);
outputs.push(format!("/midi/cc/{cc}/{val}/chan/{chan}/dev/{dev}")); let explicit = !cmd.deltas().is_empty();
} else if let Some(bend) = get_float("bend") { let delta_list = cmd.deltas();
let bend_clamped = bend.clamp(-1.0, 1.0); let count = if explicit {
let bend_14bit = ((bend_clamped + 1.0) * 8191.5) as u16; arp_count.max(delta_list.len())
outputs.push(format!("/midi/bend/{bend_14bit}/chan/{chan}/dev/{dev}")); } else {
} else if let Some(pressure) = get_int("pressure") { arp_count
let pressure = pressure.clamp(0, 127) as u8; };
outputs.push(format!("/midi/pressure/{pressure}/chan/{chan}/dev/{dev}")); (0..count)
} else if let Some(program) = get_int("program") { .map(|i| {
let program = program.clamp(0, 127) as u8; let delta_secs = if explicit {
outputs.push(format!("/midi/program/{program}/chan/{chan}/dev/{dev}")); 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 { } else {
let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8; let poly_count = compute_poly_count(cmd);
let velocity = get_int("velocity").unwrap_or(100).clamp(0, 127) as u8; let deltas: Vec<f64> = if cmd.deltas().is_empty() {
let dur = get_float("dur").unwrap_or(1.0); vec![0.0]
let dur_secs = dur * ctx.step_duration(); } else {
outputs.push(format!( cmd.deltas()
"/midi/note/{note}/vel/{velocity}/chan/{chan}/dur/{dur_secs}/dev/{dev}" .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<i64> {
params
.iter()
.rev()
.find(|(k, _)| *k == name)
.and_then(|(_, v)| {
resolve_cycling(v, emit_idx).as_int().ok()
})
};
let get_float = |name: &str| -> Option<f64> {
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 => { Op::MidiClock => {

100
docs/tutorial_at.md Normal file
View File

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

View File

@@ -27,13 +27,13 @@ Sequences of values drive music: arpeggios, parameter sweeps, rhythmic patterns.
100 0.5 4 geom.. ;; 100 50 25 12.5 100 0.5 4 geom.. ;; 100 50 25 12.5
``` ```
Musical use -- build a harmonic series: Build a harmonic series:
```forth ```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 ## 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. 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 ## Transforming Sequences
Four words reshape values already on the stack. All take n (the count of items to operate on) from the top: 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 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. The generator words produce raw material. The transform words shape it. Together they let you express complex musical ideas in a few words.

View File

@@ -1180,10 +1180,12 @@ fn sequencer_loop(
// Route commands: audio direct to doux, MIDI through dispatcher // Route commands: audio direct to doux, MIDI through dispatcher
for tsc in output.audio_commands { 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 { let _ = dispatch_tx.send(TimedMidiCommand {
command: MidiDispatch::Send(midi_cmd.clone()), command: MidiDispatch::Send(midi_cmd.clone()),
target_time_us: current_time_us, target_time_us,
}); });
if let ( if let (
@@ -1196,7 +1198,7 @@ fn sequencer_loop(
Some(dur_secs), Some(dur_secs),
) = (&midi_cmd, dur) ) = (&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 { let _ = dispatch_tx.send(TimedMidiCommand {
command: MidiDispatch::Send(MidiCommand::NoteOff { command: MidiDispatch::Send(MidiCommand::NoteOff {
device: *device, device: *device,
@@ -1229,7 +1231,7 @@ fn sequencer_loop(
} }
} }
fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> { fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>, f64)> {
if !cmd.starts_with("/midi/") { if !cmd.starts_with("/midi/") {
return None; return None;
} }
@@ -1254,10 +1256,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
}; };
let device: u8 = find_param("dev").and_then(|s| s.parse().ok()).unwrap_or(0); 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] { match parts[1] {
"note" => { "note" => {
// /midi/note/<note>/vel/<vel>/chan/<chan>/dur/<dur>/dev/<dev>
let note: u8 = parts.get(2)?.parse().ok()?; let note: u8 = parts.get(2)?.parse().ok()?;
let vel: u8 = find_param("vel")?.parse().ok()?; let vel: u8 = find_param("vel")?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?;
@@ -1270,10 +1272,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
velocity: vel, velocity: vel,
}, },
dur, dur,
delta,
)) ))
} }
"cc" => { "cc" => {
// /midi/cc/<cc>/<val>/chan/<chan>/dev/<dev>
let cc: u8 = parts.get(2)?.parse().ok()?; let cc: u8 = parts.get(2)?.parse().ok()?;
let val: u8 = parts.get(3)?.parse().ok()?; let val: u8 = parts.get(3)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?;
@@ -1285,10 +1287,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
value: val, value: val,
}, },
None, None,
delta,
)) ))
} }
"bend" => { "bend" => {
// /midi/bend/<value>/chan/<chan>/dev/<dev>
let value: u16 = parts.get(2)?.parse().ok()?; let value: u16 = parts.get(2)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?;
Some(( Some((
@@ -1298,10 +1300,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
value, value,
}, },
None, None,
delta,
)) ))
} }
"pressure" => { "pressure" => {
// /midi/pressure/<value>/chan/<chan>/dev/<dev>
let value: u8 = parts.get(2)?.parse().ok()?; let value: u8 = parts.get(2)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?;
Some(( Some((
@@ -1311,10 +1313,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
value, value,
}, },
None, None,
delta,
)) ))
} }
"program" => { "program" => {
// /midi/program/<value>/chan/<chan>/dev/<dev>
let program: u8 = parts.get(2)?.parse().ok()?; let program: u8 = parts.get(2)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?;
Some(( Some((
@@ -1324,12 +1326,13 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
program, program,
}, },
None, None,
delta,
)) ))
} }
"clock" => Some((MidiCommand::Clock { device }, None)), "clock" => Some((MidiCommand::Clock { device }, None, delta)),
"start" => Some((MidiCommand::Start { device }, None)), "start" => Some((MidiCommand::Start { device }, None, delta)),
"stop" => Some((MidiCommand::Stop { device }, None)), "stop" => Some((MidiCommand::Stop { device }, None, delta)),
"continue" => Some((MidiCommand::Continue { device }, None)), "continue" => Some((MidiCommand::Continue { device }, None, delta)),
_ => None, _ => None,
} }
} }

View File

@@ -67,6 +67,7 @@ pub const DOCS: &[DocEntry] = &[
"Generators", "Generators",
include_str!("../../docs/tutorial_generators.md"), include_str!("../../docs/tutorial_generators.md"),
), ),
Topic("Timing with at", include_str!("../../docs/tutorial_at.md")),
]; ];
pub fn topic_count() -> usize { pub fn topic_count() -> usize {

View File

@@ -230,6 +230,52 @@ fn test_midi_continue() {
assert_eq!(outputs[0], "/midi/continue/dev/0"); 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 message type priority (first matching type wins)
#[test] #[test]
fn test_midi_message_priority_cc_over_note() { 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(); let outputs = f.evaluate("60 note m.", &ctx).unwrap();
assert!(outputs[0].contains("/dur/0.0625")); 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/"));
}