diff --git a/Makefile b/Makefile index f849e7dc..21072ee2 100644 --- a/Makefile +++ b/Makefile @@ -124,6 +124,7 @@ amy-message: $(OBJECTS) src/amy-message.o # Plain C tests for things the audio-rendering suite can't reach -- e.g. clock # rollovers 50 days out, which you can only hit by fast-forwarding the counters. CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds \ + tests/test_nested_sequencer \ tests/test_bus_config tests/test_patch_slots \ tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \ tests/test_timebase_reset tests/test_osc_free_on_release \ diff --git a/amy/__init__.py b/amy/__init__.py index f77187b6..79fed719 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -373,6 +373,78 @@ def send(**kwargs): send_raw(m) +def pattern_begin(pattern, length_ticks): + """Begin a new staging definition for a finite-length sequencer pattern.""" + send_raw("zQB%d,%dZ" % (pattern, length_ticks)) + + +def pattern_event_wire(pattern, tick, wire, period=None, tag=None): + """Add one ordinary wire event to a staging pattern. + + ``tick``, ``period`` and ``tag`` have the same meanings as the values in + ``send(..., ticks=...)``. The payload must be exactly one ordinary AMY + message; scheduling or triggering another pattern is deliberately refused. + """ + if not isinstance(wire, str) or not wire.endswith('Z'): + raise ValueError("pattern wire payload must be one Z-terminated string") + values = [str(int(pattern)), str(int(tick))] + if period is not None or tag is not None: + values.append('' if period is None else str(int(period))) + if tag is not None: + values.append(str(int(tag))) + send_raw('zQE' + ','.join(values) + wire) + + +def pattern_event(pattern, tick, period=None, tag=None, **kwargs): + """Add an event built with the normal ``amy.message`` keyword API.""" + if 'ticks' in kwargs: + raise ValueError("use pattern_event's tick, period and tag arguments") + pattern_event_wire(pattern, tick, message(**kwargs), period, tag) + + +def pattern_commit(pattern): + """Atomically publish a staging pattern for future playback.""" + send_raw("zQC%dZ" % pattern) + + +def pattern_trigger(pattern, mode=AMY_PATTERN_ONE_SHOT, quantize_ticks=0, + instance_tag=None): + """Start a committed pattern in one-shot or loop mode on a tick boundary.""" + values = [str(int(pattern)), str(int(mode)), str(int(quantize_ticks))] + if instance_tag is not None: + values.append(str(int(instance_tag))) + send_raw('zQT' + ','.join(values) + 'Z') + + +def pattern_schedule(pattern, sequence_tag, mode=AMY_PATTERN_ONE_SHOT, + offset_ticks=0, period_ticks=0, quantize_ticks=0, + instance_tag=None): + """Schedule a pattern trigger relative to a quantized root boundary.""" + values = [ + str(int(pattern)), str(int(mode)), str(int(offset_ticks)), + str(int(period_ticks)), str(int(quantize_ticks)), + str(int(sequence_tag)), + ] + if instance_tag is not None: + values.append(str(int(instance_tag))) + send_raw('zQA' + ','.join(values) + 'Z') + + +def pattern_stop(instance_tag, quantize_ticks=0): + """Stop all instances with this tag on the requested tick boundary.""" + send_raw("zQS%d,%dZ" % (instance_tag, quantize_ticks)) + + +def pattern_mute(instance_tag, duration_ticks): + """Suppress onsets from tagged running patterns while phase advances.""" + send_raw("zQM%d,%dZ" % (instance_tag, duration_ticks)) + + +def pattern_clear(pattern): + """Remove a pattern definition; already-running one-shots finish safely.""" + send_raw("zQR%dZ" % pattern) + + # Plots a time domain and spectra of audio def show(data): import matplotlib.pyplot as plt diff --git a/amy/constants.py b/amy/constants.py index ef33569a..9620e178 100644 --- a/amy/constants.py +++ b/amy/constants.py @@ -124,6 +124,9 @@ TICKS_TICK=0 TICKS_PERIOD=1 TICKS_TAG=2 +AMY_PATTERN_ONE_SHOT=0 +AMY_PATTERN_LOOP=1 +AMY_PATTERN_UNTAGGED=4294967295 RESET_SEQUENCER=4096 RESET_ALL_OSCS=8192 RESET_TIMEBASE=16384 diff --git a/amy/test.py b/amy/test.py index 2539b32e..9f7c9d1b 100644 --- a/amy/test.py +++ b/amy/test.py @@ -2163,6 +2163,52 @@ def test(self): return True, self.__class__.__name__ + ' : ok (%.1f dB)' % level +class TestPatternHelpers(AmyTest): + """The Python pattern API must be a transport-independent wire wrapper.""" + + def test(self): + captured = [] + saved_override = amy.override_send + amy.override_send = captured.append + problems = [] + try: + amy.pattern_begin(3, 96) + amy.pattern_event(3, 0, period=96, tag=7, + synth=10, note=36, vel=1) + amy.pattern_event_wire(3, 24, 'v2l0Z', tag=8) + amy.pattern_commit(3) + amy.pattern_trigger(3, amy.AMY_PATTERN_LOOP, 96, instance_tag=12) + amy.pattern_schedule(3, 9, amy.AMY_PATTERN_ONE_SHOT, + offset_ticks=24, period_ticks=384, + quantize_ticks=192) + amy.pattern_mute(12, 48) + amy.pattern_stop(12, 96) + amy.pattern_clear(3) + expected = [ + 'zQB3,96Z', + 'zQE3,0,96,7n36l1i10Z', + 'zQE3,24,,8v2l0Z', + 'zQC3Z', + 'zQT3,1,96,12Z', + 'zQA3,0,24,384,192,9Z', + 'zQM12,48Z', + 'zQS12,96Z', + 'zQR3Z', + ] + if captured != expected: + problems.append('wire mismatch: %r != %r' % (captured, expected)) + try: + amy.pattern_event(3, 0, ticks='0,4', osc=0, vel=1) + problems.append('nested ticks= was accepted') + except ValueError: + pass + finally: + amy.override_send = saved_override + if problems: + return False, self.__class__.__name__ + ': ' + '; '.join(problems) + return True, self.__class__.__name__ + ' : ok' + + class TestFuzzWireParser(AmyTest): """Arbitrary junk fed to amy.send_wire() must never crash the engine. @@ -2341,4 +2387,3 @@ def main(argv): if __name__ == "__main__": main(sys.argv) - diff --git a/docs/api.md b/docs/api.md index 0a19e45f..28be40f1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -118,6 +118,42 @@ Notes: +### Nested-pattern C API + +Native hosts can author and play the same finite/looping patterns without +constructing wire strings: + +```c +uint8_t amy_pattern_begin(uint32_t pattern, uint32_t length_ticks); +uint8_t amy_pattern_add_event(uint32_t pattern, const amy_event *event); +uint8_t amy_pattern_add_wire(uint32_t pattern, uint32_t tick, + uint32_t period, uint32_t tag, bool has_tag, + const char *wire); +uint8_t amy_pattern_commit(uint32_t pattern); +uint8_t amy_pattern_trigger(uint32_t pattern, uint8_t mode, + uint32_t quantize_ticks, uint32_t instance_tag); +uint8_t amy_pattern_schedule(uint32_t pattern, uint8_t mode, + uint32_t offset_ticks, uint32_t period_ticks, + uint32_t quantize_ticks, uint32_t sequence_tag, + uint32_t instance_tag); +uint8_t amy_pattern_stop(uint32_t instance_tag, uint32_t quantize_ticks); +uint8_t amy_pattern_mute(uint32_t instance_tag, uint32_t duration_ticks); +uint8_t amy_pattern_clear(uint32_t pattern); +``` + +For `amy_pattern_add_event`, set `event.ticks[TICKS_TICK]`, +`[TICKS_PERIOD]`, and optionally `[TICKS_TAG]` exactly as for the existing +`amy_add_event()` sequencer path. Playback mode is +`AMY_PATTERN_ONE_SHOT` or `AMY_PATTERN_LOOP`; pass +`AMY_PATTERN_UNTAGGED` when an instance should coexist independently. All +functions return nonzero on success. `amy_pattern_schedule()` stores an +ordinary root-sequencer trigger under `sequence_tag`; clear it with the normal +root `tick=0,period=0,tag` operation. `amy_pattern_mute()` suppresses new +onsets from matching tagged instances while their local clocks continue. See +[finite and looping +patterns](synth.md#finite-and-looping-patterns) for lifecycle, quantization, +reset, and nesting semantics. + ## JavaScript API AMY provides a high-level JavaScript API (`amy_send`) that mirrors the Python `amy.send()` interface. It is auto-generated from the same source of truth (`amy/__init__.py` and `amy/constants.py`) so parameter names are always identical to Python. The connector and API are bundled into `amy.js`, so you only need two includes: @@ -204,6 +240,9 @@ amy_start(amy_config); | `max_oscs` | Int | 180 | How many oscillators to support | | `max_buses` | Int | 4 | How many FX buses to support. No compile-time ceiling — every bus-indexed table is allocated from this at `amy_start`. Each bus costs a few KB of mix buffers even when idle, plus whatever its effects allocate once switched on | | `max_sequencer_tags` | Int | 256 | How many sequencer items to handle | +| `max_patterns` | Int | 32 | Number of stored two-level pattern slots. Set to 0 to disable nested patterns | +| `max_pattern_tags` | Int | 64 | Addressable event tags in each stored pattern. Anonymous events use a separate internal pool | +| `max_pattern_instances` | Int | 32 | Maximum running and quantized-pending one-shot/loop pattern instances | | `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 | @@ -507,6 +546,14 @@ At bus scope only the constant term of `GD`/`GM` is used; a bus sum has no per-n | `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. | +| `zQB` | — | `pattern_begin()` | pattern,length | Begin or replace a staging pattern definition | +| `zQE` | — | `pattern_event()` / `pattern_event_wire()` | pattern,tick[,period[,tag]] + event | Add one ordinary event to a staging pattern, with the root sequencer's tick/period/tag semantics. Example: `zQE3,0,96,7i10n36l1Z` | +| `zQC` | — | `pattern_commit()` | pattern | Atomically publish a staging pattern | +| `zQT` | — | `pattern_trigger()` | pattern[,mode[,quantize_ticks[,instance_tag]]] | Trigger `mode=0` one-shot or `mode=1` loop at the next tick boundary; an omitted instance tag creates an independent instance | +| `zQA` | — | `pattern_schedule()` | pattern,mode,offset,period,quantize_ticks,sequence_tag[,instance_tag] | Store a root event which triggers the pattern at an offset from the next quantized boundary; nonzero period repeats it and the normal `H0,0,sequence_tag` operation clears it | +| `zQS` | — | `pattern_stop()` | instance_tag[,quantize_ticks] | Stop every matching tagged instance at the requested boundary | +| `zQM` | — | `pattern_mute()` | instance_tag,duration_ticks | Suppress new onsets from matching running instances for a finite duration without stopping or changing their phase; unlike other `zQ` controls, this leaf operation may be stored in a pattern | +| `zQR` | — | `pattern_clear()` | pattern | Remove staging/current definitions; running instances retain their committed version | | `N` | `latency_ms`| `latency_ms` | uint | Sets latency in ms. default 0 (see LATENCY) | | `s` | `pitch_bend` | `pitch_bend` | float | Sets the global pitch bend, by default modifying all note frequencies by (fractional) octaves up or down | | `V` | `volume`| `volume` | float | Volume knob for the addressed bus (`bus`/`y`, default 0) in the final mixdown, default 1.0 | diff --git a/docs/nested-pattern-abstractions.md b/docs/nested-pattern-abstractions.md new file mode 100644 index 00000000..a7232fa0 --- /dev/null +++ b/docs/nested-pattern-abstractions.md @@ -0,0 +1,141 @@ +# Stored-pattern abstractions and implementation + +AMY's root sequencer stores individual events on one global musical timeline. +Stored patterns add one reusable level below that timeline: a root event can +start a finite or looping collection of ordinary AMY events. They do not add a +drum machine, arpeggiator, arrangement model, or recursive sequencer. + +For a worked example, see the [stored-pattern how-to](nested-pattern-howto.md). +For the applications that motivated the facility, see [musical use +cases](nested-pattern-musical-use-cases.md). + +## The model + +There are five distinct objects in the model: + +| Object | Purpose | Lifetime | +| --- | --- | --- | +| Root sequencer event | Decides *when* a phrase starts | Existing `H` tick/period/tag semantics | +| Pattern definition | Stores the phrase's events on a local timeline | Until replaced or cleared | +| Staging definition | Receives edits before they become visible | From `begin` until `commit` or `clear` | +| Playback instance | Plays one committed definition as a one-shot or loop | Until it finishes or is stopped | +| Instance tag | Addresses running instances for replacement, stop, or mute | Supplied when an instance is triggered | + +The event tag inside a definition, the root sequence tag, and the instance tag +are deliberately separate: + +- An **event tag** edits or clears one event within the staging definition. +- A **root sequence tag** edits or clears a future/repeating trigger in the + existing root sequencer. +- An **instance tag** addresses playback that has already been armed or is + running. + +Keeping these identities separate lets a host replace future arpeggio triggers +without truncating the arpeggio that is currently sounding. + +## Definition and playback lifecycle + +Definitions are constructed using `begin`, one or more `event` operations, and +`commit`. `begin` creates private staging storage. `commit` publishes that +complete version atomically; playback can never observe a half-written phrase. + +A trigger captures the currently committed definition. Replacing or clearing +the slot later affects new triggers only. An instance already using the old +definition retains it until that instance ends. This immutability is what makes +live changes predictable: a host changes the next phrase, not the tail of the +current phrase. + +The same definition supports two playback modes: + +- `ONE_SHOT` plays local ticks `0` through `length - 1` once. +- `LOOP` wraps the local timeline at `length` until stopped. + +Playback may start or stop on a requested multiple of the global sequencer +tick. A trigger fired from a root event can start on that exact root tick, so +the child's local tick-zero events remain sample-clock driven without a host +timer. + +## Why the nesting limit is one level + +The root sequencer may trigger a stored pattern. A stored pattern may contain +ordinary AMY events and the finite mute leaf operation, but it may not contain +`H` or an operation that starts or schedules another stored pattern. + +That gives two musical levels—arrangement and phrase—which cover the motivating +uses. Refusing a third level prevents cycles and makes the number of active +players, their lifetimes, and the work performed on every tick bounded by the +configuration. This is intentionally composition, not recursion. + +## Scheduling and mute + +`pattern_schedule` / `zQA` installs a normal tagged event in the root +sequencer. Its offset is relative to the next requested quantization boundary. +Giving it a period makes that root trigger repeat; clearing its root sequence +tag with the existing `H0,0,Z` operation prevents future instances from +starting without cutting off an instance that has already begun. + +`pattern_mute` / `zQM` temporarily suppresses events emitted by every running +instance with a matching instance tag. The local clocks continue to advance, +so playback resumes at its original phase after the duration. It is a generic +gate over a sequenced layer, not a synth mute and not a musical priority rule. +Applications choose which events belong to independently controllable layers. + +## Wire and API surface + +All added wire operations are grouped under the extended `zQ` family: + +| Wire operation | API operation | Meaning | +| --- | --- | --- | +| `zQB` | `pattern_begin` | Begin a staging definition | +| `zQE` | `pattern_event` / `pattern_event_wire` | Add an ordinary event with local tick/period/tag metadata | +| `zQC` | `pattern_commit` | Publish the staged version atomically | +| `zQT` | `pattern_trigger` | Start a one-shot or loop | +| `zQA` | `pattern_schedule` | Put a pattern trigger in the root sequencer | +| `zQS` | `pattern_stop` | Stop matching tagged instances, optionally quantized | +| `zQM` | `pattern_mute` | Suppress matching instances for a finite number of ticks | +| `zQR` | `pattern_clear` | Remove staged/current definitions | + +`zQE,[,[,]]Z` intentionally mirrors the +root sequencer's `tick,period,tag` event model. `H` remains unchanged and keeps +its existing special parsing rules. + +The public C functions are declared in [`src/sequencer.h`](../src/sequencer.h), +and the Python wrappers are in [`amy/__init__.py`](../amy/__init__.py). The full +argument reference is in [`api.md`](api.md#nested-pattern-c-api). + +## Implementation outline + +The implementation in [`src/sequencer.c`](../src/sequencer.c) reuses the root +sequencer's internal event record for each local definition. The main pieces +are: + +- A fixed array of pattern slots points to staged and committed definitions. +- Each authored definition owns a fixed-capacity tagged event table plus the + same bounded anonymous-event pool used by the event model. +- A separately bounded player pool holds only active or pending instances. +- Committed definitions are reference-counted, so retired versions remain + valid while an instance still uses them. +- Occupied-event lists keep per-tick work proportional to active content rather + than configured tag capacity. +- Root events are processed before child events on each tick, allowing a root + trigger and the child's local tick zero to occur on the same tick. + +The portable defaults are 32 definition slots, 64 local event tags per +definition, and 32 active or pending instances. These are independent limits +in `amy_config_t`; `max_patterns=0` disables the feature. Slots and players are +allocated at initialization, while a definition's event storage is allocated +only when that definition is begun. + +## Compatibility contract + +The feature is opt-in. Existing `H` messages and C `amy_add_event()` tick +scheduling retain their parsing and execution paths. `RESET_SEQUENCER` clears +root playback and pattern instances but preserves definitions; `RESET_TIMEBASE` +rebases running and pending instances without changing their local phase. + +[`tests/test_nested_sequencer.c`](../tests/test_nested_sequencer.c) runs legacy +root-sequencer behavior and the new behavior in the same native test process. +It covers legacy modulo timing, repeat, tag replacement/clear, anonymous-event +coexistence and absolute C-API delivery, as well as one-shot/loop timing, +quantization, immutable replacement, reset, rollover, nesting rejection, mute, +and configured bounds. diff --git a/docs/nested-pattern-howto.md b/docs/nested-pattern-howto.md new file mode 100644 index 00000000..bfd17517 --- /dev/null +++ b/docs/nested-pattern-howto.md @@ -0,0 +1,253 @@ +# Stored-pattern how-to: two arpeggios and a live percussion mute + +This example uses exact AMY wire messages. Send each line as one complete +message, including its final `Z`. AMY's sequencer runs at 48 ticks per quarter +note, so this example uses 24 ticks per eighth note and a 96-tick, four-note +phrase. + +The arpeggio uses oscillator 0. Configure it first; a sine wave keeps the +example independent of a stored patch bank: + +```text +v0w0Z +``` + +## 1. Preload an ascending arpeggio + +Pattern 10 plays C4, E4, G4, and C5. Each note starts 24 ticks after the +previous one and has an 18-tick gate. + +```text +zQB10,96Z +zQE10,0,96,0v0n60l1Z +zQE10,18,96,1v0l0Z +zQE10,24,96,2v0n64l1Z +zQE10,42,96,3v0l0Z +zQE10,48,96,4v0n67l1Z +zQE10,66,96,5v0l0Z +zQE10,72,96,6v0n72l1Z +zQE10,90,96,7v0l0Z +zQC10Z +``` + +`zQB` begins a private staging definition, each `zQE` adds one event on its +local timeline, and `zQC` publishes the complete definition atomically. + +
+Python API equivalent + +```python +import amy + +amy.send(osc=0, wave=amy.SINE) +amy.pattern_begin(10, length_ticks=96) +amy.pattern_event(10, 0, period=96, tag=0, osc=0, note=60, vel=1) +amy.pattern_event(10, 18, period=96, tag=1, osc=0, vel=0) +amy.pattern_event(10, 24, period=96, tag=2, osc=0, note=64, vel=1) +amy.pattern_event(10, 42, period=96, tag=3, osc=0, vel=0) +amy.pattern_event(10, 48, period=96, tag=4, osc=0, note=67, vel=1) +amy.pattern_event(10, 66, period=96, tag=5, osc=0, vel=0) +amy.pattern_event(10, 72, period=96, tag=6, osc=0, note=72, vel=1) +amy.pattern_event(10, 90, period=96, tag=7, osc=0, vel=0) +amy.pattern_commit(10) +``` + +
+ +## 2. Preload a descending arpeggio + +Pattern 11 uses the same timing but reverses the pitches: + +```text +zQB11,96Z +zQE11,0,96,0v0n72l1Z +zQE11,18,96,1v0l0Z +zQE11,24,96,2v0n67l1Z +zQE11,42,96,3v0l0Z +zQE11,48,96,4v0n64l1Z +zQE11,66,96,5v0l0Z +zQE11,72,96,6v0n60l1Z +zQE11,90,96,7v0l0Z +zQC11Z +``` + +
+Python API equivalent + +```python +amy.pattern_begin(11, length_ticks=96) +amy.pattern_event(11, 0, period=96, tag=0, osc=0, note=72, vel=1) +amy.pattern_event(11, 18, period=96, tag=1, osc=0, vel=0) +amy.pattern_event(11, 24, period=96, tag=2, osc=0, note=67, vel=1) +amy.pattern_event(11, 42, period=96, tag=3, osc=0, vel=0) +amy.pattern_event(11, 48, period=96, tag=4, osc=0, note=64, vel=1) +amy.pattern_event(11, 66, period=96, tag=5, osc=0, vel=0) +amy.pattern_event(11, 72, period=96, tag=6, osc=0, note=60, vel=1) +amy.pattern_event(11, 90, period=96, tag=7, osc=0, vel=0) +amy.pattern_commit(11) +``` + +
+ +## 3. Turn the ascending arpeggio on + +Schedule pattern 10 as a one-shot every 96 ticks. The start is quantized to the +next 96-tick boundary, and root sequence tag 200 makes this future schedule +replaceable: + +```text +zQA10,0,0,96,96,200Z +``` + +The arguments are: + +```text +zQA pattern,mode,offset,period,quantize,sequence_tag Z + 10 0 0 96 96 200 +``` + +Mode `0` is `ONE_SHOT`. The repeating object is the small root trigger; each +96-tick child instance is finite. + +
+Python API equivalent + +```python +amy.pattern_schedule( + 10, + sequence_tag=200, + mode=amy.AMY_PATTERN_ONE_SHOT, + offset_ticks=0, + period_ticks=96, + quantize_ticks=96, +) +``` + +
+ +## 4. Switch to the descending arpeggio + +Use the same root sequence tag. This replaces the future repeating trigger on +the next musical boundary; an ascending one-shot that already started retains +its note-offs and finishes its 96-tick lifetime. + +```text +zQA11,0,0,96,96,200Z +``` + +
+Python API equivalent + +```python +amy.pattern_schedule( + 11, + sequence_tag=200, + mode=amy.AMY_PATTERN_ONE_SHOT, + offset_ticks=0, + period_ticks=96, + quantize_ticks=96, +) +``` + +
+ +## 5. Turn the arpeggio off + +Clear root sequence tag 200 with the existing root-sequencer operation: + +```text +H0,0,200Z +``` + +This removes future triggers. It does not stop a child that has already begun, +so the current note gate and phrase end remain intact. Sending the `zQA` command +from step 3 or 4 again turns the chosen arpeggio back on. + +
+Python API equivalent + +```python +amy.send(ticks="0,0,200") +``` + +
+ +For a single performance instead of a repeating arpeggio, trigger a committed +definition directly. This plays pattern 10 once at the next 96-tick boundary: + +```text +zQT10,0,96Z +``` + +
+Python API equivalent + +```python +amy.pattern_trigger(10, amy.AMY_PATTERN_ONE_SHOT, quantize_ticks=96) +``` + +
+ +## Live-mute one percussion instrument + +Mute addresses a pattern **instance tag**, not a synth or note. Put an +independently controllable percussion instrument in its own small pattern. In +this example synth 10 is assumed to be configured as the desired percussion +kit, and MIDI note 42 is its closed hi-hat. Pattern 20 emits that hit every 24 +ticks: + +```text +zQB20,24Z +zQE20,0,24,0n42l1i10Z +zQC20Z +zQT20,1,24,300Z +``` + +Mode `1` starts a `LOOP`; instance tag 300 is the live control address. Other +percussion instruments should use other definitions and instance tags if they +must remain audible independently. + +Suppose a MIDI footpedal handler has already converted pedal-down and pedal-up +into outgoing AMY commands. It need only send the following. On pedal-down, +start a deliberately long finite mute (over 100 days even at 300 BPM): + +```text +zQM300,2147483647Z +``` + +On pedal-up, a zero duration clears that mute immediately: + +```text +zQM300,0Z +``` + +The hi-hat pattern keeps advancing while muted and resumes on its original +24-tick phase. These are only AMY commands; reading and mapping the MIDI pedal +belongs to the controller application. + +
+Python API equivalent + +```python +# Preload and start the independently controllable hi-hat layer. +amy.pattern_begin(20, length_ticks=24) +amy.pattern_event(20, 0, period=24, tag=0, + synth=10, note=42, vel=1) +amy.pattern_commit(20) +amy.pattern_trigger( + 20, + amy.AMY_PATTERN_LOOP, + quantize_ticks=24, + instance_tag=300, +) + +# Pedal down, then pedal up. +amy.pattern_mute(300, 2147483647) +amy.pattern_mute(300, 0) +``` + +
+ +If the silence has a known musical duration, send that duration directly—for +example, `zQM300,192Z` suppresses the tagged layer for exactly four quarter +notes at 48 PPQ and then lets it resume automatically. diff --git a/docs/nested-pattern-musical-use-cases.md b/docs/nested-pattern-musical-use-cases.md new file mode 100644 index 00000000..f1653b59 --- /dev/null +++ b/docs/nested-pattern-musical-use-cases.md @@ -0,0 +1,86 @@ +# Musical use cases for stored patterns + +Stored patterns are useful when a musical phrase should remain a coherent unit +while a controller changes *which* phrase will play next. Two representative +applications are an interactive rhythm engine with selectable drum fills and +an arpeggiator whose timing or direction can change during playback. Both are +expressed as ordinary AMY events on a local timeline; AMY contains no policy +specific to either application. + +## Dynamic drum fills + +Consider a rhythm engine that combines a repeating rhythm with a selectable +fill and a fill density. Each rhythm offers multiple fills, and the player may +change the selection or density while playback continues. During a fill, +selected percussion layers can be silent while other layers continue. + +A flat sequencer can render any one final arrangement, but live editing makes +the host responsible for considerably more state. It must expand every chosen +fill into root events, determine which future events may safely be replaced, +coordinate the mute boundaries, avoid truncating the fill already in progress, +and resend a large schedule whenever the selection changes. Combining rhythms, +fill choices, densities, and independently gated layers multiplies those edge +cases even though each individual phrase is short. + +Stored patterns move the phrase boundary into AMY: + +1. The host preloads each fill once as a short `ONE_SHOT` definition. +2. A small repeating root event triggers the selected definition at a musical + boundary. +3. The fill may contain a finite `zQM` leaf event for each background layer + that should be suppressed during that fill. +4. Changing the root trigger changes only future fills. A fill that already + started retains its committed definition and finishes normally. + +The host still owns every musical choice—selection, density, instrument roles, +and which layers continue. AMY only provides coherent phrase playback. This +reduces live control from rewriting many individual sequencer events to +replacing or clearing one tagged root trigger. + +A large rhythm engine may preload hundreds of fills and configure more +definition slots than AMY's conservative portable default. That does not imply +the same number of simultaneous players: stored definitions and active +instances have separate limits. + +## Arpeggios with clean live changes + +An arpeggio can also be written directly into the flat root sequencer. The +difficult part is changing rate, direction, or pitch while notes are already in +flight. Deleting the old root events can remove a scheduled note-off and leave +a note hanging. Sending an all-off avoids the hang but cuts a valid note short. +A host-side timer can defer the edit, but then the host must reproduce AMY's +musical clock and account for every overlapping note gate. + +The arpeggiator can instead represent each sounding note as a short one-shot +containing its note-on and matching note-off. Root events decide which +one-shots will start in the future. When the player changes the arpeggio: + +- future root triggers are replaced or cleared by tag; +- an already-started one-shot keeps its immutable definition; +- its matching note-off therefore remains present and occurs at the original + gate time; +- the replacement starts on a quantized boundary. + +The result is neither an abrupt stop nor a late, hanging note. Overlapping notes +remain possible because each trigger creates a separate playback instance. +Again, AMY does not know what an arpeggio is; the same lifetime rule applies to +any finite musical gesture. + +## The common abstraction + +The two applications share one structure: + +``` +root timeline: choose when phrase A or phrase B starts +stored phrase: play a coherent set of ordinary events once (or loop it) +``` + +Without this boundary, the controlling application must track and mutate the +expanded leaf events. With it, the application edits references to immutable +phrases and lets AMY's sequencer own their timing and completion. The gain is +therefore not that a flat sequence is unable to represent the music; it is that +the nested form keeps live musical changes atomic, compact, and independent of +host timing. + +See the [step-by-step arpeggio and live-mute example](nested-pattern-howto.md) +for the corresponding wire commands. diff --git a/docs/synth.md b/docs/synth.md index cf0e6e39..3d778681 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -241,6 +241,169 @@ For pattern sequencers like drum machines, you will also want to use `tick` alon 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. +### Finite and looping patterns + +The feature is described from three complementary viewpoints: + +- [Stored-pattern abstractions and implementation](nested-pattern-abstractions.md) +- [Musical use cases](nested-pattern-musical-use-cases.md) +- [Step-by-step arpeggio and live-mute how-to](nested-pattern-howto.md) + +#### Motivation and scope + +I added stored patterns because the root sequencer is intentionally a flat +table of events, while some musical applications need to recall a complete +phrase from a single quantized event. Without that extra level, a host must +either resend the whole phrase for every occurrence or reproduce AMY's musical +clock and stream the phrase at exactly the right time. Both approaches turn a +small musical action into a large block of sequencer traffic. + +The motivating downstream example is [LB Omnichord's rhythm +engine](https://github.com/linuxificator/LB_Omnichord/blob/feature/drum_fills/amysynth_version/qt_frontend/docs/RHYTHM_PATTERNS.md). +It keeps repeating rhythm parts running while it schedules short fills from a +preloaded catalogue; its concrete catalogue and integration checks are +available in +[`tests/test_drum_patterns.py`](https://github.com/linuxificator/LB_Omnichord/blob/feature/drum_fills/amysynth_version/qt_frontend/tests/test_drum_patterns.py). +I used that application to validate the design, but I kept all knowledge of +drums, fills, instrument roles, fill selection and continuation policy outside +AMY. AMY only stores and schedules ordinary wire events, so the same mechanism +can represent any reusable musical phrase. + +I also use stored one-shots for the Omnichord's arpeggios to get better control +over their start and stop behavior. Each sounding arpeggio note is a short +`ONE_SHOT` containing its note-on and matching note-off. A live rate, direction +or pitch change can replace future root triggers or definitions without +deleting the release owned by a child which has already started; that child +keeps its immutable definition and ends at its original gate. This avoids both +an abrupt all-off and a host timer, while remaining ordinary reusable AMY event +scheduling rather than an arpeggiator-specific engine feature. The downstream +wire and overlap tests are documented in [the Omnichord sequencer +contract](https://github.com/linuxificator/LB_Omnichord/blob/feature/drum_fills/amysynth_version/qt_frontend/docs/SEQUENCER_TAGS.md). + +I chose a playback mode on an otherwise identical stored definition instead +of adding a separate fill abstraction. `LOOP` makes a phrase repeat until it +is stopped; `ONE_SHOT` gives the same phrase a finite lifetime. This reuses the +root sequencer's existing `tick,period,tag` event model and lets a definition +serve either purpose without duplicating events or synthesis logic. Quantized +trigger, schedule and stop operations keep both modes on AMY's clock, while a +finite tag mute is an explicit, generic way for an application to suppress +selected running parts without resetting their phase. AMY assigns no musical +lane or priority policy; the application chooses which tagged parts to mute. + +I deliberately allow one child level only: the root sequencer may trigger a +stored pattern, but a stored pattern may not trigger another pattern. In the +musical structures I expect in practice, arrangement events triggering finite +or repeating phrases cover the useful hierarchy. A deeper hierarchy adds no +clear musical capability here, but it would admit recursive cycles and make +execution time, lifetime and memory harder to bound. The parser therefore +rejects `H` and pattern-creating `zQ` controls inside pattern payloads; +the finite `zQM` mute is the only safe leaf-control exception. + +I kept the portable defaults conservative: 32 stored definitions, 64 local +event tags per definition and 32 active or pending instances. Applications can +change these limits in `amy_config_t`, or set `max_patterns` to zero to disable +the feature. The LB Omnichord integration uses 1,024 definition slots while +keeping the 64/32 event and instance limits, because its current public test +catalogue preloads 270 fills and its reserved ID space is intended to hold a +future catalogue of more than 700. Configuring 1,024 slots does not create +1,024 players: definition event tables are allocated only when authored, and +only triggered patterns consume the separately bounded instance pool. + +Preserving existing sequencer behavior is a hard requirement. The new `zQ` +wire operations and matching C/Python calls are opt-in; existing `H` wire +commands and `amy_add_event()` tick scheduling retain their previous paths and +semantics. The regression suite proves both legacy paths in +[`tests/test_nested_sequencer.c`](../tests/test_nested_sequencer.c), alongside +tests for one-shot/loop timing, quantization, immutable commits, reset and +timebase behavior, rollover, nesting rejection and configured bounds. The +legacy checks cover `H` modulo timing, repetition, tag replacement and clear, +anonymous coexistence, and absolute C-API tick delivery. The unchanged +Shorepine sequencer, bounds, rollover and timebase-reset tests run in the same +native suite; the existing Python/audio suite also passes with the feature +enabled. + +I kept every new wire operation in the `zQ` extended-control family. In +particular, `zQE,[,[,]]Z` uses the existing +multi-letter `z` grammar while retaining the root sequencer's familiar +tick/period/tag model for each stored event. I did not extend `H`: it remains +the legacy root-scheduling envelope, including its special rule that it must +be the first command and owns the remaining payload. This avoids both a new +top-level command family and any ambiguity in existing `H` messages. + +A stored pattern is a small, local sequencer. Its events have exactly the +same `tick,period,tag` model as the root sequencer, while each playback +instance adds only a local starting point and a lifetime: `ONE_SHOT` runs +local ticks `0..length-1` once and `LOOP` wraps at `length` until stopped. +The same definition can therefore be a repeating rhythm or a one-time fill; +only its playback mode changes. + +Pattern definitions are built in staging storage and published atomically: + +```python +amy.pattern_begin(0, length_ticks=384) +amy.pattern_event(0, tick=0, period=384, tag=0, + synth=10, note=36, vel=1) +amy.pattern_event(0, tick=192, period=384, tag=1, + synth=10, note=38, vel=1) +amy.pattern_commit(0) + +# Start on the next 384-tick boundary and keep looping. +amy.pattern_trigger(0, amy.AMY_PATTERN_LOOP, quantize_ticks=384, + instance_tag=100) + +# The same pattern as a one-time performance. +amy.pattern_trigger(0, amy.AMY_PATTERN_ONE_SHOT, quantize_ticks=384) +amy.pattern_stop(100, quantize_ticks=384) +``` + +As in the root sequencer, a nonzero `period` makes `tick` an offset and +`tick=0,period=0,tag=N` clears tag `N`; anonymous zero/zero is a no-op. For a +single hit at local tick zero, use the pattern length as its period, as in the +example. Events without a tag coexist and cannot be replaced individually. +`pattern_event_wire()` is the raw-wire counterpart of `pattern_event()`. + +`quantize_ticks=Q` chooses the next global sequencer-tick multiple of `Q`. +A normal host/API call made exactly on a boundary chooses the following +boundary, so a late call cannot fire retroactively inside the call. A `zQT` +trigger fired by a root `H` event on a boundary is already in the sequencer +tick and starts there; local tick-zero events then fire on that same tick. +Use zero for the next tick (or the current root-event tick). + +For explicit control over an already-running pattern, give its instance a tag +and schedule `pattern_mute(tag, duration_ticks)`. A mute suppresses only new +events; the target's local clock and any already-ringing sounds continue. It +therefore resumes at its original phase on the first tick after the duration. +`zQM` is the only pattern control allowed as a stored pattern event because it +cannot start or schedule another pattern: + +```python +amy.pattern_begin(1, length_ticks=96) +amy.pattern_event_wire(1, 0, "zQM100,96Z", period=96, tag=0) +amy.pattern_event(1, 0, period=96, tag=1, synth=10, note=38, vel=1) +amy.pattern_commit(1) +``` + +`pattern_schedule()` puts a pattern trigger in the root sequencer relative to +the next requested quantization boundary. Its `offset_ticks` is relative to +that boundary; a nonzero `period_ticks` repeats the trigger. The supplied +`sequence_tag` is a normal root tag, so `send(ticks="0,0,tag")` removes future +triggers without truncating a one-shot which is already playing. This is useful +for sparse arrangements: the child definition can stay short while the root +event repeats it over a much longer cycle. + +Definitions are immutable after commit. Replacing or clearing one affects new +triggers only; an already-running instance safely finishes its version. +`RESET_SEQUENCER` stops root events and all pattern instances, but keeps stored +pattern definitions, just as oscillator resets do not erase stored patches. +`RESET_TIMEBASE` preserves an instance's local phase and the remaining delay +of a pending quantized start. The sequencer transport (`sequencer_run` / `zY`) +clocks both levels. + +Nesting is deliberately limited to two levels: a root event may trigger a +pattern, but pattern payloads cannot contain `H` or a pattern-creating +`zQ` control. The finite `zQM` gate is the sole leaf-control exception. This +keeps execution and memory bounded. + ## Core oscillators We support bandlimited saw, pulse/square and triangle waves, alongside sine and noise. Use the wave parameter: 0=SINE, PULSE, SAW_DOWN, SAW_UP, TRIANGLE, NOISE. Each oscillator can have a frequency (or set by midi note), amplitude and phase (set in 0-1.). You can also set `duty` for the pulse type. We also have a karplus-strong type (KS=6), plus `WAVETABLE` when compiled with `AMY_WAVETABLE` that plays back 16,384 sample long wavetable packs, such as those hosted on [waveeditonline.com](http://waveeditonline.com). @@ -475,7 +638,3 @@ amy.start_sample(preset=1024, source=amy.SAMPLE_FROM_OUTPUT, max_frames=11025, m amy.send(osc=0, wave=amy.PCM_LEFT, preset=1024, pan=0, note=72, vel=1) # play back AUDIO_IN sample an octave higher amy.send(osc=1, wave=amy.PCM_RIGHT, preset=1024, pan=1, note=72, vel=1) ``` - - - - diff --git a/src/amy.c b/src/amy.c index 6919023c..a41601fd 100644 --- a/src/amy.c +++ b/src/amy.c @@ -1298,7 +1298,10 @@ int8_t oscs_init() { algo_init(); patches_init(amy_global.config.max_memory_patches); instruments_init(amy_global.config.max_synths); - sequencer_init(amy_global.config.max_sequencer_tags); + sequencer_init(amy_global.config.max_sequencer_tags, + amy_global.config.max_patterns, + amy_global.config.max_pattern_tags, + amy_global.config.max_pattern_instances); if(pcm_samples) pcm_init(); if(AMY_HAS_CUSTOM) custom_init(); // synth and msynth are now pointers to arrays of pointers to dynamically-allocated synth structures. @@ -2476,6 +2479,7 @@ int16_t * amy_fill_buffer() { amy_global.total_blocks = 0; amy_global.total_samples = 0; amy_global.time = 0; + sequencer_rebase_patterns(amy_global.sequencer_tick_count); amy_global.sequencer_tick_count = 0; sequencer_recompute(); amy_global.reset_timebase_pending = 0; diff --git a/src/amy.h b/src/amy.h index 3022cade..b203b534 100644 --- a/src/amy.h +++ b/src/amy.h @@ -364,6 +364,13 @@ enum coefs{ #define TICKS_PERIOD 1 #define TICKS_TAG 2 +// Nested sequencer pattern playback modes. Patterns use the same +// tick/period/tag event model as the root sequencer; only their lifetime is +// different. UINT32_MAX means that an instance is deliberately untagged. +#define AMY_PATTERN_ONE_SHOT 0 +#define AMY_PATTERN_LOOP 1 +#define AMY_PATTERN_UNTAGGED 4294967295 + // Reset masks #define RESET_SEQUENCER 4096 #define RESET_ALL_OSCS 8192 @@ -954,6 +961,14 @@ typedef struct { int8_t capture_device_id; int8_t playback_device_id; + // Appended to preserve every pre-existing config field's offset and the + // meaning of positional initializers. Pattern event tables are allocated + // only when a definition is authored; set max_patterns to 0 to disable + // the feature entirely on a memory-constrained target. + uint32_t max_patterns; + uint32_t max_pattern_tags; + uint32_t max_pattern_instances; + } amy_config_t; typedef struct eq_state { diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index 1b590b54..3ab5710f 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -406,6 +406,9 @@ var AMY = { TICKS_TICK: 0, TICKS_PERIOD: 1, TICKS_TAG: 2, + AMY_PATTERN_ONE_SHOT: 0, + AMY_PATTERN_LOOP: 1, + AMY_PATTERN_UNTAGGED: 4294967295, RESET_SEQUENCER: 4096, RESET_ALL_OSCS: 8192, RESET_TIMEBASE: 16384, diff --git a/src/api.c b/src/api.c index fd70fbef..769c8799 100644 --- a/src/api.c +++ b/src/api.c @@ -48,6 +48,9 @@ amy_config_t amy_default_config() { c.max_oscs = 250; c.max_buses = AMY_DEFAULT_NUM_BUSES; c.max_sequencer_tags = 256; + c.max_patterns = 32; + c.max_pattern_tags = 64; + c.max_pattern_instances = 32; c.max_voices = 64; c.max_synths = 64; c.max_memory_patches = 32; diff --git a/src/parse.c b/src/parse.c index 436a4549..c9c1160f 100644 --- a/src/parse.c +++ b/src/parse.c @@ -677,6 +677,11 @@ uint16_t amy_parse_transfer_layer_message(char *message) { amy_external_midi_sync((uint8_t)atoi(message)); return 1; } + else if (cmd == 'Q') { + // zQ: immutable nested-pattern lifecycle and playback controls. + // The action byte and its arguments consume the rest of this message. + return amy_parse_pattern_control_message(message); + } else fprintf(stderr, "Unrecognized transfer-level command '%s'\n", message - 1); return 0; } @@ -727,6 +732,91 @@ void handle_ticks_message(char *message) { } } +// zQ actions: +// Bpattern,length begin/replace staging definition +// Epattern,tick[,period[,tag]]event +// add an ordinary event to staging +// Cpattern atomically commit staging definition +// Tpattern,mode,quantum[,tag] trigger one-shot/loop, quantized in ticks +// Apattern,mode,offset,period,quantum,sequence_tag[,instance_tag] +// schedule a quantized relative root trigger +// Stag[,quantum] stop tagged instance(s), quantized +// Mtag,duration mute matching running instance(s) +// Rpattern clear current and staging definitions +uint16_t amy_parse_pattern_control_message(char *message) { + uint16_t consumed = (uint16_t)strlen(message) + 1; // include the Q + if (message[0] == '\0') return consumed; + char action = message[0]; + uint32_t values[7] = {0, 0, 0, 0, 0, 0, 0}; + int num_vals = parse_list_uint32_t(message + 1, values, 7, 0); + switch (action) { + case 'B': + if (num_vals == 2) { + amy_pattern_begin(values[0], values[1]); + } else { + fprintf(stderr, "zQB requires exactly pattern,length\n"); + } + break; + case 'E': { + uint16_t header_len = 1 + _next_alpha(message + 1); + char *payload = message + header_len; + if (num_vals >= 2 && num_vals <= 4 && payload[0] != '\0') { + amy_pattern_add_wire( + values[0], values[1], num_vals >= 3 ? values[2] : 0, + num_vals >= 4 ? values[3] : 0, num_vals >= 4, payload); + } else { + fprintf(stderr, + "zQE requires pattern,tick[,period[,tag]] and an event\n"); + } + break; + } + case 'C': + if (num_vals >= 1) amy_pattern_commit(values[0]); + else fprintf(stderr, "zQC requires pattern\n"); + break; + case 'T': + if (num_vals >= 1) { + amy_pattern_trigger( + values[0], + (uint8_t)(num_vals >= 2 ? values[1] + : AMY_PATTERN_ONE_SHOT), + num_vals >= 3 ? values[2] : 0, + num_vals >= 4 ? values[3] : AMY_PATTERN_UNTAGGED); + } else { + fprintf(stderr, "zQT requires pattern\n"); + } + break; + case 'A': + if (num_vals >= 6) { + amy_pattern_schedule( + values[0], (uint8_t)values[1], values[2], values[3], + values[4], values[5], + num_vals >= 7 ? values[6] : AMY_PATTERN_UNTAGGED); + } else { + fprintf(stderr, + "zQA requires pattern,mode,offset,period,quantum,sequence_tag\n"); + } + break; + case 'S': + if (num_vals >= 1) + amy_pattern_stop(values[0], num_vals >= 2 ? values[1] : 0); + else fprintf(stderr, "zQS requires instance tag\n"); + break; + case 'M': + if (num_vals >= 2) amy_pattern_mute(values[0], values[1]); + else fprintf(stderr, "zQM requires instance tag,duration\n"); + break; + case 'R': + if (num_vals >= 1) amy_pattern_clear(values[0]); + else fprintf(stderr, "zQR requires pattern\n"); + break; + default: + fprintf(stderr, "unrecognized zQ pattern action '%c'\n", action); + break; + } + return consumed; +} + // given a string return a parsed event // // Transfer payloads never reach here: amy_add_message() traps them before @@ -906,4 +996,3 @@ int amy_parse_message(char * message, amy_event *e) { // Return exactly how many characters we used. return pos; } - diff --git a/src/pyamy.c b/src/pyamy.c index 49771038..e5bb5bf8 100644 --- a/src/pyamy.c +++ b/src/pyamy.c @@ -97,6 +97,33 @@ static int parse_live_kwarg(amy_config_t *cfg, const char *key, PyObject *value) } cfg->max_sequencer_tags = (uint32_t)llv; return 0; + } else if (strcmp(key, "max_patterns") == 0) { + llv = PyLong_AsLongLong(value); + if (PyErr_Occurred()) return -1; + if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { + PyErr_SetString(PyExc_ValueError, "max_patterns must be in range [0, 4294967295]"); + return -1; + } + cfg->max_patterns = (uint32_t)llv; + return 0; + } else if (strcmp(key, "max_pattern_tags") == 0) { + llv = PyLong_AsLongLong(value); + if (PyErr_Occurred()) return -1; + if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { + PyErr_SetString(PyExc_ValueError, "max_pattern_tags must be in range [0, 4294967295]"); + return -1; + } + cfg->max_pattern_tags = (uint32_t)llv; + return 0; + } else if (strcmp(key, "max_pattern_instances") == 0) { + llv = PyLong_AsLongLong(value); + if (PyErr_Occurred()) return -1; + if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { + PyErr_SetString(PyExc_ValueError, "max_pattern_instances must be in range [0, 4294967295]"); + return -1; + } + cfg->max_pattern_instances = (uint32_t)llv; + return 0; } else if (strcmp(key, "max_voices") == 0) { llv = PyLong_AsLongLong(value); if (PyErr_Occurred()) return -1; diff --git a/src/sequencer.c b/src/sequencer.c index 243bafd1..bcf27b64 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -47,7 +47,50 @@ static volatile bool sequencer_external_clock = false; // flag makes those nested calls no-ops so a tick is never processed twice. static volatile bool wire_firing = false; -void sequencer_init(int max_sequencer_tags) { +// Nested patterns deliberately reuse sequence_info_t: each definition is a +// small sequencer with the same tick/period/tag semantics as the root table. +// Definitions are immutable after commit. An instance only adds an origin, +// a finite/looping playback mode; pattern events themselves are always +// ordinary AMY events, so nesting stops at exactly two levels. +typedef struct pattern_definition_t { + sequence_info_t *events; + int32_t first_active; + int32_t anon_cursor; + uint32_t length_ticks; + uint32_t refs; + bool retired; +} pattern_definition_t; + +typedef struct pattern_slot_t { + pattern_definition_t *current; + pattern_definition_t *staging; +} pattern_slot_t; + +typedef struct pattern_instance_t { + pattern_definition_t *definition; + uint32_t start_tick; + uint32_t stop_tick; + uint32_t mute_tick; + uint32_t mute_duration; + uint32_t instance_tag; + uint8_t mode; + bool occupied; + bool muted; +} pattern_instance_t; + +static pattern_slot_t *pattern_slots = NULL; +static pattern_instance_t *pattern_instances = NULL; +static int32_t max_pattern_slots = 0; +static int32_t max_pattern_event_tags = 0; +static int32_t max_pattern_players = 0; + +static void pattern_definition_free(pattern_definition_t *definition); +static void pattern_instances_reset(void); +static void pattern_process_tick(uint32_t tick); + +void sequencer_init(int max_sequencer_tags, uint32_t max_patterns, + uint32_t max_pattern_tags, + uint32_t max_pattern_instances) { // These are statics, so a stop/start of AMY within one process needs them // put back to their boot state (internal clock, running). sequencer_running = true; @@ -65,6 +108,50 @@ void sequencer_init(int max_sequencer_tags) { sequences[i].next_active = -1; } first_active = -1; + + // malloc_caps takes a uint32_t byte count and the active-list indices use + // -1 as their sentinel. Refuse impossible configurations before either + // conversion can wrap into a small allocation followed by a large write. + max_pattern_slots = max_patterns <= INT32_MAX + && max_patterns <= UINT32_MAX / sizeof(pattern_slot_t) + ? (int32_t)max_patterns : 0; + max_pattern_event_tags = max_pattern_tags <= INT32_MAX - AMY_ANON_SEQUENCE_SLOTS + && max_pattern_tags + AMY_ANON_SEQUENCE_SLOTS + <= UINT32_MAX / sizeof(sequence_info_t) + ? (int32_t)max_pattern_tags : 0; + max_pattern_players = max_pattern_instances <= INT32_MAX + && max_pattern_instances <= UINT32_MAX / sizeof(pattern_instance_t) + ? (int32_t)max_pattern_instances : 0; + if (max_pattern_slots == 0 && max_patterns != 0) + fprintf(stderr, "max_patterns is too large, nested patterns disabled\n"); + if (max_pattern_event_tags == 0 && max_pattern_tags != 0) + fprintf(stderr, "max_pattern_tags is too large, nested patterns disabled\n"); + if (max_pattern_players == 0 && max_pattern_instances != 0) + fprintf(stderr, "max_pattern_instances is too large, nested patterns disabled\n"); + if (max_pattern_slots > 0) { + pattern_slots = (pattern_slot_t *)malloc_caps( + (uint32_t)max_pattern_slots * sizeof(pattern_slot_t), + amy_global.config.ram_caps_synth); + if (pattern_slots == NULL) { + amy_oom("pattern slots"); + max_pattern_slots = 0; + } else { + bzero(pattern_slots, + (size_t)max_pattern_slots * sizeof(pattern_slot_t)); + } + } + if (max_pattern_players > 0) { + pattern_instances = (pattern_instance_t *)malloc_caps( + (uint32_t)max_pattern_players * sizeof(pattern_instance_t), + amy_global.config.ram_caps_synth); + if (pattern_instances == NULL) { + amy_oom("pattern instances"); + max_pattern_players = 0; + } else { + bzero(pattern_instances, + (size_t)max_pattern_players * sizeof(pattern_instance_t)); + } + } // We are read to go. sequencer_recompute(); } @@ -82,6 +169,10 @@ void sequencer_reset() { sequences[i].next_active = -1; } first_active = -1; + // Stored definitions are analogous to stored patches and survive a + // sequencer reset. Running/pending instances are transport state and do + // not: RESET_SEQUENCER must silence every sequencer level. + pattern_instances_reset(); } void sequencer_deinit() { @@ -91,6 +182,22 @@ void sequencer_deinit() { sequences = NULL; // sequencer_check_and_fill guards on this } max_sequences = 0; + pattern_instances_reset(); + if (pattern_slots != NULL) { + for (int32_t i = 0; i < max_pattern_slots; ++i) { + pattern_definition_free(pattern_slots[i].staging); + pattern_definition_free(pattern_slots[i].current); + } + free(pattern_slots); + pattern_slots = NULL; + } + if (pattern_instances != NULL) { + free(pattern_instances); + pattern_instances = NULL; + } + max_pattern_slots = 0; + max_pattern_event_tags = 0; + max_pattern_players = 0; } void sequencer_debug() { @@ -158,6 +265,522 @@ static void active_unlink(int32_t tag) *prev = sequences[tag].next_active; /* one store, again */ } +static int32_t pattern_total_slots(void) { + return max_pattern_event_tags + AMY_ANON_SEQUENCE_SLOTS; +} + +static pattern_definition_t *pattern_definition_new(uint32_t length_ticks) { + if (length_ticks == 0 || max_pattern_event_tags <= 0) return NULL; + int32_t total_slots = pattern_total_slots(); + if (total_slots <= 0) return NULL; + + pattern_definition_t *definition = + (pattern_definition_t *)malloc_caps( + sizeof(pattern_definition_t), amy_global.config.ram_caps_events); + if (definition == NULL) { + amy_oom("pattern definition"); + return NULL; + } + bzero(definition, sizeof(pattern_definition_t)); + definition->events = (sequence_info_t *)malloc_caps( + (uint32_t)total_slots * sizeof(sequence_info_t), + amy_global.config.ram_caps_events); + if (definition->events == NULL) { + amy_oom("pattern events"); + free(definition); + return NULL; + } + for (int32_t i = 0; i < total_slots; ++i) { + definition->events[i].wire = NULL; + definition->events[i].tick = 0; + definition->events[i].period = 0; + definition->events[i].next_active = -1; + } + definition->first_active = -1; + definition->anon_cursor = 0; + definition->length_ticks = length_ticks; + return definition; +} + +static void pattern_definition_free(pattern_definition_t *definition) { + if (definition == NULL) return; + if (definition->events != NULL) { + int32_t total_slots = pattern_total_slots(); + for (int32_t i = 0; i < total_slots; ++i) { + if (definition->events[i].wire != NULL) + free(definition->events[i].wire); + } + free(definition->events); + } + free(definition); +} + +static void pattern_active_link(pattern_definition_t *definition, + int32_t tag) { + int32_t *previous = &definition->first_active; + while (*previous != -1 && *previous < tag) + previous = &definition->events[*previous].next_active; + if (*previous == tag) return; + definition->events[tag].next_active = *previous; + *previous = tag; +} + +static void pattern_active_unlink(pattern_definition_t *definition, + int32_t tag) { + int32_t *previous = &definition->first_active; + while (*previous != -1 && *previous != tag) + previous = &definition->events[*previous].next_active; + if (*previous == tag) + *previous = definition->events[tag].next_active; + definition->events[tag].next_active = -1; +} + +static bool pattern_index_valid(uint32_t pattern) { + if (pattern_slots != NULL && pattern < (uint32_t)max_pattern_slots) + return true; + fprintf(stderr, "pattern %" PRIu32 " is outside configured range 0..%" PRIi32 "\n", + pattern, max_pattern_slots > 0 ? max_pattern_slots - 1 : -1); + return false; +} + +static bool pattern_payload_is_leaf(const char *wire) { + if (wire == NULL || wire[0] == '\0') return false; + // H is meaningful only at the root ingest boundary; zQ is the pattern + // control family. zQM is the one leaf exception: it + // only gates already-running instances and cannot create another level. + const char *pattern_control = strstr(wire, "zQ"); + bool is_mute = pattern_control == wire && strncmp(wire, "zQM", 3) == 0 + && strstr(wire + 3, "zQ") == NULL; + if (wire[0] == 'H' || (pattern_control != NULL && !is_mute)) { + fprintf(stderr, "nested pattern events cannot schedule or trigger patterns\n"); + return false; + } + const char *end = strchr(wire, 'Z'); + if (end == NULL || end[1] != '\0') { + fprintf(stderr, "nested pattern event must be one Z-terminated wire message\n"); + return false; + } + return true; +} + +uint8_t amy_pattern_begin(uint32_t pattern, uint32_t length_ticks) { + if (!pattern_index_valid(pattern) || length_ticks == 0) return 0; + pattern_definition_t *definition = pattern_definition_new(length_ticks); + if (definition == NULL) return 0; + + amy_grab_lock(); + pattern_definition_t *old_staging = pattern_slots[pattern].staging; + pattern_slots[pattern].staging = definition; + amy_release_lock(); + pattern_definition_free(old_staging); + return 1; +} + +uint8_t amy_pattern_add_wire(uint32_t pattern, uint32_t tick, + uint32_t period, uint32_t tag, bool has_tag, + const char *wire) { + if (!pattern_index_valid(pattern) || !pattern_payload_is_leaf(wire)) + return 0; + + size_t length = strlen(wire); + char *copy = (char *)malloc_caps((uint32_t)length + 1, + amy_global.config.ram_caps_events); + if (copy == NULL) { + amy_oom("pattern event wire"); + return 0; + } + memcpy(copy, wire, length + 1); + + amy_grab_lock(); + pattern_definition_t *definition = pattern_slots[pattern].staging; + if (definition == NULL) { + amy_release_lock(); + free(copy); + fprintf(stderr, "pattern %" PRIu32 " has no staging definition\n", + pattern); + return 0; + } + + if (has_tag) { + if (tag >= (uint32_t)max_pattern_event_tags) { + amy_release_lock(); + free(copy); + fprintf(stderr, "pattern tag %" PRIu32 " is outside configured range 0..%" PRIi32 "\n", + tag, max_pattern_event_tags - 1); + return 0; + } + } else { + // Keep the root sequencer's exact anonymous zero/zero semantics. + if (tick == 0 && period == 0) { + amy_release_lock(); + free(copy); + return 0; + } + tag = (uint32_t)(max_pattern_event_tags + definition->anon_cursor); + definition->anon_cursor = + (definition->anon_cursor + 1) % AMY_ANON_SEQUENCE_SLOTS; + } + + sequence_info_t *event = &definition->events[tag]; + if (event->wire != NULL) free(event->wire); + event->wire = NULL; + event->tick = 0; + event->period = 0; + pattern_active_unlink(definition, (int32_t)tag); + if (tick == 0 && period == 0) { + amy_release_lock(); + free(copy); + return 0; + } + event->tick = tick; + event->period = period; + event->wire = copy; + pattern_active_link(definition, (int32_t)tag); + amy_release_lock(); + return 1; +} + +uint8_t amy_pattern_add_event(uint32_t pattern, const amy_event *event) { + if (event == NULL) return 0; + amy_event payload = *event; + uint32_t tick = AMY_IS_SET(payload.ticks[TICKS_TICK]) + ? payload.ticks[TICKS_TICK] : 0; + uint32_t period = AMY_IS_SET(payload.ticks[TICKS_PERIOD]) + ? payload.ticks[TICKS_PERIOD] : 0; + bool has_tag = AMY_IS_SET(payload.ticks[TICKS_TAG]); + uint32_t tag = has_tag ? payload.ticks[TICKS_TAG] : 0; + AMY_UNSET(payload.ticks[TICKS_TICK]); + AMY_UNSET(payload.ticks[TICKS_PERIOD]); + AMY_UNSET(payload.ticks[TICKS_TAG]); + + char *wire = (char *)malloc_caps(MAX_MESSAGE_LEN, + amy_global.config.ram_caps_events); + if (wire == NULL) { + amy_oom("pattern add event"); + return 0; + } + sprint_event(&payload, wire, MAX_MESSAGE_LEN, /* wirecode= */ true); + uint8_t result = amy_pattern_add_wire( + pattern, tick, period, tag, has_tag, wire); + free(wire); + return result; +} + +uint8_t amy_pattern_commit(uint32_t pattern) { + if (!pattern_index_valid(pattern)) return 0; + amy_grab_lock(); + pattern_definition_t *replacement = pattern_slots[pattern].staging; + if (replacement == NULL) { + amy_release_lock(); + fprintf(stderr, "pattern %" PRIu32 " has no staging definition\n", + pattern); + return 0; + } + pattern_definition_t *old = pattern_slots[pattern].current; + pattern_slots[pattern].current = replacement; + pattern_slots[pattern].staging = NULL; + if (old != NULL) old->retired = true; + bool free_old = old != NULL && old->refs == 0; + amy_release_lock(); + if (free_old) pattern_definition_free(old); + return 1; +} + +uint8_t amy_pattern_clear(uint32_t pattern) { + if (!pattern_index_valid(pattern)) return 0; + amy_grab_lock(); + pattern_definition_t *current = pattern_slots[pattern].current; + pattern_definition_t *staging = pattern_slots[pattern].staging; + pattern_slots[pattern].current = NULL; + pattern_slots[pattern].staging = NULL; + if (current != NULL) current->retired = true; + bool free_current = current != NULL && current->refs == 0; + amy_release_lock(); + pattern_definition_free(staging); + if (free_current) pattern_definition_free(current); + return current != NULL || staging != NULL; +} + +static bool tick_reached(uint32_t now, uint32_t target) { + return (int32_t)(now - target) >= 0; +} + +static uint32_t pattern_activation_tick(uint32_t quantum) { + uint32_t now = amy_global.sequencer_tick_count; + if (quantum == 0) return wire_firing ? now : now + 1; + uint32_t remainder = now % quantum; + if (wire_firing && remainder == 0) return now; + return now + (remainder == 0 ? quantum : quantum - remainder); +} + +uint8_t amy_pattern_trigger(uint32_t pattern, uint8_t mode, + uint32_t quantize_ticks, + uint32_t instance_tag) { + if (!pattern_index_valid(pattern) + || (mode != AMY_PATTERN_ONE_SHOT && mode != AMY_PATTERN_LOOP)) + return 0; + + amy_grab_lock(); + pattern_definition_t *definition = pattern_slots[pattern].current; + if (definition == NULL) { + amy_release_lock(); + fprintf(stderr, "pattern %" PRIu32 " is not committed\n", pattern); + return 0; + } + int32_t free_index = -1; + for (int32_t i = 0; i < max_pattern_players; ++i) { + if (!pattern_instances[i].occupied) { + free_index = i; + break; + } + } + if (free_index < 0) { + amy_release_lock(); + fprintf(stderr, "no free nested pattern instance\n"); + return 0; + } + + uint32_t start_tick = pattern_activation_tick(quantize_ticks); + if (instance_tag != AMY_PATTERN_UNTAGGED) { + for (int32_t i = 0; i < max_pattern_players; ++i) { + pattern_instance_t *existing = &pattern_instances[i]; + if (existing->occupied && existing->instance_tag == instance_tag + && (existing->stop_tick == UINT32_MAX + || tick_reached(existing->stop_tick, start_tick))) { + existing->stop_tick = start_tick; + } + } + } + + pattern_instance_t *instance = &pattern_instances[free_index]; + instance->definition = definition; + instance->start_tick = start_tick; + instance->stop_tick = UINT32_MAX; + instance->instance_tag = instance_tag; + instance->mode = mode; + instance->occupied = true; + definition->refs++; + amy_release_lock(); + return 1; +} + +uint8_t amy_pattern_stop(uint32_t instance_tag, uint32_t quantize_ticks) { + if (instance_tag == AMY_PATTERN_UNTAGGED) return 0; + uint32_t stop_tick = pattern_activation_tick(quantize_ticks); + uint8_t found = 0; + amy_grab_lock(); + for (int32_t i = 0; i < max_pattern_players; ++i) { + pattern_instance_t *instance = &pattern_instances[i]; + if (instance->occupied && instance->instance_tag == instance_tag) { + instance->stop_tick = stop_tick; + found = 1; + } + } + amy_release_lock(); + return found; +} + +uint8_t amy_pattern_mute(uint32_t instance_tag, uint32_t duration_ticks) { + if (instance_tag == AMY_PATTERN_UNTAGGED) return 0; + uint32_t now = amy_global.sequencer_tick_count; + uint8_t found = 0; + amy_grab_lock(); + for (int32_t i = 0; i < max_pattern_players; ++i) { + pattern_instance_t *instance = &pattern_instances[i]; + if (!instance->occupied || instance->instance_tag != instance_tag) + continue; + instance->mute_tick = now; + instance->mute_duration = duration_ticks; + instance->muted = duration_ticks != 0; + found = 1; + } + amy_release_lock(); + return found; +} + +uint8_t amy_pattern_schedule(uint32_t pattern, uint8_t mode, + uint32_t offset_ticks, uint32_t period_ticks, + uint32_t quantize_ticks, uint32_t sequence_tag, + uint32_t instance_tag) { + if (!pattern_index_valid(pattern) + || (mode != AMY_PATTERN_ONE_SHOT && mode != AMY_PATTERN_LOOP)) + return 0; + + char encoded[96]; + int length; + if (instance_tag == AMY_PATTERN_UNTAGGED) { + length = snprintf(encoded, sizeof(encoded), "zQT%" PRIu32 ",%u,0Z", + pattern, mode); + } else { + length = snprintf(encoded, sizeof(encoded), + "zQT%" PRIu32 ",%u,0,%" PRIu32 "Z", + pattern, mode, instance_tag); + } + if (length < 0 || (size_t)length >= sizeof(encoded)) return 0; + char *wire = strdup(encoded); + if (wire == NULL) { + amy_oom("scheduled pattern trigger"); + return 0; + } + + uint32_t tick = pattern_activation_tick(quantize_ticks) + offset_ticks; + if (period_ticks != 0) tick %= period_ticks; + return sequencer_add_wire( + tick, period_ticks, sequence_tag, true, wire); +} + +static void pattern_instance_release(pattern_instance_t *instance) { + pattern_definition_t *definition = instance->definition; + bzero(instance, sizeof(pattern_instance_t)); + if (definition != NULL && definition->refs > 0) definition->refs--; + if (definition != NULL && definition->retired && definition->refs == 0) + pattern_definition_free(definition); +} + +static void pattern_instances_reset(void) { + if (pattern_instances == NULL) return; + for (int32_t i = 0; i < max_pattern_players; ++i) { + if (pattern_instances[i].occupied) + pattern_instance_release(&pattern_instances[i]); + } +} + +void sequencer_rebase_patterns(uint32_t old_tick) { + if (pattern_instances == NULL) return; + for (int32_t i = 0; i < max_pattern_players; ++i) { + pattern_instance_t *instance = &pattern_instances[i]; + if (!instance->occupied) continue; + instance->start_tick -= old_tick; + if (instance->stop_tick != UINT32_MAX) { + instance->stop_tick = tick_reached(old_tick, instance->stop_tick) + ? 0 : instance->stop_tick - old_tick; + } + if (instance->muted) instance->mute_tick -= old_tick; + } +} + +static bool pattern_instance_running(const pattern_instance_t *instance, + uint32_t tick) { + if (!instance->occupied || !tick_reached(tick, instance->start_tick)) + return false; + if (instance->stop_tick != UINT32_MAX + && tick_reached(tick, instance->stop_tick)) + return false; + uint32_t elapsed = tick - instance->start_tick; + return instance->mode == AMY_PATTERN_LOOP + || elapsed < instance->definition->length_ticks; +} + +static bool pattern_instance_audible(const pattern_instance_t *instance, + uint32_t tick) { + if (!pattern_instance_running(instance, tick)) return false; + if (instance->muted + && tick - instance->mute_tick < instance->mute_duration) + return false; + return true; +} + +static bool pattern_event_hits(const pattern_instance_t *instance, + const sequence_info_t *event, uint32_t tick) { + uint32_t local_tick = tick - instance->start_tick; + if (instance->mode == AMY_PATTERN_LOOP) + local_tick %= instance->definition->length_ticks; + return event->period != 0 + ? local_tick % event->period == event->tick + : local_tick == event->tick; +} + +static bool pattern_event_is_mute(const sequence_info_t *event) { + return event->wire != NULL && strncmp(event->wire, "zQM", 3) == 0; +} + +static void pattern_process_tick(uint32_t tick) { + if (pattern_instances == NULL) return; + + // Retire stopped/finished instances before processing events. A + // replacement scheduled on this exact tick takes effect here. + amy_grab_lock(); + for (int32_t i = 0; i < max_pattern_players; ++i) { + pattern_instance_t *instance = &pattern_instances[i]; + if (!instance->occupied || !tick_reached(tick, instance->start_tick)) + continue; + uint32_t elapsed = tick - instance->start_tick; + bool stopped = instance->stop_tick != UINT32_MAX + && tick_reached(tick, instance->stop_tick); + bool finished = instance->mode == AMY_PATTERN_ONE_SHOT + && elapsed >= instance->definition->length_ticks; + if (stopped || finished) pattern_instance_release(instance); + } + amy_release_lock(); + + // Mute is a schedulable leaf control. Fire every due mute before any + // ordinary child event so the target cannot leak an onset on the first + // muted tick merely because its instance occupies an earlier pool slot. + for (int32_t i = 0; i < max_pattern_players; ++i) { + amy_grab_lock(); + pattern_instance_t *instance = &pattern_instances[i]; + if (!pattern_instance_audible(instance, tick)) { + amy_release_lock(); + continue; + } + pattern_definition_t *definition = instance->definition; + definition->refs++; + amy_release_lock(); + + int32_t tag = definition->first_active; + while (tag != -1) { + sequence_info_t *event = &definition->events[tag]; + int32_t next = event->next_active; + if (pattern_event_is_mute(event) + && pattern_event_hits(instance, event, tick)) + amy_play_message(event->wire); + tag = next; + } + + amy_grab_lock(); + if (definition->refs > 0) definition->refs--; + bool free_definition = definition->retired && definition->refs == 0; + amy_release_lock(); + if (free_definition) pattern_definition_free(definition); + } + + for (int32_t i = 0; i < max_pattern_players; ++i) { + amy_grab_lock(); + pattern_instance_t *instance = &pattern_instances[i]; + if (!pattern_instance_audible(instance, tick)) { + amy_release_lock(); + continue; + } + pattern_definition_t *definition = instance->definition; + uint32_t local_tick = tick - instance->start_tick; + if (instance->mode == AMY_PATTERN_LOOP) + local_tick %= definition->length_ticks; + // Keep the immutable definition alive while ordinary event playback + // runs without the lock. A payload can itself execute + // RESET_SEQUENCER, which releases the instance that owned this ref. + definition->refs++; + amy_release_lock(); + + int32_t tag = definition->first_active; + while (tag != -1) { + sequence_info_t *event = &definition->events[tag]; + int32_t next = event->next_active; + bool hit = event->period != 0 + ? local_tick % event->period == event->tick + : local_tick == event->tick; + if (hit && event->wire != NULL && !pattern_event_is_mute(event)) + amy_play_message(event->wire); + tag = next; + } + + amy_grab_lock(); + if (definition->refs > 0) definition->refs--; + bool free_definition = definition->retired && definition->refs == 0; + amy_release_lock(); + if (free_definition) pattern_definition_free(definition); + } +} + void sequencer_recompute() { // 60000000 us/min / (bpm * ticks per beat); keep it single-precision - // unsuffixed double literals pull in software double emulation on 32-bit. @@ -300,6 +923,11 @@ static void sequencer_process_tick(void) { } tag = next; } + // Root items fire first. A root event can therefore start a pattern on + // this exact tick; the new instance then emits its local tick-zero events + // below. Pattern payloads cannot trigger another pattern, fixing nesting + // at two levels and keeping the processing cost bounded. + pattern_process_tick(amy_global.sequencer_tick_count); wire_firing = was_firing; if(amy_global.config.amy_external_sequencer_hook != NULL) { amy_global.config.amy_external_sequencer_hook(amy_global.sequencer_tick_count); @@ -338,7 +966,10 @@ void sequencer_midi_start() { // If external clock was not previously enabled, keep using internal clock // so the sequencer advances on its own without needing F8 ticks. if (sequencer_external_clock) { + amy_grab_lock(); + sequencer_rebase_patterns(amy_global.sequencer_tick_count); amy_global.sequencer_tick_count = 0; + amy_release_lock(); } // Reset the tick timer to now so sequencer_check_and_fill doesn't try to // catch up all the ticks that elapsed while stopped. diff --git a/src/sequencer.h b/src/sequencer.h index d073e642..1746383e 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -4,13 +4,20 @@ #include "amy.h" #define MIDI_SEQUENCER_PPQ 24 // MIDI clocks per quarter note + uint32_t sequencer_ticks(); -void sequencer_init(int max_num_sequences); +void sequencer_init(int max_num_sequences, uint32_t max_patterns, + uint32_t max_pattern_tags, + uint32_t max_pattern_instances); void sequencer_deinit(); void sequencer_reset(); void sequencer_debug(); void sequencer_recompute(); +// Rebase pattern-instance origins when the shared tick counter is reset. +// Caller holds the AMY lock; root sequence entries deliberately retain their +// existing RESET_TIMEBASE semantics. +void sequencer_rebase_patterns(uint32_t old_tick); void sequencer_check_and_fill(); // called once per block from amy_execute_deltas() #ifdef __EMSCRIPTEN__ void sequencer_check_and_call_js_hook(); // called from the browser main loop @@ -22,6 +29,36 @@ void sequencer_check_and_call_js_hook(); // called from the browser main loop // anonymously (round-robin in a small reserved pool) and can't be addressed // or cancelled by any tag. Takes ownership of wire. uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire); + +// Immutable, two-level nested sequences. Build into a staging definition, +// commit atomically, then trigger the committed definition in one-shot or +// loop mode. Pattern events are ordinary AMY wire events and cannot trigger +// another pattern. Existing instances retain the committed version they +// started with, so replacing/clearing a definition never truncates playback. +uint8_t amy_pattern_begin(uint32_t pattern, uint32_t length_ticks); +uint8_t amy_pattern_add_wire(uint32_t pattern, uint32_t tick, + uint32_t period, uint32_t tag, bool has_tag, + const char *wire); +uint8_t amy_pattern_add_event(uint32_t pattern, const amy_event *event); +uint8_t amy_pattern_commit(uint32_t pattern); +uint8_t amy_pattern_clear(uint32_t pattern); +uint8_t amy_pattern_trigger(uint32_t pattern, uint8_t mode, + uint32_t quantize_ticks, uint32_t instance_tag); +uint8_t amy_pattern_stop(uint32_t instance_tag, uint32_t quantize_ticks); +// Temporarily suppress onsets from every running instance with this tag. The +// instance keeps advancing, and resumes at its original phase after duration. +uint8_t amy_pattern_mute(uint32_t instance_tag, uint32_t duration_ticks); +// Store a root-sequencer event which triggers a pattern relative to the next +// quantized boundary. A nonzero period repeats that trigger without making +// the child pattern itself loop through the intervening silence. +uint8_t amy_pattern_schedule(uint32_t pattern, uint8_t mode, + uint32_t offset_ticks, uint32_t period_ticks, + uint32_t quantize_ticks, uint32_t sequence_tag, + uint32_t instance_tag); + +// Wire entry point. zQE stores events in a staging definition; the remaining +// zQ actions handle lifecycle, scheduling and mute. +uint16_t amy_parse_pattern_control_message(char *message); void sequencer_midi_clock_tick(); void sequencer_midi_start(); void sequencer_midi_stop(); diff --git a/tests/test_nested_sequencer.c b/tests/test_nested_sequencer.c new file mode 100644 index 00000000..0051b442 --- /dev/null +++ b/tests/test_nested_sequencer.c @@ -0,0 +1,517 @@ +// Two-level sequencer patterns: wire/C authoring, one-shot and loop playback, +// quantized activation and immutable committed versions. +// +// The existing H sequencer is intentionally exercised in the same process: +// the new zQ path must not change its modulo, tag or wire behavior. + +#include +#include +#include +#include "amy.h" +#include "sequencer.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +typedef struct mark_t { + char name[24]; + uint32_t tick; +} mark_t; + +static mark_t marks[128]; +static int mark_count = 0; + +static void mark_hook(const char *code) { + if (mark_count >= (int)(sizeof(marks) / sizeof(marks[0]))) return; + snprintf(marks[mark_count].name, sizeof(marks[mark_count].name), "%s", + code); + marks[mark_count].tick = sequencer_ticks(); + mark_count++; +} + +static void clear_marks(void) { + mark_count = 0; + bzero(marks, sizeof(marks)); +} + +static void clock_to(uint32_t target) { + while (sequencer_ticks() < target) sequencer_midi_clock_tick(); +} + +static uint32_t next_boundary(uint32_t now, uint32_t quantum) { + uint32_t remainder = now % quantum; + return now + (remainder == 0 ? quantum : quantum - remainder); +} + +static int mark_at(const char *name, uint32_t tick) { + for (int i = 0; i < mark_count; ++i) { + if (!strcmp(marks[i].name, name) && marks[i].tick == tick) return 1; + } + return 0; +} + +static int marks_named(const char *name) { + int count = 0; + for (int i = 0; i < mark_count; ++i) + if (!strcmp(marks[i].name, name)) count++; + return count; +} + +static void test_existing_h_is_unchanged(void) { + printf("existing H wire semantics remain unchanged\n"); + sequencer_reset(); + clear_marks(); + uint32_t now = sequencer_ticks(); + uint32_t first = now + (4 - now % 4); + if (first == now) first += 4; + + amy_add_message("H0,4,0zProotZ"); + clock_to(first + 4); + CHECK(mark_at("root", first), "H period event fires at old global modulo"); + CHECK(mark_at("root", first + 4), "H period event keeps looping"); + amy_add_message("H0,0,0Z"); +} + +static void test_existing_h_tag_and_anonymous_behavior_is_unchanged(void) { + printf("existing H tag replacement, clear and anonymous behavior remain unchanged\n"); + sequencer_reset(); + clear_marks(); + uint32_t first = sequencer_ticks() + 4; + char wire[80]; + + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,17zPold-tagZ", first); + amy_add_message(wire); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,17zPnew-tagZ", first); + amy_add_message(wire); + snprintf(wire, sizeof(wire), "H%" PRIu32 "zPanon-aZ", first); + amy_add_message(wire); + snprintf(wire, sizeof(wire), "H%" PRIu32 "zPanon-bZ", first); + amy_add_message(wire); + clock_to(first); + CHECK(!marks_named("old-tag") && mark_at("new-tag", first), + "a legacy H tag still replaces only its previous entry"); + CHECK(mark_at("anon-a", first) && mark_at("anon-b", first), + "legacy anonymous H entries still coexist at one tick"); + + clear_marks(); + uint32_t second = sequencer_ticks() + 4; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,18zPcleared-tagZ", second); + amy_add_message(wire); + amy_add_message("H0,0,18Z"); + clock_to(second); + CHECK(!marks_named("cleared-tag"), + "legacy H zero/zero/tag still clears a future entry"); +} + +static void test_existing_c_ticks_are_unchanged(void) { + printf("existing C amy_event tick scheduling remains unchanged\n"); + sequencer_reset(); + amy_add_message("S0Z"); + amy_execute_deltas(); + uint32_t target = sequencer_ticks() + 4; + amy_event event = amy_default_event(); + event.osc = 0; + event.wave = TRIANGLE; + event.ticks[TICKS_TICK] = target; + event.ticks[TICKS_PERIOD] = 0; + event.ticks[TICKS_TAG] = 12; + amy_add_event(&event); + clock_to(target - 2); + amy_execute_deltas(); + CHECK(synth[0] == NULL || synth[0]->wave != TRIANGLE, + "C tick event does not fire early"); + clock_to(target); + amy_execute_deltas(); + CHECK(synth[0] != NULL && synth[0]->wave == TRIANGLE, + "C tick event fires at its original absolute tick"); +} + +static void test_wire_one_shot_and_loop(void) { + printf("wire-authored pattern supports one-shot and loop modes\n"); + sequencer_reset(); + clear_marks(); + + amy_add_message("zQB0,8Z"); + amy_add_message("zQE0,0,8,0zPwire0Z"); + amy_add_message("zQE0,2,8,1zPwire2Z"); + amy_add_message("zQC0Z"); + + uint32_t one_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQT0,0,4Z"); + CHECK(!marks_named("wire0"), + "an asynchronous trigger never fires inside the API call"); + clock_to(one_start - 2); + CHECK(!marks_named("wire0"), "quantized one-shot waits for its boundary"); + clock_to(one_start + 10); + CHECK(mark_at("wire0", one_start), "one-shot emits local tick zero"); + CHECK(mark_at("wire2", one_start + 2), "one-shot emits relative tick two"); + CHECK(marks_named("wire0") == 1 && marks_named("wire2") == 1, + "one-shot does not wrap"); + + clear_marks(); + uint32_t loop_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQT0,1,4,41Z"); + clock_to(loop_start + 10); + CHECK(mark_at("wire0", loop_start) + && mark_at("wire0", loop_start + 8), + "loop wraps at pattern length"); + CHECK(mark_at("wire2", loop_start + 2) + && mark_at("wire2", loop_start + 10), + "loop preserves relative offsets"); + + uint32_t stop = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQS41,4Z"); + clock_to(stop + 8); + CHECK(!mark_at("wire0", stop), "quantized stop suppresses boundary event"); +} + +static void test_c_event_api(void) { + printf("C event API uses the same tick/period/tag pattern model\n"); + sequencer_reset(); + amy_event event = amy_default_event(); + event.osc = 0; + event.wave = SINE; + event.midi_note = 60; + event.velocity = 1; + event.ticks[TICKS_TICK] = 0; + event.ticks[TICKS_PERIOD] = 4; + event.ticks[TICKS_TAG] = 0; + + CHECK(amy_pattern_begin(1, 4), "C API begins staging pattern"); + CHECK(amy_pattern_add_event(1, &event), "C API stores amy_event"); + CHECK(amy_pattern_commit(1), "C API commits atomically"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + CHECK(amy_pattern_trigger(1, AMY_PATTERN_ONE_SHOT, 4, + AMY_PATTERN_UNTAGGED), + "C API arms quantized one-shot"); + clock_to(start); + amy_execute_deltas(); + CHECK(synth[0] != NULL && synth[0]->status == SYNTH_AUDIBLE, + "C-authored event reaches normal AMY event playback"); + amy_add_message("v0l0Z"); + amy_execute_deltas(); +} + +static void test_committed_version_survives_replacement(void) { + printf("running instance retains committed definition version\n"); + sequencer_reset(); + clear_marks(); + + CHECK(amy_pattern_begin(2, 8), "begin old definition"); + CHECK(amy_pattern_add_wire(2, 0, 8, 0, true, "zPold0Z"), + "store old tick zero"); + CHECK(amy_pattern_add_wire(2, 6, 8, 1, true, "zPold6Z"), + "store old tail"); + CHECK(amy_pattern_commit(2), "commit old definition"); + uint32_t old_start = next_boundary(sequencer_ticks(), 4); + CHECK(amy_pattern_trigger(2, AMY_PATTERN_ONE_SHOT, 4, + AMY_PATTERN_UNTAGGED), + "trigger old definition"); + + CHECK(amy_pattern_begin(2, 8), "begin replacement"); + CHECK(amy_pattern_add_wire(2, 0, 8, 0, true, "zPnew0Z"), + "store replacement"); + CHECK(amy_pattern_commit(2), "commit replacement while old is pending"); + clock_to(old_start + 6); + CHECK(mark_at("old0", old_start) && mark_at("old6", old_start + 6), + "old one-shot finishes after definition replacement"); + CHECK(!marks_named("new0"), "replacement did not leak into old instance"); + + uint32_t new_start = next_boundary(sequencer_ticks(), 4); + CHECK(amy_pattern_trigger(2, AMY_PATTERN_ONE_SHOT, 4, + AMY_PATTERN_UNTAGGED), + "trigger replacement"); + clock_to(new_start); + CHECK(mark_at("new0", new_start), "next instance uses replacement"); +} + +static void test_mute_event_targets_tag_and_preserves_phase(void) { + printf("a scheduled mute gates selected running tags and preserves phase\n"); + sequencer_reset(); + clear_marks(); + + CHECK(amy_pattern_begin(11, 4), "begin muted background"); + CHECK(amy_pattern_add_wire(11, 0, 2, 0, true, "zPmutedZ"), + "store muted background pulse"); + CHECK(amy_pattern_commit(11), "commit muted background"); + CHECK(amy_pattern_begin(12, 4), "begin independent background"); + CHECK(amy_pattern_add_wire(12, 0, 2, 0, true, "zPkeptZ"), + "store independent background pulse"); + CHECK(amy_pattern_commit(12), "commit independent background"); + + uint32_t background_start = next_boundary(sequencer_ticks(), 4); + CHECK(amy_pattern_trigger(11, AMY_PATTERN_LOOP, 4, 81), + "start tagged background to mute"); + CHECK(amy_pattern_trigger(12, AMY_PATTERN_LOOP, 4, 82), + "start tagged background to retain"); + clock_to(background_start + 2); + CHECK(mark_at("muted", background_start) + && mark_at("kept", background_start), + "both backgrounds initially sound"); + + CHECK(amy_pattern_begin(13, 4), "begin explicit mute overlay"); + CHECK(amy_pattern_add_wire(13, 0, 4, 0, true, "zQM81,4Z"), + "mute is accepted as a non-nesting leaf event"); + CHECK(amy_pattern_add_wire(13, 0, 4, 1, true, "zPfillZ"), + "overlay also contains an ordinary event"); + CHECK(amy_pattern_commit(13), "commit explicit mute overlay"); + uint32_t fill_start = next_boundary(sequencer_ticks(), 4); + CHECK(amy_pattern_trigger(13, AMY_PATTERN_ONE_SHOT, 4, + AMY_PATTERN_UNTAGGED), + "arm explicit mute overlay"); + clock_to(fill_start + 4); + CHECK(mark_at("fill", fill_start), "overlay event fires"); + CHECK(!mark_at("muted", fill_start) + && !mark_at("muted", fill_start + 2), + "target tag has no onsets for the exact mute duration"); + CHECK(mark_at("kept", fill_start) && mark_at("kept", fill_start + 2), + "untargeted tag keeps running"); + CHECK(mark_at("muted", fill_start + 4), + "target resumes at its original phase after the mute"); + + clear_marks(); + CHECK(amy_pattern_mute(82, 4), "C API can mute a running tag directly"); + uint32_t direct_start = sequencer_ticks(); + clock_to(direct_start + 4); + CHECK(!mark_at("kept", direct_start + 2), + "direct mute applies to the next due onset"); + CHECK(mark_at("kept", direct_start + 4), + "direct mute expires without stopping the loop"); + + clear_marks(); + amy_add_message("zQM82,2147483647Z"); + uint32_t held_start = sequencer_ticks(); + clock_to(held_start + 2); + CHECK(!mark_at("kept", held_start + 2), + "wire mute can hold a controller-addressed layer silent"); + amy_add_message("zQM82,0Z"); + clock_to(held_start + 4); + CHECK(mark_at("kept", held_start + 4), + "zero-duration wire mute releases the layer on its original phase"); + amy_pattern_stop(81, 0); + amy_pattern_stop(82, 0); + clock_to(sequencer_ticks() + 2); +} + +static void test_relative_pattern_schedule(void) { + printf("pattern triggers can be scheduled relative to a quantized boundary\n"); + sequencer_reset(); + clear_marks(); + CHECK(amy_pattern_begin(14, 2), "begin scheduled one-shot"); + CHECK(amy_pattern_add_wire(14, 0, 2, 0, true, "zPscheduledZ"), + "store scheduled marker"); + CHECK(amy_pattern_commit(14), "commit scheduled one-shot"); + + uint32_t first = next_boundary(sequencer_ticks(), 4) + 2; + CHECK(amy_pattern_schedule(14, AMY_PATTERN_ONE_SHOT, 2, 8, 4, 20, + AMY_PATTERN_UNTAGGED), + "C API installs recurring root trigger"); + clock_to(first + 8); + CHECK(mark_at("scheduled", first), "first trigger uses relative offset"); + CHECK(mark_at("scheduled", first + 8), "root trigger repeats by period"); + amy_add_message("H0,0,20Z"); + clock_to(first + 16); + CHECK(marks_named("scheduled") == 2, + "ordinary H tag clear removes future triggers only"); + + clear_marks(); + uint32_t wire_first = next_boundary(sequencer_ticks(), 4) + 1; + amy_add_message("zQA14,0,1,8,4,21Z"); + clock_to(wire_first); + CHECK(mark_at("scheduled", wire_first), + "wire scheduler matches the C scheduling API"); + amy_add_message("H0,0,21Z"); +} + +static void test_third_level_is_rejected(void) { + printf("pattern payloads cannot create a third sequencer level\n"); + CHECK(amy_pattern_begin(5, 4), "begin leaf-only definition"); + CHECK(!amy_pattern_add_wire(5, 0, 4, 0, true, "H0,4v0l1Z"), + "root H payload rejected"); + CHECK(!amy_pattern_add_wire(5, 0, 4, 0, true, "zQE0,0,4,0v0l1Z"), + "nested zQE payload rejected"); + CHECK(!amy_pattern_add_wire(5, 0, 4, 0, true, "zQT0,0,0Z"), + "pattern trigger payload rejected"); + CHECK(!amy_pattern_add_wire(5, 0, 4, 0, true, "v0zQT0,0,0Z"), + "pattern trigger after an ordinary field is also rejected"); + CHECK(amy_pattern_add_wire(5, 0, 4, 0, true, "zQM77,2Z"), + "mute payload is allowed because it cannot create a level"); + CHECK(!amy_pattern_add_wire(5, 0, 4, 0, true, "v0l1"), + "unterminated pattern payload rejected"); + amy_pattern_clear(5); +} + +static void test_root_can_trigger_local_tick_zero(void) { + printf("a root sequence event can trigger pattern tick zero atomically\n"); + sequencer_reset(); + clear_marks(); + CHECK(amy_pattern_begin(6, 4), "begin root-triggered pattern"); + CHECK(amy_pattern_add_wire(6, 0, 4, 0, true, "zProot-childZ"), + "store local tick zero"); + CHECK(amy_pattern_commit(6), "commit root-triggered pattern"); + + uint32_t start = next_boundary(sequencer_ticks(), 4); + char wire[64]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,21zQT6,0,4Z", start); + amy_add_message(wire); + clock_to(start); + CHECK(mark_at("root-child", start), + "root trigger and child tick zero share one sequencer tick"); +} + +static void test_pattern_event_tag_semantics(void) { + printf("pattern tags and anonymous events match root sequencer semantics\n"); + sequencer_reset(); + clear_marks(); + CHECK(amy_pattern_begin(7, 4), "begin tag-semantics pattern"); + CHECK(amy_pattern_add_wire(7, 0, 4, 3, true, "zPclearedZ"), + "store tagged event"); + CHECK(!amy_pattern_add_wire(7, 0, 0, 3, true, "zPignoredZ"), + "zero/zero with a tag clears it"); + CHECK(!amy_pattern_add_wire(7, 0, 0, 0, false, "zPignoredZ"), + "anonymous zero/zero remains a no-op"); + CHECK(amy_pattern_add_wire(7, 1, 4, 0, false, "zPanon-aZ"), + "first anonymous event is stored"); + CHECK(amy_pattern_add_wire(7, 1, 4, 0, false, "zPanon-bZ"), + "second anonymous event does not replace the first"); + CHECK(amy_pattern_commit(7), "commit tag-semantics pattern"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + CHECK(amy_pattern_trigger(7, AMY_PATTERN_ONE_SHOT, 4, + AMY_PATTERN_UNTAGGED), + "trigger tag-semantics pattern"); + clock_to(start + 1); + CHECK(!marks_named("cleared"), "cleared tag never fires"); + CHECK(mark_at("anon-a", start + 1) && mark_at("anon-b", start + 1), + "anonymous entries coexist at the same local tick"); +} + +static void test_reset_stops_instances_but_keeps_definitions(void) { + printf("RESET_SEQUENCER clears playback but preserves stored definitions\n"); + sequencer_reset(); + clear_marks(); + CHECK(amy_pattern_begin(8, 4), "begin reset-survival pattern"); + CHECK(amy_pattern_add_wire(8, 0, 4, 0, true, "zPsurvivorZ"), + "store reset-survival event"); + CHECK(amy_pattern_commit(8), "commit reset-survival pattern"); + uint32_t first_start = next_boundary(sequencer_ticks(), 4); + CHECK(amy_pattern_trigger(8, AMY_PATTERN_LOOP, 4, 88), + "start loop before reset"); + clock_to(first_start); + CHECK(mark_at("survivor", first_start), "loop sounded before reset"); + + clear_marks(); + sequencer_reset(); + clock_to(first_start + 4); + CHECK(!marks_named("survivor"), "reset stopped the running instance"); + + uint32_t second_start = next_boundary(sequencer_ticks(), 4); + CHECK(amy_pattern_trigger(8, AMY_PATTERN_ONE_SHOT, 4, + AMY_PATTERN_UNTAGGED), + "stored definition is still triggerable"); + clock_to(second_start); + CHECK(mark_at("survivor", second_start), + "preserved definition plays after reset"); +} + +static void test_timebase_reset_preserves_local_phase(void) { + printf("RESET_TIMEBASE preserves pattern phase and pending distances\n"); + sequencer_reset(); + clear_marks(); + CHECK(amy_pattern_begin(9, 8), "begin phase pattern"); + CHECK(amy_pattern_add_wire(9, 3, 8, 0, true, "zPphase-threeZ"), + "store local phase marker"); + CHECK(amy_pattern_commit(9), "commit phase pattern"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + CHECK(amy_pattern_trigger(9, AMY_PATTERN_LOOP, 4, 90), + "start loop before timebase reset"); + clock_to(start + 2); + clear_marks(); + + amy_add_message("S16384Z"); + amy_simple_fill_buffer(); + CHECK(sequencer_ticks() == 0, "shared tick counter restarted at zero"); + sequencer_midi_clock_tick(); + CHECK(mark_at("phase-three", 1), + "active loop continued at the same local phase"); + sequencer_reset(); +} + +static void test_pattern_activation_wraps_with_tick_clock(void) { + printf("quantized pattern activation survives uint32 tick rollover\n"); + sequencer_reset(); + clear_marks(); + CHECK(amy_pattern_begin(10, 4), "begin rollover pattern"); + CHECK(amy_pattern_add_wire(10, 0, 4, 0, true, "zPwrappedZ"), + "store rollover tick zero"); + CHECK(amy_pattern_commit(10), "commit rollover pattern"); + amy_global.sequencer_tick_count = UINT32_MAX - 2; + CHECK(amy_pattern_trigger(10, AMY_PATTERN_ONE_SHOT, 4, + AMY_PATTERN_UNTAGGED), + "arm activation across rollover"); + sequencer_midi_clock_tick(); + sequencer_midi_clock_tick(); + CHECK(mark_at("wrapped", 0), "local tick zero fired after wrap"); + sequencer_reset(); + amy_global.sequencer_tick_count = 0; +} + +static void test_configured_bounds_are_enforced(void) { + printf("configured pattern, tag and instance bounds are enforced\n"); + sequencer_reset(); + CHECK(amy_pattern_begin(31, 8), "last configured pattern is valid"); + CHECK(!amy_pattern_begin(32, 8), + "first pattern past the configured range is refused"); + CHECK(amy_pattern_add_wire(31, 0, 8, 63, true, "zPlast-tagZ"), + "last configured event tag is valid"); + CHECK(!amy_pattern_add_wire(31, 0, 8, 64, true, "zPbad-tagZ"), + "first event tag past the configured range is refused"); + CHECK(amy_pattern_commit(31), "commit bounds pattern"); + for (int i = 0; i < 32; ++i) { + CHECK(amy_pattern_trigger(31, AMY_PATTERN_ONE_SHOT, 64, + AMY_PATTERN_UNTAGGED), + "instance slot %d is available", i); + } + CHECK(!amy_pattern_trigger(31, AMY_PATTERN_ONE_SHOT, 64, + AMY_PATTERN_UNTAGGED), + "one instance beyond the configured pool is refused"); + sequencer_reset(); +} + +// examples.c calls this; the platform normally provides it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + amy_start(config); + + test_existing_h_is_unchanged(); + test_existing_h_tag_and_anonymous_behavior_is_unchanged(); + test_existing_c_ticks_are_unchanged(); + test_wire_one_shot_and_loop(); + test_c_event_api(); + test_committed_version_survives_replacement(); + test_mute_event_targets_tag_and_preserves_phase(); + test_relative_pattern_schedule(); + test_third_level_is_rejected(); + test_root_can_trigger_local_tick_zero(); + test_pattern_event_tag_semantics(); + test_reset_stops_instances_but_keeps_definitions(); + test_timebase_reset_preserves_local_phase(); + test_pattern_activation_wraps_with_tick_clock(); + test_configured_bounds_are_enforced(); + + amy_stop(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall nested sequencer checks passed\n"); + return 0; +}