From 41993eae60c70217104ede5068d361188a9df757 Mon Sep 17 00:00:00 2001 From: Dan Ellis Date: Wed, 9 Sep 2026 09:00:16 -0400 Subject: [PATCH 1/3] sequencer.py: one AMY tag per Sequence, not one per step 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 --- amy | 2 +- tulip/server/refdocs/amy/_VENDORED_FROM.txt | 2 +- tulip/server/refdocs/amy/api.md | 4 +- tulip/server/refdocs/amy/synth.md | 24 ++- tulip/shared/py/drums.py | 9 +- tulip/shared/py/sequencer.py | 115 +++++++++-- tulip/shared/test_amy_sequencer.py | 211 ++++++++++++++++++++ 7 files changed, 344 insertions(+), 23 deletions(-) create mode 100644 tulip/shared/test_amy_sequencer.py diff --git a/amy b/amy index 0fb0a00b5..a83c27bd7 160000 --- a/amy +++ b/amy @@ -1 +1 @@ -Subproject commit 0fb0a00b5a9f9443d7e1f85261cc7e70a0adb76b +Subproject commit a83c27bd7f710bb571e3750dee5324a7391017b5 diff --git a/tulip/server/refdocs/amy/_VENDORED_FROM.txt b/tulip/server/refdocs/amy/_VENDORED_FROM.txt index 1bf38795e..627d7a1c9 100644 --- a/tulip/server/refdocs/amy/_VENDORED_FROM.txt +++ b/tulip/server/refdocs/amy/_VENDORED_FROM.txt @@ -1,3 +1,3 @@ Auto-generated by tulip/server/sync_amy_docs.py — do not edit by hand. -Source: amy@0fb0a00b5a9f9443d7e1f85261cc7e70a0adb76b +Source: amy@a83c27bd7f710bb571e3750dee5324a7391017b5 Files: api.md, arduino.md, billie_jean.md, distortions.md, godot.md, juno_patches.md, midi.md, synth.md, upgrading.md diff --git a/tulip/server/refdocs/amy/api.md b/tulip/server/refdocs/amy/api.md index 0a19e45fb..839ef487e 100644 --- a/tulip/server/refdocs/amy/api.md +++ b/tulip/server/refdocs/amy/api.md @@ -206,7 +206,7 @@ amy_start(amy_config); | `max_sequencer_tags` | Int | 256 | How many sequencer items to handle | | `max_voices` | Int | 64 | How many voices | | `max_synths` | Int | 64 | How many synths | -| `max_memory_patches` | Int | 32 | How many in memory patches to supprot | +| `max_memory_patches` | Int | 32 | How many in memory patches to support | | `i2s_lrc`, `i2s_dout`, `i2s_din`, `i2s_bclk`, `i2s_mclk` | Int | -1 | Pin numbers for the I2S interface | | `midi_out`, `midi_in` | Int | -1 | Pin number for the MIDI UART pins | | `midi_uart` | 0,1,[2] | -1 | UART device index for MCU. Default 1 (`UART1`) on Pi Pico and ESP. Teensy is always `8` | @@ -503,7 +503,7 @@ At bus scope only the constant term of `GD`/`GM` is used; a bus sum has no per-n | Wire code | C `amy_event` | Python / JS | Type-range | Notes | | ------ | -------- | ---------- | ---------- | ------------------------------------- | -| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | Tick, period, tag for sequencing (see "AMY's sequencer" in synth.md). `tag` omitted: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. **If used in a wire string message**, the `H` **must** be the first character of the message. | +| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | Tick, period, tag for sequencing (see "AMY's sequencer" in synth.md). Events on a `tag` ACCUMULATE -- a second send to the same tag adds another scheduled event rather than replacing the first -- so a tag can name a whole pattern, and `ticks="0,0,"` clears the whole of it. `tag` omitted: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. **If used in a wire string message**, the `H` **must** be the first character of the message. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | | `zY` | **TODO** | `sequencer_run` | 0/1 | Sequencer transport: `zY1` starts the sequencer, `zY0` stops it. Lets a host drive playback without MIDI clock sync (see `external_midi_sync`). | | `zC` | **TODO** | `external_midi_sync` | 0/1/2 | MIDI clock sync: 1 = the sequencer follows incoming MIDI realtime clock/start/stop (0xF8/0xFA/0xFC); 2 = AMY is the clock master, sending those messages (0xF8 at 24 PPQ from the internal tempo, 0xFA/0xFC on transport start/stop); 0 (default) = internal clock, neither follows nor sends. | diff --git a/tulip/server/refdocs/amy/synth.md b/tulip/server/refdocs/amy/synth.md index cf0e6e395..0ca8cf153 100644 --- a/tulip/server/refdocs/amy/synth.md +++ b/tulip/server/refdocs/amy/synth.md @@ -226,7 +226,8 @@ You can schedule an event with `amy.send(..., ticks="tick,period,tag")`. All thr amy.send(osc=0, wave=amy.SAW_UP, eg0="0,1,500,0,500,0") # Pluck tone amy.send(osc=0, note=50, vel=1, ticks=amy.sequencer_ticks() + 96) # one-off: fires once, ~1s from now amy.send(osc=0, note=38, vel=1, ticks="0,24,7") # repeating, cancelable via tag 7 -amy.send(osc=0, ticks="0,0,7") # cancel tag 7 +amy.send(osc=0, note=45, vel=1, ticks="12,24,7") # ...adds a second event to tag 7 +amy.send(ticks="0,0,7") # clear everything on tag 7 amy.send(osc=0, note=72, vel=1, ticks="0,24") # repeating, not individually cancelable amy.reset() # Stop everything ``` @@ -237,7 +238,26 @@ You can schedule repeating events (like a step sequencer or drum machine) with ` For pattern sequencers like drum machines, you will also want to use `tick` alongside `period`. If both are given and `period` is nonzero, `tick` is assumed to be an offset on the `period`. For example, for a 16-step drum machine pattern running on eighth notes (PPQ/2), you would use a `period` of `16 * 24 = 384`. The first slot of the drum machine would have a `tick` of 0, the 2nd would have a `tick` offset of 24, and so on. -`tag` is optional. If you give one, you can cancel that event later by sending `ticks="0,0,tag"` with the same `tag`. If you omitted `tag` when setting up the sequence (a 1- or 2-value `ticks=`), the event is still scheduled and still fires, but it isn't addressable by any tag -- there's no way to cancel or replace it individually (only by something like `amy.reset()`, discarding all sequenced events), so only omit `tag` for events you don't need to manage later. +`tag` is optional. If you give one, events sent to that tag **accumulate**: each `ticks="tick,period,tag"` send adds another scheduled event under that tag, with its own `tick` and `period`, rather than replacing what was already there. So a tag names a *pattern*, not a single event -- a whole drum part, with a different `tick` offset per hit, can live on one tag: + +```python +for step, note in ((0, 36), (12, 42), (24, 38), (36, 42)): + amy.send(osc=0, note=note, vel=1, ticks="%d,48,3" % step) # four events, one tag +``` + +You clear a tag by sending it with neither `tick` nor `period` -- `amy.send(ticks="0,0,tag")`, the same cancel spelling as before -- which drops **every** event stored under that tag. There is no way to remove one event from a tag while leaving its siblings, so an edit means clearing the tag and re-sending the events that survive. `ticks=` claims the rest of its message, so the clear has to be its own send: + +```python +amy.send(ticks="0,0,3") # clear the pattern +for step, note in ((0, 36), (12, 42), (24, 40), (36, 42)): # ...and rebuild it + amy.send(osc=0, note=note, vel=1, ticks="%d,48,3" % step) +``` + +Tags are also what ordering is defined on: two events that land on the same tick fire in ascending tag order, and within one tag in the order you added them. + +If you omitted `tag` when setting up the sequence (a 1- or 2-value `ticks=`), the event is still scheduled and still fires, but it isn't addressable by any tag -- there's no way to cancel or replace it individually (only by something like `amy.reset()`, discarding all sequenced events), so only omit `tag` for events you don't need to manage later. + +The number of tags AMY accepts is `max_sequencer_tags` (default 256), and because events accumulate that same number now caps the total count of tagged events, not just the count of distinct tags. Overflowing it prints a warning and drops the new event; everything already scheduled keeps playing. If you are including AMY in a program, you can set the [hook `void (*amy_external_sequencer_hook)(uint32_t)`](docs/api.md) to any function. This will be called at every tick with the current tick number as an argument. diff --git a/tulip/shared/py/drums.py b/tulip/shared/py/drums.py index 21a3484fc..c91ed1b47 100644 --- a/tulip/shared/py/drums.py +++ b/tulip/shared/py/drums.py @@ -230,8 +230,13 @@ def update_synth(self, name=None): def update_switches(self): """For each on switch in the row, we have to update the sequencer with the new midi base_note.""" - for switch in self.objs: - switch.update_sequencer() + # The whole pattern lives under one AMY sequencer tag, and AMY can only + # erase a tag whole -- so each switch's update() re-sends every step. + # Batching collapses a row's worth of those into one rebuild at the end + # rather than one per switch. + with app.drum_seq.batch(): + for switch in self.objs: + switch.update_sequencer() def vel_cb(self, e): self.vel = e.get_target_obj().get_value() / 100.0 diff --git a/tulip/shared/py/sequencer.py b/tulip/shared/py/sequencer.py index f64a168f9..bddc3b27f 100644 --- a/tulip/shared/py/sequencer.py +++ b/tulip/shared/py/sequencer.py @@ -28,28 +28,67 @@ def stop(): amy.send(sequencer_run=0) class AMYSequenceEvent: - SEQUENCE_TAG = 0 + """One scheduled step of an AMYSequence. + + An event does not own an AMY sequencer tag; its Sequence does, and every + event in that Sequence is scheduled under it. AMY accumulates events on a + tag -- each ticks= adds another entry rather than replacing what was there + -- which is what lets a whole pattern live on one tag. The cost is that + there is no way to replace or drop ONE entry: editing or removing a step + means clearing the tag (ticks="0,0,", which now takes the whole tag) + and re-sending the events that remain. That is what Sequence.rebuild() + does, and why an edit costs a message per surviving step. Use + `with seq.batch():` to coalesce a run of edits into one rebuild. + """ def __init__(self, sequence): self.sequence = sequence - self.tag = None - def amy_sequence_string(self): - return "%d,%d,%d" % (self.tick, self.sequence.period, self.tag) + @property + def tag(self): + # Events used to carry their own tag, one AMY tag per step, which ran + # the 256-tag space out after a few minutes of editing. Kept readable + # here for anything that still looks at it. + return self.sequence.tag - def remove(self): - amy.send(ticks=",,%d" % (self.tag)) - self.sequence.events.remove(self) + def amy_sequence_string(self): + return "%d,%d,%d" % (self.tick, self.sequence.period, self.sequence.tag) - def update(self, position, func, args=[], amy_sequenceable=False, **kwargs): + def store(self, position, func, args=[], amy_sequenceable=False, **kwargs): + """Record what this step plays and when, without telling AMY.""" self.tick = self.sequence.event_length_ticks * position self.func = func self.g_args = args self.g_kwargs = kwargs - if self.tag is None: - self.tag = AMYSequenceEvent.SEQUENCE_TAG - AMYSequenceEvent.SEQUENCE_TAG = AMYSequenceEvent.SEQUENCE_TAG + 1 - sequence = self.amy_sequence_string() - self.func(*self.g_args, **self.g_kwargs, ticks=sequence) + + def schedule(self): + """Add this step to AMY under the Sequence's tag.""" + self.func(*self.g_args, **self.g_kwargs, ticks=self.amy_sequence_string()) + + def remove(self): + self.sequence.events.remove(self) + self.sequence.rebuild() # the tag is shared: put back what's left + + def update(self, position, func, args=[], amy_sequenceable=False, **kwargs): + self.store(position, func, args=args, amy_sequenceable=amy_sequenceable, **kwargs) + self.sequence.rebuild() # ditto -- one entry can't be edited in place + + +class _Batch: + """Context manager returned by AMYSequence.batch(); see there.""" + def __init__(self, sequence): + self.sequence = sequence + + def __enter__(self): + self.sequence.deferred = self.sequence.deferred + 1 + return self.sequence + + def __exit__(self, *exc): + seq = self.sequence + seq.deferred = seq.deferred - 1 + if seq.deferred == 0 and seq.dirty: + seq.dirty = False + seq.rebuild() + return False class Sequence: @@ -75,13 +114,59 @@ def clear(self): tulip.seq_remove_callback(self.tag) class AMYSequence(Sequence): + # One AMY sequencer tag per Sequence, taken at construction and held for + # the Sequence's life. AMY's tag space is max_sequencer_tags (256 by + # default) and this counter doesn't recycle, so a session that builds + # hundreds of Sequences will eventually run out -- but a Sequence is an + # app-sized object, where the old one-tag-per-step scheme burned a tag on + # every step edit and ran out during ordinary use. + SEQUENCE_TAG = 0 + def __init__(self, length=1, divider=8): super().__init__(length, divider) - + self.tag = AMYSequence.SEQUENCE_TAG + AMYSequence.SEQUENCE_TAG = AMYSequence.SEQUENCE_TAG + 1 + self.deferred = 0 # depth of open batch() blocks + self.dirty = False # a rebuild was asked for while batching + def add(self, position, func, args=[], amy_sequenceable=False, **kwargs): e = AMYSequenceEvent(self) - e.update(position, func=func, args=args, amy_sequenceable=amy_sequenceable, **kwargs) + e.store(position, func, args=args, amy_sequenceable=amy_sequenceable, **kwargs) self.events = self.events + [e] + if self.deferred: + self.dirty = True + else: + e.schedule() # adding accumulates, so this needs no rebuild return e + def rebuild(self): + """Re-send every event, replacing what AMY holds under our tag. + + Editing or removing a step needs this because AMY can only erase a + whole tag, never one entry in it. Inside a batch() it just marks the + Sequence dirty and the rebuild happens once, on the way out. + """ + if self.deferred: + self.dirty = True + return + amy.send(ticks=",,%d" % (self.tag)) # neither tick nor period: clear the tag + for e in self.events: + e.schedule() + + def batch(self): + """Coalesce the rebuilds from a run of edits into a single one: + + with seq.batch(): + for switch in row: + switch.sequencer_event.update(...) + + Without it each edit rebuilds the whole tag, so N edits to a sequence + of N steps cost N**2 messages. Nests, and rebuilds only if something + inside actually changed. + """ + return _Batch(self) + def clear(self): + # One message, whatever the sequence holds -- the tag is ours alone. + amy.send(ticks=",,%d" % (self.tag)) + self.events = [] diff --git a/tulip/shared/test_amy_sequencer.py b/tulip/shared/test_amy_sequencer.py new file mode 100644 index 000000000..c4fbb38ee --- /dev/null +++ b/tulip/shared/test_amy_sequencer.py @@ -0,0 +1,211 @@ +"""Host-side test of sequencer.py's AMYSequence against a real AMY. + +sequencer.py is frozen into the firmware, so this stubs the two firmware-only +modules it imports (tulip, synth) and drives AMYSequence against the pip- +installed `amy` module, reading back what AMY actually holds via its own +`debug=6` dump. + +What it pins down is the accumulate rule. AMY's `ticks="tick,period,tag"` used +to REPLACE whatever sat on a tag; it now ADDS another event under it, so a tag +can carry a whole pattern. sequencer.py leans on that: an AMYSequence takes ONE +tag and schedules all of its steps under it, where it used to burn a tag per +step and exhaust AMY's 256 after a few minutes of editing. + +The consequence, and the thing most of these checks are about, is that AMY can +only erase a whole tag -- never one entry in it. (The erase is the same +ticks="0,0," cancel that has always been there; it just takes everything +on the tag now.) So editing or removing a step has to rebuild the tag from the +surviving events, and a naive edit loop costs a message per step per edit; +batch() exists to collapse that. + +Needs the `amy` module built from the pinned submodule (`cd amy && make +amy-module`). + +Run from anywhere: python3 tulip/shared/test_amy_sequencer.py +""" +import os, re, sys, tempfile, types + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "py")) +# sequencer.py imports these at module level; only its Tulip-callback half uses +# them, and nothing here touches that half. +for name in ("tulip", "synth"): + m = types.ModuleType(name) + m.seq_bpm = lambda *a: 108 + m.seq_add_callback = lambda *a, **k: 0 + m.seq_remove_callback = lambda *a, **k: None + m.seq_remove_callbacks = lambda *a, **k: None + sys.modules[name] = m + +import amy +import sequencer + +_ENTRY = re.compile(r'sequence slot \d+ tag (\d+) tick (\d+) period (\d+) wire "(.*)"') + + +def entries(): + """What AMY currently has scheduled: [(tag, tick, wire), ...], sorted. + + amy.send(debug=6) prints to the C library's stderr, so grab fd 2 rather + than sys.stderr. + """ + sys.stderr.flush() + saved = os.dup(2) + path = tempfile.mktemp(suffix=".seqdebug") + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC) + os.dup2(fd, 2) + os.close(fd) + try: + amy.send(debug=6) + sys.stderr.flush() + finally: + os.dup2(saved, 2) + os.close(saved) + found = [] + with open(path) as f: + for line in f: + m = _ENTRY.match(line) + if m: + found.append((int(m.group(1)), int(m.group(2)), m.group(4))) + os.unlink(path) + return sorted(found) + + +failures = 0 + + +def check(cond, msg): + global failures + print((" ok " if cond else " FAIL ") + msg) + if not cond: + failures += 1 + + +def note_event(seq, position, note): + return seq.add(position, amy.send, osc=0, wave=amy.SINE, note=note, vel=1) + + +class CountingSend: + """Wraps amy.send to count the wire messages an operation costs.""" + def __init__(self): + self.n = 0 + self.real = amy.send + + def __call__(self, *a, **k): + self.n += 1 + return self.real(*a, **k) + + def __enter__(self): + self.n = 0 + amy.send = self + return self + + def __exit__(self, *exc): + amy.send = self.real + return False + + +def main(): + global failures + amy.restart() + seq = sequencer.AMYSequence(length=1, divider=8) # 8 steps of 24 ticks + + print("adding steps") + e0 = note_event(seq, 0, 36) + e1 = note_event(seq, 2, 38) + e2 = note_event(seq, 4, 42) + amy.render(0.2) + check(len(entries()) == 3, "three added steps give three entries") + + print("re-editing a step (the accumulate case)") + e1.update(6, amy.send, osc=0, wave=amy.SINE, note=45, vel=1) + amy.render(0.2) + es = entries() + check(len(es) == 3, "editing a step leaves three entries, not four: %r" % (es,)) + ticks = dict((tag, tick) for tag, tick, _ in es) + check(ticks.get(e1.tag) == 144, "the edited step moved to tick 144 (got %r)" % (ticks.get(e1.tag),)) + check(not any("n38" in w for _, _, w in es), "the pre-edit version of the step is gone") + check(any("n45" in w for _, _, w in es), "the post-edit version is there") + + for position in (1, 3, 5): + e1.update(position, amy.send, osc=0, wave=amy.SINE, note=45, vel=1) + amy.render(0.2) + check(len(entries()) == 3, "three further edits still leave three entries") + + print("one tag for the whole sequence") + es = entries() + check(len(set(tag for tag, _, _ in es)) == 1, + "all three steps share one tag: %r" % ([t for t, _, _ in es],)) + check(es[0][0] == seq.tag, "and it is the Sequence's tag (%d)" % seq.tag) + check(e0.tag == seq.tag and e2.tag == seq.tag, + "an event reports its Sequence's tag") + other = sequencer.AMYSequence(length=1, divider=8) + check(other.tag != seq.tag, "a second Sequence gets a different tag") + note_event(other, 0, 60) + amy.render(0.2) + check(len(entries()) == 4, "the second Sequence's step is scheduled too") + check(len([1 for tag, _, _ in entries() if tag == seq.tag]) == 3, + "and it did not disturb the first Sequence") + + print("batching") + # Editing every step one at a time rebuilds the tag every time. Inside a + # batch() block it should rebuild exactly once, on the way out. + with CountingSend() as c: + for e in (e0, e1, e2): + e.update(2, amy.send, osc=0, wave=amy.SINE, note=50, vel=1) + unbatched = c.n + with CountingSend() as c: + with seq.batch(): + for e in (e0, e1, e2): + e.update(3, amy.send, osc=0, wave=amy.SINE, note=50, vel=1) + batched = c.n + print(" (%d messages unbatched, %d batched)" % (unbatched, batched)) + check(batched == 4, "a batch of three edits costs one reset + three steps (got %d)" % batched) + check(batched < unbatched, "which is fewer than editing one at a time (%d)" % unbatched) + amy.render(0.2) + check(len(entries()) == 4, "and the sequence still holds three steps (+1 elsewhere)") + other.clear() + amy.render(0.2) + + # drums.py drives all three operations from one batched loop (a row's + # switches can be turned on, edited, or turned off in the same pass). + print("mixed operations in one batch") + with seq.batch(): + e2.update(7, amy.send, osc=0, wave=amy.SINE, note=55, vel=1) + e3 = note_event(seq, 5, 57) + e0.remove() + amy.render(0.2) + es = entries() + check(len(es) == 3, "an edit, an add and a remove batch to the right three: %r" % (es,)) + check(any("n55" in w for _, _, w in es), "the edited step is there") + check(any("n57" in w for _, _, w in es), "the added step is there") + check(not any("n36" in w for _, _, w in es), "the removed step is not") + check(all(tag == seq.tag for tag, _, _ in es), "all still on the Sequence's tag") + e3.remove() + e0 = seq.add(0, amy.send, osc=0, wave=amy.SINE, note=36, vel=1) + e2.update(4, amy.send, osc=0, wave=amy.SINE, note=42, vel=1) + amy.render(0.2) + + print("removing") + e1.remove() + amy.render(0.2) + es = entries() + check(len(es) == 2, "removing a step leaves the other two: %r" % (es,)) + check(e1 not in seq.events, "and the event is off the Sequence's list") + check(all(tag == seq.tag for tag, _, _ in es), "the survivors kept the Sequence's tag") + + with CountingSend() as c: + seq.clear() + amy.render(0.2) + check(entries() == [], "Sequence.clear() removes everything") + check(seq.events == [], "...and empties the event list") + check(c.n == 1, "...in a single message, whatever the sequence held (got %d)" % c.n) + + if failures: + print("\n%d check(s) FAILED" % failures) + return 1 + print("\nall AMY sequencer.py checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 9c48f1b320cb595b615422d5d8ddd8a3143d57a8 Mon Sep 17 00:00:00 2001 From: Dan Ellis Date: Wed, 9 Sep 2026 19:17:30 -0400 Subject: [PATCH 2/3] docs/amyboard/python.md: document the sequencer accumulate rule 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," 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 --- docs/amyboard/python.md | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/amyboard/python.md b/docs/amyboard/python.md index 5cc8c94e2..1e4bb37d1 100644 --- a/docs/amyboard/python.md +++ b/docs/amyboard/python.md @@ -296,8 +296,37 @@ import amy amy.send(osc=0, wave=amy.AMY_MIDI) # osc 0 now sends MIDI instead of audio # Play a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note. -amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # note on at tick 0 of each period -amy.send(osc=0, note=60, vel=0, ticks="24,48,2") # note off at tick 24 of each period +amy.send(osc=0, note=60, vel=1, ticks="0,48,7") # note on at tick 0 of each period +amy.send(osc=0, note=60, vel=0, ticks="24,48,7") # note off at tick 24 of each period +``` + +`ticks="tick,period,tag"` schedules an event on AMY's sequencer, and events sent +to the same `tag` **accumulate** -- the second send above adds to tag 7 rather +than replacing the first -- so one tag can hold a whole pattern. Both events +above are on tag 7, and clearing tag 7 takes the pattern down as a unit: + +```python +amy.send(ticks="0,0,7") # neither tick nor period: drop everything on tag 7 +``` + +There's no way to remove one event from a tag while leaving the others, so +changing a pattern means clearing the tag and re-sending the events that +survive. `ticks=` claims the rest of its message, so the clear has to be its own +`amy.send()`. Events that land on the same tick play in ascending tag order, and +within one tag in the order you added them. See ["AMY's sequencer and +ticks"](https://github.com/shorepine/amy/blob/main/docs/synth.md#amys-sequencer-and-ticks) +for the full picture, including the untagged one-off form (`ticks=`) used +elsewhere in these docs. + +If you'd rather not manage tags by hand, `sequencer.AMYSequence` wraps all of +this -- it takes a tag of its own and handles the clear-and-rebuild for you: + +```python +import sequencer +seq = sequencer.AMYSequence(length=16, divider=8) # 16 steps of an eighth note +seq.add(0, amy.send, osc=0, note=60, vel=1) # step 0 +seq.add(4, amy.send, osc=0, note=67, vel=1) # step 4 +# seq.clear() # ...and take the whole pattern down ``` `amy.AMY_MIDI` always sends on MIDI channel 1, and notes that arrived over MIDI in are not echoed back out. See the [AMY MIDI docs](https://github.com/shorepine/amy/blob/main/docs/midi.md#sending-midi-out) for the full details. From a4fc5e7341854eb4735c1591b566471dc6dc9b55 Mon Sep 17 00:00:00 2001 From: Dan Ellis Date: Wed, 9 Sep 2026 20:11:38 -0400 Subject: [PATCH 3/3] Pin amy to 5c97024f (1.2.166), the merged sequencer-accumulate-tags 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 --- amy | 2 +- tulip/server/refdocs/amy/_VENDORED_FROM.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/amy b/amy index a83c27bd7..5c97024f0 160000 --- a/amy +++ b/amy @@ -1 +1 @@ -Subproject commit a83c27bd7f710bb571e3750dee5324a7391017b5 +Subproject commit 5c97024f01bd112fc24170299f0d5a0da1f489ed diff --git a/tulip/server/refdocs/amy/_VENDORED_FROM.txt b/tulip/server/refdocs/amy/_VENDORED_FROM.txt index 627d7a1c9..3da3a2ed7 100644 --- a/tulip/server/refdocs/amy/_VENDORED_FROM.txt +++ b/tulip/server/refdocs/amy/_VENDORED_FROM.txt @@ -1,3 +1,3 @@ Auto-generated by tulip/server/sync_amy_docs.py — do not edit by hand. -Source: amy@a83c27bd7f710bb571e3750dee5324a7391017b5 +Source: amy@5c97024f01bd112fc24170299f0d5a0da1f489ed Files: api.md, arduino.md, billie_jean.md, distortions.md, godot.md, juno_patches.md, midi.md, synth.md, upgrading.md