Skip to content

sequencer.py: one AMY tag per Sequence (pairs with amy#1156) - #1352

Merged
dpwe merged 3 commits into
mainfrom
sequencer-one-tag-per-sequence
Sep 10, 2026
Merged

sequencer.py: one AMY tag per Sequence (pairs with amy#1156)#1352
dpwe merged 3 commits into
mainfrom
sequencer-one-tag-per-sequence

Conversation

@dpwe

@dpwe dpwe commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Pairs with shorepine/amy#1156, where events sent to an AMY sequencer tag now accumulate instead of replacing what was there.

Blocked on that PR. The amy pin here is a83c27bd, which only exists on amy#1156's branch. Merge that first, then re-pin this to the merged SHA (and re-run sync_amy_docs.py against it if the merge changes any amy docs). Opened as a draft for that reason.

The bug

AMYSequenceEvent.update() keeps its tag across calls, so re-editing an already-scheduled step re-sent ticks= to a tag that still held the previous version. Under accumulate that stacks and both versions play. Before the fix, editing one step of a three-step sequence gives four entries:

[(0, 0, 'v0w0n36l1Z'), (1, 48, 'v0w0n38l1Z'), (1, 144, 'v0w0n45l1Z'), (2, 96, 'v0w0n42l1Z')]
                        ^^^^ the pre-edit version, still scheduled

The redesign accumulate makes possible

An AMYSequence now takes one tag at construction and schedules every step under it. Each step used to burn a tag of its own, which ran AMY's 256-tag space out during ordinary use — 300 step edits now consume 0 tags, where before they consumed 300.

The cost is that AMY can only erase a tag whole, never one entry in it, so editing or removing a step rebuilds the tag from the surviving events. add() still costs one message, since adding is exactly what accumulate does, and clear() is now a single message whatever the sequence holds.

That makes a naive edit loop N**2 messages, so AMYSequence.batch() coalesces a run of edits into one rebuild. drums.py's DrumRow.update_switches() is the one loop that hits it — a row voice change over a filled 16-step row drops from 152 messages to 17.

Contents

  • tulip/shared/py/sequencer.py — the above.
  • tulip/shared/py/drums.py — batch the row-voice-change loop.
  • tulip/shared/test_amy_sequencer.py — new host-side test (below).
  • docs/amyboard/python.md — the MIDI-out example was the only tagged ticks= in the AMYboard docs and put its note-on/note-off on two tags, which now reads as teaching the rule that changed. Both on one tag, plus what follows from accumulate.
  • tulip/server/refdocs/amy/ — refreshed for the new pin, per the AMY submodule policy.

Testing

tulip/shared/test_amy_sequencer.py follows test_amyboard_encoder.py's pattern: stub the two firmware-only imports, drive the real AMYSequence against the pip-installed amy, and read back what AMY actually holds via its own debug=6 dump. 24 checks — the shared tag, two sequences not colliding, batched vs unbatched message counts, a mixed edit/add/remove batch matching drums.py's real usage, single-message clear(). Reverting the fix fails 3 of them.

python3 tulip/shared/test_amy_sequencer.py

I also replayed AMYboard World sketch #1139 (seinfeld_theme.py, the densest AMYSequence user on World: 3 sequences, 31 adds) against the new code:

adds issued        : 31
entries in AMY     : 31   OK
tags used          : [0, 1, 2]  (was 31 under the old one-per-step scheme)
periods per tag    : [(0, 384), (1, 192), (2, 192)]

It also has four drum events colliding on one step. AMY fires them in add order, which is what ascending-tag order gave before — playback is unchanged.

Sketch audit

I scanned all 1486 AMYboard World sketches (586 live) plus the in-repo ones for anything exposed to the accumulate change:

pattern sketches exposed?
literal tagged ticks="a,b,c" 0
raw H… wire string 0
retired sequence= kwarg 0
dynamic ticks=<expr> 23 (11 live) no — all one-off absolute ticks, no tag
sequencer.AMYSequence 22 (10 live) goes through this PR's sequencer.py
tulip.seq_* 7 (4 live) no — Tulip's callback sequencer, no AMY tags

Nothing on World breaks. All 10 live AMYSequence users call only .add(), which is the cheap path.

Note on the coupling

sequencer.py fails quietly against an older amy: each add() would replace rather than accumulate, so a pattern would play only its last step. These changes have to move with the pin.

🤖 Generated with Claude Code

dpwe and others added 2 commits September 9, 2026 09:00
Bumps the amy pin to a83c27bd, where events sent to a sequencer tag
ACCUMULATE instead of replacing what was there. Two things follow.

The bug first: AMYSequenceEvent.update() keeps its tag across calls, so
re-editing an already-scheduled step re-sent ticks= to a tag that still
held the old version. Under accumulate that stacks, and both versions play.

The rest is the redesign that accumulate makes possible. An AMYSequence now
takes ONE tag at construction and schedules every step under it, where each
step used to burn a tag of its own -- which ran AMY's 256-tag space out
after a few minutes of editing (300 step edits now consume 0 tags). The
cost is that AMY can only erase a tag whole, so editing or removing a step
rebuilds the tag from the surviving events; add() still costs one message,
since adding is exactly what accumulate does, and clear() is now a single
message whatever the sequence holds.

A naive edit loop is then N**2 messages, so AMYSequence.batch() coalesces a
run of edits into one rebuild. drums.py's DrumRow.update_switches() is the
one loop that hits it: a row voice change over a filled 16-step row drops
from 152 messages to 17.

tulip/shared/test_amy_sequencer.py drives the real AMYSequence against a
real AMY (stubbing the two firmware-only imports, reading back what AMY
holds via its own debug dump) and pins all of the above, including the
batched message counts.

refdocs/amy refreshed for the new pin, per the AMY submodule policy.

NOTE: sequencer.py now depends on the accumulate semantics and will
misbehave quietly against an older amy -- each add() would replace rather
than accumulate, so a pattern would play only its last step. It has to move
with the pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The MIDI-out example was the only place in the AMYboard docs that shows a
tagged ticks=, and it predates events accumulating on a tag. It put its
note-on and note-off on two tags, which reads as "a tag holds one event" --
the rule that just changed.

Puts both on tag 7 instead, which is what a tag is for now, and explains
what follows: sends to a tag accumulate, ticks="0,0,<tag>" drops the whole
tag, there is no way to remove one event from a tag, the clear needs its own
send because ticks= claims the rest of its message, and same-tick events
play in tag order then add order. Links out to amy's synth.md for the rest.

Also points at sequencer.AMYSequence for callers who would rather not manage
tags by hand, since it now takes one tag and does the clear-and-rebuild
itself.

Every snippet was run against a real AMY.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

🔌 AMYboard PR preview

Editor + flasher: https://amyboard-pr-1352.vercel.app/editor/

This preview bundles this PR's firmware — its flasher only flashes this build (not the release). Rebuilt on every push; removed when the PR closes.

The hardware CI has been kicked off and should return within a few minutes, stand by!

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

🌷 Tulip Web PR preview

Tulip Web: https://tulip-pr-1352.vercel.app/run/

Flash a Tulip to this build: on-device run tulip.upgrade(pr=1352).

Rebuilt on every push; removed when the PR closes.

amy#1156 merged as 0c05eba, and the release workflow then bumped the
version to 1.2.166. Moves the pin from the PR-branch commit (a83c27bd) to
the current tip of amy main, per the submodule policy.

a83c27bd was not dangling -- #1156 landed as a merge commit, not a squash,
so the old pin was still an ancestor of main -- but it was not the latest
known working version, which is what the policy asks for before tulipcc
goes to main.

The two bump commits changed only the version string in amy/__init__.py,
docs/amy.js, docs/amy.wasm, library.properties and pyproject.toml. No
docs/*.md changed, so the refdocs snapshot is byte-identical and only its
recorded source SHA moves; _KW_MAP_LIST is untouched, so amy_kwmap.h needs
no regeneration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🎛️ HW CI (physical bench)

AMYboard (USB-MIDI + AMY zP → audio; built-in tones + AMYboard World sketches acid/house/woodpiano over the SysEx control API): ✅ PASS — flashed this PR’s firmware; all checks matched the references.

Tulip (TULIP4_R11; serial-REPL audio + WiFi screenshot): ✅ PASS — flashed this PR’s firmware; all checks matched the references.

⬇️ Artifacts: recordings · screenshot · serial logs · run logs

Self-hosted bench. Audio spectral-compared to ref/hwci_basic.wav, the AMYboard World sketch refs (ref/{acid_generator,house_generator,woodpiano}.wav) + ref/tulip_basic.wav; Tulip screenshot pixel-compared to ref/tulip_screenshot.png. Both analog outs share one capture card, so the tests run sequentially.

@dpwe
dpwe marked this pull request as ready for review September 10, 2026 01:16
@dpwe
dpwe merged commit 4469977 into main Sep 10, 2026
4 checks passed
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.

1 participant