Feat: improving MIDI
This commit is contained in:
@@ -1231,49 +1231,117 @@ impl Forth {
|
||||
// MIDI operations
|
||||
Op::MidiEmit => {
|
||||
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
|
||||
|
||||
// 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 poly_count = compute_poly_count(cmd);
|
||||
let deltas: Vec<f64> = 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<i64> {
|
||||
params
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(k, _)| *k == name)
|
||||
.and_then(|(_, v)| v.as_int().ok())
|
||||
.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)| v.as_float().ok())
|
||||
.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 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}"));
|
||||
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}"));
|
||||
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}"));
|
||||
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}"));
|
||||
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 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}"
|
||||
"/midi/note/{note}/vel/{velocity}/chan/{chan}/dur/{dur_secs}/dev/{dev}{delta_suffix}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Op::MidiClock => {
|
||||
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
|
||||
let dev = extract_dev_param(params);
|
||||
|
||||
100
docs/tutorial_at.md
Normal file
100
docs/tutorial_at.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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<f64>)> {
|
||||
fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>, f64)> {
|
||||
if !cmd.starts_with("/midi/") {
|
||||
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 delta: f64 = find_param("delta").and_then(|s| s.parse().ok()).unwrap_or(0.0);
|
||||
|
||||
match parts[1] {
|
||||
"note" => {
|
||||
// /midi/note/<note>/vel/<vel>/chan/<chan>/dur/<dur>/dev/<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<f64>)> {
|
||||
velocity: vel,
|
||||
},
|
||||
dur,
|
||||
delta,
|
||||
))
|
||||
}
|
||||
"cc" => {
|
||||
// /midi/cc/<cc>/<val>/chan/<chan>/dev/<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<f64>)> {
|
||||
value: val,
|
||||
},
|
||||
None,
|
||||
delta,
|
||||
))
|
||||
}
|
||||
"bend" => {
|
||||
// /midi/bend/<value>/chan/<chan>/dev/<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<f64>)> {
|
||||
value,
|
||||
},
|
||||
None,
|
||||
delta,
|
||||
))
|
||||
}
|
||||
"pressure" => {
|
||||
// /midi/pressure/<value>/chan/<chan>/dev/<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<f64>)> {
|
||||
value,
|
||||
},
|
||||
None,
|
||||
delta,
|
||||
))
|
||||
}
|
||||
"program" => {
|
||||
// /midi/program/<value>/chan/<chan>/dev/<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<f64>)> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user