Skip to content

sequencer: a due or overdue one-off ticks= plays now, not never - #1036

Merged
dpwe merged 1 commit into
mainfrom
dpwe/past-ticks-play-now
Aug 2, 2026
Merged

sequencer: a due or overdue one-off ticks= plays now, not never#1036
dpwe merged 1 commit into
mainfrom
dpwe/past-ticks-play-now

Conversation

@dpwe

@dpwe dpwe commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

The problem

A one-off ticks= whose tick has already passed is silently dropped:

(tick != 0 && period == 0 && tick <= amy_global.sequencer_tick_count)  // don't schedule things in the past.
    -> free(wire); return 0;

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:

  • Every Python path into the sequencer arrives via a deferred callback (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.
  • At 48 PPQ, any offset under one tick (~11.6 ms at the default 108 BPM) rounds straight back onto the current count.

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_lock is a plain non-recursive mutex and amy_play_message() re-enters the parser, which can land back in this function.
  • The tick loop fires one-offs at 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 == 0 is 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), including TestSequencer, TestSequencedSynthDrums, TestSequencerOsc.

Behavior, verified directly (peak amplitude over 1s, note scheduled relative to a tick count of 25):

case tick before after
future (+20) 45 0.1227 0.1227
current tick 25 0.0000 0.1224
past (-5) 20 0.0000 0.1224
past (-20) 5 0.0000 0.1224
cancelled tag 0.0000 0.0000

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🎛️ AMY HW CI (AMYboard bench)

Flashed this PR's AMY (LoadTestChord: 6-voice Juno patch=1, one held note every 2 s) onto the physical AMYboard and measured the smoothed render load as the chord grows — back-to-back with the same sketch built at the PR's merge base, so Δ is this PR's own cost.

PASS — the bench ran the test to completion.

notes held main @ 3020e20 this PR Δ
1 1040 1050 +10
2 1196 1203 +7
3 1796 1800 +4
4 1954 1953 -1
5 2608 2614 +6
6 2761 2773 +12

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 tools/arduino_loadsweep/.

@dpwe
dpwe merged commit 6c84ffe into main Aug 2, 2026
12 checks passed
@bwhitman

bwhitman commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

⛓️ tulipcc integration PR opened

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

ovelhaaa pushed a commit to ovelhaaa/amy that referenced this pull request Aug 3, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants