diff --git a/.github/sequence-lsan.supp b/.github/sequence-lsan.supp new file mode 100644 index 00000000..72829584 --- /dev/null +++ b/.github/sequence-lsan.supp @@ -0,0 +1,7 @@ +# amy_reset_oscs() reinitializes the global bus filters without releasing the +# previous filter arrays. This is present on the Shorepine-main baseline and is +# outside the reusable-sequence ownership being validated here. +leak:filters_init +# Repeated amy_start()/amy_stop() in the baseline leaves replaced patch-slot +# arrays allocated as well. Keep sequence allocations visible. +leak:patches_init diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index 30b12070..5322b243 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -111,7 +111,7 @@ jobs: python-version: '3.13' - name: Check generated C API bindings are in sync - run: make check-c-api + run: make check-c-api js-api-test godot-build: # Build the Godot GDExtension for Linux. amy_midi.c is excluded from the diff --git a/.github/workflows/godot-addon.yml b/.github/workflows/godot-addon.yml index b741f6db..ff9039ba 100644 --- a/.github/workflows/godot-addon.yml +++ b/.github/workflows/godot-addon.yml @@ -4,6 +4,8 @@ on: push: tags: - '*' + pull_request: + branches: [ "main" ] workflow_dispatch: permissions: @@ -15,14 +17,6 @@ jobs: fail-fast: false matrix: include: - - name: macOS - runner: macos-latest - platform: macos - arch: universal - - name: Linux - runner: ubuntu-22.04 - platform: linux - arch: x86_64 - name: Windows runner: windows-latest platform: windows @@ -71,6 +65,7 @@ jobs: package: needs: build + if: github.event_name != 'pull_request' runs-on: ubuntu-latest name: Package Addon diff --git a/.github/workflows/premerge-sequence-sanitizers.yml b/.github/workflows/premerge-sequence-sanitizers.yml new file mode 100644 index 00000000..73884dbf --- /dev/null +++ b/.github/workflows/premerge-sequence-sanitizers.yml @@ -0,0 +1,60 @@ +name: Pre-merge sequence sanitizers + +on: + pull_request: + branches: [ "main" ] + +jobs: + address-and-leak: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Install header-generation dependency + run: pip install numpy + - name: Build sequence tests with AddressSanitizer + run: >- + make tests/test_sequencer_active tests/test_sequencer_bounds + tests/test_sequencer_sequences tests/test_sequencer_oom + tests/test_sequencer_concurrency + CFLAGS="-O1 -g -Wall -Wextra -Wno-unused-parameter + -Wno-strict-aliasing -Wno-float-conversion + -Wno-missing-declarations -Wpointer-arith -DAMY_WAVETABLE + -fsanitize=address -fno-omit-frame-pointer" + - name: Run legacy tests without process-exit leak checks + env: + ASAN_OPTIONS: detect_leaks=0:halt_on_error=1 + run: | + tests/test_sequencer_active + tests/test_sequencer_bounds + - name: Run lifecycle tests with leak detection + env: + ASAN_OPTIONS: detect_leaks=1:halt_on_error=1 + LSAN_OPTIONS: suppressions=.github/sequence-lsan.supp:print_suppressions=1 + run: | + tests/test_sequencer_sequences + tests/test_sequencer_oom + tests/test_sequencer_concurrency + + thread: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Install header-generation dependency + run: pip install numpy + - name: Build the concurrency test with ThreadSanitizer + run: >- + make tests/test_sequencer_concurrency + CFLAGS="-O1 -g -Wall -Wextra -Wno-unused-parameter + -Wno-strict-aliasing -Wno-float-conversion + -Wno-missing-declarations -Wpointer-arith -DAMY_WAVETABLE + -fsanitize=thread -fno-omit-frame-pointer" + - name: Run the concurrency test + env: + TSAN_OPTIONS: halt_on_error=1 + run: tests/test_sequencer_concurrency diff --git a/.gitignore b/.gitignore index db16b158..77fa66f4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ tests/tst tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds +tests/test_sequencer_sequences +tests/test_sequencer_oom +tests/test_sequencer_concurrency tests/test_bus_config tests/test_patch_slots tests/test_synth_readout diff --git a/Makefile b/Makefile index f849e7dc..45703259 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,7 @@ EMSCRIPTEN_OPTIONS = -s WASM=1 --bind \ -s ASYNCIFY -s ASYNCIFY_STACK_SIZE=128000 PYTHON = python3 -.PHONY: default all clean amy-module test ctest web deploy-web godot-api c-api check-c-api +.PHONY: default all clean amy-module test ctest web deploy-web godot-api c-api check-c-api js-api-test default: $(TARGET) all: default @@ -83,6 +83,9 @@ check-c-api: $(PYTHON) scripts/gen_patches_js.py --check $(PYTHON) scripts/gen_pcm_presets_js.py --check +js-api-test: + node tests/test_js_api.js + SOURCES += src/algorithms.c src/amy.c src/envelope.c src/examples.c src/parse.c \ src/filters.c src/oscillators.c src/pcm.c src/interp_partials.c src/custom.c \ src/delay.c src/log2_exp2.c src/patches.c src/transfer.c src/sequencer.c \ @@ -124,6 +127,9 @@ 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_sequencer_sequences \ + tests/test_sequencer_oom \ + tests/test_sequencer_concurrency \ 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 \ @@ -131,12 +137,28 @@ CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_ # Static pattern rules, so these win over the generic %.o: %.c above (which # would compile without -Isrc and fail to find amy.h). -$(addsuffix .o,$(CTESTS)): %.o: %.c $(HEADERS) src/patches.h +SEQUENCE_SPECIAL_TESTS = tests/test_sequencer_oom tests/test_sequencer_concurrency + +$(addsuffix .o,$(filter-out $(SEQUENCE_SPECIAL_TESTS),$(CTESTS))): %.o: %.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -Isrc -c $< -o $@ -$(CTESTS): %: %.o $(OBJECTS) +$(filter-out $(SEQUENCE_SPECIAL_TESTS),$(CTESTS)): %: %.o $(OBJECTS) $(CC) $(CFLAGS) $(OBJECTS) $< -Wall $(LIBS) -o $@ +# Build only the sequencer and its OOM test with the test-only allocation hook; +# every other test and every production target uses the ordinary object. +tests/sequencer_testing_impl.o: src/sequencer.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -c $< -o $@ + +tests/test_sequencer_oom.o: tests/test_sequencer_oom.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -Isrc -c $< -o $@ + +tests/test_sequencer_concurrency.o: tests/test_sequencer_concurrency.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -Isrc -c $< -o $@ + +$(SEQUENCE_SPECIAL_TESTS): %: %.o tests/sequencer_testing_impl.o $(filter-out src/sequencer.o,$(OBJECTS)) + $(CC) $(CFLAGS) $(filter-out src/sequencer.o,$(OBJECTS)) tests/sequencer_testing_impl.o $< -Wall $(LIBS) -o $@ + ctest: $(CTESTS) @for t in $(CTESTS); do echo "== $$t"; ./$$t || exit 1; done @@ -144,6 +166,7 @@ amy-module: amy-example ${EXTRA_PIP_ENV} ${PYTHON} -m pip install -r requirements.txt; touch src/amy.c; ${EXTRA_PIP_ENV} ${PYTHON} -m pip install . --force-reinstall --no-deps; cd .. test: amy-module + ${PYTHON} tests/test_sequence_api.py ${PYTHON} -m amy.test qtest: amy-module diff --git a/README.md b/README.md index 77d9e83e..e0dfa8df 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ AMY was built by [DAn Ellis](https://research.google/people/DanEllis/) and [Bria * [**Interactive AMY tutorial**](https://shorepine.github.io/amy/tutorial.html) * [**AMY API**](docs/api.md) * [**AMY Synthesizer Details**](docs/synth.md) + * [**AMY Reusable Sequences**](docs/sequencer-sequences.md) * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) @@ -171,6 +172,7 @@ It's good to understand what wire messages are but you don't need to construct t * [**Interactive AMY tutorial**](https://shorepine.github.io/amy/tutorial.html) * [**AMY API**](docs/api.md) * [**AMY Synthesizer Details**](docs/synth.md) + * [**AMY Reusable Sequences**](docs/sequencer-sequences.md) * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) diff --git a/amy/__init__.py b/amy/__init__.py index 13b40e53..7347dba2 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -240,8 +240,164 @@ def str_of_int(arg): return str(int(arg)) +def _list_values(value): + """Return a wire-list argument as individual values for validation.""" + if isinstance(value, str): + return value.split(',') + if isinstance(value, (list, tuple)): + return list(value) + return [value] + + +_SEQUENCE_UINT32_MAX = (1 << 32) - 1 +_SEQUENCE_MAX_INTERVAL = (1 << 31) - 1 + + +def _sequence_uint32(value, name, allow_template=False): + """Return one exact sequence integer without lossy numeric coercion.""" + if allow_template and isinstance(value, str) and value.startswith('%'): + return value + if isinstance(value, bool): + raise ValueError('%s must be a non-negative integer.' % name) + if isinstance(value, int): + result = value + elif isinstance(value, str) and value.strip().isdigit(): + result = int(value.strip()) + else: + raise ValueError('%s must be a non-negative integer.' % name) + if result < 0: + raise ValueError('%s must be non-negative.' % name) + if result > _SEQUENCE_UINT32_MAX: + raise ValueError('%s must be in uint32 range.' % name) + return result + + +def _sequence_interval(value, name, allow_template=False): + result = _sequence_uint32(value, name, allow_template=allow_template) + if isinstance(result, str): + return result + if result > _SEQUENCE_MAX_INTERVAL: + raise ValueError('%s must not exceed 2147483647 ticks.' % name) + return result + + +def _message_ticks(value): + values = _list_values(value) + if not 1 <= len(values) <= 3: + raise ValueError('ticks needs tick, optional period, and optional tag.') + names = ('ticks tick', 'ticks period', 'ticks tag') + normalized = [] + numeric = [] + for index, item in enumerate(values): + # Empty list fields have always meant zero on the AMY wire. Preserve + # that spelling as well as the meaning; the tutorial and existing + # callers use ticks=",period,tag" for a tick-zero event. + if item is None or (isinstance(item, str) and not item.strip()): + normalized.append(item) + numeric.append(0) + else: + parsed = _sequence_uint32(item, names[index]) + normalized.append(parsed) + numeric.append(parsed) + # tick < period is a reusable-sequence invariant. Legacy untagged two- + # field scheduling retains its historical wire behavior. + if (len(numeric) == 3 and numeric[1] + and numeric[0] >= numeric[1]): + raise ValueError('ticks tick must be below its nonzero period.') + return normalized + + +def _sequence_control_values(value): + """Validate the low-level ``HC`` payload without blocking templates.""" + values = _list_values(value) + if len(values) < 2: + raise ValueError('sequence_control needs at least tag and action.') + values[0] = _sequence_uint32( + values[0], 'sequence_control tag', allow_template=True) + raw_action = values[1] + if isinstance(raw_action, str) and raw_action.startswith('%'): + # Command templates substitute the token before AMY parses HC. The + # resulting wire value must still be the integer 0, 1, or 2. + if not 2 <= len(values) <= 4: + raise ValueError('A templated sequence_control needs tag, action, and up to duration and alignment_period.') + for index in range(2, len(values)): + values[index] = _sequence_interval( + values[index], 'templated sequence_control field', + allow_template=True) + return values + if isinstance(raw_action, int) and not isinstance(raw_action, bool): + action = raw_action + elif isinstance(raw_action, str) and raw_action.isdigit(): + action = int(raw_action) + else: + raise ValueError('sequence_control action must be an integer: stop=0, start=1, or gate=2.') + if action in (SEQUENCE_CONTROL_STOP, SEQUENCE_CONTROL_START): + if len(values) not in (2, 3): + raise ValueError('A start/stop sequence_control needs tag, action, and optional alignment_period.') + elif action == SEQUENCE_CONTROL_GATE: + if len(values) not in (3, 4): + raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.') + else: + raise ValueError('sequence_control action must be stop=0, start=1, or gate=2.') + values[1] = action + field_names = ('sequence_control duration', 'sequence_control alignment_period') \ + if action == SEQUENCE_CONTROL_GATE else ('sequence_control alignment_period',) + for index, name in enumerate(field_names, start=2): + if index < len(values): + values[index] = _sequence_interval( + values[index], name, allow_template=True) + return values + + +def _normalize_sequence_action(kwargs): + """Translate a named sequence action into the existing HC primitive.""" + if 'sequence' not in kwargs: + for key in ('action', 'duration', 'alignment_period'): + if key in kwargs: + raise ValueError('%s is only valid with sequence.' % key) + return kwargs + if 'sequence_control' in kwargs or 'sequence_reset' in kwargs: + raise ValueError('sequence cannot be combined with sequence_control or sequence_reset.') + extra = set(kwargs) - { + 'sequence', 'action', 'duration', 'alignment_period', 'ticks' + } + if extra: + raise ValueError('sequence can only be combined with action, duration, alignment_period, and ticks.') + if 'action' not in kwargs: + raise ValueError("sequence needs action='start', 'stop', or 'gate'.") + tag = _sequence_uint32(kwargs['sequence'], 'Sequence tag') + alignment = _sequence_interval( + kwargs.get('alignment_period', 0), 'Sequence alignment_period') + action_name = kwargs['action'] + actions = { + 'stop': SEQUENCE_CONTROL_STOP, + 'start': SEQUENCE_CONTROL_START, + 'gate': SEQUENCE_CONTROL_GATE, + } + if not isinstance(action_name, str) or action_name not in actions: + raise ValueError("Sequence action must be 'start', 'stop', or 'gate'.") + action = actions[action_name] + if action == SEQUENCE_CONTROL_GATE: + if 'duration' not in kwargs: + raise ValueError("Sequence action='gate' needs a duration in ticks.") + duration = _sequence_interval( + kwargs['duration'], 'Sequence gate duration') + control = (tag, action, duration, alignment) + else: + if 'duration' in kwargs: + raise ValueError('Sequence duration is only valid with action=\'gate\'.') + control = (tag, action, alignment) + normalized = {} + if 'ticks' in kwargs: + normalized['ticks'] = kwargs['ticks'] + normalized['sequence_control'] = control + return normalized + + _KW_MAP_LIST = [ # Order matters because patch_string must come last. - # 'ticks' must come first: 'H' is recognized only as first char in wire message. + # Sequence/ticks headers must come first: 'H' is only recognized as the + # first wire character. sequence_control follows a ticks + # header when it is used as that scheduled event's payload. ('ticks', 'HL'), ('osc', 'vI'), ('wave', 'wI'), ('note', 'nF'), ('vel', 'lF'), ('amp', 'aC'), ('freq', 'fC'), ('duty', 'dC'), ('feedback', 'bF'), ('reset', 'SI'), ('phase', 'PF'), ('sample_offset', 'poI'), ('fit', 'pFF'), ('fit_search', 'pSI'), ('pan', 'QC'), ('client', 'gI'), @@ -253,6 +409,8 @@ def str_of_int(arg): ('dist_clip', 'GCI'), ('dist_fold', 'GFI'), ('dist_crush', 'GHL'), ('dist_drive', 'GDC'), ('dist_mix', 'GMC'), ('algo_source', 'OL'), ('load_sample', 'zL'), ('transfer_file', 'zTL'), ('disk_sample', 'zFL'), ('algorithm', 'oI'), ('chorus', 'kL'), ('reverb', 'hL'), ('echo', 'ML'), ('patch', 'KI'), + ('sequence_reset', 'HRI'), + ('sequence_control', 'HCL'), ('external_channel', 'WI'), ('portamento', 'mI'), ('tempo', 'jF'), ('sequencer_run', 'zYI'), ('external_midi_sync', 'zCI'), ('synth', 'iI'), ('pedal', 'ipI'), ('synth_flags', 'ifI'), ('num_voices', 'ivI'), ('oscs_per_voice', 'inI'), @@ -277,6 +435,9 @@ def message(**kwargs): # Each keyword maps to two or three chars, first one or two are the wire protocol prefix, last is an arg type code # I=int, F=float, S=str, L=list, C=ctrl_coefs global show_warnings, _KW_MAP, _KW_PRIORITY, _ARG_HANDLERS + kwargs = _normalize_sequence_action(kwargs) + if kwargs.get('ticks') is not None: + kwargs['ticks'] = _message_ticks(kwargs['ticks']) if show_warnings: # Check for possible user confusions. if 'voices' in kwargs and 'preset' in kwargs and 'osc' not in kwargs: @@ -296,6 +457,20 @@ def message(**kwargs): if 'wave' not in kwargs or kwargs['wave'] != BYO_PARTIALS: raise ValueError('\'num_partials\' must be used with \'wave\'=BYO_PARTIALS.') + outer_sequence_keys = {'ticks', 'sequence_reset'} & kwargs.keys() + if len(outer_sequence_keys) > 1: + raise ValueError('Use only one of sequence_reset or ticks in a message.') + if 'sequence_reset' in kwargs and len(kwargs) != 1: + raise ValueError('sequence_reset must be sent as a standalone message.') + if 'sequence_reset' in kwargs: + kwargs['sequence_reset'] = _sequence_uint32( + kwargs['sequence_reset'], 'sequence_reset tag') + if 'sequence_control' in kwargs: + if set(kwargs) - {'sequence_control', 'ticks'}: + raise ValueError('sequence_control can only be combined with ticks.') + kwargs['sequence_control'] = _sequence_control_values( + kwargs['sequence_control']) + # Validity check all the passed args. prioritized_keys = [] for key, arg in kwargs.items(): @@ -373,6 +548,51 @@ def send(**kwargs): send_raw(m) +def _sequence_ticks(value): + """Normalize a stored-sequence event's local (tick, period) tuple.""" + if isinstance(value, str): + values = value.split(',') + elif isinstance(value, (list, tuple)): + values = list(value) + else: + values = [value] + if not 1 <= len(values) <= 2: + raise ValueError('A stored sequence event needs ticks=(tick,) or ticks=(tick, period).') + tick = _sequence_uint32(values[0], 'Stored sequence tick') + period = _sequence_uint32(values[1], 'Stored sequence period') \ + if len(values) == 2 else 0 + if period and tick >= period: + raise ValueError('A stored sequence tick must be below its nonzero period.') + return tick, period + + +def define_sequence(tag, events): + """Replace one reusable tagged sequence with ordinary AMY events. + + Each event is a mapping accepted by :func:`message` and must contain a + local ``ticks`` value with one or two fields. All event messages are + validated before the reset is sent, then the definition is written as a + per-tag reset followed by explicit cumulative event appends. Executions + which already started keep their previous immutable definition. + """ + sequence_tag = _sequence_uint32(tag, 'Sequence tag') + event_messages = [] + for event in events: + values = dict(event) + if 'ticks' not in values: + raise ValueError('Every stored sequence event needs a ticks value.') + if 'sequence_reset' in values: + raise ValueError('Stored sequence events cannot contain sequence authoring commands.') + tick, period = _sequence_ticks(values.pop('ticks')) + if not values: + raise ValueError('Every stored sequence event needs an AMY payload.') + event_messages.append(message(ticks=(tick, period, sequence_tag), **values)) + + send_raw(message(sequence_reset=sequence_tag)) + for event_message in event_messages: + send_raw(event_message) + + # 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..9f85e94d 100644 --- a/amy/constants.py +++ b/amy/constants.py @@ -124,6 +124,9 @@ TICKS_TICK=0 TICKS_PERIOD=1 TICKS_TAG=2 +SEQUENCE_CONTROL_STOP=0 +SEQUENCE_CONTROL_START=1 +SEQUENCE_CONTROL_GATE=2 RESET_SEQUENCER=4096 RESET_ALL_OSCS=8192 RESET_TIMEBASE=16384 diff --git a/amy/examples.py b/amy/examples.py index 3cf35772..309516ec 100644 --- a/amy/examples.py +++ b/amy/examples.py @@ -257,18 +257,14 @@ def example_sequencer_drums(): # Update high cowbell amy.send(osc=4, note=70) - # Add patterns - # Hi hat every 1/8th note - amy.send(ticks=[0, 24, 0], osc=2, vel=2.0) - - # Bass drum every quarter note - amy.send(ticks=[0, 96, 1], osc=0, vel=1.0) - - # Snare every quarter note, counterphase to BD - amy.send(ticks=[24, 96, 2], osc=1, vel=1.0) - - # Cow once every other cycle - amy.send(ticks=[0, 192, 3], osc=3, vel=1.0) + # Store all parts as one reusable pattern, then start it explicitly. + amy.define_sequence(0, [ + dict(ticks=(0, 24), osc=2, vel=2.0), # hi-hat every eighth note + dict(ticks=(0, 96), osc=0, vel=1.0), # bass drum every quarter + dict(ticks=(24, 96), osc=1, vel=1.0), # counterphase snare + dict(ticks=(0, 192), osc=3, vel=1.0), # cowbell every other cycle + ]) + amy.send(sequence=0, action='start', alignment_period=1) def example_fm(): amy.reset() diff --git a/amy/test.py b/amy/test.py index 2539b32e..116440ff 100644 --- a/amy/test.py +++ b/amy/test.py @@ -2034,7 +2034,7 @@ def __init__(self): self.default_synths = True def run(self): - amy_send_at(time=100, ticks='20,24,0', synth=1, note=64, vel=1) + amy_send_at(time=100, ticks='20,24', synth=1, note=64, vel=1) class TestSequencedSynthDrums(AmyTest): @@ -2046,7 +2046,7 @@ def __init__(self): def run(self): # The sequencer working on the SYNTH_FLAGS_NOTES_VIA_MIDI synth 10 (38 = Acoustic Snare). - amy_send_at(time=100, ticks='20,24,0', synth=10, note=38, vel=1) + amy_send_at(time=100, ticks='20,24', synth=10, note=38, vel=1) class TestSequencerOsc(AmyTest): @@ -2058,10 +2058,10 @@ class TestSequencerOsc(AmyTest): def run(self): amy_send_at(time=0, osc=0, wave=amy.SINE, freq=1000) # Absolute-tick events: note on at tick 20 (~231 ms), off at tick 40 (~463 ms). - amy.send(osc=0, vel=1, ticks="20,0,1") - amy.send(osc=0, vel=0, ticks="40,0,2") + amy.send(osc=0, vel=1, ticks="20") + amy.send(osc=0, vel=0, ticks="40") # Periodic event: a lower note every 60 ticks, lands once at ~694 ms. - amy.send(osc=1, wave=amy.SINE, freq=500, vel=1, ticks="0,60,3") + amy.send(osc=1, wave=amy.SINE, freq=500, vel=1, ticks="0,60") amy_send_at(time=900, osc=1, vel=0) @@ -2341,4 +2341,3 @@ def main(argv): if __name__ == "__main__": main(sys.argv) - diff --git a/docs/api.md b/docs/api.md index 0a19e45f..750a7893 100644 --- a/docs/api.md +++ b/docs/api.md @@ -203,7 +203,9 @@ amy_start(amy_config); | `write_samples_fn` | fn ptr | `NULL` | If provided, `amy_update` will call this with each new block of samples | | `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_sequencer_tags` | Int | 256 | Number of reusable sequencer tag identities | +| `max_sequence_events` | Int | 64 | Maximum ordinary events in one reusable tagged sequence | +| `max_sequence_executions` | Int | 32 | Maximum active or alignment-pending reusable-sequence executions | | `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 | @@ -503,7 +505,9 @@ At bus scope only the constant term of `GD`/`GM` is used; a bus sum has no per-n | Wire code | C `amy_event` | Python / JS | Type-range | Notes | | ------ | -------- | ---------- | ---------- | ------------------------------------- | -| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | Tick, period, tag for sequencing (see "AMY's sequencer" in synth.md). `tag` omitted: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. **If used in a wire string message**, the `H` **must** be the first character of the message. | +| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | `tag` omitted: schedule directly on the global clock. `tag` supplied: append to that reusable sequence using local ticks; repeating a tag cumulates. **If used in a wire string message**, the `H` **must** be the first character of the message. | +| `HR` | — | `sequence_reset` | tag | Clear the future definition at one tag; already-started immutable executions may finish. | +| `HC` | — | `sequence_control` | tag,action[,alignment] or tag,gate,duration[,alignment] | Stop (`action=0`), start (`action=1`), align, or temporarily gate (`action=2`) a reusable tagged sequence. Actions are integers, not velocity or fractional values. Python callers use the named `action='stop'`, `'start'`, or `'gate'`; gate also requires `duration`. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | | `zY` | **TODO** | `sequencer_run` | 0/1 | Sequencer transport: `zY1` starts the sequencer, `zY0` stops it. Lets a host drive playback without MIDI clock sync (see `external_midi_sync`). | | `zC` | **TODO** | `external_midi_sync` | 0/1/2 | MIDI clock sync: 1 = the sequencer follows incoming MIDI realtime clock/start/stop (0xF8/0xFA/0xFC); 2 = AMY is the clock master, sending those messages (0xF8 at 24 PPQ from the internal tempo, 0xFA/0xFC on transport start/stop); 0 (default) = internal clock, neither follows nor sends. | diff --git a/docs/billie_jean.md b/docs/billie_jean.md index 56a38cad..3ec4ea56 100644 --- a/docs/billie_jean.md +++ b/docs/billie_jean.md @@ -293,7 +293,7 @@ timed_note chord_notes[] = { }; ``` -We have a new function that takes an entire table of `timed_notes` along with a starting sequencer tick and a channel (synth), and schedules them all, including note-offs if the table includes nonzero note durations. The scheduling itself is the `ticks` field of the `amy_event` structure: setting `e.ticks[0]` to an absolute sequencer tick makes AMY hold the event and play it when its clock reaches that tick. (The `ticks` field can also describe repeating patterns - `e.ticks[1]` is a repeat period and `e.ticks[2]` a tag you can use to replace or cancel an entry - but here we only need the one-shot absolute-tick form.) The sequencer counts 48 ticks per quarter note, and each “tick” of our pattern tables is an eighth note, so we convert between the two with `amy_ticks_per_tick = 24`. +We have a new function that takes an entire table of `timed_notes` along with a starting sequencer tick and a channel (synth), and schedules them all, including note-offs if the table includes nonzero note durations. The scheduling itself is the `ticks` field of the `amy_event` structure: setting `e.ticks[0]` to an absolute sequencer tick makes AMY hold the event and play it when its clock reaches that tick. (The `ticks` field can also describe repeating patterns with `e.ticks[1]`, while `e.ticks[2]` adds the event to a reusable tagged sequence; here we only need the untagged one-shot absolute-tick form.) The sequencer counts 48 ticks per quarter note, and each “tick” of our pattern tables is an eighth note, so we convert between the two with `amy_ticks_per_tick = 24`. ```C float amy_ticks_per_tick = 24.0f; diff --git a/docs/midi.md b/docs/midi.md index 6ef8988e..96f0f6e3 100644 --- a/docs/midi.md +++ b/docs/midi.md @@ -81,9 +81,9 @@ Because an `AMY_MIDI` osc emits MIDI in response to ordinary note events, you ca amy.send(osc=0, wave=amy.AMY_MIDI) # set up the MIDI sender once # Send a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note. -amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # note on at tick 0 of each 48-tick period -amy.send(osc=0, note=60, vel=0, ticks="24,48,2") # note off at tick 24 of each 48-tick period +amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # both events accumulate behind tag 1 +amy.send(osc=0, note=60, vel=0, ticks="24,48,1") +amy.send(sequence=1, action='start', alignment_period=48) ``` -AMY keeps sending those MIDI messages out the port at the configured tempo until you remove them (by their `tag`) or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. - +AMY keeps sending those MIDI messages out the port at the configured tempo until you stop tag 1 or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md new file mode 100644 index 00000000..dc812fe8 --- /dev/null +++ b/docs/sequencer-sequences-abstractions.md @@ -0,0 +1,150 @@ +# Reusable sequence abstractions and implementation + +## Public abstractions + +### Definition + +A three-value `ticks=(tick, period, tag)` event contributes one ordinary AMY +event to the reusable definition identified by `tag`. Repeating the tag +accumulates events. Ticks in a definition are local to each execution. + +`amy.define_sequence(tag, events)` is a Python replace-as-a-list convenience: +it validates every event, resets the future definition, and then sends the +tagged events. `sequence_reset=tag` resets only the definition used by future +starts. It does not rewrite an execution which already started. + +### Execution + +The action `start` creates an execution with its own local start tick. Several +finite executions of the same definition may overlap. The action `stop` +selects all executions of the tag which are active when the action is issued. +If the stop is aligned to a later boundary, an execution started after the +stop request is not implicitly captured by it. + +An execution containing only period-zero events is finite and retires after +its greatest local tick. If any event has a nonzero period, the execution +repeats until stopped. + +### Gate + +The action `gate` suppresses ordinary event dispatch for a duration while +local phase advances. It does not stop audio which is already ringing. +Sequence-control events continue to run while gated, allowing a finite +controller sequence to restore or change another sequence without being +blocked by its own gate. + +Suppression is deliberately event-agnostic: an ordinary event which falls in +the gated interval is skipped and is not replayed later. This includes +note-offs and parameter-restoration events. A definition which requires such +an event for cleanup should keep it outside the gated interval or put the +complete gesture in a separately started finite sequence. + +Gate duration and control alignment are limited to 2,147,483,647 ticks. This +keeps every pending boundary within the unambiguous half-range of AMY's +wrapping 32-bit tick comparisons. Once an execution has reached its start it +is latched as started, so an indefinitely running periodic sequence continues +across subsequent clock wraparounds. + +### Composition + +A stored payload may be an ordinary AMY event or a control for another +sequence. A finite sequence can therefore launch note gestures, control a +periodic sequence for a fixed number of repeats, or coordinate several +independent phrases. Cycles are not recursively expanded through C call +frames: each successful start occupies a slot in the bounded execution pool, +so a cyclic graph fails further starts once that pool is full and remains +recoverable through stop or reset. + +## Event ordering and ownership + +For a given tick, sequence controls are processed before ordinary events. A +stop on a boundary therefore prevents the ordinary event on that boundary, +and a child start can include the child's local tick-zero event on the same +tick. + +Stopping an execution cancels its future payloads. AMY cannot synthesize a +generic inverse for arbitrary events: a payload may change a filter, load a +patch, start another sequence, or send a note. If a phrase must complete a +release, store that release in a finite child and stop the parent which creates +future children. If the caller intentionally stops the child itself, its +remaining payloads are intentionally cancelled. + +## Immutable snapshots + +A definition and an execution have different lifetimes. Once an execution +starts, it holds a reference to the exact definition version it observed. +Changing the tag publishes a new version for future starts; existing +executions continue to read their old versions. This prevents a live edit from +removing a pending note-off or changing another payload halfway through a +phrase. + +The implementation uses copy-on-write snapshot semantics. A definition owned +only by its tag can be appended in place. If an execution or competing writer +also holds it, an editor pins that source and constructs a complete candidate +copy. This is the data-versioning rule; it is not by itself sufficient for a +real-time audio thread because copying and freeing are variable-time work. + +## RCU-like publication and deferred reclamation + +Candidate construction happens outside `amy_queue_lock`. After cloning the +events and their wire strings, the editor briefly reacquires the lock and +publishes the candidate only if the tag still points to the source it cloned. +Publication is therefore a checked pointer swap. If another writer won the +race, the losing writer discards its private candidate outside the lock and +retries from the newly published definition. Concurrent cumulative writers do +not silently lose one another's events. + +Executions act as readers by retaining references to their immutable versions. +When the render path releases the last reference, it does not free the event +array or its strings. It links the definition onto an intrusive retired list, +which requires no allocation. A later non-rendering command boundary detaches +that list under the lock and performs destruction after releasing the lock. +Internally fired sequence payloads bypass the public command boundary so they +cannot accidentally reclaim memory from the render path. + +This is an RCU-like publication and reclamation scheme with explicit reference +counts, not a tracing garbage collector. Copy-on-write still describes how a +new immutable version is created; RCU-like publication describes how readers +continue safely and how old versions are retired without waiting or freeing on +the audio path. + +Two fixed ping-pong buffers are insufficient. Multiple overlapping or +indefinitely repeating executions may retain more than two historical +generations while additional edits are published. Explicit references allow +exactly the generations which remain in use to survive. A general garbage +collector would add machinery without improving that already-known ownership. + +## Why this matters on ESP32 + +At 48 kHz with 128-sample render blocks, one block represents approximately +2.67 ms. Heap allocation, copying many variable-length wire strings, heap +coalescing, PSRAM/cache latency, and destruction of an entire definition are +not usefully bounded operations within that deadline. Performing them while +holding the lock shared with sequence rendering can turn an infrequent live +edit into an audio dropout. + +The current design limits the shared-lock publication step to reference +updates, validation, and a pointer swap. The render path releases references +and links retired objects without allocating or freeing. This removes the +known variable-time definition work from the render critical section. + +That architecture reduces and bounds the source-level risk; it is not a claim +that every ESP32 configuration is proven hard real-time. Final assurance still +requires measurement on the target board with the intended sample rate, block +size, memory capabilities, effects load, concurrent authoring traffic, heap +low-water mark, and worst observed render deadline. + +## Capacity and per-tick cost + +`max_sequencer_tags` bounds definition identities. `max_sequence_events` +bounds events in one definition, and `max_sequence_executions` bounds active +or alignment-pending executions. Definitions allocate lazily. The tick loop +visits active executions and directly scheduled entries, not every inactive +definition. + +Allocation failure, a full definition, an unavailable execution slot, an +invalid tag, and malformed action shapes fail with diagnostics. A failed +publication leaves the previously published definition intact. + +See [Status and compatibility](sequencer-sequences-status.md) for validated +behavior, platform limits, and migration guidance. diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md new file mode 100644 index 00000000..348a6f07 --- /dev/null +++ b/docs/sequencer-sequences-howto.md @@ -0,0 +1,165 @@ +# Reusable sequence how-to + +This example preloads two arpeggios, starts one, and switches to the other on a +musical boundary. Python is the primary interface; the equivalent wire +messages are collected afterward. + +AMY's sequencer uses 48 ticks per quarter note. The example gives every note +an 18-tick gate and uses 48 ticks as its switching boundary. + +## 1. Define complete note gestures + +Store each note-on together with its note-off in a finite sequence: + +```python +import amy + +amy.define_sequence(20, [ + dict(ticks=(0,), synth=1, note=60, vel=1), + dict(ticks=(18,), synth=1, note=60, vel=0), +]) + +amy.define_sequence(21, [ + dict(ticks=(0,), synth=1, note=64, vel=1), + dict(ticks=(18,), synth=1, note=64, vel=0), +]) +``` + +Both definitions contain only period-zero events. Each start therefore creates +a finite execution which retires after its tick-18 note-off. + +## 2. Define two arpeggios + +The slower arpeggio starts the two note gestures half a quarter note apart. +The faster one starts them an eighth note apart: + +```python +amy.define_sequence(30, [ + dict(ticks=(0, 48), sequence=20, + action='start', alignment_period=1), + dict(ticks=(24, 48), sequence=21, + action='start', alignment_period=1), +]) + +amy.define_sequence(31, [ + dict(ticks=(0, 24), sequence=20, + action='start', alignment_period=1), + dict(ticks=(12, 24), sequence=21, + action='start', alignment_period=1), +]) +``` + +The nonzero periods make these parent executions repeat until explicitly +stopped. A stored sequence may contain ordinary AMY events or controls for +other sequences. + +## 3. Start and switch + +```python +amy.send(sequence=30, action='start', alignment_period=48) + +# Later: stop the old parent and start the new one at the same boundary. +amy.send(sequence=30, action='stop', alignment_period=48) +amy.send(sequence=31, action='start', alignment_period=48) +``` + +The stop prevents sequence 30 from launching another child at the selected +boundary. A note gesture launched before that boundary is an independent +execution, so it still sends its original note-off. The caller does not need +to mirror AMY's tick count or remember pending releases. + +Start may be sent again while an earlier finite execution of the same tag is +active. Each execution has its own local start tick and immutable definition +snapshot. + +## 4. Stop playback + +```python +amy.send(sequence=31, action='stop', alignment_period=48) +``` + +Stopping a parent cancels its future child launches. Stopping a leaf such as +sequence 20 instead deliberately cancels the future events of every selected +active leaf execution, including any pending note-off. This lets the caller +choose between a graceful parent stop and explicit truncation. + +
+Equivalent wire messages + +`H,,Z` appends a normal event to a reusable +definition. `HRZ` resets future contents. `HC` uses action `0` for stop, +`1` for start, and `2` for gate. + +```text +HR20Z +H0,0,20n60l1i1Z +H18,0,20n60l0i1Z + +HR21Z +H0,0,21n64l1i1Z +H18,0,21n64l0i1Z + +HR30Z +H0,48,30HC20,1,1Z +H24,48,30HC21,1,1Z + +HR31Z +H0,24,31HC20,1,1Z +H12,24,31HC21,1,1Z + +HC30,1,48Z +HC30,0,48Z +HC31,1,48Z +HC31,0,48Z +``` + +The final field of each `HC` message is the alignment period. Direct controls +with alignment `0` or `1` act on the next available sequencer tick; a larger +value selects the next global tick divisible by that value. + +
+ +## Temporarily gate one layer + +Suppose sequence 50 is a running periodic percussion layer. Suppress its +ordinary events for one quarter note without stopping its local clock: + +```python +amy.send( + sequence=50, + action='gate', + duration=48, + alignment_period=1, +) +``` + +After 48 ticks, ordinary event dispatch resumes on the original phase. Audio +which was already ringing is not cut off. A zero-duration gate removes the +current gate at the selected boundary: + +Gate skips every ordinary event in the interval rather than postponing it. In +particular, a note-off or parameter reset inside the interval will not run +later. Keep state-restoring events outside the gate or package a complete +note-on/note-off gesture in its own finite sequence. + +```python +amy.send( + sequence=50, + action='gate', + duration=0, + alignment_period=1, +) +``` + +
+Equivalent gate wire messages + +```text +HC50,2,48,1Z +HC50,2,0,1Z +``` + +
+ +For the complete lifecycle and reset rules, see +[Reusable sequences](sequencer-sequences.md). diff --git a/docs/sequencer-sequences-musical-use-cases.md b/docs/sequencer-sequences-musical-use-cases.md new file mode 100644 index 00000000..84a6fe78 --- /dev/null +++ b/docs/sequencer-sequences-musical-use-cases.md @@ -0,0 +1,81 @@ +# Musical use cases for reusable sequences + +Reusable sequences let a caller define a collection of ordinary AMY events +once and launch that collection as one musical unit. AMY gives no musical +meaning to a sequence tag: a sequence may contain notes, parameter changes, or +controls for other sequences. + +## Preloaded fills and phrases + +A rhythm engine can preload each fill or phrase as a finite sequence. Its live +schedule then needs only a sequence start instead of another copy of every +event in the phrase. This keeps controller traffic and controller code small +even when the phrase catalogue is large. + +An execution retains the definition with which it started. Rebuilding the +stored definition affects later starts but does not alter a phrase already in +progress. The caller therefore does not need to stream the phrase repeatedly, +calculate when it ends, or track which definition version is sounding. + +## Arpeggios with complete note ownership + +A short finite sequence can hold a note-on together with its matching +note-off. A periodic parent sequence can start these note-pair sequences in an +arpeggio pattern. + +Stopping or replacing the parent prevents later child starts. Children which +already started remain independent and deliver their original note-offs. A +live change of rate, direction, voicing, or harmony can therefore be expressed +without mirroring AMY's clock or maintaining pending-note state in the caller. + +Starting the same child again while an older execution is active is valid. +This permits note gates to overlap their trigger interval. If a caller instead +wants to truncate every active instance of the child, it can explicitly stop +the child's tag. + +## Temporarily thinning a rhythm + +A repeating percussion layer can be stored as a periodic sequence. The `gate` +action suppresses its ordinary event dispatch for a chosen number of ticks +while local phase continues. When the gate expires, the layer resumes where it +would otherwise have been. + +This action does not silence audio which is already ringing. It controls +future event dispatch and continues to process sequence-control events, so a +controller sequence cannot gate away its own recovery. The caller decides +which tags represent musical layers; AMY implements only generic action, +duration, and phase behavior. + +Ordinary events inside the interval are skipped, not delayed. For material +with a required note-off or parameter restoration, the author must place that +cleanup outside the gate or express the complete gesture as a separate finite +sequence. This keeps gate semantics independent of any particular instrument +or application. + +## A fixed number of repeats + +An event with a nonzero period repeats until its execution is stopped. To play +it exactly `N` times, a finite controller sequence can start the periodic +sequence at local tick zero and stop it at `N * period`. + +Sequence controls are processed before ordinary events on the same tick, so +the event at the stop boundary is not dispatched. This composes finite and +periodic sequences without adding a separate repeat-counter state. + +## Parameter automation and compound gestures + +Stored events are not limited to notes. A finite sequence can apply filter, +amplitude, pan, effects, patch, or other AMY changes at local ticks. This can +represent a reusable automation curve or a compound control gesture. AMY does +not invent inverse events when such an execution is stopped; the definition +must contain any restoration required by the caller's musical intent. + +## Live definition changes + +A controller can stop future launches, reset a tag, append a replacement +definition, and start it at a selected alignment. Executions which began before +the change keep their immutable snapshots; later starts use the replacement. + +The controller continues to own musical policy and the ordering of the edit. +It does not need to own definition versions, phrase completion, sequence phase, +or note-release bookkeeping. diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md new file mode 100644 index 00000000..39bd5eb1 --- /dev/null +++ b/docs/sequencer-sequences-status.md @@ -0,0 +1,167 @@ +# Reusable sequence status and compatibility + +This document records the implemented interface, the compatibility boundary, +and the validation which still depends on a particular target or downstream +application. It describes the reusable-sequence model in this source tree. + +## Implemented interface + +Python callers normally use named actions: + +```python +amy.send(sequence=40, action='start', alignment_period=48) +amy.send(sequence=40, action='stop', alignment_period=48) +amy.send(sequence=40, action='gate', duration=24, alignment_period=1) +``` + +`amy.define_sequence(tag, events)` is the validated replace-as-a-list helper. +The corresponding lower-level fields are `sequence_reset` and +`sequence_control`. JavaScript and Godot bindings expose those lower-level +fields through the generated API. + +The wire protocol uses: + +| Operation | Wire shape | Meaning | +| --- | --- | --- | +| append | `Htick,period,tagZ` | Add an ordinary event to a definition | +| reset | `HRtagZ` | Clear the definition used by future starts | +| stop | `HCtag,0,alignmentZ` | Stop the selected executions | +| start | `HCtag,1,alignmentZ` | Create an execution | +| gate | `HCtag,2,duration,alignmentZ` | Temporarily suppress ordinary events | + +The numeric action is deliberately a three-value action rather than a boolean +or a note velocity. Fractional values are rejected for every sequence tag, +tick, period, duration and alignment field. Tags, ticks and periods use uint32; +duration and alignment are capped at 2,147,483,647 ticks for wrap-safe pending +boundaries. + +## Compatibility summary + +| Existing use | Status | Required action | +| --- | --- | --- | +| Untagged `ticks=(tick,)` | Compatible | None | +| Untagged `ticks=(tick, period)` | Compatible | None | +| Empty zero fields such as `ticks=",period,tag"` | Compatible | None | +| Repeated tagged writes used to replace one event | Changed | Reset and rebuild the definition, or omit the tag for direct scheduling | +| A tagged event expected to become active immediately | Changed | Start its sequence explicitly | +| C `amy_event.ticks` with `TICKS_TAG` set | Changed like any tagged event | Build the definition, then issue an explicit start | +| Empty `H0,0,tagZ` used as cancellation | Compatible reset spelling | It still resets the future definition; stop an active execution separately | +| C code using `amy_config_t` | Source compatible after rebuild | Initialize with `amy_default_config()` and override named fields | +| Generated JavaScript or Godot bindings | Regeneration required | Rebuild the bindings with this AMY source | + +The intentional breaking change is limited to tagged scheduling. A tag now +identifies a stopped, cumulative definition: repeated tagged writes append, +and playback begins only after an explicit start. This replaces two properties +of the earlier tagged-event behavior, where a later write replaced the event +and the tagged event was active immediately. + +## Migrating a replaceable tagged event + +If the tag was only being used as a replace/remove handle, the smallest +migration is to omit it and keep using direct one-off or periodic scheduling. + +If the contents need to remain addressable as a reusable sequence, replace +them explicitly: + +```python +amy.send(sequence=tag, action='stop', alignment_period=period) +amy.define_sequence(tag, events) +amy.send(sequence=tag, action='start', alignment_period=period) +``` + +The low-level wire equivalent is: + +```text +HC,0,Z +HRZ +H,,Z +... +HC,1,Z +``` + +An aligned stop captures the executions which exist when the command is sent. +Replacing the definition changes future starts, while an execution which +already began retains its immutable snapshot. This lets a wrapper migrate +without tracking AMY's current tick, active note state, or definition version. +The wrapper must still choose its musical update boundary: replacing on the +next full period is simple and phase-stable, but may have more latency than an +application-specific mid-cycle update. + +One known first-party consumer of the replace-on-tag behavior is Tulip's +`AMYSequenceEvent` wrapper. Its `update()` and `remove()` operations need the +explicit stop/reset/append/start lifecycle above. That migration is localized, +but its live-edit boundary is a product choice and should be tested together +with the consumers of that wrapper. + +## Other source-compatibility details + +`amy_config_t` appends `max_sequence_events` and +`max_sequence_executions`. Appending preserves the offsets of existing +members, but changing the size of a public C structure is not a binary ABI +promise. Applications should be recompiled against the matching header and +library. As with other AMY configuration, begin with `amy_default_config()` so +new fields receive supported defaults. + +Limits are explicit. `max_sequencer_tags` bounds identities, +`max_sequence_events` bounds one definition, and +`max_sequence_executions` bounds active or alignment-pending executions. +Exhaustion, invalid tags, malformed actions, publication allocation failure, +and cyclic start graphs reject the affected operation without corrupting the +previously published generation. Callers which deliberately choose small +limits should treat a rejected operation as a normal bounded-resource failure. + +A multi-message upload is not a wire-level transaction. `define_sequence()` +validates every Python event before sending its reset, but a target-side +capacity or transport failure during the subsequent messages can leave the +successfully accepted prefix as the new definition. A protocol which needs +acknowledged all-or-nothing remote upload must add that acknowledgement above +AMY's one-way wire command stream; after a detected failure, reset the tag +before retrying. + +Resetting a definition does not stop an execution which already holds a +snapshot. `RESET_TIMEBASE` removes active and pending executions while +retaining definitions. `RESET_SEQUENCER` clears direct events, definitions, +and executions. + +## Automated validation + +The host test suite covers: + +- unchanged one- and two-value direct scheduling; +- cumulative definitions, explicit reset, finite and repeating executions; +- overlapping executions and more than two simultaneously retained snapshot + generations; +- same-tick control ordering, alignment, tick rollover, gate phase, and global + reset behavior; +- current-execution capture for aligned stop and gate; +- arbitrary payloads, sequence composition, bounded cycles, and exhausted + execution pools; +- allocation failure during pool initialization, new-definition creation and + candidate cloning, with recovery and no partial single-event publication; +- two competing writers, including checked publication and retry; +- Python validation and exact wire serialization; +- executable JavaScript serialization and generated binding freshness. + +The reusable-sequence C tests run as part of `make ctest`. Python API coverage +is in `tests/test_sequence_api.py`, and generated API checks are included in +`make check-c-api` and `make js-api-test`. + +## Target-dependent validation still required + +The ownership design keeps definition allocation, cloning, string copying, +and destruction off the render path and outside the shared render-lock +critical section. That is a source-level real-time property, not a substitute +for measuring a complete device. + +On an ESP32 target, validate the intended sample rate, block and DMA sizes, +memory capabilities, effects load, and authoring traffic. Record maximum +render time, missed DMA deadlines, publication critical-section time, heap +low-water mark, largest free block, and maximum retired-list depth. At 48 kHz +and 128 samples, the block deadline is approximately 2.67 ms. + +Generated Godot source is checked for freshness and syntax when the parser is +available. An executable Godot runtime behavior test remains target-dependent; +the sequence behavior itself is implemented in the common C core. + +See [Abstractions and implementation](sequencer-sequences-abstractions.md) for +the snapshot publication and deferred-reclamation design. diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md new file mode 100644 index 00000000..843eb398 --- /dev/null +++ b/docs/sequencer-sequences.md @@ -0,0 +1,112 @@ +# Reusable sequences + +A sequencer tag identifies a reusable sequence of ordinary AMY events. Sending +more than one event with the same tag accumulates those events, in the same way +that repeated `synth=` messages configure one synth. Tagged events use local +ticks and remain inactive until the sequence is started. + +Untagged `ticks` events keep their direct scheduling behavior on the global +sequencer clock. + +## Defining a sequence + +The Python convenience API replaces all future contents at a tag: + +```python +amy.define_sequence(40, [ + dict(ticks=(0,), synth=2, note=60, vel=1), + dict(ticks=(12,), synth=2, note=60, vel=0), +]) +``` + +Each event uses normal AMY keyword arguments. Its `ticks` value is local to the +start of the sequence and contains `tick` plus an optional `period`. +`define_sequence()` validates every event, resets the tag, then sends ordinary +tagged `ticks` messages: + +```python +amy.send(sequence_reset=40) +amy.send(ticks=(0, 0, 40), synth=2, note=60, vel=1) +amy.send(ticks=(12, 0, 40), synth=2, note=60, vel=0) +``` + +Repeating tag `40` accumulates both events. `sequence_reset=40` explicitly +replaces the definition; the empty wire form `H0,0,40Z` is an equivalent reset. +With an event payload, `ticks=(0, 0, 40)` is a valid local tick-zero event. + +## Starting and stopping + +```python +amy.send(sequence=40, action='start', alignment_period=1) +amy.send(sequence=40, action='stop', alignment_period=48) +amy.send(sequence=40, action='gate', duration=24, alignment_period=1) +``` + +The named actions expose the complete control model: `start` creates an +execution, `stop` terminates the selected executions, and `gate` temporarily +suppresses their ordinary events for the required `duration`. `vel` keeps its +usual meaning of note velocity. At the lower-level `sequence_control` API and +on the wire, actions use integers: stop `0`, start `1`, and gate `2`. +Fractional values are invalid. The optional `alignment_period` is the alignment +quantum. `0` or `1` acts at the next available sequencer tick for a direct +command. A larger value selects the next global tick divisible by that period. +When a sequenced parent starts a child, the child's local tick zero participates +in the same tick. + +A start creates a bounded execution. Finite executions of one tag may overlap, +so callers do not need execution IDs or note-lifetime bookkeeping. Stop targets +all executions of that tag which are active when the command is sent. If the +stop is aligned to a future boundary, a separate execution started after that +command does not inherit its pending stop. This avoids hidden per-tag control +state. Stopping a parent prevents future child starts, while children already +started retain their own event pairs. + +## Finite and repeating lifetime + +Lifetime follows directly from the periods of the stored events: + +- a definition containing only `period=0` events is finite and retires after + its last event; +- an event with nonzero `period` repeats on its local period until stopped; +- a finite controller sequence can start a periodic child and stop it after a + chosen number of periods. + +## Temporary event gating + +```python +amy.send(sequence=40, action='gate', duration=24, alignment_period=1) +``` + +This suppresses ordinary event dispatch from active executions of tag `40` for +24 ticks. Local phase continues, and dispatch resumes on the original phase. +Audio already ringing is not cut off. Sequence-control payloads remain active, +so a controller sequence can still complete its lifecycle. Duration zero +removes a gate at the selected boundary. + +Gated ordinary events are skipped and are not replayed. That rule also applies +to note-offs and parameter-restoration events. Keep required cleanup outside +the interval or in a separately started finite gesture. Duration and alignment +must not exceed 2,147,483,647 ticks so their boundaries remain unambiguous +across the wrapping 32-bit tick clock. + +## Reset behavior + +- `amy.send(sequence_reset=tag)` removes the future definition. Active + executions retain the snapshot they started with and may finish. +- `RESET_TIMEBASE` discards active or pending executions because their absolute + activation ticks cannot be rebased, but retains stored definitions. +- `RESET_SEQUENCER` clears untagged events, tagged definitions, and executions. + +## Capacity and realtime behavior + +`max_sequencer_tags` bounds public tag identities. `max_sequence_events` bounds +the number of events in one definition, and `max_sequence_executions` bounds +active or alignment-pending executions. Definitions allocate only when used; +inactive definitions are not scanned on each tick. + +See the [implementation model](sequencer-sequences-abstractions.md), +[musical use cases](sequencer-sequences-musical-use-cases.md), and +[step-by-step examples](sequencer-sequences-howto.md). The +[status and compatibility guide](sequencer-sequences-status.md) records the +intentional tagged-scheduling change, migration path, test coverage, and +target-dependent validation boundary. diff --git a/docs/synth.md b/docs/synth.md index cf0e6e39..fbf646ab 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -221,12 +221,13 @@ AMY starts a musical sequencer that works on `ticks` from startup. You can reset Ticks run at 48 PPQ at the set tempo. The tempo defaults to 108 BPM. This means there are 108 quarter notes a minute, and `48 * 108 = 5184` ticks a minute, 86 ticks a second. The tempo can be changed with `amy.send(tempo=120)`. -You can schedule an event with `amy.send(..., ticks="tick,period,tag")`. All three values are optional past `tick`: +You can schedule an event with `amy.send(..., ticks="tick,period,tag")`. +`period` and `tag` are optional. As in other AMY list fields, an empty numeric +field means zero, so `ticks=",24,7"` is the compact spelling for a tick-zero +event with period 24 and tag 7: ```python amy.send(osc=0, wave=amy.SAW_UP, eg0="0,1,500,0,500,0") # Pluck tone amy.send(osc=0, note=50, vel=1, ticks=amy.sequencer_ticks() + 96) # one-off: fires once, ~1s from now -amy.send(osc=0, note=38, vel=1, ticks="0,24,7") # repeating, cancelable via tag 7 -amy.send(osc=0, ticks="0,0,7") # cancel tag 7 amy.send(osc=0, note=72, vel=1, ticks="0,24") # repeating, not individually cancelable amy.reset() # Stop everything ``` @@ -237,9 +238,30 @@ You can schedule repeating events (like a step sequencer or drum machine) with ` For pattern sequencers like drum machines, you will also want to use `tick` alongside `period`. If both are given and `period` is nonzero, `tick` is assumed to be an offset on the `period`. For example, for a 16-step drum machine pattern running on eighth notes (PPQ/2), you would use a `period` of `16 * 24 = 384`. The first slot of the drum machine would have a `tick` of 0, the 2nd would have a `tick` offset of 24, and so on. -`tag` is optional. If you give one, you can cancel that event later by sending `ticks="0,0,tag"` with the same `tag`. If you omitted `tag` when setting up the sequence (a 1- or 2-value `ticks=`), the event is still scheduled and still fires, but it isn't addressable by any tag -- there's no way to cancel or replace it individually (only by something like `amy.reset()`, discarding all sequenced events), so only omit `tag` for events you don't need to manage later. +`tag` is optional. Without one, an event is scheduled directly on the global +sequencer clock and cannot be addressed individually. With a tag, the event is +added to a reusable sequence and its tick becomes local to each start of that +sequence. Repeating a tag accumulates events; reset the tag explicitly before +replacing its contents. -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. +If you are including AMY in a program, you can set the [hook `void (*amy_external_sequencer_hook)(uint32_t)`](api.md) to any function. This will be called at every tick with the current tick number as an argument. + +### Reusable tagged sequences + +A sequencer tag holds one or more ordinary events with local tick values. +Repeated three-value `ticks=(tick, period, tag)` messages cumulate behind the +same tag. `amy.define_sequence(tag, events)` is the convenient replace-as-a-list +operation. `sequence_control` starts, stops, aligns, or temporarily gates an +active tagged sequence. Component periods define looping; a definition +containing only period-zero events finishes after its last event. + +See [Reusable sequences](sequencer-sequences.md) for the concise API +and lifecycle reference. The accompanying guides explain the +[abstractions and implementation](sequencer-sequences-abstractions.md), +[musical use cases](sequencer-sequences-musical-use-cases.md), and a +[step-by-step Python example](sequencer-sequences-howto.md). See +[status and compatibility](sequencer-sequences-status.md) when migrating +existing tagged scheduling or configuring a target build. ## Core oscillators @@ -475,7 +497,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/docs/tutorial.html b/docs/tutorial.html index 6f0bdcae..035cdf50 100644 --- a/docs/tutorial.html +++ b/docs/tutorial.html @@ -162,16 +162,23 @@

