Apfelstrudel (Strudel for CoreMIDI with accurate timing via a headless V8 in deno_core)

Hi all,

I write music for MIDI-controlled organs (here in Germany that's done with the SINUA system), and I prototype in Sweelinq VPO. MIDI timing accuracy is very important in those things, and Strudel unfortunately falls a bit short there, especially when the machine is under load. On macOS this is solvable with CoreMIDI timestamping, so that's what I did: I detached the JS engine from the browser and moved it to a headless V8 via deno_core. I called the project Apfelstrudel (Grüße aus Deutschland!), since it's all macOS-specific. I wasn't planning to publish it, because it solves one particular problem for my own projects, but maybe someone finds it useful, so here it is.

Here's what the precision looks like in Cubase for this code:

$:note("[c3 bb2 f3 eb3]*32").midichan(1).midi('IAC Driver Sweelinq')

That's 128th notes.

Upd: I recorded a 16 min. run under load in Cubase (same pattern as above) at 120 bpm, 128th notes, 61,472 notes total. Zero notes lost, zero hung notes, zero reordering, and every inter-onset interval outside the incidents was exactly 15.625 ms. 61,456 notes (99.976%) sat exactly on the grid. There were 4 incidents where the stream stalled and the backlog flushed onto a later grid node, one at 2:58 (93.75 ms of silence, 9 notes displaced, worst case 78 ms) and three single-step ones at 3:37, 9:12 and 9:16 (46.88 ms each, 2 notes displaced, 31 ms worst case). All displacements were exact multiples of the grid step, so the backlog goes out batched with the next on-time note rather than at an arbitrary wake-up moment. One duplicate note also appeared, 4.17 ms off-grid, the only off-grid event in the whole run, that one I still need to track down. Cubase records at 480 PPQ, so the measurement floor is +/-0.52 ms and this says nothing about the microsecond figures above (what it does show is zero clock drift, under 0.54 ppm over 16 minutes) and a DAW recording can't tell whether the stalls came from the sender or from Cubase's own MIDI input under load, separating those needs a CoreMIDI logger that records both the packet timestamp and the arrival time, which is next on the list.

Upd2: I built the CoreMIDI logger, it opens a read-only input port on the same IAC endpoint the engine sends to and records, for every message, both the packet timestamp the engine stamped (the grid deadline it asked for) and the arrival time captured as the first thing in the callback, both on the same mach host clock so there is no clock sync to do and no 0.52 ms DAW floor anymore, and it runs as its own process so it never shares a heap or GC or scheduler slice with the engine under test. I reran 16 min under load at 120 bpm, 128th notes, 60,979 notes captured, and the headline is that sender_error is identically 0.000 ms across all 60,979 notes (min, p50, p99, p999, max all zero), meaning every single packet was stamped exactly on the grid node and the engine never once missed a deadline, so the earlier guess that the stalls were V8 GC or scheduler preemption on the sender is disproven, plus 0 notes lost, 0 duplicates, 0 reordering, and the off-grid duplicate note from the Cubase run did not reproduce. The receive side is where everything interesting lives now, delivery latency (arrival minus packet timestamp) sat at a floor of p50 61 µs, p99 128 µs, p999 175 µs, which finally confirms the tens-of-microseconds figures the 480 PPQ Cubase recording could neither prove nor deny, and there was exactly one incident in the whole run, at 12:52.5, a 17.5 ms delivery spike where a note-off and the next note-on arrived together, and critically their packet timestamps were dead on the grid (sender_error 0) so the engine had already handed CoreMIDI the correct future timestamps and it was the OS midiserver or the receiving callback thread that got preempted for 17 ms and flushed both events on wake-up, culprit receiver not sender. So the four Cubase incidents up to 94 ms were almost certainly Cubase's own MIDI input thread under load, not the engine, exactly the ambiguity a DAW recording could not resolve, and the true OS-level delivery jitter over IAC is a single 17.5 ms blip in 16 minutes. Woohoo!

Upd3: Recorded a short demo of my composition "Höhere Gewalt", which I rewrote in Strudel. It was recorded using OBS, which is quite resource-hungry. Playback was done via Sweelinq VPO. In some spots you can hear the short notes sort of stutter a bit: these are Sweelinq artifacts under load, apfelstrudel itself is fine. At the end (timestamp 05:08) I ran the same thing on strudel without the apfel engine, and the difference under the same load is clearly audible. On more powerful machines this will of course be less noticeable, but I have nothing to test that on.

Demo:

Time is kept in integer femtoseconds. Strudel hands note positions to Rust as exact rationals (5/8 of a cycle stays the literal pair 5/8), not as rounded milliseconds, and Rust does the conversion to host time. Rust doesn't try to guess the send moment with sleep(), it hands the packet to CoreMIDI ahead of time with a future timestamp, and the macOS midiserver emits the bytes at exactly that timestamp. There's no gap between consecutive identical notes, the note-off is batched into the same packet buffer as the next note-on, off first.

Grid error is 0.0 µs at every percentile. Delivery jitter: 16 µs min, 31 µs median, 95 µs max, that's CoreMIDI's own floor on a virtual/IAC port on Apple Silicon. On physical USB/DIN the floor is higher for physical reasons, but the grid feeding it is still exact.

CC, program change, pitch bend, channel aftertouch, NRPN, SysEx, transport messages (clock/start/stop/continue) are also supported. MIDI 2.0 UMP with Jitter-Reduction timestamps is there too, but it's opt-in and off by default, channel messages stay MIDI 1.0, and a legacy endpoint like IAC or Cubase gets UMP down-translated back to bytes with the JR timestamps dropped, so it only pays off with a UMP-native receiver.

MIDI input are not supported at all.

I didn't touch CoreAudio, only CoreMIDI, so if you run Strudel's internal audio engine alongside it, the two will drift apart over time: WebAudio schedules on the AudioContext clock, which isn't the same clock. Making MIDI precise was all I needed, since I don't use Strudel's audio. To line them up there's an offset slider, but that's obviously not a real fix. The proper way would be to run supradough in the same native runtime and output through CoreAudio. I don't know if I'll do that (probably not), but the idea is interesting in itself. It would also be good to update Tauri to a newer version, bump the Rust edition and so on, but that's for perfectionists, what's there now is enough for me.

Hope someone finds it useful.

René Coignard