Store sequencer (H) and timed (t) events as wire strings, parsed at play time - #1001
Store sequencer (H) and timed (t) events as wire strings, parsed at play time#1001bwhitman wants to merge 1 commit into
Conversation
…lay time
Bulk ingest of scheduled wire messages was slow (~400ms for a few hundred
sequencer events on a 400MHz RISC-V) because every message was fully parsed,
voice-allocated and expanded into deltas up front. Now a light scanner
(amy_scan_wire_message) finds top-level t/H commands, strips them, and stores
the remaining wire string with its schedule metadata; the full parse happens
when the entry comes due, in sequencer_check_and_fill / the new
timed_wire_check_and_fire.
- sequences[tag] stores a wire string instead of a delta list; timed ('t' in
the future) messages go into a time-sorted list in the same module, so the
sequencer and timed events share one storage and playback mechanism.
- C-API events with sequence set are serialized via sprint_event and routed
through the same wire scheduler; C-API events with a future time are parked
as event copies in the same timed store.
- MIDI input with a future timestamp is parked as raw bytes, so mapping
lookup / template expansion / voice allocation happen against the synth
state at play time, not at ingest.
- The delta queue remains only as the block-boundary mailbox between ingest
threads and the render thread (and for internal short delays like
noteon_delay); it no longer holds long-horizon scheduled events.
- Removed the vestigial sequence_entry_ll_t.
All 119 WAV tests pass bit-exact; ctest (clock wrap) passes. Ingest of 400
piano-roll-style H messages: 2.3us -> 0.17us per message on an M-series Mac,
and now invariant to message content since no parse happens at ingest.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🎛️ AMY HW CI (AMYboard bench)Flashed this PR's AMY (LoadTestChord: 6-voice Juno ✅ PASS — the bench ran the test to completion.
Full chord settled render μs: 2762 (was 2739, Δ +0.8%) (peak 2771, 39 samples) ⬇️ Artifacts: serial log · load trace · report Self-hosted bench (amyboardci). FAIL means only that the test could not run — the load values are informational, with no threshold and no audio compare. See |
|
I'm not crazy about this. Pros:
Cons:
We could make this change simpler if we insisted that My bigger question is: Do we need the speedup benefit any more? AIUI, translating the MicroPython I'm not sure what to do. I already wanted to store sequencer events as wire strings, but I was planning to do this after they had been converted to |
|
The rest i agree with except
I'm not sure i know what you mean. amy.test() (clearly) works just fine with this change. It always did used to use a "different code path" (stuff was put in delta queue instead of played immediately). So I'm not sure what the issue is here PS i'd be fine with forcing H and t to be at the start, and also changing |
|
Oh also in my head i see |
|
Oh yeh, and for the record, yes, the C amy message was a huge speedup compared to this - this saved us 11% on risc-v "only" - not a massive thing but not bad! |
Well IIUC "playing immediately" involves "put in the delta queue for later dequeue", it's just that sometimes it will happen on the very next Now, events from I like treating |
Per PR shorepine#1001 review: restrict amy_scan_wire_message() to only treat a leading 't' (time) or 'H' (sequence) as a scheduling command -- i.e. the very first character of the wire message, mirroring the existing rule that a patch string ('u') argument must be last. A 't'/'H' anywhere else in the message is left alone for the real parser, keeping its ordinary per-event meaning instead of being stripped as a schedule. Since a message can only have one scheduling command, at most one of has_time/has_sequence is ever set now; updated the stale "H wins if both present" comment to reflect that they're mutually exclusive. This requires 'sequence' and 'time' to always serialize as the first wire command when present, so also reordered _KW_MAP_LIST in amy/__init__.py (the shared source of truth for the Python/JS/GDScript message builders) to put them first, ahead of every other keyword. Regenerated src/amy_api.generated.js via `make c-api` to match (godot/amy.gd and the other c-api outputs were unaffected). Verified with `make test` (all 119 WAV tests still pass bit-exact, including TestSequencer/TestSequencerOsc/TestSequencedSynthDrums) and `make ctest` (clock-wrap suite).
|
The commit here landed on main as 71e3e50 (rebased, same content) and the design has since been reworked on top of it — tick-based |
Motivation
Ingesting a few hundred sequencer (
H) wire messages took ~400ms on a fast 400MHz RISC-V, because every message was fully parsed, voice-allocated and expanded into deltas at ingest. Separately, timed (t) events used a different mechanism (long-lived deltas in the global queue) than the sequencer (per-tag delta lists).What this does
Ingest fast path.
amy_add_messagenow runs a light scanner (amy_scan_wire_messagein parse.c) over each message that only walks command letters and skips their arguments. If it finds a top-levelH, or atin the future, it strips those commands out and stores the raw remaining wire string plus the schedule metadata — no event parse, no voice allocation, no deltas. Ingest cost is a scan + one malloc + memcpy, invariant to message content. Commands with string payloads that can embed wire code (u,ic/io/ig,zT/zF/zD/zP) are treated as opaque so their payloads are never misread as scheduling commands; transfer-mode payloads bypass the scanner entirely.One store for sequenced + timed.
sequences[tag]now holds a wire string (tick/period/tag semantics unchanged: overwrite, clear on0,0,tag, one-shot delete after firing, repeat on period). Timed one-shots live in a time-sorted list in the same module, checked once per block bytimed_wire_check_and_fire()(independent of sequencer transport, wrap-relative compares). When an entry comes due it's parsed and played right then — timed messages keep their originaltas the base event time so delta times/ordering are identical to the old behavior.All future-dated events unify on the timed store:
t→ stored as strings.amy_add_eventwith a future time → stored as an event-struct copy (no lossy string round-trip).sequenceset → serialized viasprint_eventand routed through the same wire scheduler.The delta queue is no longer a scheduler. It survives only as the block-boundary mailbox between ingest threads and the render thread, plus internal short delays (
synth_delay_msnote-on delay, voice-steal note-offs). It can't be removed entirely: immediate cross-thread play would mutate synth state mid-render (deltas executing at block boundaries under the lock is the render-thread synchronization model). But it never accumulates long-horizon events anymore, so its O(n) sorted insert is on a always-tiny list.Results
TestSequencer*,TestOscResetIsScheduled,TestClearSynth.make ctest(clock-wrap) passes, including scheduling across the 2^32 ms rollover.Hmessages (synth note-ons): 2.3µs → 0.17µs per message on an M-series Mac (~14x), and the new cost no longer scales with message complexity — on-device the gap should be much larger since the old path did per-delta lock/pool-alloc/sorted-insert plus voice allocation in SPIRAM.tmessages: ~1.1µs → ~0.4µs per message (raw osc events; bigger win for synth events).make web) compiles; no files added/removed so the Godot source lists are untouched;sequencer_add_event(removed) had no external users (checked tulipcc).Semantics notes
num_voices),Sresets, midi-CC mapping installs — instead of config executing at ingest with only deltas deferred. This is the coherent model (and whatTestOscResetIsScheduledwanted), and no test refs changed.sprint_event(%.3f floats). Sequenced note events are unaffected in practice, but exotic float params in a C-API-struct sequence entry could lose a little precision. Wire-sourced sequences (Python/JS/MIDI, i.e. everything in the test suite) are stored verbatim.🤖 Generated with Claude Code