AMY sequencer

amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks=",24,1") # play a PCM drum every eighth note. amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks=",48,2") # play a different PCM drum every quarter note. +amy.send(sequence=1, action='start', alignment_period=1) +amy.send(sequence=2, action='start', alignment_period=1)
-

You can remove or update sequence events by addressing their tag number

+

Events with the same tag cumulate into a reusable sequence. Stop and reset a tag before replacing its contents:

-amy.send(ticks=",,1") # remove the eighth note sequence -amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, note=70, ticks=",48,2") # change the quarter note event +amy.send(sequence=1, action='stop', alignment_period=1) +amy.send(sequence_reset=1) +amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, note=70, ticks=",48,1") +amy.send(sequence=1, action='start', alignment_period=1)

For patterns you want to also address their "slots", which is the offset within the pattern, like this

+amy.send(sequence=1, action='stop', alignment_period=1) +amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks="0,384,1") # first slot of a 16 1/8th note drum machine -amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,2") # ninth slot of a 16 1/8th note drum machine +amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,1") # ninth slot in the same tagged sequence +amy.send(sequence=1, action='start', alignment_period=384)
@@ -287,5 +294,3 @@ < - - diff --git a/docs/upgrading.md b/docs/upgrading.md index 6701af81..ed09f90a 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -3,6 +3,57 @@ Here we will post breaking APIs between releases of AMY and tips on porting. +## Reusable sequencer sequences (unreleased) + +For the complete compatibility matrix, migration lifecycle, validation status, +and target-dependent checks, see +[Reusable sequence status and compatibility](sequencer-sequences-status.md). + +Supplying the same tag in more than one three-value `ticks=` message now +accumulates all those events into a stopped reusable sequence. Previously, a +later event replaced the earlier event at that tag. This intentional change +makes a tag behave like a synth identity: repeated messages build it up. + +Tagged events therefore no longer begin repeating merely because they were +defined. Callers which used a unique tag as the replace/remove identity of one +automatically active event must either use tagless direct scheduling or adapt +their wrapper to reset, define, and explicitly start that tag. Updating such a +wrapper should stop the old execution, reset the future definition, append the +replacement events, and start it at the required alignment boundary. + +Code which only needs direct one-off or periodic scheduling should omit the +tag and keep using one- or two-value `ticks`: + +```python +amy.send(ticks=(20,), synth=1, note=60, vel=1) +amy.send(ticks=(24,), synth=1, note=60, vel=0) +``` + +To replace a tagged definition, reset it explicitly before appending its new +events. The Python helper validates the complete replacement before sending +anything: + +```python +amy.define_sequence(7, [ + dict(ticks=(0,), synth=1, note=60, vel=1), + dict(ticks=(12,), synth=1, note=60, vel=0), +]) +amy.send(sequence=7, action='start', alignment_period=1) +``` + +Sequence control is an explicit action, not a note velocity. Use +`action='start'`, `action='stop'`, or `action='gate'` in the Python convenience +API; gate additionally requires `duration`. The corresponding low-level and +wire action values are the integers `1`, `0`, and `2`; fractional values are +rejected. + +The C configuration adds `max_sequence_events` and +`max_sequence_executions`. They are appended to `amy_config_t`; initialize the +structure with `amy_default_config()` and then override named fields, as in all +current AMY examples. Recompile applications together with the updated AMY +headers and library whenever the public configuration structure changes. + + ## 1.0.X -> 1.1.X This is a big change that moves a lot of stuff you used to have to do yourself into AMY itself -- voice and synth handling, note stealing, MIDI, I2S, sequencer. @@ -69,5 +120,3 @@ void loop() { e.patch_number = 1024; patches_store_patch(&e, "v0w7f0"); // Or whatever the wire string defining your patch is. ``` - - diff --git a/experiments/sampler/play_cleanbreaks.py b/experiments/sampler/play_cleanbreaks.py index e094b0b4..34f170de 100644 --- a/experiments/sampler/play_cleanbreaks.py +++ b/experiments/sampler/play_cleanbreaks.py @@ -73,7 +73,6 @@ def main(): # Line tick 0 up with the first note-on (loading above consumed time). amy.send(reset=amy.RESET_TIMEBASE) t = 0 # ticks - tag = 1 print(f"\n when bars native break") for i, e in enumerate(picks): fit = e['bars'] * BAR_TICKS @@ -85,8 +84,7 @@ def main(): # past by the first render); play the opener directly. amy.send(**kw) else: - amy.send(ticks=[t, 0, tag], **kw) - tag += 1 + amy.send(ticks=[t], **kw) t += fit us_per_tick = int(60000000.0 / (args.bpm * PPQ)) # matches sequencer.c total = int(t * us_per_tick / 1e6 * SR) diff --git a/experiments/sampler/play_sampler.py b/experiments/sampler/play_sampler.py index a5e189dc..6cf4f527 100644 --- a/experiments/sampler/play_sampler.py +++ b/experiments/sampler/play_sampler.py @@ -187,7 +187,7 @@ def demo_hits(args): # Quantized to sequencer ticks live (PPQ/4 ticks per 16th). amy.send(tempo=args.bpm) for i, k in enumerate(order): - amy.send(ticks=[int(i * PPQ / 4), 0, i + 1], osc=(i % 24) + 1, + amy.send(ticks=[int(i * PPQ / 4)], osc=(i % 24) + 1, wave=amy.PCM, preset=presets[k], vel=1) time.sleep(len(order) * step + 2) return @@ -233,7 +233,7 @@ def demo_loops(args): amy.send(**kw) # ...and let the sequencer re-trigger every `fit` ticks after that. if args.loops > 1: - amy.send(ticks=[0, fit, i + 1], **kw) + amy.send(ticks=[0, fit], **kw) # "N loops" = N cycles of the longest break. total_ticks = max(l[4] for l in loops) * args.loops total = int(total_ticks * tick_samples(args.bpm)) diff --git a/godot/amy.gd b/godot/amy.gd index 7c8980af..165a0d31 100644 --- a/godot/amy.gd +++ b/godot/amy.gd @@ -339,6 +339,8 @@ var _KW_MAP: Dictionary = { "reverb": ["h", "L"], "echo": ["M", "L"], "patch": ["K", "I"], + "sequence_reset": ["HR", "I"], + "sequence_control": ["HC", "L"], "external_channel": ["W", "I"], "portamento": ["m", "I"], "tempo": ["j", "F"], @@ -414,31 +416,33 @@ var _KW_PRIORITY: Dictionary = { "reverb": 44, "echo": 45, "patch": 46, - "external_channel": 47, - "portamento": 48, - "tempo": 49, - "sequencer_run": 50, - "external_midi_sync": 51, - "synth": 52, - "pedal": 53, - "synth_flags": 54, - "num_voices": 55, - "oscs_per_voice": 56, - "synth_level": 57, - "to_synth": 58, - "grab_midi_notes": 59, - "note_source_channel": 60, - "synth_delay": 61, - "preset": 62, - "num_partials": 63, - "start_sample": 64, - "stop_sample": 65, - "bus": 66, - "mode": 67, - "midi_cc": 68, - "midi_note_cmd": 69, - "cv_trigger": 70, - "patch_string": 71, + "sequence_reset": 47, + "sequence_control": 48, + "external_channel": 49, + "portamento": 50, + "tempo": 51, + "sequencer_run": 52, + "external_midi_sync": 53, + "synth": 54, + "pedal": 55, + "synth_flags": 56, + "num_voices": 57, + "oscs_per_voice": 58, + "synth_level": 59, + "to_synth": 60, + "grab_midi_notes": 61, + "note_source_channel": 62, + "synth_delay": 63, + "preset": 64, + "num_partials": 65, + "start_sample": 66, + "stop_sample": 67, + "bus": 68, + "mode": 69, + "midi_cc": 70, + "midi_note_cmd": 71, + "cv_trigger": 72, + "patch_string": 73, } ## The control coefficient inputs, in wire order. Prefer naming these in a diff --git a/src/amy.c b/src/amy.c index 6919023c..75cc47b0 100644 --- a/src/amy.c +++ b/src/amy.c @@ -1298,7 +1298,9 @@ 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_sequence_events, + amy_global.config.max_sequence_executions); 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 +2478,7 @@ int16_t * amy_fill_buffer() { amy_global.total_blocks = 0; amy_global.total_samples = 0; amy_global.time = 0; + sequencer_sequence_reset_timebase(); 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..a37ae701 100644 --- a/src/amy.h +++ b/src/amy.h @@ -364,6 +364,10 @@ enum coefs{ #define TICKS_PERIOD 1 #define TICKS_TAG 2 +#define SEQUENCE_CONTROL_STOP 0 +#define SEQUENCE_CONTROL_START 1 +#define SEQUENCE_CONTROL_GATE 2 + // Reset masks #define RESET_SEQUENCER 4096 #define RESET_ALL_OSCS 8192 @@ -954,6 +958,11 @@ typedef struct { int8_t capture_device_id; int8_t playback_device_id; + // Append new configuration fields here so existing members retain their + // offsets for callers compiled against an earlier amy_config_t layout. + uint32_t max_sequence_events; + uint32_t max_sequence_executions; + } amy_config_t; typedef struct eq_state { @@ -1160,6 +1169,10 @@ uint32_t ms_to_samples(uint32_t ms) ; // API void amy_add_message(char *message); +// Internal render-side ingress, used by CV triggers. It deliberately avoids +// variable-time sequence reclamation and gives sequence controls the current +// render tick rather than pretending they came from an external caller. +void amy_add_message_from_render(char *message); // Parse and play a stored wire message now (a fired sequencer entry). void amy_play_message(char *message); // Like amy_add_message but the data is treated as coming from an external diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index 1b590b54..f8a7311e 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -51,6 +51,8 @@ var AMY_KW_MAP = { reverb: {wire: "h", type: "L"}, echo: {wire: "M", type: "L"}, patch: {wire: "K", type: "I"}, + sequence_reset: {wire: "HR", type: "I"}, + sequence_control: {wire: "HC", type: "L"}, external_channel: {wire: "W", type: "I"}, portamento: {wire: "m", type: "I"}, tempo: {wire: "j", type: "F"}, @@ -126,31 +128,33 @@ var AMY_KW_PRIORITY = { reverb: 44, echo: 45, patch: 46, - external_channel: 47, - portamento: 48, - tempo: 49, - sequencer_run: 50, - external_midi_sync: 51, - synth: 52, - pedal: 53, - synth_flags: 54, - num_voices: 55, - oscs_per_voice: 56, - synth_level: 57, - to_synth: 58, - grab_midi_notes: 59, - note_source_channel: 60, - synth_delay: 61, - preset: 62, - num_partials: 63, - start_sample: 64, - stop_sample: 65, - bus: 66, - mode: 67, - midi_cc: 68, - midi_note_cmd: 69, - cv_trigger: 70, - patch_string: 71 + sequence_reset: 47, + sequence_control: 48, + external_channel: 49, + portamento: 50, + tempo: 51, + sequencer_run: 52, + external_midi_sync: 53, + synth: 54, + pedal: 55, + synth_flags: 56, + num_voices: 57, + oscs_per_voice: 58, + synth_level: 59, + to_synth: 60, + grab_midi_notes: 61, + note_source_channel: 62, + synth_delay: 63, + preset: 64, + num_partials: 65, + start_sample: 66, + stop_sample: 67, + bus: 68, + mode: 69, + midi_cc: 70, + midi_note_cmd: 71, + cv_trigger: 72, + patch_string: 73 }; var AMY_COEF_FIELDS = ["const", "note", "vel", "eg0", "eg1", "mod0", "bend", "ext0", "ext1", "mod1"]; @@ -406,6 +410,9 @@ var AMY = { TICKS_TICK: 0, TICKS_PERIOD: 1, TICKS_TAG: 2, + SEQUENCE_CONTROL_STOP: 0, + SEQUENCE_CONTROL_START: 1, + SEQUENCE_CONTROL_GATE: 2, RESET_SEQUENCER: 4096, RESET_ALL_OSCS: 8192, RESET_TIMEBASE: 16384, diff --git a/src/api.c b/src/api.c index fd70fbef..daa68ee3 100644 --- a/src/api.c +++ b/src/api.c @@ -2,6 +2,7 @@ // C callable entry points to amy #include "amy.h" +#include "sequencer.h" amy_config_t amy_default_config() { amy_config_t c; @@ -48,6 +49,8 @@ amy_config_t amy_default_config() { c.max_oscs = 250; c.max_buses = AMY_DEFAULT_NUM_BUSES; c.max_sequencer_tags = 256; + c.max_sequence_events = 64; + c.max_sequence_executions = 32; c.max_voices = 64; c.max_synths = 64; c.max_memory_patches = 32; @@ -291,6 +294,7 @@ void amy_add_message_with_sysex_flag(char *message, bool sysex) { // Transfer status can't change mid-message, so the whole string is // one chunk of transfer payload. parse_transfer_message(message, (uint16_t)strlen(message)); + sequencer_reclaim_retired(); return; } // Fast pre-check of this message for a leading 'H' (ticks) scheduling @@ -301,6 +305,10 @@ void amy_add_message_with_sysex_flag(char *message, bool sysex) { // Not scheduled: parse and play every command in the message now. amy_play_message(message); } + // Public wire ingestion is a control-side boundary. Sequence playback uses + // amy_play_message()/handle_ticks_message() directly, so it can never enter + // this reclamation path from the render thread. + sequencer_reclaim_retired(); } // given a wire message string play / schedule the event directly (WIRE API) @@ -308,6 +316,16 @@ void amy_add_message(char *message) { amy_add_message_with_sysex_flag(message, /* sysex */ false); } +void amy_add_message_from_render(char *message) { + if (message[0] == 'H') { + handle_ticks_message_with_origin( + message, SEQUENCER_ORIGIN_RENDER, + amy_global.sequencer_tick_count); + } else { + amy_play_message(message); + } +} + // Like amy_add_message but marks the message as coming from an external // sysex source so the transfer routing in amy_message_is_transfer_chunk() // applies. diff --git a/src/cv_trigger.c b/src/cv_trigger.c index 4f9bd6a4..3fd3ae13 100644 --- a/src/cv_trigger.c +++ b/src/cv_trigger.c @@ -116,7 +116,7 @@ void cv_trigger_generate_events(float *cv_inputs) { char message[AMY_WIRE_COMMAND_LEN]; substitute_midi_special_values(message, cv_trig->message_template, 0, 0, note); //fprintf(stderr, "update_external_cv_in: message %s\n", message); - amy_add_message(message); + amy_add_message_from_render(message); } } } else if ((polarity * cv_val) < (polarity * cv_trig->thresh_reset)) { diff --git a/src/parse.c b/src/parse.c index 436a4549..b06cb4ff 100644 --- a/src/parse.c +++ b/src/parse.c @@ -5,6 +5,7 @@ #include "transfer.h" // for amy_dump_state_to_sysex, amy_dump_file_to_sysex #include // for isalpha(). #include +#include #if defined(TULIP) || defined(AMYBOARD) #include "py/runtime.h" #endif @@ -704,29 +705,167 @@ size_t yield_event_from_message(char *message, amy_event *e, size_t pos) { return pos; } +static bool sequence_uint32(const char *cursor, const char **end, + uint32_t *value) { + while (*cursor == ' ') ++cursor; + if (!isdigit((unsigned char)*cursor)) return false; + errno = 0; + char *parsed_end = NULL; + unsigned long long parsed = strtoull(cursor, &parsed_end, 10); + if (errno == ERANGE || parsed > UINT32_MAX) return false; + while (*parsed_end == ' ') ++parsed_end; + *value = (uint32_t)parsed; + *end = parsed_end; + return true; +} + +static int sequence_control_uint_tail(const char *cursor, uint32_t *values, + int capacity) { + int count = 0; + while (*cursor == ',') { + ++cursor; + if (count == capacity + || !sequence_uint32(cursor, &cursor, &values[count])) + return -1; + count++; + } + if (*cursor != '\0' && (*cursor != 'Z' || cursor[1] != '\0')) return -1; + return count; +} + +static int sequence_ticks_prefix(const char *cursor, uint32_t values[3], + const char **payload) { + int count = 0; + while (count < 3) { + const char *field = cursor; + while (*field == ' ') ++field; + if (*field == ',') { + // The generic AMY list syntax uses an empty field for zero. Keep + // accepting H,period,tag and H,,tag legacy spellings. + values[count] = 0; + cursor = field; + } else if (!sequence_uint32(cursor, &cursor, &values[count])) { + return -1; + } + count++; + if (*cursor != ',') break; + if (count == 3) return -1; + cursor++; + const char *next = cursor; + while (*next == ' ') ++next; + // A trailing comma did not add another value in the legacy parser. + if (*next == '\0' || isalpha((unsigned char)*next)) { + cursor = next; + break; + } + } + if (*cursor != '\0' && !isalpha((unsigned char)*cursor)) return -1; + *payload = cursor; + return count; +} + // Called from amy_add_message when the first char is 'H', indicating a ticks message. // It claims the rest of the message as its payload -- stored as a raw // wire string and only parsed when it comes due -- so a schedule command // is only ever honored as the first command of a message. -void handle_ticks_message(char *message) { +void handle_ticks_message_with_origin(char *message, + sequencer_origin_t origin, + uint32_t current_tick) { assert(message[0] == 'H'); + if (message[1] == 'C') { + // HCtag,action[,alignment_period], for stop=0 or start=1. + // HCtag,gate,duration[,alignment_period] + const char *tag_end = NULL; + uint32_t tag = 0; + bool tag_valid = sequence_uint32(message + 2, &tag_end, &tag); + const char *action_start = tag_valid && *tag_end == ',' + ? tag_end + 1 : ""; + const char *action_end = NULL; + uint32_t action = 0; + bool action_valid = sequence_uint32( + action_start, &action_end, &action); + const char *tail = action_valid ? action_end : ""; + uint32_t rest[2] = {0, 0}; + int rest_count = action_valid + ? sequence_control_uint_tail(tail, rest, 2) : -1; + if (!tag_valid || *tag_end != ',' + || !action_valid || rest_count < 0) { + fprintf(stderr, + "invalid sequence_control: expected " + "HCtag,action[,alignment_period] (stop=0, start=1) or " + "HCtag,gate,duration[,alignment_period]\n"); + return; + } + + uint32_t value = 0; + uint32_t alignment = 0; + bool shape_valid = false; + if (action == SEQUENCE_CONTROL_STOP + || action == SEQUENCE_CONTROL_START) { + shape_valid = rest_count <= 1; + if (rest_count == 1) alignment = rest[0]; + } else if (action == SEQUENCE_CONTROL_GATE) { + shape_valid = rest_count >= 1 && rest_count <= 2; + value = rest[0]; + if (rest_count == 2) alignment = rest[1]; + } else { + shape_valid = false; + } + + if (!shape_valid) { + fprintf(stderr, + "invalid sequence_control: action must be stop=0, " + "start=1, or use " + "gate=2 with a duration; tag, duration, and " + "alignment must be non-negative integers\n"); + } else { + sequencer_sequence_control_with_origin( + tag, action, value, alignment, origin, current_tick); + } + return; + } + if (message[1] == 'R') { + // HRtag: clear the future stored events for this tag. Already-active + // immutable sequence executions are intentionally unaffected. + const char *end = NULL; + uint32_t tag = 0; + if (!sequence_uint32(message + 2, &end, &tag) + || (*end != '\0' && (*end != 'Z' || end[1] != '\0'))) + fprintf(stderr, "invalid sequence reset: expected HRtag\n"); + else + sequencer_sequence_reset_with_origin(tag, origin); + return; + } + uint32_t ticks[3] = {0, 0, 0}; - int num_vals = parse_list_uint32_t(message + 1, ticks, 3, 0); - uint16_t schedule_len = 1 + _next_alpha(message + 1); - char *payload = message + schedule_len; - uint16_t payload_len = (uint16_t)strlen(payload); - char *stripped = (char *)malloc_caps(payload_len + 1, amy_global.config.ram_caps_events); + const char *payload = NULL; + int num_vals = sequence_ticks_prefix(message + 1, ticks, &payload); + if (num_vals < 1) { + fprintf(stderr, + "invalid ticks command: expected Htick[,period[,tag]]payload, " + "HCtag,action, or HRtag\n"); + return; + } + size_t payload_len = strlen(payload); + char *stripped = payload_len >= UINT32_MAX ? NULL + : (char *)malloc_caps((uint32_t)(payload_len + 1), + amy_global.config.ram_caps_events); if (stripped == NULL) { amy_oom("ticks_message"); } else { memcpy(stripped, payload, payload_len + 1); - // A tag is only "given" if all 3 values were present; fewer + // A root tag is only "given" if all 3 values were present; fewer // than that (a 1- or 2-value ticks=) stores anonymously. - sequencer_add_wire(ticks[TICKS_TICK], ticks[TICKS_PERIOD], ticks[TICKS_TAG], - num_vals >= 3, stripped); + sequencer_add_wire_with_origin( + ticks[TICKS_TICK], ticks[TICKS_PERIOD], ticks[TICKS_TAG], + num_vals >= 3, stripped, origin); } } +void handle_ticks_message(char *message) { + handle_ticks_message_with_origin(message, SEQUENCER_ORIGIN_EXTERNAL, 0); +} + // given a string return a parsed event // // Transfer payloads never reach here: amy_add_message() traps them before @@ -906,4 +1045,3 @@ int amy_parse_message(char * message, amy_event *e) { // Return exactly how many characters we used. return pos; } - diff --git a/src/pcm.c b/src/pcm.c index 552085b7..c1468904 100644 --- a/src/pcm.c +++ b/src/pcm.c @@ -3,6 +3,10 @@ #include "amy.h" #include "transfer.h" +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + #ifdef __EMSCRIPTEN__ #include "emscripten.h" #endif diff --git a/src/pyamy.c b/src/pyamy.c index 49771038..d7b69fef 100644 --- a/src/pyamy.c +++ b/src/pyamy.c @@ -97,6 +97,24 @@ 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_sequence_events") == 0) { + llv = PyLong_AsLongLong(value); + if (PyErr_Occurred()) return -1; + if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { + PyErr_SetString(PyExc_ValueError, "max_sequence_events must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_events = (uint32_t)llv; + return 0; + } else if (strcmp(key, "max_sequence_executions") == 0) { + llv = PyLong_AsLongLong(value); + if (PyErr_Occurred()) return -1; + if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { + PyErr_SetString(PyExc_ValueError, "max_sequence_executions must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_executions = (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..198e6e08 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -1,6 +1,8 @@ #include "sequencer.h" #include "amy.h" +#include + #ifdef __EMSCRIPTEN__ #include #endif @@ -21,10 +23,10 @@ typedef struct sequence_info_t { int32_t next_active; } sequence_info_t; -struct sequence_info_t *sequences = NULL; // An array indexed by tag. -int32_t max_sequences = 0; // Number of user-addressable tags. -// Head of the ascending list of occupied slots (user tags and anonymous -// entries alike); -1 when nothing is scheduled. This replaces `highest_tag`, +struct sequence_info_t *sequences = NULL; // Anonymous direct-schedule slots. +uint32_t max_sequences = 0; // Number of user-addressable tags. +// Head of the ascending list of occupied anonymous slots; -1 when nothing is +// scheduled. This replaces `highest_tag`, // which was a HIGH-WATER MARK: it only ever grew, so one event at a high tag // made every tick scan that far for the rest of the session, long after that // sequence was cleared. The anonymous pool made that the common case, not a @@ -33,11 +35,8 @@ int32_t max_sequences = 0; // Number of user-addressable tags. // end of the table permanently. The cost is proportional to what is // scheduled now. int32_t first_active = -1; -// Anonymous (no-tag) entries live past the user-addressable tag range, at -// indices [max_sequences .. max_sequences+AMY_ANON_SEQUENCE_SLOTS), so a -// user-supplied tag (bounds-checked against max_sequences) can never reach -// or clobber one. Allocated round-robin; a new anonymous entry silently -// evicts the oldest one once the pool wraps around. +// Anonymous (no-tag) entries have their own fixed pool. Allocated round-robin; +// a new anonymous entry silently evicts the oldest once the pool wraps. #define AMY_ANON_SEQUENCE_SLOTS 256 static int32_t anon_cursor = 0; static volatile bool sequencer_running = true; @@ -47,7 +46,276 @@ 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) { +// Reusable sequences use the same public tag space as legacy root events. A +// definition is copy-on-write: executions retain the exact event list they +// started with while cumulative edits become the definition for future starts. +typedef struct stored_sequence_event_t { + char *wire; + uint32_t tick; + uint32_t period; +} stored_sequence_event_t; + +typedef struct stored_sequence_definition_t { + stored_sequence_event_t *events; + uint32_t event_count; + uint32_t last_one_shot_tick; + bool has_periodic_event; + uint32_t refs; + // Zero-reference definitions are linked here by the render path. A + // non-rendering sequence API call detaches the complete list under the + // queue lock and performs the variable-time frees after releasing it. + struct stored_sequence_definition_t *next_retired; +} stored_sequence_definition_t; + +typedef struct stored_sequence_execution_t { + stored_sequence_definition_t *definition; + uint32_t tag; + uint32_t start_tick; + uint32_t stop_tick; + uint32_t gate_change_tick; + uint32_t gate_duration; + uint32_t gate_end_tick; + uint32_t controls_processed_tick; + bool occupied; + bool started; + bool stop_pending; + bool gate_change_pending; + bool gated; + bool controls_processed; +} stored_sequence_execution_t; + +static stored_sequence_definition_t **stored_sequences = NULL; +static stored_sequence_execution_t *sequence_executions = NULL; +static uint32_t max_stored_sequence_events = 0; +static uint32_t max_stored_sequence_executions = 0; +static size_t stored_sequence_event_bytes = 0; +static stored_sequence_definition_t *retired_sequence_definitions = NULL; + +#ifdef AMY_SEQUENCE_TESTING +static int32_t stored_sequence_allocations_before_failure = -1; +static void (*stored_sequence_after_pin_hook)(void) = NULL; + +void sequencer_test_fail_allocation_after(int32_t successful_allocations) { + stored_sequence_allocations_before_failure = successful_allocations; +} + +void sequencer_test_set_after_pin_hook(void (*hook)(void)) { + stored_sequence_after_pin_hook = hook; +} +#endif + +static void *stored_sequence_allocate(size_t size, uint32_t caps) { +#ifdef AMY_SEQUENCE_TESTING + if (stored_sequence_allocations_before_failure == 0) return NULL; + if (stored_sequence_allocations_before_failure > 0) + stored_sequence_allocations_before_failure--; +#endif + if (size > UINT32_MAX) return NULL; + return malloc_caps((uint32_t)size, caps); +} + +static bool checked_array_size(uint32_t count, size_t element_size, + size_t *bytes) { + if (element_size == 0 || element_size > UINT32_MAX + || count > UINT32_MAX / element_size) + return false; + *bytes = (size_t)count * element_size; + return true; +} + +static void stored_sequence_definition_destroy( + stored_sequence_definition_t *definition) { + if (definition == NULL) return; + for (uint32_t i = 0; i < definition->event_count; ++i) + if (definition->events[i].wire != NULL) free(definition->events[i].wire); + free(definition->events); + free(definition); +} + +// References are changed only while amy_queue_lock is held. Return the object +// which reached zero so the caller can either retire it (render path) or free +// it after dropping the lock (control path). +static stored_sequence_definition_t *stored_sequence_definition_unref_locked( + stored_sequence_definition_t *definition) { + if (definition == NULL) return NULL; + assert(definition->refs != 0); + definition->refs--; + return definition->refs == 0 ? definition : NULL; +} + +static void stored_sequence_definition_retire_locked( + stored_sequence_definition_t *definition) { + stored_sequence_definition_t *retired = + stored_sequence_definition_unref_locked(definition); + if (retired == NULL) return; + retired->next_retired = retired_sequence_definitions; + retired_sequence_definitions = retired; +} + +static void stored_sequence_definition_destroy_list( + stored_sequence_definition_t *definition) { + while (definition != NULL) { + stored_sequence_definition_t *next = definition->next_retired; + stored_sequence_definition_destroy(definition); + definition = next; + } +} + +// External API boundaries call this after parsing. Render-side dispatch only +// retires definitions; it never enters this variable-time destruction path. +void sequencer_reclaim_retired(void) { + amy_grab_lock(); + stored_sequence_definition_t *retired = retired_sequence_definitions; + retired_sequence_definitions = NULL; + amy_release_lock(); + stored_sequence_definition_destroy_list(retired); +} + +static bool sequence_origin_may_reclaim(sequencer_origin_t origin) { + return origin == SEQUENCER_ORIGIN_EXTERNAL; +} + +static stored_sequence_definition_t * +stored_sequence_definition_release_locked( + stored_sequence_definition_t *definition, + sequencer_origin_t origin) { + if (sequence_origin_may_reclaim(origin)) + return stored_sequence_definition_unref_locked(definition); + stored_sequence_definition_retire_locked(definition); + return NULL; +} + +static stored_sequence_definition_t *stored_sequence_definition_new(void) { + stored_sequence_definition_t *definition = + (stored_sequence_definition_t *)stored_sequence_allocate( + sizeof(stored_sequence_definition_t), + amy_global.config.ram_caps_synth); + if (definition == NULL) return NULL; + definition->events = (stored_sequence_event_t *)stored_sequence_allocate( + stored_sequence_event_bytes, amy_global.config.ram_caps_synth); + if (definition->events == NULL) { + free(definition); + return NULL; + } + memset(definition->events, 0, stored_sequence_event_bytes); + definition->event_count = 0; + definition->last_one_shot_tick = 0; + definition->has_periodic_event = false; + definition->refs = 1; + definition->next_retired = NULL; + return definition; +} + +static char *stored_sequence_wire_copy(const char *wire) { + size_t len = strlen(wire); + char *copy = (char *)stored_sequence_allocate( + len + 1, amy_global.config.ram_caps_events); + if (copy != NULL) memcpy(copy, wire, len + 1); + return copy; +} + +static stored_sequence_definition_t *stored_sequence_definition_clone( + const stored_sequence_definition_t *source) { + stored_sequence_definition_t *copy = stored_sequence_definition_new(); + if (copy == NULL) return NULL; + if (source == NULL) return copy; + copy->event_count = source->event_count; + copy->last_one_shot_tick = source->last_one_shot_tick; + copy->has_periodic_event = source->has_periodic_event; + for (uint32_t i = 0; i < source->event_count; ++i) { + const stored_sequence_event_t *from = &source->events[i]; + copy->events[i].wire = stored_sequence_wire_copy(from->wire); + if (copy->events[i].wire == NULL) { + stored_sequence_definition_destroy(copy); + return NULL; + } + copy->events[i].tick = from->tick; + copy->events[i].period = from->period; + } + return copy; +} + +static void stored_sequence_execution_release_deferred( + stored_sequence_execution_t *execution) { + if (!execution->occupied) return; + stored_sequence_definition_t *definition = execution->definition; + memset(execution, 0, sizeof(*execution)); + stored_sequence_definition_retire_locked(definition); +} + +static void stored_sequence_executions_reset(void) { + if (sequence_executions == NULL) return; + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) + stored_sequence_execution_release_deferred(&sequence_executions[i]); +} + +static void stored_sequences_clear_definitions(void) { + if (stored_sequences == NULL) return; + for (uint32_t i = 0; i < max_sequences; ++i) { + stored_sequence_definition_retire_locked( + stored_sequences[i]); + stored_sequences[i] = NULL; + } +} + +static void stored_sequences_deinit(void) { + stored_sequence_executions_reset(); + stored_sequences_clear_definitions(); + if (stored_sequences != NULL) { + free(stored_sequences); + stored_sequences = NULL; + } + if (sequence_executions != NULL) { + free(sequence_executions); + sequence_executions = NULL; + } + max_stored_sequence_events = 0; + max_stored_sequence_executions = 0; + stored_sequence_event_bytes = 0; + stored_sequence_definition_t *retired = retired_sequence_definitions; + retired_sequence_definitions = NULL; + stored_sequence_definition_destroy_list(retired); +} + +static void stored_sequences_init(uint32_t events, uint32_t executions) { + max_stored_sequence_events = events; + max_stored_sequence_executions = executions; + if (max_sequences == 0 || events == 0 || executions == 0) return; + + size_t slot_bytes = 0; + size_t execution_bytes = 0; + if (!checked_array_size(max_sequences, + sizeof(*stored_sequences), &slot_bytes) + || !checked_array_size(events, sizeof(stored_sequence_event_t), + &stored_sequence_event_bytes) + || !checked_array_size(executions, + sizeof(stored_sequence_execution_t), + &execution_bytes)) { + fprintf(stderr, + "stored sequence configuration exceeds addressable memory: " + "tags=%" PRIu32 ", events=%" PRIu32 + ", executions=%" PRIu32 "\n", + max_sequences, events, executions); + stored_sequences_deinit(); + return; + } + stored_sequences = (stored_sequence_definition_t **)stored_sequence_allocate( + slot_bytes, amy_global.config.ram_caps_synth); + if (stored_sequences != NULL) + memset(stored_sequences, 0, slot_bytes); + sequence_executions = (stored_sequence_execution_t *)stored_sequence_allocate( + execution_bytes, amy_global.config.ram_caps_synth); + if (sequence_executions != NULL) + memset(sequence_executions, 0, execution_bytes); + if (stored_sequences == NULL || sequence_executions == NULL) { + amy_oom("stored sequences: out of memory\n"); + stored_sequences_deinit(); + return; + } +} + +void sequencer_init(uint32_t max_sequencer_tags, uint32_t sequence_events, + uint32_t sequence_execution_count) { // 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; @@ -55,16 +323,16 @@ void sequencer_init(int max_sequencer_tags) { wire_firing = false; anon_cursor = 0; max_sequences = max_sequencer_tags; - int32_t total_slots = max_sequences + AMY_ANON_SEQUENCE_SLOTS; - sequences = (struct sequence_info_t *)malloc_caps(total_slots * sizeof(struct sequence_info_t), + sequences = (struct sequence_info_t *)malloc_caps(AMY_ANON_SEQUENCE_SLOTS * sizeof(struct sequence_info_t), amy_global.config.ram_caps_synth); - for (int32_t i = 0; i < total_slots; ++i) { + for (int32_t i = 0; i < AMY_ANON_SEQUENCE_SLOTS; ++i) { sequences[i].wire = NULL; sequences[i].tick = 0; sequences[i].period = 0; sequences[i].next_active = -1; } first_active = -1; + stored_sequences_init(sequence_events, sequence_execution_count); // We are read to go. sequencer_recompute(); } @@ -72,7 +340,7 @@ void sequencer_init(int max_sequencer_tags) { void sequencer_reset() { // Remove all events (tagged and anonymous). No lock here: this is called // from play_delta() (RESET_SEQUENCER), which already runs under the amy lock. - for (int32_t i = 0; i < max_sequences + AMY_ANON_SEQUENCE_SLOTS; ++i) { + for (int32_t i = 0; i < AMY_ANON_SEQUENCE_SLOTS; ++i) { if (sequences[i].wire) { free(sequences[i].wire); sequences[i].wire = NULL; @@ -82,6 +350,8 @@ void sequencer_reset() { sequences[i].next_active = -1; } first_active = -1; + stored_sequence_executions_reset(); + stored_sequences_clear_definitions(); } void sequencer_deinit() { @@ -91,16 +361,25 @@ void sequencer_deinit() { sequences = NULL; // sequencer_check_and_fill guards on this } max_sequences = 0; + stored_sequences_deinit(); +} + +void sequencer_sequence_reset_timebase() { + // Absolute activation/control ticks cannot be meaningfully rebased across + // a timebase reset. Stored definitions remain available for relaunch. + stored_sequence_executions_reset(); } void sequencer_debug() { int32_t n_active = 0; for (int32_t t = first_active; t != -1; t = sequences[t].next_active) ++n_active; - fprintf(stderr, "sequencer: max_sequences %" PRIi32" active %" PRIi32 "\n", max_sequences, n_active); + fprintf(stderr, "sequencer: max_sequences %" PRIu32" active %" PRIi32 "\n", max_sequences, n_active); for (int32_t tag = first_active; tag != -1; tag = sequences[tag].next_active) { if (sequences[tag].wire) { - fprintf(stderr, "sequence tag %" PRIi32"%s tick %" PRIu32 " period %"PRIu32 " wire \"%s\"\n", - tag, tag >= max_sequences ? " (anon)" : "", sequences[tag].tick, sequences[tag].period, sequences[tag].wire); + fprintf(stderr, "anonymous sequence slot %" PRIi32 " tick %" PRIu32 + " period %" PRIu32 " wire \"%s\"\n", + tag, sequences[tag].tick, sequences[tag].period, + sequences[tag].wire); } } } @@ -171,25 +450,40 @@ void sequencer_recompute() { // Store a wire message in the sequencer. Takes ownership of wire (malloc'd). // // has_tag false means tag wasn't supplied by the caller (a 1- or 2-value -// ticks= form): the entry is allocated round-robin from the anonymous pool -// instead of the given tag value, so it's stored but not addressable or -// individually cancelable. has_tag true is the normal tag-indexed form: tick -// and period both zero clears that tag's entry (the only way to cancel one). +// ticks= form): the entry is allocated round-robin from the anonymous pool, so +// it is stored but not addressable or individually cancelable. has_tag true +// appends to the reusable definition at that tag; an empty tick-zero message +// resets the definition. // // A one-off whose tick is already due or overdue is not stored at all -- it // plays immediately, before returning. See the comment at that branch. -uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire) { +uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, + uint32_t tag, bool has_tag, char *wire, + sequencer_origin_t origin) { if (sequences == NULL) { // sequencer_init hasn't run free(wire); return 0; } if (has_tag) { - if (tag >= (uint32_t)max_sequences) { - fprintf(stderr, "sequencer tag %" PRIu32" (with tick %" PRIu32", period %" PRIu32") is greater than or eq max_sequences %" PRIi32"\n", + if (tag >= max_sequences) { + fprintf(stderr, "sequencer tag %" PRIu32" (with tick %" PRIu32", period %" PRIu32") is greater than or eq max_sequences %" PRIu32"\n", tag, tick, period, max_sequences); free(wire); return 0; } + // Tagged ticks are the events of the reusable sequence identified by + // that tag. Repeating a tag therefore accumulates events, matching + // the way repeated synth= messages build one synth. The historical + // empty H0,0,tag form remains a convenient spelling for per-tag reset; + // with a payload, tick zero is an ordinary (and essential) local + // one-shot event. + if (tick == 0 && period == 0 + && (wire == NULL || wire[0] == '\0' || wire[0] == 'Z')) { + free(wire); + return sequencer_sequence_reset_with_origin(tag, origin); + } + return sequencer_sequence_add_wire_with_origin( + tag, tick, period, wire, origin); } else { // Anonymous: tick==0 && period==0 has nothing to cancel (no tag was // given), so just drop it rather than allocating a slot for a no-op. @@ -197,16 +491,16 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha free(wire); return 0; } - tag = (uint32_t)(max_sequences + anon_cursor); + tag = (uint32_t)anon_cursor; anon_cursor = (anon_cursor + 1) % AMY_ANON_SEQUENCE_SLOTS; } amy_grab_lock(); - // Release any existing message for this tag, even if we're just going to rewrite it. + // Reuse the selected anonymous slot, evicting its previous message. if (sequences[tag].wire) free(sequences[tag].wire); sequences[tag].wire = NULL; sequences[tag].tick = 0; sequences[tag].period = 0; - active_unlink(tag); // out of the list while it has nothing in it + active_unlink((int32_t)tag); // Anonymous slots are bounded to 0..255. if (tick == 0 && period == 0) { // Non-schedulable event: just clear the tag. amy_release_lock(); free(wire); @@ -235,13 +529,459 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha sequences[tag].tick = tick; sequences[tag].period = period; sequences[tag].wire = wire; - active_link(tag); // ...and back in, now that it has a message again + active_link((int32_t)tag); // ...and back in, now that it has a message again + amy_release_lock(); + return 1; +} + +uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, + bool has_tag, char *wire) { + return sequencer_add_wire_with_origin( + tick, period, tag, has_tag, wire, SEQUENCER_ORIGIN_EXTERNAL); +} + +static stored_sequence_definition_t **stored_sequence_slot(uint32_t tag) { + if (stored_sequences == NULL || tag >= max_sequences) return NULL; + return &stored_sequences[tag]; +} + +static void stored_sequence_definition_append_owned( + stored_sequence_definition_t *definition, uint32_t tick, + uint32_t period, char *wire) { + stored_sequence_event_t *event = + &definition->events[definition->event_count++]; + event->wire = wire; + event->tick = tick; + event->period = period; + if (period != 0) definition->has_periodic_event = true; + else if (tick > definition->last_one_shot_tick) + definition->last_one_shot_tick = tick; +} + +// A candidate owns the incoming wire in its final event. If publication loses +// a race, detach that event before destroying the private candidate so the +// same caller-owned wire can be retried against the newly published version. +static void stored_sequence_candidate_discard( + stored_sequence_definition_t *candidate, char *wire) { + if (candidate != NULL && candidate->event_count != 0) { + stored_sequence_event_t *event = + &candidate->events[candidate->event_count - 1]; + if (event->wire == wire) { + event->wire = NULL; + candidate->event_count--; + } + } + stored_sequence_definition_destroy(candidate); +} + +uint8_t sequencer_sequence_add_wire_with_origin( + uint32_t tag, uint32_t tick, uint32_t period, char *wire, + sequencer_origin_t origin) { + stored_sequence_definition_t **slot = stored_sequence_slot(tag); + if (slot == NULL) { + if (stored_sequences == NULL) + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": stored sequences are disabled\n", tag); + else + fprintf(stderr, "cannot append event: sequence tag %" PRIu32 + " is outside the configured range [0, %" PRIu32 "]\n", + tag, max_sequences - 1); + free(wire); + return 0; + } + if (wire == NULL || wire[0] == '\0' || wire[0] == 'Z') { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": event payload is empty\n", tag); + free(wire); + return 0; + } + if (wire[0] == 'H' && wire[1] != 'C') { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": only HC sequence-control payloads may be composed\n", tag); + free(wire); + return 0; + } + if (period != 0 && tick >= period) { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": tick %" PRIu32 " must be below period %" PRIu32 "\n", + tag, tick, period); + free(wire); + return 0; + } + + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); +#ifdef AMY_SEQUENCE_TESTING + bool test_pin_hook_called = false; +#endif + for (;;) { + amy_grab_lock(); + stored_sequence_definition_t *source = *slot; + if (source != NULL + && source->event_count >= max_stored_sequence_events) { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": configured limit of %" PRIu32 " events is full\n", + tag, max_stored_sequence_events); + amy_release_lock(); + free(wire); + return 0; + } + + // No execution or other writer can observe a refs==1 definition, so + // appending the already-allocated incoming wire is a bounded mutation. + // This keeps bulk preload O(n) instead of cloning on every event. + if (source != NULL && source->refs == 1) { + stored_sequence_definition_append_owned(source, tick, period, + wire); + amy_release_lock(); + if (sequence_origin_may_reclaim(origin)) + sequencer_reclaim_retired(); + return 1; + } + + // Pin a shared source before leaving the lock. From this point it is + // immutable, so allocation and all copying can happen without holding + // up the render thread. + if (source != NULL) source->refs++; + amy_release_lock(); + +#ifdef AMY_SEQUENCE_TESTING + // Tests use this one-shot rendezvous to make two writers clone the + // same pinned generation. It is absent from production builds. + if (!test_pin_hook_called && stored_sequence_after_pin_hook != NULL) { + test_pin_hook_called = true; + stored_sequence_after_pin_hook(); + } +#endif + + stored_sequence_definition_t *candidate = source == NULL + ? stored_sequence_definition_new() + : stored_sequence_definition_clone(source); + if (candidate == NULL) { + stored_sequence_definition_t *dead = NULL; + if (source != NULL) { + amy_grab_lock(); + dead = stored_sequence_definition_release_locked(source, + origin); + amy_release_lock(); + } + stored_sequence_definition_destroy(dead); + amy_oom("stored sequence edit: out of memory\n"); + free(wire); + return 0; + } + stored_sequence_definition_append_owned(candidate, tick, period, wire); + + amy_grab_lock(); + if (*slot == source) { + *slot = candidate; + stored_sequence_definition_t *dead = NULL; + if (source != NULL) { + // Drop the old slot ownership and our temporary writer pin. + dead = stored_sequence_definition_release_locked(source, + origin); + stored_sequence_definition_t *after_pin = + stored_sequence_definition_release_locked(source, + origin); + if (after_pin != NULL) dead = after_pin; + } + amy_release_lock(); + stored_sequence_definition_destroy(dead); + if (sequence_origin_may_reclaim(origin)) + sequencer_reclaim_retired(); + return 1; + } + + // Another writer published first. Keep the caller's wire, release our + // source pin, discard the private candidate outside the lock, and retry + // against the new cumulative definition. + stored_sequence_definition_t *dead = source == NULL ? NULL + : stored_sequence_definition_release_locked(source, origin); + amy_release_lock(); + stored_sequence_candidate_discard(candidate, wire); + stored_sequence_definition_destroy(dead); + } +} + +uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, + uint32_t period, char *wire) { + return sequencer_sequence_add_wire_with_origin( + tag, tick, period, wire, SEQUENCER_ORIGIN_EXTERNAL); +} + +uint8_t sequencer_sequence_reset_with_origin(uint32_t tag, + sequencer_origin_t origin) { + stored_sequence_definition_t **slot = stored_sequence_slot(tag); + if (slot == NULL) { + if (stored_sequences == NULL) + fprintf(stderr, "cannot reset sequence %" PRIu32 + ": stored sequences are disabled\n", tag); + else + fprintf(stderr, "cannot reset sequence: tag %" PRIu32 + " is outside the configured range [0, %" PRIu32 "]\n", + tag, max_sequences - 1); + return 0; + } + if (origin == SEQUENCER_ORIGIN_STORED) { + fprintf(stderr, "sequence %" PRIu32 + " cannot reset definitions from a stored sequence event\n", + tag); + return 0; + } + + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); + amy_grab_lock(); + stored_sequence_definition_t *definition = *slot; + *slot = NULL; + stored_sequence_definition_t *dead = NULL; + dead = stored_sequence_definition_release_locked(definition, origin); amy_release_lock(); + stored_sequence_definition_destroy(dead); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); return 1; } +uint8_t sequencer_sequence_reset(uint32_t tag) { + return sequencer_sequence_reset_with_origin( + tag, SEQUENCER_ORIGIN_EXTERNAL); +} + +static uint32_t sequence_control_tick(uint32_t alignment_period, + sequencer_origin_t origin, + uint32_t current_tick) { + // A control fired by the root sequencer participates in this tick. A + // control arriving between ticks begins no earlier than the next tick. + uint32_t tick = origin == SEQUENCER_ORIGIN_EXTERNAL + ? amy_global.sequencer_tick_count + 1 + : current_tick; + if (alignment_period != 0) { + uint32_t remainder = tick % alignment_period; + if (remainder != 0) { + uint32_t delta = alignment_period - remainder; + // The visible uint32 clock restarts at zero on rollover, and zero + // is an alignment boundary for every period. Do not carry a + // pre-rollover modulo phase into the wrapped clock. + tick = delta > UINT32_MAX - tick ? 0 : tick + delta; + } + } + return tick; +} + +uint8_t sequencer_sequence_control_with_origin( + uint32_t tag, uint32_t action, uint32_t value, + uint32_t alignment_period, sequencer_origin_t origin, + uint32_t current_tick) { + stored_sequence_definition_t **slot = stored_sequence_slot(tag); + if (slot == NULL) { + if (stored_sequences == NULL) + fprintf(stderr, "cannot control sequence %" PRIu32 + ": stored sequences are disabled\n", tag); + else + fprintf(stderr, "cannot control sequence %" PRIu32 + ": valid tags are [0, %" PRIu32 "]\n", + tag, max_sequences - 1); + return 0; + } + if (alignment_period > INT32_MAX) { + fprintf(stderr, "cannot control sequence %" PRIu32 + ": alignment %" PRIu32 " exceeds the maximum %" PRIi32 + " ticks\n", tag, alignment_period, INT32_MAX); + return 0; + } + if (action == SEQUENCE_CONTROL_GATE && value > INT32_MAX) { + fprintf(stderr, "cannot gate sequence %" PRIu32 + ": duration %" PRIu32 " exceeds the maximum %" PRIi32 + " ticks\n", tag, value, INT32_MAX); + return 0; + } + + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); + uint8_t result = 0; + amy_grab_lock(); + if (action == SEQUENCE_CONTROL_START) { + if (*slot == NULL || (*slot)->event_count == 0) { + fprintf(stderr, "cannot start sequence %" PRIu32 + ": its definition is empty\n", tag); + } else { + uint32_t start_tick = sequence_control_tick( + alignment_period, origin, current_tick); + stored_sequence_execution_t *available = NULL; + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { + stored_sequence_execution_t *execution = &sequence_executions[i]; + if (!execution->occupied && available == NULL) available = execution; + } + if (available == NULL) { + fprintf(stderr, "cannot start sequence %" PRIu32 + ": all %" PRIu32 " execution slots are occupied\n", + tag, max_stored_sequence_executions); + } else { + memset(available, 0, sizeof(*available)); + available->definition = *slot; + available->definition->refs++; + available->tag = tag; + available->start_tick = start_tick; + available->occupied = true; + result = 1; + } + } + } else if (action == SEQUENCE_CONTROL_STOP + || action == SEQUENCE_CONTROL_GATE) { + uint32_t control_tick = sequence_control_tick( + alignment_period, origin, current_tick); + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { + stored_sequence_execution_t *execution = &sequence_executions[i]; + if (!execution->occupied || execution->tag != tag) + continue; + if (action == SEQUENCE_CONTROL_STOP) { + execution->stop_tick = control_tick; + execution->stop_pending = true; + } else { + execution->gate_change_tick = control_tick; + execution->gate_duration = value; + execution->gate_change_pending = true; + } + result = 1; + } + } else { + fprintf(stderr, "cannot control sequence %" PRIu32 + ": action %" PRIu32 " is unknown; valid actions are " + "stop=0, start=1, gate=2\n", tag, action); + } + amy_release_lock(); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); + return result; +} + +uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period) { + return sequencer_sequence_control_with_origin( + tag, action, value, alignment_period, SEQUENCER_ORIGIN_EXTERNAL, 0); +} + +static bool stored_sequence_event_hits(const stored_sequence_event_t *event, + uint32_t local_tick) { + return event->period != 0 ? local_tick % event->period == event->tick + : local_tick == event->tick; +} + +static bool stored_sequence_event_is_control( + const stored_sequence_event_t *event) { + return strncmp(event->wire, "HC", 2) == 0; +} + +static void sequence_play_wire_now(char *wire, sequencer_origin_t origin, + uint32_t current_tick) { + if (wire[0] == 'H') + handle_ticks_message_with_origin(wire, origin, current_tick); + else amy_play_message(wire); +} + +static void stored_sequence_play_wire(const char *wire, uint32_t current_tick) { + sequence_play_wire_now( + (char *)wire, SEQUENCER_ORIGIN_STORED, current_tick); +} + +static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, + bool controls) { + amy_grab_lock(); + stored_sequence_execution_t *execution = &sequence_executions[slot]; + if (!execution->occupied) { + amy_release_lock(); + return false; + } + if (!execution->started) { + if (!AMY_TIME_GEQ(tick, execution->start_tick)) { + amy_release_lock(); + return false; + } + execution->started = true; + } + uint32_t elapsed = tick - execution->start_tick; + stored_sequence_definition_t *definition = execution->definition; + if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) + || (!definition->has_periodic_event + && elapsed > definition->last_one_shot_tick)) { + stored_sequence_execution_release_deferred(execution); + amy_release_lock(); + return false; + } + if (controls) { + if (execution->controls_processed + && execution->controls_processed_tick == tick) { + amy_release_lock(); + return false; + } + // Mark before dispatch: a control graph may stop/reuse this slot, and a + // newly created execution in that slot must remain distinguishable. + execution->controls_processed = true; + execution->controls_processed_tick = tick; + } else { + if (execution->gate_change_pending + && AMY_TIME_GEQ(tick, execution->gate_change_tick)) { + execution->gate_change_pending = false; + execution->gated = execution->gate_duration != 0; + execution->gate_end_tick = execution->gate_change_tick + + execution->gate_duration; + } + if (execution->gated && AMY_TIME_GEQ(tick, execution->gate_end_tick)) + execution->gated = false; + } + bool suppress = !controls && execution->gated; + definition->refs++; + amy_release_lock(); + + if (!suppress) { + for (uint32_t event_index = 0; + event_index < definition->event_count; ++event_index) { + stored_sequence_event_t *event = &definition->events[event_index]; + if (stored_sequence_event_is_control(event) == controls + && stored_sequence_event_hits(event, elapsed)) + stored_sequence_play_wire(event->wire, tick); + } + } + + bool finite_complete = !controls && !definition->has_periodic_event + && elapsed == definition->last_one_shot_tick; + amy_grab_lock(); + stored_sequence_definition_retire_locked(definition); + if (finite_complete && execution->occupied + && execution->definition == definition + && execution->start_tick == tick - elapsed) + stored_sequence_execution_release_deferred(execution); + amy_release_lock(); + return true; +} + +static void stored_sequence_process_controls(uint32_t tick) { + // A control can start an execution in a lower-numbered slot already passed + // by this scan. Repeat until no due execution remains unvisited. At most one + // control visit per configured slot is allowed per tick; this both covers + // every simultaneously active execution and bounds stop/reuse cycles. + uint32_t visits_left = max_stored_sequence_executions; + bool progressed; + do { + progressed = false; + for (uint32_t i = 0; + i < max_stored_sequence_executions && visits_left != 0; ++i) { + if (stored_sequence_process_slot(i, tick, true)) { + visits_left--; + progressed = true; + } + } + } while (progressed && visits_left != 0); +} + +static void stored_sequence_process_events(uint32_t tick) { + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) + stored_sequence_process_slot(i, tick, false); +} + static void sequencer_process_tick(void) { - amy_global.sequencer_tick_count++; + // External sequence controls take their next-tick snapshot under this same + // lock, so current-tick versus next-tick activation has one ordering point. + amy_grab_lock(); + uint32_t tick = ++amy_global.sequencer_tick_count; + amy_release_lock(); midi_clock_out_tick(); // no-op unless in AMY_MIDI_SYNC_SEND mode // Guard nested check-and-fire calls (via a fired message's own parse) // while still processing this tick's fires; restore on the way out. @@ -257,7 +997,7 @@ static void sequencer_process_tick(void) { bool hit = false; bool delete = false; if(sequences[tag].period != 0) { // period set - uint32_t offset = amy_global.sequencer_tick_count % sequences[tag].period; + uint32_t offset = tick % sequences[tag].period; if (offset == sequences[tag].tick) hit = true; } else { // Test for absolute tick (no period set). <= rather than ==: @@ -268,7 +1008,7 @@ static void sequencer_process_tick(void) { // playing. <= lets it fire on the next tick instead, matching // the play-it-late rule sequencer_add_wire() uses for a // one-off that is already due when it arrives. - if (sequences[tag].tick <= amy_global.sequencer_tick_count) { hit = true; delete = true; } + if (sequences[tag].tick <= tick) { hit = true; delete = true; } } if(hit) { // Take the message out (one-shot) or a copy of it (repeating) @@ -285,7 +1025,10 @@ static void sequencer_process_tick(void) { active_unlink(tag); } else { size_t len = strlen(sequences[tag].wire); - wire = (char *)malloc_caps(len + 1, amy_global.config.ram_caps_events); + wire = len >= UINT32_MAX ? NULL + : (char *)malloc_caps( + (uint32_t)(len + 1), + amy_global.config.ram_caps_events); if (wire != NULL) memcpy(wire, sequences[tag].wire, len + 1); else amy_oom("sequencer fire"); } @@ -293,16 +1036,21 @@ static void sequencer_process_tick(void) { amy_release_lock(); if (wire != NULL) { // Parse and play now; the deltas play back within this block. - amy_play_message(wire); + sequence_play_wire_now( + wire, SEQUENCER_ORIGIN_RENDER, tick); free(wire); } } } tag = next; } + // Composed controls take effect before ordinary stored-sequence events on + // the same tick. This lets a parent stop a child without one extra onset. + stored_sequence_process_controls(tick); + stored_sequence_process_events(tick); 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); + amy_global.config.amy_external_sequencer_hook(tick); } } @@ -338,7 +1086,9 @@ 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(); 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..3902e13a 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -5,23 +5,72 @@ #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(uint32_t max_num_sequences, uint32_t max_sequence_events, + uint32_t max_sequence_executions); void sequencer_deinit(); void sequencer_reset(); void sequencer_debug(); void sequencer_recompute(); void sequencer_check_and_fill(); // called once per block from amy_execute_deltas() +// Destroy zero-reference immutable sequence definitions retired by the render +// path. The caller must be a control/non-render thread. +void sequencer_reclaim_retired(); + +// Internal dispatch origin. External commands start no earlier than the next +// tick and may reclaim retired definitions. Render-originated commands use the +// supplied current tick and may only retire storage. A stored event is also +// prohibited from editing sequence definitions while they are being walked. +typedef enum sequencer_origin_t { + SEQUENCER_ORIGIN_EXTERNAL = 0, + SEQUENCER_ORIGIN_RENDER, + SEQUENCER_ORIGIN_STORED +} sequencer_origin_t; + +void handle_ticks_message_with_origin(char *message, + sequencer_origin_t origin, + uint32_t current_tick); #ifdef __EMSCRIPTEN__ void sequencer_check_and_call_js_hook(); // called from the browser main loop #endif // Store a wire message (with its leading 'H' already stripped) in the -// sequencer. If has_tag is true, it's stored under tag (replacing/clearing -// any existing entry there, addressable later by that same tag); clears the -// tag if tick and period are both 0. If has_tag is false, it's stored -// anonymously (round-robin in a small reserved pool) and can't be addressed -// or cancelled by any tag. Takes ownership of wire. +// sequencer. If has_tag is true, append it to the reusable sequence identified +// by tag. An empty tick=period=0 command clears that sequence; the same timing +// with a payload appends a local tick-zero event. If has_tag is false, store it +// anonymously (round-robin in a small reserved pool) for immediate sequencer +// playback. Takes ownership of wire. uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire); +uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, + uint32_t tag, bool has_tag, char *wire, + sequencer_origin_t origin); +// Append one ordinary ticks event to the reusable sequence identified by tag. +// Takes ownership of wire. A tick=period=0 event is a valid one-shot when its +// wire payload is nonempty. +uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, + uint32_t period, char *wire); +uint8_t sequencer_sequence_add_wire_with_origin( + uint32_t tag, uint32_t tick, uint32_t period, char *wire, + sequencer_origin_t origin); +// Clear the future definition at tag. Executions which already started retain +// their immutable definition and may finish. +uint8_t sequencer_sequence_reset(uint32_t tag); +uint8_t sequencer_sequence_reset_with_origin(uint32_t tag, + sequencer_origin_t origin); +// sequence_control is [tag, action, alignment_period] for stop/start or +// [tag, gate, duration, alignment_period]. +uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period); +uint8_t sequencer_sequence_control_with_origin(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period, + sequencer_origin_t origin, + uint32_t current_tick); +void sequencer_sequence_reset_timebase(); +#ifdef AMY_SEQUENCE_TESTING +void sequencer_test_fail_allocation_after(int32_t successful_allocations); +void sequencer_test_set_after_pin_hook(void (*hook)(void)); +#endif void sequencer_midi_clock_tick(); void sequencer_midi_start(); void sequencer_midi_stop(); diff --git a/tests/test_js_api.js b/tests/test_js_api.js new file mode 100644 index 00000000..8382b2c0 --- /dev/null +++ b/tests/test_js_api.js @@ -0,0 +1,22 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const path = require("node:path"); + +require(path.join(__dirname, "..", "src", "amy_api.generated.js")); + +assert.equal( + amy_message({sequence_control: [7, 1, 48]}), + "HC7,1,48Z" +); +assert.equal( + amy_message({ticks: [0, 48, 3], sequence_control: [7, 1, 1]}), + "H0,48,3HC7,1,1Z" +); +assert.equal(amy_message({sequence_reset: 7}), "HR7Z"); +assert.equal( + amy_message({sequence_control: [7, AMY.SEQUENCE_CONTROL_GATE, 24, 1]}), + "HC7,2,24,1Z" +); + +console.log("JavaScript reusable-sequence API checks passed"); diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py new file mode 100644 index 00000000..2b6f6ae3 --- /dev/null +++ b/tests/test_sequence_api.py @@ -0,0 +1,111 @@ +"""Small, audio-independent checks for the reusable-sequence Python API.""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +import amy + + +def expect_error(fragment, fn): + try: + fn() + except ValueError as exc: + assert fragment in str(exc), str(exc) + else: + raise AssertionError("expected ValueError containing %r" % fragment) + + +def main(): + assert amy.message(ticks=(0, 0, 7), synth=1, note=60, vel=1) \ + == "H0,0,7n60l1i1Z" + assert amy.message(sequence_control=(7, amy.SEQUENCE_CONTROL_START, 48)) \ + == "HC7,1,48Z" + assert amy.message(sequence_control=("%v", "%v", "%v", "%v")) \ + == "HC%v,%v,%v,%vZ" + assert amy.message(ticks=(0, 48, 3), + sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ + == "H0,48,3HC7,1,1Z" + assert amy.message(sequence=7, action="start") == "HC7,1,0Z" + assert amy.message(sequence=7, action="stop", alignment_period=48) \ + == "HC7,0,48Z" + assert amy.message(sequence=7, action="gate", duration=24, + alignment_period=1) == "HC7,2,24,1Z" + assert amy.message(ticks=(0, 48, 3), sequence=7, action="start", + alignment_period=1) == "H0,48,3HC7,1,1Z" + assert amy.message(sequence_reset=7) == "HR7Z" + assert amy.message(ticks=(1, 4, 2), synth=1, note=60, vel=1) \ + == "H1,4,2n60l1i1Z" + assert amy.message(ticks=",24,2", osc=1) == "H,24,2v1Z" + assert amy.message(ticks=(None, 24, 2), osc=1) == "H,24,2v1Z" + assert amy.message(ticks=(4, 4), osc=1) == "H4,4v1Z" + + sent = [] + old_override = amy.override_send + amy.override_send = sent.append + try: + amy.define_sequence(7, [ + {"ticks": (0,), "synth": 1, "note": 60, "vel": 1}, + {"ticks": (3, 8), "synth": 1, "note": 60, "vel": 0}, + ]) + finally: + amy.override_send = old_override + assert sent == [ + "HR7Z", + "H0,0,7n60l1i1Z", + "H3,8,7n60l0i1Z", + ] + + expect_error("standalone", lambda: amy.message(sequence_reset=2, synth=1)) + expect_error("tick", lambda: amy.message(ticks=(1.5,), osc=1)) + expect_error("period", lambda: amy.message(ticks=(4, 4, 2), osc=1)) + expect_error("tag", lambda: amy.message(ticks=(0, 4, True), osc=1)) + expect_error("only be combined", lambda: amy.message( + sequence_control=(2, 1), synth=1)) + expect_error("only be combined", lambda: amy.message( + ticks=(0,), sequence_control=(2, 1), synth=1)) + expect_error("start/stop", lambda: amy.message(sequence_control=(2, 1, 3, 4))) + expect_error("duration", lambda: amy.message(sequence_control=(2, 2))) + expect_error("action", lambda: amy.message(sequence_control=(2, 99))) + expect_error("action", lambda: amy.message(sequence_control=(2, -0.1))) + expect_error("integer", lambda: amy.message(sequence_control=(2, 0.625))) + expect_error("integer", lambda: amy.message(sequence_control=(2, True))) + expect_error("tag", lambda: amy.message(sequence_control=(1.5, 1))) + expect_error("alignment", lambda: amy.message(sequence_control=(2, 1, 1.5))) + expect_error("uint32", lambda: amy.message( + sequence_control=(2, 2, 1 << 32))) + expect_error("2147483647", lambda: amy.message( + sequence_control=(2, 2, 1 << 31))) + expect_error("tag", lambda: amy.message(sequence_reset=1.5)) + expect_error("tag", lambda: amy.message(sequence=True, action="start")) + expect_error("tag", lambda: amy.message(sequence=1.5, action="start")) + expect_error("duration", lambda: amy.message( + sequence=2, action="gate", duration=1.5)) + expect_error("alignment", lambda: amy.message( + sequence=2, action="start", alignment_period=1.5)) + expect_error("2147483647", lambda: amy.message( + sequence=2, action="start", alignment_period=1 << 31)) + expect_error("needs action", lambda: amy.message(sequence=2)) + expect_error("can only be combined", lambda: amy.message( + sequence=2, action="start", synth=1)) + expect_error("only valid", lambda: amy.message(alignment_period=4, synth=1)) + expect_error("only valid", lambda: amy.message(action="start", synth=1)) + expect_error("start", lambda: amy.message(sequence=2, action=True)) + expect_error("start", lambda: amy.message(sequence=2, action=1)) + expect_error("duration", lambda: amy.message(sequence=2, action="gate")) + expect_error("only valid", lambda: amy.message( + sequence=2, action="start", duration=1)) + expect_error("non-negative", lambda: amy.message( + sequence=2, action="gate", duration=-1)) + expect_error("needs a ticks", lambda: amy.define_sequence(2, [{"synth": 1}])) + expect_error("needs an AMY payload", lambda: amy.define_sequence( + 2, [{"ticks": (0,)}])) + expect_error("tick", lambda: amy.define_sequence( + 2, [{"ticks": (1.5,), "osc": 1}])) + expect_error("period", lambda: amy.define_sequence( + 2, [{"ticks": (1, 1 << 32), "osc": 1}])) + + +if __name__ == "__main__": + main() diff --git a/tests/test_sequencer_active.c b/tests/test_sequencer_active.c index 4fd7087b..e0895ca1 100644 --- a/tests/test_sequencer_active.c +++ b/tests/test_sequencer_active.c @@ -1,21 +1,8 @@ -// The sequencer's per-tick cost should track what is SCHEDULED, not what -// tag number happened to be used. -// -// sequencer_process_tick() used to sweep 0..highest_tag, and highest_tag -// was a high-water mark that only ever grew — cleared sequences never -// brought it down. So one event parked at a high tag made every tick -// scan that far for the rest of the session, and raising -// max_sequencer_tags made the worst case proportionally worse. The -// anonymous pool made this the common case, not a corner: anonymous -// ticks= entries are allocated round-robin at indices past -// max_sequences, so a burst of one-shots pinned the mark at the very -// end of the table permanently. The occupied slots are threaded through -// the table as an ascending list now. -// -// The headline check here is an INVARIANT rather than a benchmark: one -// sequence at tag 0 and one sequence at tag max-1 must cost the same, -// because both are one sequence. Under the old sweep the second cost -// ~max times the first. +// The sequencer's per-tick cost should track active work, not the numeric value +// of a public tag. Tagged definitions are stored separately from the small +// anonymous direct-scheduling pool, and active executions occupy a bounded +// pool. Consequently one sequence at tag 0 and one at tag max-1 have the same +// scan cost. // // Build/run with `make ctest`. @@ -53,10 +40,12 @@ static void seq_note_on(int32_t tag, int osc) { e.ticks[TICKS_PERIOD] = 16; e.ticks[TICKS_TAG] = (uint32_t)tag; amy_add_event(&e); + sequencer_sequence_control((uint32_t)tag, SEQUENCE_CONTROL_START, 0, 0); } -// Clearing is a send to the same tag with neither tick nor period. +// Stop active playback, then clear the future definition. static void seq_clear(int32_t tag) { + sequencer_sequence_control((uint32_t)tag, SEQUENCE_CONTROL_STOP, 0, 0); amy_event e = amy_default_event(); e.ticks[TICKS_TICK] = 0; e.ticks[TICKS_PERIOD] = 0; @@ -105,10 +94,8 @@ static void test_out_of_order_and_clear(void) { all_off(); } -// Anonymous entries (1- or 2-value ticks=, no tag) live past the user tag -// range. They should fire once, disappear, and — with the active list — -// leave no lasting per-tick cost behind. Under the old sweep, one -// anonymous entry pinned the scan at the far end of the table forever. +// Anonymous entries (1- or 2-value ticks=, no tag) use a separate pool. They +// should fire once, disappear, and leave no lasting per-tick cost behind. static void test_anonymous_one_shots(void) { printf("anonymous one-shots fire once and leave the list empty\n"); sequencer_reset(); diff --git a/tests/test_sequencer_bounds.c b/tests/test_sequencer_bounds.c index 960e3989..ba886364 100644 --- a/tests/test_sequencer_bounds.c +++ b/tests/test_sequencer_bounds.c @@ -1,13 +1,12 @@ // Regression test for the sequencer tag bounds check. // -// User-addressable tags index `sequences[0 .. max_sequences-1]`, and the -// anonymous pool lives immediately after, at -// [max_sequences .. max_sequences+AMY_ANON_SEQUENCE_SLOTS). An earlier +// User-addressable tags once indexed `sequences[0 .. max_sequences-1]`, with +// the anonymous pool immediately after it. An earlier // version of the sequencer guarded with `tag > max_sequences` (and read // the tag into an int32_t), which let tag == max_sequences write one // entry past the user range — in those days one element past the whole -// allocation, a heap overflow; today it would silently clobber an -// anonymous entry instead. sequencer_add_wire() now checks +// allocation, a heap overflow. Tagged definitions and anonymous direct events +// now use separate storage, and sequencer_add_wire() still checks // `tag >= (uint32_t)max_sequences` unsigned, which also disposes of the // negative-reindex case: a tag past INT32_MAX stays a huge unsigned // value and fails the same compare, so it can never index backwards. @@ -76,16 +75,14 @@ static int audible(int osc) { return synth[osc] != NULL && synth[osc]->status == SYNTH_AUDIBLE; } -// Whether a tag was accepted is observable two ways: the sequence fires -// (osc goes audible), and something is in the active list at all. -extern int32_t first_active; - static int accepted(uint32_t tag) { sequencer_reset(); seq_note_on_at_tag(tag, 0); + int scheduled = sequencer_sequence_control( + tag, SEQUENCE_CONTROL_START, 0, 0); advance_secs(0.5); int fired = audible(0); - int scheduled = (first_active != -1); + sequencer_sequence_control(tag, SEQUENCE_CONTROL_STOP, 0, 0); seq_clear(tag); all_off(); sequencer_reset(); @@ -110,9 +107,7 @@ static void test_tag_bounds(void) { CHECK(!accepted(0x80000000u), "a tag past INT32_MAX is rejected"); } -// An out-of-range user tag must not clobber the anonymous pool that sits -// right past the user range. Occupy anonymous slot 0 (the entry a -// too-lenient check would land tag==max on), then try to overwrite it. +// An out-of-range user tag must not affect the separate anonymous pool. static void test_no_anon_clobber(void) { printf("an out-of-range tag can't clobber an anonymous entry\n"); sequencer_reset(); diff --git a/tests/test_sequencer_concurrency.c b/tests/test_sequencer_concurrency.c new file mode 100644 index 00000000..45313155 --- /dev/null +++ b/tests/test_sequencer_concurrency.c @@ -0,0 +1,174 @@ +// Deterministic two-writer publication/retry regression test. + +#include +#include +#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) + +static pthread_mutex_t rendezvous_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t rendezvous_changed = PTHREAD_COND_INITIALIZER; +static int writers_at_pin = 0; +static int release_writers = 0; +static int a_hits = 0; +static int b_hits = 0; +static int control_failures = 0; +static int edit_failures = 0; + +static void after_source_pin(void) { + pthread_mutex_lock(&rendezvous_lock); + writers_at_pin++; + if (writers_at_pin == 2) { + release_writers = 1; + pthread_cond_broadcast(&rendezvous_changed); + } else { + while (!release_writers) + pthread_cond_wait(&rendezvous_changed, &rendezvous_lock); + } + pthread_mutex_unlock(&rendezvous_lock); +} + +typedef struct writer_args_t { + uint32_t tick; + const char *wire; + uint8_t result; +} writer_args_t; + +static void *append_event(void *opaque) { + writer_args_t *args = (writer_args_t *)opaque; + args->result = sequencer_sequence_add_wire( + 1, args->tick, 0, strdup(args->wire)); + return NULL; +} + +static void mark_hook(const char *code) { + if (!strcmp(code, "writer-a")) a_hits++; + if (!strcmp(code, "writer-b")) b_hits++; +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); +} + +static void test_losing_writer_retries_cumulatively(void) { + printf("two writers publishing from one generation both survive\n"); + sequencer_reset(); + CHECK(sequencer_sequence_add_wire(1, 0, 0, strdup("zPbaseZ")), + "base definition exists"); + CHECK(sequencer_sequence_add_wire(1, 6, 0, strdup("zPtailZ")), + "base definition has a finite tail"); + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "an execution pins the shared source generation"); + + writer_args_t a = {2, "zPwriter-aZ", 0}; + writer_args_t b = {4, "zPwriter-bZ", 0}; + pthread_t a_thread; + pthread_t b_thread; + sequencer_test_set_after_pin_hook(after_source_pin); + CHECK(pthread_create(&a_thread, NULL, append_event, &a) == 0, + "writer A starts"); + CHECK(pthread_create(&b_thread, NULL, append_event, &b) == 0, + "writer B starts"); + pthread_join(a_thread, NULL); + pthread_join(b_thread, NULL); + sequencer_test_set_after_pin_hook(NULL); + CHECK(a.result && b.result, "both competing edits report success"); + + a_hits = 0; + b_hits = 0; + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "the cumulatively published generation starts"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 6); + CHECK(a_hits == 1 && b_hits == 1, + "the losing compare/retry path loses and duplicates no event"); +} + +static void *advance_render_ticks(void *opaque) { + uint32_t count = *(uint32_t *)opaque; + for (uint32_t i = 0; i < count; ++i) sequencer_midi_clock_tick(); + return NULL; +} + +static void *change_sequence_gate_and_definition(void *opaque) { + uint32_t count = *(uint32_t *)opaque; + for (uint32_t i = 0; i < count; ++i) { + if (!sequencer_sequence_control( + 2, SEQUENCE_CONTROL_GATE, i & 1U, 1)) + control_failures++; + // Resetting the future definition must not disturb the immutable + // snapshot currently read by the render thread. Rebuild it each time + // so publication and reclamation race with real tick processing. + if (!sequencer_sequence_reset(2) + || !sequencer_sequence_add_wire( + 2, 0, 1, strdup("zPthread-pulseZ"))) + edit_failures++; + } + return NULL; +} + +static void test_render_and_control_threads_share_no_sequence_context(void) { + printf("render ticks and external controls keep separate context\n"); + sequencer_reset(); + CHECK(sequencer_sequence_add_wire(2, 0, 1, strdup("zPthread-pulseZ")), + "periodic definition exists"); + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_START, 0, 0), + "periodic execution starts"); + + uint32_t iterations = 2000; + pthread_t render_thread; + pthread_t control_thread; + control_failures = 0; + edit_failures = 0; + CHECK(pthread_create(&render_thread, NULL, advance_render_ticks, + &iterations) == 0, + "render thread starts"); + CHECK(pthread_create(&control_thread, NULL, + change_sequence_gate_and_definition, + &iterations) == 0, + "control thread starts"); + pthread_join(render_thread, NULL); + pthread_join(control_thread, NULL); + + CHECK(control_failures == 0, + "all concurrent controls target the active execution"); + CHECK(edit_failures == 0, + "concurrent future-definition replacement remains available"); + CHECK(sequencer_sequence_reset(2), + "external reset is not confused with stored-event dispatch"); +} + +// 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; + config.max_sequencer_tags = 4; + config.max_sequence_events = 8; + config.max_sequence_executions = 8; + amy_start(config); + + test_losing_writer_retries_cumulatively(); + test_render_and_control_threads_share_no_sequence_context(); + + amy_stop(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall concurrent sequence publication checks passed\n"); + return 0; +} diff --git a/tests/test_sequencer_oom.c b/tests/test_sequencer_oom.c new file mode 100644 index 00000000..ab32a993 --- /dev/null +++ b/tests/test_sequencer_oom.c @@ -0,0 +1,126 @@ +// Allocation-failure regression tests for immutable sequence publication. + +#include +#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) + +static int base_hits = 0; +static int unexpected_hits = 0; + +static void mark_hook(const char *code) { + if (!strcmp(code, "base-head") || !strcmp(code, "base-tail")) + base_hits++; + if (!strcmp(code, "must-not-publish")) unexpected_hits++; +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); +} + +static void define_base(void) { + CHECK(sequencer_sequence_add_wire(1, 0, 0, strdup("zPbase-headZ")), + "base head is defined"); + CHECK(sequencer_sequence_add_wire(1, 4, 0, strdup("zPbase-tailZ")), + "base tail is defined"); +} + +static void test_initialization_allocation_failures(amy_config_t config) { + printf("partial sequence-pool initialization fails closed\n"); + for (int32_t fail_after = 0; fail_after < 2; ++fail_after) { + sequencer_test_fail_allocation_after(fail_after); + amy_start(config); + sequencer_test_fail_allocation_after(-1); + CHECK(!sequencer_sequence_add_wire( + 1, 0, 0, strdup("zPmust-not-publishZ")), + "pool allocation failure %" PRIi32 " disables definitions", + fail_after); + CHECK(!sequencer_sequence_control( + 1, SEQUENCE_CONTROL_START, 0, 0), + "pool allocation failure %" PRIi32 " disables executions", + fail_after); + amy_stop(); + } +} + +static void test_new_definition_allocation_failures(void) { + printf("new-definition allocation failure leaves an empty tag\n"); + for (int32_t fail_after = 0; fail_after < 2; ++fail_after) { + sequencer_reset(); + sequencer_test_fail_allocation_after(fail_after); + CHECK(!sequencer_sequence_add_wire( + 1, 0, 0, strdup("zPmust-not-publishZ")), + "definition allocation failure %" PRIi32 " rejects the append", + fail_after); + sequencer_test_fail_allocation_after(-1); + CHECK(!sequencer_sequence_control( + 1, SEQUENCE_CONTROL_START, 0, 0), + "definition allocation failure %" PRIi32 + " publishes no empty candidate", fail_after); + } +} + +static void test_clone_allocation_failures_preserve_source(void) { + printf("every clone allocation failure preserves the published definition\n"); + // Clone allocation order: definition, event array, then two wire strings. + for (int32_t fail_after = 0; fail_after < 4; ++fail_after) { + sequencer_reset(); + define_base(); + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "source execution pins the definition (failure %" PRIi32 ")", + fail_after); + + char *incoming = strdup("zPmust-not-publishZ"); + sequencer_test_fail_allocation_after(fail_after); + uint8_t appended = sequencer_sequence_add_wire(1, 2, 0, incoming); + sequencer_test_fail_allocation_after(-1); + CHECK(!appended, "allocation failure %" PRIi32 " rejects the edit", + fail_after); + + base_hits = 0; + unexpected_hits = 0; + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "old definition remains startable"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 4); + CHECK(base_hits >= 2 && unexpected_hits == 0, + "failure %" PRIi32 " publishes neither a partial nor corrupt edit", + fail_after); + } +} + +// 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; + config.max_sequencer_tags = 4; + config.max_sequence_events = 8; + config.max_sequence_executions = 8; + test_initialization_allocation_failures(config); + amy_start(config); + + test_new_definition_allocation_failures(); + test_clone_allocation_failures_preserve_source(); + + amy_stop(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall sequence allocation-failure checks passed\n"); + return 0; +} diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c new file mode 100644 index 00000000..012f1032 --- /dev/null +++ b/tests/test_sequencer_sequences.c @@ -0,0 +1,688 @@ +// Regression and behavior tests for reusable tagged sequencer sequences. + +#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[32]; + uint32_t tick; +} mark_t; + +static mark_t marks[256]; +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; + memset(marks, 0, sizeof(marks)); +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(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_untagged_ticks_and_cumulative_tags(void) { + printf("untagged root ticks and cumulative tagged sequences\n"); + sequencer_reset(); + clear_marks(); + uint32_t first = next_boundary(sequencer_ticks(), 4); + + amy_add_message("H,4zProotZ"); + clock_to(first + 4); + CHECK(mark_at("root", first), + "an omitted tick remains a tick-zero legacy list field"); + CHECK(mark_at("root", first + 4), "periodic root event keeps looping"); + sequencer_reset(); + + CHECK(sequencer_add_wire(4, 4, 0, false, strdup("zPlegacy-periodZ")), + "untagged tick equal to period retains legacy acceptance"); + sequencer_reset(); + + clear_marks(); + amy_add_message("H,4,8zPomitted-local-zeroZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC8,1,4Z"); + clock_to(start); + CHECK(mark_at("omitted-local-zero", start), + "H,period,tag remains a reusable tick-zero event"); + sequencer_reset(); + + clear_marks(); + amy_add_message("H0,0,9zPfirstZ"); + amy_add_message("H2,0,9zPsecondZ"); + start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC9,1,4Z"); + clock_to(start + 2); + CHECK(mark_at("first", start) && mark_at("second", start + 2), + "repeating a tag cumulates ordinary events into one sequence"); +} + +static void test_legacy_c_event_wire_is_unchanged(void) { + printf("legacy C events retain three-value ticks\n"); + amy_event event = amy_default_event(); + event.osc = 2; + event.wave = TRIANGLE; + event.ticks[TICKS_TICK] = 3; + event.ticks[TICKS_PERIOD] = 8; + event.ticks[TICKS_TAG] = 7; + char wire[MAX_MESSAGE_LEN]; + sprint_event(&event, wire, sizeof(wire), true); + CHECK(strncmp(wire, "H3,8,7", 6) == 0, + "C ticks serialization remains three values: %s", wire); +} + +static void test_repeated_tag_and_one_shot_lifetime(void) { + printf("repeated tagged events accumulate and finite events retire\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,10zPzeroZ"); + amy_add_message("H2,0,10zPtwoZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC10,1,4Z"); + clock_to(start + 4); + CHECK(mark_at("zero", start), "local tick zero fires at activation"); + CHECK(mark_at("two", start + 2), "a second event shares the same tag"); + CHECK(marks_named("zero") == 1 && marks_named("two") == 1, + "period-zero sequence events fire once and execution retires"); +} + +static void test_empty_tick_zero_is_reset_but_payload_is_an_event(void) { + printf("empty tick-zero reset remains distinct from a tick-zero event\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,10zPstoredZ"); + amy_add_message("H,,10Z"); + CHECK(!sequencer_sequence_control(10, SEQUENCE_CONTROL_START, 0, 0), + "the legacy empty H,,tag spelling resets that tag"); + amy_add_message("H0,0,10zPstoredZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC10,1,4Z"); + clock_to(start); + CHECK(mark_at("stored", start), + "H0,0,tag with a payload is a local tick-zero event"); +} + +static void test_active_definition_is_immutable(void) { + printf("active executions retain the definition they started with\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,11zPold-headZ"); + amy_add_message("H4,0,11zPold-tailZ"); + uint32_t old_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC11,1,4Z"); + clock_to(old_start + 2); + + amy_add_message("HR11Z"); + amy_add_message("H0,0,11zPnew-headZ"); + clock_to(old_start + 4); + CHECK(mark_at("old-tail", old_start + 4), + "resetting future contents does not remove an old note release"); + + clear_marks(); + uint32_t new_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC11,1,4Z"); + clock_to(new_start + 2); + CHECK(mark_at("new-head", new_start) && !marks_named("old-head") + && !marks_named("old-tail"), + "a later start uses only the replacement definition"); +} + +static void test_append_while_active_uses_copy_on_write(void) { + printf("appending while active publishes a future definition\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,11zPbaseZ"); + amy_add_message("H6,0,11zPold-tailZ"); + amy_add_message("HC11,1,0Z"); + uint32_t old_start = sequencer_ticks() + 1; + clock_to(old_start + 1); + + amy_add_message("H2,0,11zPappendedZ"); + clock_to(old_start + 6); + CHECK(mark_at("base", old_start) && mark_at("old-tail", old_start + 6), + "the active execution retains its original events"); + CHECK(!mark_at("appended", old_start + 2), + "an append cannot enter an already-running snapshot"); + + clear_marks(); + amy_add_message("HC11,1,0Z"); + uint32_t new_start = sequencer_ticks() + 1; + clock_to(new_start + 6); + CHECK(mark_at("base", new_start) + && mark_at("appended", new_start + 2) + && mark_at("old-tail", new_start + 6), + "a later execution sees the cumulative appended definition"); +} + +static void test_three_definition_generations_overlap(void) { + printf("three immutable definition generations can overlap\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,12zPbaseZ"); + amy_add_message("H12,0,12zPtailZ"); + + amy_add_message("HC12,1,0Z"); + uint32_t first_start = sequencer_ticks() + 1; + clock_to(first_start); + + amy_add_message("H2,0,12zPsecondZ"); + amy_add_message("HC12,1,0Z"); + uint32_t second_start = sequencer_ticks() + 1; + clock_to(second_start); + + amy_add_message("H4,0,12zPthirdZ"); + amy_add_message("HC12,1,0Z"); + uint32_t third_start = sequencer_ticks() + 1; + clock_to(third_start + 12); + + CHECK(mark_at("base", first_start) + && !mark_at("second", first_start + 2) + && !mark_at("third", first_start + 4), + "the first execution keeps generation one"); + CHECK(mark_at("base", second_start) + && mark_at("second", second_start + 2) + && !mark_at("third", second_start + 4), + "the second execution keeps generation two"); + CHECK(mark_at("base", third_start) + && mark_at("second", third_start + 2) + && mark_at("third", third_start + 4), + "the third execution sees generation three"); +} + +static void test_root_launches_local_zero_on_same_tick(void) { + printf("root events can launch stored sequences\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,12zPchild-zeroZ"); + uint32_t start = sequencer_ticks() + 4; + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HC12,1,0Z", start); + amy_add_message(wire); + clock_to(start); + CHECK(mark_at("child-zero", start), + "a root launch includes the child's local tick zero"); +} + +static void test_root_can_reset_a_future_definition(void) { + printf("root events can reset future stored definitions\n"); + sequencer_reset(); + amy_add_message("H0,0,12zPfutureZ"); + uint32_t reset_tick = sequencer_ticks() + 2; + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HR12Z", reset_tick); + amy_add_message(wire); + clock_to(reset_tick); + CHECK(!sequencer_sequence_control(12, SEQUENCE_CONTROL_START, 0, 0), + "a render-fired reset removes the future definition"); +} + +static void test_overlapping_executions_need_no_host_identity(void) { + printf("one sequence tag supports bounded overlapping executions\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,13zPonZ"); + amy_add_message("H4,0,13zPoffZ"); + uint32_t first = next_boundary(sequencer_ticks(), 4); + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HC13,1,0Z", first); + amy_add_message(wire); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HC13,1,0Z", first + 2); + amy_add_message(wire); + clock_to(first + 6); + CHECK(mark_at("on", first) && mark_at("on", first + 2), + "two starts of one tag can overlap"); + CHECK(mark_at("off", first + 4) && mark_at("off", first + 6), + "each overlap retains its own scheduled release"); +} + +static void test_parent_stop_leaves_started_child_to_finish(void) { + printf("stopping a parent prevents future children without truncating one\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,15zPnote-onZ"); + amy_add_message("H4,0,15zPnote-offZ"); + amy_add_message("H0,4,14HC15,1,0Z"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC14,1,4Z"); + clock_to(start + 2); + amy_add_message("HC14,0,0Z"); + clock_to(start + 8); + CHECK(mark_at("note-on", start), "parent starts its child"); + CHECK(mark_at("note-off", start + 4), + "the already-started child delivers its own note-off"); + CHECK(marks_named("note-on") == 1, + "the stopped parent launches no later child"); +} + +static void test_controller_sequence_bounds_repetition(void) { + printf("a finite controller sequence can bound a periodic child\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,4,8zPpulseZ"); + amy_add_message("H0,0,7HC8,1,0Z"); + amy_add_message("H12,0,7HC8,0,0Z"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC7,1,4Z"); + clock_to(start + 14); + CHECK(mark_at("pulse", start) && mark_at("pulse", start + 4) + && mark_at("pulse", start + 8), + "controller permits exactly three periods"); + CHECK(!mark_at("pulse", start + 12) && marks_named("pulse") == 3, + "same-tick stop precedes the child's ordinary event"); +} + +static void test_finite_gate_preserves_phase(void) { + printf("finite event gating preserves the target phase\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,4,6zPbeatZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC6,1,4Z"); + clock_to(start); + CHECK(mark_at("beat", start), "loop begins on its aligned boundary"); + CHECK(sequencer_sequence_control(6, SEQUENCE_CONTROL_GATE, 6, 0), + "finite gate is accepted without a host timer"); + clock_to(start + 8); + CHECK(!mark_at("beat", start + 4), "event inside gate is suppressed"); + CHECK(mark_at("beat", start + 8), + "event resumes on the original phase after gate expiry"); +} + +static void test_gate_drops_state_restoration_without_replay(void) { + printf("gate suppression is event-agnostic and does not replay events\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,5zPstate-onZ"); + amy_add_message("H2,0,5zPstate-offZ"); + amy_add_message("HC5,1,1Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("state-on", start), "event before gate is dispatched"); + CHECK(sequencer_sequence_control(5, SEQUENCE_CONTROL_GATE, 3, 1), + "gate covers the later state-restoring event"); + clock_to(start + 6); + CHECK(!marks_named("state-off"), + "suppressed state restoration is neither dispatched nor replayed"); +} + +static void test_quantized_stop_targets_current_executions(void) { + printf("quantized controls capture the current execution set\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,4,6zPpulseZ"); + amy_add_message("HC6,1,1Z"); + uint32_t first_start = sequencer_ticks() + 1; + clock_to(first_start); + CHECK(mark_at("pulse", first_start), "first execution begins"); + + CHECK(sequencer_sequence_control(6, SEQUENCE_CONTROL_STOP, 0, 8), + "first execution accepts a future aligned stop"); + uint32_t stop_boundary = next_boundary(sequencer_ticks(), 8); + amy_add_message("HC6,1,1Z"); + uint32_t second_start = sequencer_ticks() + 1; + clock_to(stop_boundary + 4); + CHECK(!mark_at("pulse", stop_boundary), + "the captured execution stops before its boundary event"); + CHECK(mark_at("pulse", second_start) + && mark_at("pulse", second_start + 4), + "a later start does not inherit an earlier pending stop"); +} + +static void test_cyclic_controls_are_bounded_and_recoverable(void) { + printf("cyclic sequence controls remain bounded and recoverable\n"); + sequencer_reset(); + amy_add_message("H0,1,1HC2,1,0Z"); + amy_add_message("H0,1,2HC1,1,0Z"); + amy_add_message("H0,0,3zPrecoveryZ"); + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "cycle root starts"); + clock_to(sequencer_ticks() + 1); + CHECK(!sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), + "the cycle fills but cannot exceed the execution pool"); + + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_STOP, 0, 0), + "all active A executions accept stop"); + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_STOP, 0, 0), + "all active B executions accept stop"); + clock_to(sequencer_ticks() + 1); + CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), + "stopping both cycle tags makes the pool reusable"); +} + +static void test_same_tick_control_is_slot_order_independent(void) { + printf("same-tick controls are independent of execution slot order\n"); + sequencer_reset(); + clear_marks(); + + // The filler occupies slot 0 for tick 1 only. The parent occupies slot 1 + // from tick 2. At tick 2 slot 0 is retired before slot 1 starts child 3, + // which therefore reuses the already-visited lower slot. Child 3 must still + // run its local-zero control and start leaf 4 on that same tick. + amy_add_message("H0,0,1zPfillerZ"); + amy_add_message("H0,0,2HC3,1,1Z"); + amy_add_message("H0,0,3HC4,1,1Z"); + amy_add_message("H0,0,4zPslot-leafZ"); + amy_add_message("HC1,1,1Z"); + amy_add_message("HC2,1,2Z"); + clock_to(sequencer_ticks() + 4); + + CHECK(marks_named("slot-leaf") == 1, + "a child in a recycled lower slot receives its tick-zero control"); +} + +static void test_per_tag_and_global_reset_semantics(void) { + printf("per-tag replacement and global reset have distinct scopes\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,5zPsurvivorZ"); + amy_add_message("HC5,1,0Z"); + uint32_t start = sequencer_ticks() + 1; + amy_add_message("HR5Z"); + clock_to(start); + CHECK(mark_at("survivor", start), + "per-tag reset leaves an already-started snapshot alive"); + CHECK(!sequencer_sequence_control(5, SEQUENCE_CONTROL_START, 0, 0), + "per-tag reset removed the future definition"); + + amy_add_message("H0,4,5zPclearedZ"); + amy_add_message("HC5,1,0Z"); + sequencer_reset(); + CHECK(!sequencer_sequence_control(5, SEQUENCE_CONTROL_START, 0, 0), + "global RESET_SEQUENCER clears stored definitions"); +} + +static void test_timebase_reset_keeps_definitions(void) { + printf("timebase reset drops runtime but keeps definitions\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,4zPafter-rebaseZ"); + amy_add_message("HC4,1,0Z"); + sequencer_sequence_reset_timebase(); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("after-rebase"), "pending execution is discarded"); + CHECK(sequencer_sequence_control(4, SEQUENCE_CONTROL_START, 0, 0), + "definition remains available after timebase reset"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("after-rebase", start), "definition can be relaunched"); +} + +static void test_bounds_and_validation(void) { + printf("tag, event and execution bounds fail deterministically\n"); + sequencer_reset(); + CHECK(!sequencer_sequence_add_wire(16, 0, 0, strdup("zPbad-tagZ")), + "first tag beyond max_sequencer_tags is rejected"); + CHECK(!sequencer_sequence_add_wire(3, 4, 4, strdup("zPbad-periodZ")), + "tick equal to period is rejected"); + CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("")), + "empty payload is rejected"); + CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("H0,0,1zPbadZ")), + "stored sequences cannot contain sequence authoring commands"); + + for (uint32_t i = 0; i < 8; ++i) { + char *payload = strdup("zPfullZ"); + CHECK(sequencer_sequence_add_wire(3, i, 0, payload), + "event slot %" PRIu32 " is available", i); + } + CHECK(!sequencer_sequence_add_wire(3, 9, 0, strdup("zPoverflowZ")), + "one event beyond configured capacity is rejected"); + + for (uint32_t i = 0; i < 8; ++i) + CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 64), + "execution slot %" PRIu32 " is available", i); + CHECK(!sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 64), + "one execution beyond configured capacity is rejected"); + CHECK(!sequencer_sequence_control(3, 99, 0, 0), + "unknown control action is rejected"); + CHECK(!sequencer_sequence_control( + 3, SEQUENCE_CONTROL_START, 0, (uint32_t)INT32_MAX + 1U), + "alignment beyond the wrap-safe interval is rejected"); + CHECK(!sequencer_sequence_control( + 3, SEQUENCE_CONTROL_GATE, (uint32_t)INT32_MAX + 1U, 0), + "gate duration beyond the wrap-safe interval is rejected"); +} + +static void test_wire_control_shape_is_strict(void) { + printf("sequence control and reset wire shapes are strict\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,3zPdefinedZ"); + + amy_add_message("HC3,1,0,99Z"); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("defined"), + "a start with an extra field is rejected"); + + amy_add_message("HC3,2Z"); + amy_add_message("HC3,1,0zPignoredZ"); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("defined") && !marks_named("ignored"), + "a missing gate duration and trailing payload are rejected"); + + amy_add_message("HR3,4Z"); + amy_add_message("HR4294967296Z"); + amy_add_message("HR3.0Z"); + amy_add_message("HR-1Z"); + amy_add_message("HA3Z"); + amy_add_message("H4294967296,0,3zPoverflow-tickZ"); + amy_add_message("H0,4294967296,3zPoverflow-periodZ"); + amy_add_message("H0,0,4294967296zPoverflow-tagZ"); + amy_add_message("H0.5,0,3zPfractional-tickZ"); + CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), + "malformed and overflowing resets leave the definition intact"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("defined", start) + && !marks_named("overflow-tick") + && !marks_named("overflow-period") + && !marks_named("overflow-tag") + && !marks_named("fractional-tick"), + "the intact definition starts without malformed additions"); + + sequencer_reset(); + clear_marks(); + amy_add_message("H0,1,4zPaction-startZ"); + amy_add_message("HC4,1,1Z"); + start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("action-start", start), "action start=1 starts a sequence"); + amy_add_message("HC4,0,1Z"); + clock_to(sequencer_ticks() + 1); + CHECK(!mark_at("action-start", sequencer_ticks()), + "action stop=0 stops a sequence"); + + sequencer_reset(); + clear_marks(); + amy_add_message("H0,1,5zPmalformed-startZ"); + amy_add_message("HC5,-1,1Z"); + amy_add_message("HC5,0.5,1Z"); + amy_add_message("HC5,0.5,1.5Z"); + amy_add_message("HC5,1,Z"); + amy_add_message("HC4294967296,1Z"); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("malformed-start"), + "invalid action, fractional, empty, and overflowing fields are rejected"); +} + +static void test_start_crosses_clock_rollover(void) { + printf("relative sequence phase crosses uint32 clock rollover\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,2zPwrap-zeroZ"); + amy_add_message("H2,0,2zPwrap-twoZ"); + amy_global.sequencer_tick_count = UINT32_MAX - 2; + amy_add_message("HC2,1,48Z"); + clock_to(2); + CHECK(mark_at("wrap-zero", 0), + "non-power-of-two alignment treats wrapped tick zero as a boundary"); + CHECK(mark_at("wrap-two", 2), "elapsed local time crosses rollover"); +} + +static void test_gate_and_stop_cross_clock_rollover(void) { + printf("pending gate and stop controls cross uint32 clock rollover\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,2,2zPwrap-pulseZ"); + amy_global.sequencer_tick_count = UINT32_MAX - 4; + amy_add_message("HC2,1,2Z"); + uint32_t start = UINT32_MAX - 3; + clock_to(start); + CHECK(mark_at("wrap-pulse", start), "loop starts before rollover"); + + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_GATE, 4, 1), + "gate spanning rollover is accepted"); + clock_to(2); + CHECK(!mark_at("wrap-pulse", UINT32_MAX - 1) + && !mark_at("wrap-pulse", 0), + "events remain gated on both sides of rollover"); + CHECK(mark_at("wrap-pulse", 2), "gate expires at its wrapped end tick"); + + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_STOP, 0, 4), + "stop aligns to a post-rollover boundary"); + clock_to(4); + CHECK(mark_at("wrap-pulse", 2) && !mark_at("wrap-pulse", 4), + "stop suppresses the event on its aligned boundary"); +} + +static void test_execution_lifetime_beyond_half_clock_range(void) { + printf("started executions remain valid across the uint32 clock\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,1,2zPlong-periodicZ"); + amy_add_message("HC2,1,1Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + clear_marks(); + + amy_global.sequencer_tick_count = start + (uint32_t)INT32_MAX; + sequencer_midi_clock_tick(); + CHECK(marks_named("long-periodic") == 2, + "a latched periodic execution keeps running past half-range"); + + sequencer_reset(); + clear_marks(); + amy_add_message("H4294967295,0,3zPuint32-tailZ"); + amy_add_message("HC3,1,1Z"); + start = sequencer_ticks() + 1; + clock_to(start); + clear_marks(); + + amy_global.sequencer_tick_count = start - 2; + sequencer_midi_clock_tick(); + CHECK(marks_named("uint32-tail") == 1, + "a finite event at UINT32_MAX fires exactly once"); + int starts = 0; + for (int i = 0; i < 8; ++i) + starts += sequencer_sequence_control( + 3, SEQUENCE_CONTROL_START, 0, 1); + CHECK(starts == 8, + "the UINT32_MAX finite execution retires on its final event"); +} + +static void test_disabled_configuration(void) { + printf("invalid reusable-sequence capacities disable the feature safely\n"); + const uint32_t capacities[][3] = { + {256, 0, 8}, + {256, 8, 0}, + {256, UINT32_MAX, 1}, + {256, 1, UINT32_MAX}, + {UINT32_MAX, 1, 1}, + }; + for (size_t i = 0; i < sizeof(capacities) / sizeof(capacities[0]); ++i) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.max_sequencer_tags = capacities[i][0]; + config.max_sequence_events = capacities[i][1]; + config.max_sequence_executions = capacities[i][2]; + amy_start(config); + CHECK(!sequencer_sequence_add_wire(1, 0, 0, strdup("zPdisabledZ")), + "append is disabled for invalid capacity set %zu", i + 1); + CHECK(!sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "control is disabled for invalid capacity set %zu", i + 1); + amy_stop(); + } +} + +// 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; + config.max_sequencer_tags = 16; + config.max_sequence_events = 8; + config.max_sequence_executions = 8; + amy_start(config); + + test_untagged_ticks_and_cumulative_tags(); + test_legacy_c_event_wire_is_unchanged(); + test_repeated_tag_and_one_shot_lifetime(); + test_empty_tick_zero_is_reset_but_payload_is_an_event(); + test_active_definition_is_immutable(); + test_append_while_active_uses_copy_on_write(); + test_three_definition_generations_overlap(); + test_root_launches_local_zero_on_same_tick(); + test_root_can_reset_a_future_definition(); + test_overlapping_executions_need_no_host_identity(); + test_parent_stop_leaves_started_child_to_finish(); + test_controller_sequence_bounds_repetition(); + test_finite_gate_preserves_phase(); + test_gate_drops_state_restoration_without_replay(); + test_quantized_stop_targets_current_executions(); + test_cyclic_controls_are_bounded_and_recoverable(); + test_same_tick_control_is_slot_order_independent(); + test_per_tag_and_global_reset_semantics(); + test_timebase_reset_keeps_definitions(); + test_start_crosses_clock_rollover(); + test_gate_and_stop_cross_clock_rollover(); + test_execution_lifetime_beyond_half_clock_range(); + test_bounds_and_validation(); + test_wire_control_shape_is_strict(); + + amy_stop(); + test_disabled_configuration(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall reusable sequencer sequence checks passed\n"); + return 0; +}