sequencer: a due or overdue one-off ticks= plays now, not never - #1036
Conversation
A one-off `ticks=` whose tick had already passed was silently dropped by sequencer_add_wire(), and even had it been stored, the tick loop only fired one-offs on exact equality, so it could never play. That lost race is easy to lose and impossible to see. Every Python path into the sequencer arrives through a deferred callback, so a caller that reads the tick clock and schedules against it always runs a little after the tick it read; at 48 PPQ any offset under one tick (~11.6ms at the default 108 BPM) rounds straight back onto the current count. It is what made the Tulip arpeggiator play nothing at all, with no error to go on -- and it is a regression against the millisecond time= this replaced, which played a past-due event immediately. So play it, a fraction of a tick late, rather than dropping it. The play happens outside the lock and the wire is freed after, exactly as the tick loop does it, because amy_queue_lock is a plain non-recursive mutex and amy_play_message() re-enters the parser, which can land back in this function. tick==0 is the cancel form and is handled before this branch, so cancelling a tag still cancels. The tick loop now fires one-offs at tick <= count for the same reason. The active-list walk runs without the lock and a stale link can make it skip an entry for a single tick (already documented there); under == such an entry would sit forever, holding an anonymous slot and never playing. 122 tests pass, bit-exact. Co-Authored-By: Claude Opus 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: 2770 (was 2762, Δ +0.3%) (peak 2776, 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 |
⛓️ tulipcc integration PR openedThis merge was pinned into tulipcc for full-system CI: shorepine/tulipcc#1272 Test it there and merge that PR to move tulipcc onto this AMY. |
A due-or-overdue one-off ticks= plays immediately as of shorepine#1036, so the BillieJeanScheduled example no longer has to aim 8 ticks into the future to keep the first notes of a cycle from being thrown away. Removes grace_ticks from the sketch and from the walkthrough's copy of it (the doc had dropped the use but kept the now-unused declaration and its now-false comment), and makes amy_ticks_per_tick 24.0f in both -- they had drifted apart, and an unsuffixed double literal pulls in software double emulation on 32-bit targets. Also corrects docs/synth.md, which still told readers an absolute tick in the past would be ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The problem
A one-off
ticks=whose tick has already passed is silently dropped:and belt-and-braces, the tick loop only fired one-offs on exact equality (
tick == count), so even a stored past tick could never play.This race is easy to lose and impossible to see:
mp_sched_schedule,tulip.defer, MIDI callbacks), so a caller that reads the tick clock and schedules relative to it always runs a little after the tick it read.It's what made the Tulip arpeggiator play nothing at all, with no error to go on — see shorepine/tulipcc#1266. It's also a regression against the millisecond
time=this replaced, which played a past-due event immediately.The change
Play it, a fraction of a tick late, rather than dropping it.
sequencer_add_wire()plays a due/overdue one-off immediately instead of storing or dropping it. The play happens outside the lock with the wire freed after, exactly as the tick loop does —amy_queue_lockis a plain non-recursive mutex andamy_play_message()re-enters the parser, which can land back in this function.tick <= count. The active-list walk runs without the lock and a stale link can make it skip an entry for a single tick (already documented in the thread-safety note); under==such an entry would sit there forever, holding an anonymous slot and never playing.tick == 0 && period == 0is the cancel form and is handled before the new branch, so cancelling a tag still cancels — tick 0 is in the past for any running sequencer, and must not be swallowed by the new rule.Testing
make test: 122 tests pass, bit-exact (err=-100.0 dB), includingTestSequencer,TestSequencedSynthDrums,TestSequencerOsc.Behavior, verified directly (peak amplitude over 1s, note scheduled relative to a tick count of 25):
🤖 Generated with Claude Code