diff --git a/amy b/amy index 0fb0a00b5..5c97024f0 160000 --- a/amy +++ b/amy @@ -1 +1 @@ -Subproject commit 0fb0a00b5a9f9443d7e1f85261cc7e70a0adb76b +Subproject commit 5c97024f01bd112fc24170299f0d5a0da1f489ed 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. diff --git a/tulip/server/refdocs/amy/_VENDORED_FROM.txt b/tulip/server/refdocs/amy/_VENDORED_FROM.txt index 1bf38795e..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@0fb0a00b5a9f9443d7e1f85261cc7e70a0adb76b +Source: amy@5c97024f01bd112fc24170299f0d5a0da1f489ed 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())