From 74bed0483367dd4bf374f1c9b0abf5f45f997f76 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 20:04:08 +0200 Subject: [PATCH 01/12] Add reusable sequencer groups --- Makefile | 1 + amy/__init__.py | 1 + amy/constants.py | 1 + src/amy.c | 6 +- src/amy.h | 7 +- src/amy_api.generated.js | 44 ++-- src/api.c | 6 +- src/parse.c | 32 ++- src/patches.c | 6 +- src/pyamy.c | 27 +++ src/sequencer.c | 457 ++++++++++++++++++++++++++++++++++- src/sequencer.h | 20 +- tests/test_sequence_groups.c | 198 +++++++++++++++ 13 files changed, 770 insertions(+), 36 deletions(-) create mode 100644 tests/test_sequence_groups.c diff --git a/Makefile b/Makefile index f849e7dc..2e465c0f 100644 --- a/Makefile +++ b/Makefile @@ -124,6 +124,7 @@ amy-message: $(OBJECTS) src/amy-message.o # Plain C tests for things the audio-rendering suite can't reach -- e.g. clock # rollovers 50 days out, which you can only hit by fast-forwarding the counters. CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds \ + tests/test_sequence_groups \ tests/test_bus_config tests/test_patch_slots \ tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \ tests/test_timebase_reset tests/test_osc_free_on_release \ diff --git a/amy/__init__.py b/amy/__init__.py index 13b40e53..7cb08362 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -254,6 +254,7 @@ def str_of_int(arg): ('algo_source', 'OL'), ('load_sample', 'zL'), ('transfer_file', 'zTL'), ('disk_sample', 'zFL'), ('algorithm', 'oI'), ('chorus', 'kL'), ('reverb', 'hL'), ('echo', 'ML'), ('patch', 'KI'), ('external_channel', 'WI'), ('portamento', 'mI'), ('tempo', 'jF'), ('sequencer_run', 'zYI'), + ('sequence_control', 'zQL'), ('external_midi_sync', 'zCI'), ('synth', 'iI'), ('pedal', 'ipI'), ('synth_flags', 'ifI'), ('num_voices', 'ivI'), ('oscs_per_voice', 'inI'), ('synth_level', 'iVF'), diff --git a/amy/constants.py b/amy/constants.py index ef33569a..ecf4bb28 100644 --- a/amy/constants.py +++ b/amy/constants.py @@ -124,6 +124,7 @@ TICKS_TICK=0 TICKS_PERIOD=1 TICKS_TAG=2 +TICKS_GROUP=3 RESET_SEQUENCER=4096 RESET_ALL_OSCS=8192 RESET_TIMEBASE=16384 diff --git a/src/amy.c b/src/amy.c index 6919023c..d20187da 100644 --- a/src/amy.c +++ b/src/amy.c @@ -1298,7 +1298,10 @@ int8_t oscs_init() { algo_init(); patches_init(amy_global.config.max_memory_patches); instruments_init(amy_global.config.max_synths); - sequencer_init(amy_global.config.max_sequencer_tags); + sequencer_init(amy_global.config.max_sequencer_tags, + amy_global.config.max_sequence_groups, + amy_global.config.max_sequence_group_tags, + amy_global.config.max_sequence_group_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 +2479,7 @@ int16_t * amy_fill_buffer() { amy_global.total_blocks = 0; amy_global.total_samples = 0; amy_global.time = 0; + sequencer_group_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..11deb495 100644 --- a/src/amy.h +++ b/src/amy.h @@ -363,6 +363,7 @@ enum coefs{ #define TICKS_TICK 0 #define TICKS_PERIOD 1 #define TICKS_TAG 2 +#define TICKS_GROUP 3 // Reset masks #define RESET_SEQUENCER 4096 @@ -667,7 +668,7 @@ typedef struct amy_event { uint16_t num_voices; uint8_t oscs_per_voice; // Used when initializing a synth without a patch. // - uint32_t ticks[3]; // tick, period, tag + uint32_t ticks[4]; // tick, period, tag, optional group tag // uint8_t note_source_channel; // .. to mark the channel of events that come from MIDI so we don't send them back out again. uint32_t reset_osc; @@ -887,6 +888,10 @@ typedef struct { uint16_t max_buses; uint8_t ks_oscs; uint32_t max_sequencer_tags; + // Group tag zero is reserved for the existing root sequencer. + uint32_t max_sequence_groups; + uint32_t max_sequence_group_tags; + uint32_t max_sequence_group_executions; uint32_t max_voices; uint32_t max_synths; uint32_t max_memory_patches; diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index 1b590b54..4f92c050 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -55,6 +55,7 @@ var AMY_KW_MAP = { portamento: {wire: "m", type: "I"}, tempo: {wire: "j", type: "F"}, sequencer_run: {wire: "zY", type: "I"}, + sequence_control: {wire: "zQ", type: "L"}, external_midi_sync: {wire: "zC", type: "I"}, synth: {wire: "i", type: "I"}, pedal: {wire: "ip", type: "I"}, @@ -130,27 +131,28 @@ var AMY_KW_PRIORITY = { 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_control: 51, + external_midi_sync: 52, + synth: 53, + pedal: 54, + synth_flags: 55, + num_voices: 56, + oscs_per_voice: 57, + synth_level: 58, + to_synth: 59, + grab_midi_notes: 60, + note_source_channel: 61, + synth_delay: 62, + preset: 63, + num_partials: 64, + start_sample: 65, + stop_sample: 66, + bus: 67, + mode: 68, + midi_cc: 69, + midi_note_cmd: 70, + cv_trigger: 71, + patch_string: 72 }; var AMY_COEF_FIELDS = ["const", "note", "vel", "eg0", "eg1", "mod0", "bend", "ext0", "ext1", "mod1"]; diff --git a/src/api.c b/src/api.c index fd70fbef..8b0371cd 100644 --- a/src/api.c +++ b/src/api.c @@ -48,6 +48,9 @@ amy_config_t amy_default_config() { c.max_oscs = 250; c.max_buses = AMY_DEFAULT_NUM_BUSES; c.max_sequencer_tags = 256; + c.max_sequence_groups = 32; + c.max_sequence_group_tags = 32; + c.max_sequence_group_executions = 16; c.max_voices = 64; c.max_synths = 64; c.max_memory_patches = 32; @@ -187,6 +190,7 @@ void amy_clear_event(amy_event *e) { AMY_UNSET(e->ticks[TICKS_TICK]); AMY_UNSET(e->ticks[TICKS_PERIOD]); AMY_UNSET(e->ticks[TICKS_TAG]); + AMY_UNSET(e->ticks[TICKS_GROUP]); AMY_UNSET(e->eq_l); AMY_UNSET(e->eq_m); AMY_UNSET(e->eq_h); @@ -320,7 +324,7 @@ void amy_send_wire_from_sysex(char *message) { void amy_add_event(amy_event *e) { peek_stack("add_event"); // was amy_process_event - if(AMY_IS_SET(e->ticks[TICKS_TICK]) || AMY_IS_SET(e->ticks[TICKS_PERIOD]) || AMY_IS_SET(e->ticks[TICKS_TAG])) { + if(AMY_IS_SET(e->ticks[TICKS_TICK]) || AMY_IS_SET(e->ticks[TICKS_PERIOD]) || AMY_IS_SET(e->ticks[TICKS_TAG]) || AMY_IS_SET(e->ticks[TICKS_GROUP])) { // C-API ticks event: serialize it to a wire message and hand it to // the sequencer, so scheduled events have a single storage format. char *buf = (char *)malloc_caps(MAX_MESSAGE_LEN, amy_global.config.ram_caps_events); diff --git a/src/parse.c b/src/parse.c index 436a4549..25c0cef5 100644 --- a/src/parse.c +++ b/src/parse.c @@ -659,6 +659,18 @@ uint16_t amy_parse_transfer_layer_message(char *message) { return total; } } + else if (cmd == 'Q') { + // zQgroup,action,value,quantize[,execution_tag] + uint32_t values[5] = {0, 0, 0, 0, 0}; + int count = parse_list_uint32_t(message, values, 5, 0); + if (count < 2) { + fprintf(stderr, "sequence_control needs at least group and action\n"); + } else { + sequencer_group_control(values[0], values[1], values[2], values[3], + values[4], count >= 5); + } + return 1; + } else if (cmd == 'Y') { // zY: sequencer transport. zY1 starts the sequencer, zY0 stops it. Lets a // host drive playback without MIDI clock sync (see external_midi_sync). @@ -710,8 +722,8 @@ size_t yield_event_from_message(char *message, amy_event *e, size_t pos) { // is only ever honored as the first command of a message. void handle_ticks_message(char *message) { assert(message[0] == 'H'); - uint32_t ticks[3] = {0, 0, 0}; - int num_vals = parse_list_uint32_t(message + 1, ticks, 3, 0); + uint32_t ticks[4] = {0, 0, 0, 0}; + int num_vals = parse_list_uint32_t(message + 1, ticks, 4, 0); uint16_t schedule_len = 1 + _next_alpha(message + 1); char *payload = message + schedule_len; uint16_t payload_len = (uint16_t)strlen(payload); @@ -720,10 +732,17 @@ void handle_ticks_message(char *message) { amy_oom("ticks_message"); } else { memcpy(stripped, payload, payload_len + 1); - // A 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); + if (num_vals >= 4 && ticks[TICKS_GROUP] != 0) { + // The fourth ticks value selects persistent group-local storage. + // Group zero deliberately follows the legacy root path below. + sequencer_group_add_wire(ticks[TICKS_TICK], ticks[TICKS_PERIOD], + ticks[TICKS_TAG], ticks[TICKS_GROUP], stripped); + } else { + // 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); + } } } @@ -906,4 +925,3 @@ int amy_parse_message(char * message, amy_event *e) { // Return exactly how many characters we used. return pos; } - diff --git a/src/patches.c b/src/patches.c index 63c07b5d..9aa7e50a 100644 --- a/src/patches.c +++ b/src/patches.c @@ -330,12 +330,12 @@ int sprint_event(amy_event *e, char *s, size_t len, bool wirecode) { snprintf(s, len - (size_t)(s - s_entry), "amy_event(time=%" PRIu32 ", osc=%u, addr_osc=%d adr_syn=%d adr_bus=%d): ", e->time, (unsigned)e->osc, event_addresses_oscs(e), event_addresses_synth(e), event_addresses_bus(e)); s += strlen(s); - _EPRINT_U_SEQ(ticks, "ticks", 3, "H"); // tick, period, tag + _EPRINT_U_SEQ(ticks, "ticks", 4, "H"); // tick, period, tag, optional group } else { // e->time has no wire representation anymore (there's no 't' command); // it's only ever meaningful as this event's own near-term playback time. // ticks ("H") must always be the first entry in wire code if used. - _EPRINT_U_SEQ(ticks, "ticks", 3, "H"); // tick, period, tag + _EPRINT_U_SEQ(ticks, "ticks", 4, "H"); // tick, period, tag, optional group _EPRINT_I(osc, "osc", "v"); } _EPRINT_I(wave, "wave", "w"); @@ -540,7 +540,7 @@ bool event_addresses_oscs(amy_event *e) { _RET_TRUE_IF_SET(eg_type[0]); _RET_TRUE_IF_SET(eg_type[1]); // We don't know - _RET_TRUE_IF_SET_SEQ(ticks, 3); // tick, period, tag + _RET_TRUE_IF_SET_SEQ(ticks, 4); // tick, period, tag, optional group // //_RET_TRUE_IF_SET(status, "status"); _RET_TRUE_IF_SET(reset_osc); diff --git a/src/pyamy.c b/src/pyamy.c index 49771038..cee64e20 100644 --- a/src/pyamy.c +++ b/src/pyamy.c @@ -97,6 +97,33 @@ static int parse_live_kwarg(amy_config_t *cfg, const char *key, PyObject *value) } cfg->max_sequencer_tags = (uint32_t)llv; return 0; + } else if (strcmp(key, "max_sequence_groups") == 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_groups must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_groups = (uint32_t)llv; + return 0; + } else if (strcmp(key, "max_sequence_group_tags") == 0) { + llv = PyLong_AsLongLong(value); + if (PyErr_Occurred()) return -1; + if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { + PyErr_SetString(PyExc_ValueError, "max_sequence_group_tags must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_group_tags = (uint32_t)llv; + return 0; + } else if (strcmp(key, "max_sequence_group_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_group_executions must be in range [0, 4294967295]"); + return -1; + } + cfg->max_sequence_group_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..1aa02cc1 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -47,7 +47,164 @@ 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) { +// A group definition is immutable once published. Edits are accumulated in a +// private copy and become visible together through SEQUENCE_CONTROL_PUBLISH. +// Active executions retain the published revision they started with. +typedef struct sequence_group_event_t { + char *wire; + uint32_t tick; + uint32_t period; +} sequence_group_event_t; + +typedef struct sequence_group_definition_t { + sequence_group_event_t *events; + uint32_t length_ticks; + uint32_t refs; +} sequence_group_definition_t; + +typedef struct sequence_group_slot_t { + sequence_group_definition_t *published; + sequence_group_definition_t *staging; +} sequence_group_slot_t; + +typedef struct sequence_group_execution_t { + sequence_group_definition_t *definition; + uint32_t group; + uint32_t start_tick; + uint32_t repeats; + uint32_t execution_tag; + uint32_t stop_tick; + uint32_t gate_change_tick; + uint32_t gate_duration; + uint32_t gate_end_tick; + bool occupied; + bool has_execution_tag; + bool stop_pending; + bool gate_change_pending; + bool gated; +} sequence_group_execution_t; + +static sequence_group_slot_t *sequence_groups = NULL; +static sequence_group_execution_t *group_executions = NULL; +static uint32_t max_sequence_groups = 0; +static uint32_t max_sequence_group_tags = 0; +static uint32_t max_sequence_group_executions = 0; +static volatile bool group_wire_firing = false; + +static void group_definition_release(sequence_group_definition_t *definition) { + if (definition == NULL || definition->refs == 0) return; + definition->refs--; + if (definition->refs != 0) return; + for (uint32_t i = 0; i < max_sequence_group_tags; ++i) + if (definition->events[i].wire != NULL) free(definition->events[i].wire); + free(definition->events); + free(definition); +} + +static sequence_group_definition_t *group_definition_new(void) { + sequence_group_definition_t *definition = + (sequence_group_definition_t *)malloc_caps(sizeof(sequence_group_definition_t), + amy_global.config.ram_caps_synth); + if (definition == NULL) return NULL; + definition->events = (sequence_group_event_t *)malloc_caps( + sizeof(sequence_group_event_t) * max_sequence_group_tags, + amy_global.config.ram_caps_synth); + if (definition->events == NULL) { + free(definition); + return NULL; + } + memset(definition->events, 0, + sizeof(sequence_group_event_t) * max_sequence_group_tags); + definition->length_ticks = 0; + definition->refs = 1; + return definition; +} + +static char *group_wire_copy(const char *wire) { + size_t len = strlen(wire); + char *copy = (char *)malloc_caps(len + 1, amy_global.config.ram_caps_events); + if (copy != NULL) memcpy(copy, wire, len + 1); + return copy; +} + +static sequence_group_definition_t *group_definition_clone( + const sequence_group_definition_t *source) { + sequence_group_definition_t *copy = group_definition_new(); + if (copy == NULL) return NULL; + if (source == NULL) return copy; + copy->length_ticks = source->length_ticks; + for (uint32_t i = 0; i < max_sequence_group_tags; ++i) { + const sequence_group_event_t *from = &source->events[i]; + if (from->wire == NULL) continue; + copy->events[i].wire = group_wire_copy(from->wire); + if (copy->events[i].wire == NULL) { + group_definition_release(copy); + return NULL; + } + copy->events[i].tick = from->tick; + copy->events[i].period = from->period; + } + return copy; +} + +static void group_execution_release(sequence_group_execution_t *execution) { + if (!execution->occupied) return; + sequence_group_definition_t *definition = execution->definition; + memset(execution, 0, sizeof(*execution)); + group_definition_release(definition); +} + +static void group_executions_reset(void) { + if (group_executions == NULL) return; + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) + group_execution_release(&group_executions[i]); +} + +static void sequence_groups_deinit(void) { + group_executions_reset(); + if (sequence_groups != NULL) { + for (uint32_t i = 0; i < max_sequence_groups; ++i) { + group_definition_release(sequence_groups[i].published); + group_definition_release(sequence_groups[i].staging); + } + free(sequence_groups); + sequence_groups = NULL; + } + if (group_executions != NULL) { + free(group_executions); + group_executions = NULL; + } + max_sequence_groups = 0; + max_sequence_group_tags = 0; + max_sequence_group_executions = 0; +} + +static void sequence_groups_init(uint32_t groups, uint32_t tags, + uint32_t executions) { + max_sequence_groups = groups; + max_sequence_group_tags = tags; + max_sequence_group_executions = executions; + group_wire_firing = false; + if (groups == 0 || tags == 0 || executions == 0) return; + sequence_groups = (sequence_group_slot_t *)malloc_caps( + sizeof(sequence_group_slot_t) * groups, amy_global.config.ram_caps_synth); + if (sequence_groups != NULL) + memset(sequence_groups, 0, sizeof(sequence_group_slot_t) * groups); + group_executions = (sequence_group_execution_t *)malloc_caps( + sizeof(sequence_group_execution_t) * executions, + amy_global.config.ram_caps_synth); + if (group_executions != NULL) + memset(group_executions, 0, + sizeof(sequence_group_execution_t) * executions); + if (sequence_groups == NULL || group_executions == NULL) { + amy_oom("sequencer groups"); + sequence_groups_deinit(); + return; + } +} + +void sequencer_init(int max_sequencer_tags, uint32_t groups, + uint32_t group_tags, uint32_t group_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; @@ -65,6 +222,7 @@ void sequencer_init(int max_sequencer_tags) { sequences[i].next_active = -1; } first_active = -1; + sequence_groups_init(groups, group_tags, group_execution_count); // We are read to go. sequencer_recompute(); } @@ -82,6 +240,9 @@ void sequencer_reset() { sequences[i].next_active = -1; } first_active = -1; + // Definitions are preloadable state and deliberately survive a transport + // reset; only their active or quantized executions are discarded. + group_executions_reset(); } void sequencer_deinit() { @@ -91,6 +252,13 @@ void sequencer_deinit() { sequences = NULL; // sequencer_check_and_fill guards on this } max_sequences = 0; + sequence_groups_deinit(); +} + +void sequencer_group_reset_timebase() { + // Absolute activation/control ticks cannot be meaningfully rebased across + // a timebase reset. Persistent definitions remain available for relaunch. + group_executions_reset(); } void sequencer_debug() { @@ -240,6 +408,289 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha return 1; } +static sequence_group_slot_t *group_slot(uint32_t group) { + if (sequence_groups == NULL || group == 0 || group > max_sequence_groups) + return NULL; + return &sequence_groups[group - 1]; +} + +uint8_t sequencer_group_add_wire(uint32_t tick, uint32_t period, + uint32_t tag, uint32_t group, char *wire) { + sequence_group_slot_t *slot = group_slot(group); + if (slot == NULL || tag >= max_sequence_group_tags) { + fprintf(stderr, "sequencer group/event tag out of range: group %" PRIu32 + ", tag %" PRIu32 "\n", group, tag); + free(wire); + return 0; + } + if (wire[0] == 'H') { + fprintf(stderr, "a grouped ticks event cannot contain another ticks event\n"); + free(wire); + return 0; + } + + amy_grab_lock(); + if (slot->staging == NULL) { + slot->staging = group_definition_clone(slot->published); + if (slot->staging == NULL) { + amy_release_lock(); + amy_oom("sequencer group edit"); + free(wire); + return 0; + } + } + sequence_group_event_t *event = &slot->staging->events[tag]; + if (event->wire != NULL) free(event->wire); + event->wire = NULL; + event->tick = 0; + event->period = 0; + if (tick != 0 || period != 0) { + event->wire = wire; + event->tick = tick; + event->period = period; + wire = NULL; + } + amy_release_lock(); + if (wire != NULL) free(wire); + return 1; +} + +static uint32_t group_control_tick(uint32_t quantize) { + // 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 = wire_firing ? amy_global.sequencer_tick_count + : amy_global.sequencer_tick_count + 1; + if (quantize != 0) { + uint32_t remainder = tick % quantize; + if (remainder != 0) tick += quantize - remainder; + } + return tick; +} + +static bool group_execution_matches(const sequence_group_execution_t *execution, + uint32_t group, uint32_t execution_tag, + bool has_execution_tag) { + if (!execution->occupied || execution->group != group) return false; + return !has_execution_tag + || (execution->has_execution_tag + && execution->execution_tag == execution_tag); +} + +static uint8_t group_publish(sequence_group_slot_t *slot, uint32_t length) { + if (length == 0) { + fprintf(stderr, "a sequencer group must have a nonzero length\n"); + return 0; + } + if (slot->staging == NULL) { + slot->staging = group_definition_clone(slot->published); + if (slot->staging == NULL) { + amy_oom("sequencer group publish"); + return 0; + } + } + for (uint32_t i = 0; i < max_sequence_group_tags; ++i) { + sequence_group_event_t *event = &slot->staging->events[i]; + if (event->wire == NULL) continue; + if (event->tick >= length + || (event->period != 0 && event->tick >= event->period)) { + fprintf(stderr, "sequencer group event %" PRIu32 + " has tick %" PRIu32 " outside its period/group length\n", + i, event->tick); + return 0; + } + } + slot->staging->length_ticks = length; + sequence_group_definition_t *previous = slot->published; + slot->published = slot->staging; + slot->staging = NULL; + group_definition_release(previous); + return 1; +} + +uint8_t sequencer_group_control(uint32_t group, uint32_t action, + uint32_t value, uint32_t quantize, + uint32_t execution_tag, + bool has_execution_tag) { + sequence_group_slot_t *slot = group_slot(group); + if (slot == NULL) { + fprintf(stderr, "sequencer group %" PRIu32 " is out of range\n", group); + return 0; + } + if (group_wire_firing + && (action == SEQUENCE_CONTROL_START + || action == SEQUENCE_CONTROL_PUBLISH + || action == SEQUENCE_CONTROL_CLEAR)) { + fprintf(stderr, "a sequencer group cannot launch or edit a group\n"); + return 0; + } + + uint8_t result = 0; + amy_grab_lock(); + if (action == SEQUENCE_CONTROL_PUBLISH) { + result = group_publish(slot, value); + } else if (action == SEQUENCE_CONTROL_CLEAR) { + group_definition_release(slot->published); + group_definition_release(slot->staging); + slot->published = NULL; + slot->staging = NULL; + result = 1; + } else if (action == SEQUENCE_CONTROL_START) { + if (slot->published == NULL || slot->published->length_ticks == 0) { + fprintf(stderr, "sequencer group %" PRIu32 " has no published definition\n", + group); + } else { + uint32_t start_tick = group_control_tick(quantize); + sequence_group_execution_t *available = NULL; + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + sequence_group_execution_t *execution = &group_executions[i]; + if (!execution->occupied && available == NULL) available = execution; + } + if (available == NULL) { + fprintf(stderr, "sequencer group execution pool is full\n"); + } else { + if (has_execution_tag) { + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + sequence_group_execution_t *execution = &group_executions[i]; + if (group_execution_matches(execution, group, execution_tag, true)) { + execution->stop_tick = start_tick; + execution->stop_pending = true; + } + } + } + memset(available, 0, sizeof(*available)); + available->definition = slot->published; + available->definition->refs++; + available->group = group; + available->start_tick = start_tick; + available->repeats = value; + available->execution_tag = execution_tag; + available->has_execution_tag = has_execution_tag; + available->occupied = true; + result = 1; + } + } + } else if (action == SEQUENCE_CONTROL_STOP + || action == SEQUENCE_CONTROL_GATE) { + uint32_t control_tick = group_control_tick(quantize); + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + sequence_group_execution_t *execution = &group_executions[i]; + if (!group_execution_matches(execution, group, execution_tag, + has_execution_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, "unknown sequencer group action %" PRIu32 "\n", action); + } + amy_release_lock(); + return result; +} + +static bool group_event_hits(const sequence_group_event_t *event, + uint32_t local_tick) { + if (event->wire == NULL) return false; + return event->period != 0 ? local_tick % event->period == event->tick + : local_tick == event->tick; +} + +static bool group_event_is_control(const sequence_group_event_t *event) { + return event->wire != NULL && strncmp(event->wire, "zQ", 2) == 0; +} + +static void group_play_wire(const char *wire) { + bool previous = group_wire_firing; + group_wire_firing = true; + amy_play_message((char *)wire); + group_wire_firing = previous; +} + +static void group_process_control_events(uint32_t tick) { + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + amy_grab_lock(); + sequence_group_execution_t *execution = &group_executions[i]; + if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { + amy_release_lock(); + continue; + } + uint32_t elapsed = tick - execution->start_tick; + sequence_group_definition_t *definition = execution->definition; + if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) + || (execution->repeats != 0 + && elapsed / definition->length_ticks >= execution->repeats)) { + group_execution_release(execution); + amy_release_lock(); + continue; + } + definition->refs++; + uint32_t local_tick = elapsed % definition->length_ticks; + amy_release_lock(); + + for (uint32_t tag = 0; tag < max_sequence_group_tags; ++tag) { + sequence_group_event_t *event = &definition->events[tag]; + if (group_event_is_control(event) && group_event_hits(event, local_tick)) + group_play_wire(event->wire); + } + + amy_grab_lock(); + group_definition_release(definition); + amy_release_lock(); + } +} + +static void group_process_events(uint32_t tick) { + for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { + amy_grab_lock(); + sequence_group_execution_t *execution = &group_executions[i]; + if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { + amy_release_lock(); + continue; + } + uint32_t elapsed = tick - execution->start_tick; + sequence_group_definition_t *definition = execution->definition; + if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) + || (execution->repeats != 0 + && elapsed / definition->length_ticks >= execution->repeats)) { + group_execution_release(execution); + amy_release_lock(); + continue; + } + 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 gated = execution->gated; + definition->refs++; + uint32_t local_tick = elapsed % definition->length_ticks; + amy_release_lock(); + + if (!gated) { + for (uint32_t tag = 0; tag < max_sequence_group_tags; ++tag) { + sequence_group_event_t *event = &definition->events[tag]; + if (!group_event_is_control(event) + && group_event_hits(event, local_tick)) + group_play_wire(event->wire); + } + } + + amy_grab_lock(); + group_definition_release(definition); + amy_release_lock(); + } +} + static void sequencer_process_tick(void) { amy_global.sequencer_tick_count++; midi_clock_out_tick(); // no-op unless in AMY_MIDI_SYNC_SEND mode @@ -300,6 +751,10 @@ static void sequencer_process_tick(void) { } tag = next; } + // Controls embedded in a group are leaf operations (stop/gate only) and + // take effect before any ordinary group event on the same tick. + group_process_control_events(amy_global.sequencer_tick_count); + group_process_events(amy_global.sequencer_tick_count); wire_firing = was_firing; if(amy_global.config.amy_external_sequencer_hook != NULL) { amy_global.config.amy_external_sequencer_hook(amy_global.sequencer_tick_count); diff --git a/src/sequencer.h b/src/sequencer.h index d073e642..2bb038da 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -5,7 +5,8 @@ #include "amy.h" #define MIDI_SEQUENCER_PPQ 24 // MIDI clocks per quarter note uint32_t sequencer_ticks(); -void sequencer_init(int max_num_sequences); +void sequencer_init(int max_num_sequences, uint32_t max_groups, + uint32_t max_group_tags, uint32_t max_group_executions); void sequencer_deinit(); void sequencer_reset(); void sequencer_debug(); @@ -22,6 +23,23 @@ void sequencer_check_and_call_js_hook(); // called from the browser main loop // anonymously (round-robin in a small reserved pool) and can't be addressed // or cancelled by any tag. Takes ownership of wire. uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire); +// Store one ordinary ticks event in a group's unpublished revision. Takes +// ownership of wire. Group zero is reserved for sequencer_add_wire(). +uint8_t sequencer_group_add_wire(uint32_t tick, uint32_t period, + uint32_t tag, uint32_t group, char *wire); + +// sequence_control actions. The wire/API representation is always +// [group, action, value, quantize, optional execution_tag]. +#define SEQUENCE_CONTROL_STOP 0 +#define SEQUENCE_CONTROL_START 1 +#define SEQUENCE_CONTROL_GATE 2 +#define SEQUENCE_CONTROL_PUBLISH 3 +#define SEQUENCE_CONTROL_CLEAR 4 +uint8_t sequencer_group_control(uint32_t group, uint32_t action, + uint32_t value, uint32_t quantize, + uint32_t execution_tag, + bool has_execution_tag); +void sequencer_group_reset_timebase(); void sequencer_midi_clock_tick(); void sequencer_midi_start(); void sequencer_midi_stop(); diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c new file mode 100644 index 00000000..c0422907 --- /dev/null +++ b/tests/test_sequence_groups.c @@ -0,0 +1,198 @@ +// Regression and behavior tests for reusable sequencer groups. + +#include +#include +#include +#include "amy.h" +#include "sequencer.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +typedef struct mark_t { + char name[24]; + uint32_t tick; +} mark_t; + +static mark_t marks[128]; +static int mark_count = 0; + +static void mark_hook(const char *code) { + if (mark_count >= (int)(sizeof(marks) / sizeof(marks[0]))) return; + snprintf(marks[mark_count].name, sizeof(marks[mark_count].name), "%s", code); + marks[mark_count].tick = sequencer_ticks(); + mark_count++; +} + +static void clear_marks(void) { + mark_count = 0; + 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 clear_group(uint32_t group) { + char wire[32]; + snprintf(wire, sizeof(wire), "zQ%" PRIu32 ",4Z", group); + amy_add_message(wire); +} + +static void test_legacy_ticks_are_unchanged(void) { + printf("legacy root ticks behavior remains unchanged\n"); + sequencer_reset(); + clear_marks(); + uint32_t first = next_boundary(sequencer_ticks(), 4); + + amy_add_message("H0,4,0zProotZ"); + clock_to(first + 4); + CHECK(mark_at("root", first), "root period event fires at global modulo"); + CHECK(mark_at("root", first + 4), "root period event keeps looping"); + amy_add_message("H0,0,0Z"); + + clear_marks(); + uint32_t target = sequencer_ticks() + 4; + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,9zPoldZ", target); + amy_add_message(wire); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,9zPnewZ", target); + amy_add_message(wire); + clock_to(target); + CHECK(!marks_named("old") && mark_at("new", target), + "legacy root tags still replace by tag"); +} + +static void test_one_n_and_infinite_repeats(void) { + printf("groups support one, N and infinite repeats\n"); + sequencer_reset(); + clear_group(1); + clear_marks(); + amy_add_message("H0,4,0,1zPzeroZ"); + amy_add_message("H2,4,1,1zPtwoZ"); + amy_add_message("zQ1,3,4Z"); + + uint32_t one = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ1,1,1,4Z"); + clock_to(one + 6); + CHECK(mark_at("zero", one) && mark_at("two", one + 2), + "one-shot uses local ticks from its activation"); + CHECK(marks_named("zero") == 1 && marks_named("two") == 1, + "one-shot does not wrap"); + + clear_marks(); + uint32_t twice = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ1,1,2,4Z"); + clock_to(twice + 10); + CHECK(mark_at("zero", twice) && mark_at("zero", twice + 4), + "repeat count two runs exactly two phrases"); + CHECK(marks_named("zero") == 2, "N-shot finishes after N phrases"); + + clear_marks(); + uint32_t loop = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ1,1,0,4,77Z"); + clock_to(loop + 8); + CHECK(mark_at("zero", loop) && mark_at("zero", loop + 8), + "repeat count zero loops indefinitely"); + amy_add_message("zQ1,0,0,0,77Z"); + clock_to(loop + 12); + CHECK(!mark_at("zero", loop + 12), "tagged stop ends the loop"); +} + +static void test_atomic_revision_lifetime(void) { + printf("published revisions are atomic and immutable while active\n"); + sequencer_reset(); + clear_group(2); + clear_marks(); + amy_add_message("H0,8,0,2zPold-zeroZ"); + amy_add_message("H6,8,1,2zPold-tailZ"); + amy_add_message("zQ2,3,8Z"); + + uint32_t old_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ2,1,1,4Z"); + amy_add_message("H0,8,0,2zPnew-zeroZ"); + amy_add_message("H0,0,1,2Z"); + + uint32_t still_old = old_start + 8; + char root[80]; + snprintf(root, sizeof(root), "H%" PRIu32 ",0,31zQ2,1,1,0Z", still_old); + amy_add_message(root); + clock_to(old_start + 6); + CHECK(mark_at("old-zero", old_start) && mark_at("old-tail", old_start + 6), + "an active execution finishes its original revision"); + + clock_to(still_old); + CHECK(mark_at("old-zero", still_old), + "staged edits are invisible before publication"); + amy_add_message("zQ2,3,8Z"); + uint32_t new_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ2,1,1,4Z"); + clock_to(new_start + 6); + CHECK(mark_at("new-zero", new_start), "future execution uses published edit"); + CHECK(!mark_at("old-tail", new_start + 6), "published local-tag clear took effect"); +} + +static void test_root_launches_local_zero_on_same_tick(void) { + printf("a root event can launch group local tick zero on the same tick\n"); + sequencer_reset(); + clear_group(3); + clear_marks(); + amy_add_message("H0,4,0,3zPchildZ"); + amy_add_message("zQ3,3,4Z"); + + uint32_t start = sequencer_ticks() + 4; + char wire[80]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,22zQ3,1,1,0Z", start); + amy_add_message(wire); + clock_to(start); + CHECK(mark_at("child", start), "root launch and group local zero coincide"); +} + +// 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_sequence_groups = 8; + config.max_sequence_group_tags = 8; + config.max_sequence_group_executions = 8; + amy_start(config); + + test_legacy_ticks_are_unchanged(); + test_one_n_and_infinite_repeats(); + test_atomic_revision_lifetime(); + test_root_launches_local_zero_on_same_tick(); + + amy_stop(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall sequencer group checks passed\n"); + return 0; +} From 066bc1f28371c279591bb8002c21ebe99c358b00 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 20:06:41 +0200 Subject: [PATCH 02/12] Expand sequencer group behavior coverage --- tests/test_sequence_groups.c | 154 +++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c index c0422907..05c19392 100644 --- a/tests/test_sequence_groups.c +++ b/tests/test_sequence_groups.c @@ -170,6 +170,154 @@ static void test_root_launches_local_zero_on_same_tick(void) { CHECK(mark_at("child", start), "root launch and group local zero coincide"); } +static void test_c_event_uses_fourth_ticks_field(void) { + printf("the C event API defines grouped events through ticks[3]\n"); + sequencer_reset(); + clear_group(6); + amy_event event = amy_default_event(); + event.osc = 0; + event.wave = TRIANGLE; + event.ticks[TICKS_TICK] = 0; + event.ticks[TICKS_PERIOD] = 4; + event.ticks[TICKS_TAG] = 0; + event.ticks[TICKS_GROUP] = 6; + amy_add_event(&event); + CHECK(sequencer_group_control(6, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "C-authored grouped event publishes"); + CHECK(sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "C-authored group starts"); + clock_to(sequencer_ticks() + 2); + amy_execute_deltas(); + CHECK(synth[0] != NULL && synth[0]->wave == TRIANGLE, + "C-authored grouped event reaches normal playback"); +} + +static void test_quantized_gate_preserves_phase(void) { + printf("finite event gating preserves local phase\n"); + sequencer_reset(); + clear_group(4); + clear_group(5); + clear_marks(); + amy_add_message("H0,2,0,4zPbackgroundZ"); + amy_add_message("zQ4,3,4Z"); + amy_add_message("H0,4,0,5zQ4,2,4,0,81Z"); + amy_add_message("H0,4,1,5zPforegroundZ"); + amy_add_message("zQ5,3,4Z"); + + uint32_t background = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ4,1,0,4,81Z"); + clock_to(background + 2); + CHECK(mark_at("background", background) + && mark_at("background", background + 2), + "background loop initially emits on phase"); + + uint32_t foreground = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ5,1,1,4Z"); + clock_to(foreground + 4); + CHECK(mark_at("foreground", foreground), "foreground group starts normally"); + CHECK(!mark_at("background", foreground) + && !mark_at("background", foreground + 2), + "gate suppresses events for its exact duration"); + CHECK(mark_at("background", foreground + 4), + "background resumes on its unchanged phase"); + amy_add_message("zQ4,0,0,0,81Z"); + clock_to(foreground + 6); +} + +static void test_quantized_stop_precedes_boundary_event(void) { + printf("quantized stop takes effect before an event at its boundary\n"); + sequencer_reset(); + clear_group(6); + clear_marks(); + amy_add_message("H0,4,0,6zPstoppedZ"); + amy_add_message("zQ6,3,4Z"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ6,1,0,4,91Z"); + clock_to(start); + CHECK(mark_at("stopped", start), "loop starts on its boundary"); + + uint32_t stop = next_boundary(sequencer_ticks(), 8); + amy_add_message("zQ6,0,0,8,91Z"); + clock_to(stop); + CHECK(!mark_at("stopped", stop), "stop suppresses the boundary event"); +} + +static void test_group_control_cannot_recurse(void) { + printf("a group cannot launch a third sequencer level\n"); + sequencer_reset(); + clear_group(7); + clear_group(8); + clear_marks(); + amy_add_message("H0,4,0,8zPgrandchildZ"); + amy_add_message("zQ8,3,4Z"); + amy_add_message("H0,4,0,7zQ8,1,1,0Z"); + amy_add_message("zQ7,3,4Z"); + + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ7,1,1,4Z"); + clock_to(start + 4); + CHECK(!marks_named("grandchild"), "nested group launch is rejected"); +} + +static void test_resets_keep_definitions_only(void) { + printf("sequencer and timebase resets stop executions but keep definitions\n"); + sequencer_reset(); + clear_group(8); + clear_marks(); + amy_add_message("H0,4,0,8zPsurvivorZ"); + amy_add_message("zQ8,3,4Z"); + uint32_t first = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ8,1,0,4Z"); + clock_to(first); + CHECK(mark_at("survivor", first), "definition runs before reset"); + + clear_marks(); + sequencer_reset(); + clock_to(first + 4); + CHECK(!marks_named("survivor"), "RESET_SEQUENCER stops active executions"); + uint32_t second = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ8,1,1,4Z"); + clock_to(second); + CHECK(mark_at("survivor", second), "definition survives RESET_SEQUENCER"); + + clear_marks(); + amy_add_message("zQ8,1,0,0Z"); + clock_to(sequencer_ticks() + 2); + sequencer_group_reset_timebase(); + clear_marks(); + uint32_t after_reset = sequencer_ticks() + 4; + clock_to(after_reset); + CHECK(!marks_named("survivor"), "RESET_TIMEBASE stops active executions"); + amy_add_message("zQ8,1,1,0Z"); + clock_to(sequencer_ticks() + 2); + CHECK(marks_named("survivor") == 1, "definition survives RESET_TIMEBASE"); +} + +static void test_configured_bounds(void) { + printf("configured group, local-tag and execution bounds are enforced\n"); + sequencer_reset(); + clear_group(8); + char *valid = strdup("zPlastZ"); + char *bad_group = strdup("zPbad-groupZ"); + char *bad_tag = strdup("zPbad-tagZ"); + CHECK(sequencer_group_add_wire(0, 4, 7, 8, valid), + "last configured group and local tag are valid"); + CHECK(!sequencer_group_add_wire(0, 4, 0, 9, bad_group), + "first group past the configured range is rejected"); + CHECK(!sequencer_group_add_wire(0, 4, 8, 8, bad_tag), + "first local tag past the configured range is rejected"); + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "last group publishes"); + for (uint32_t i = 0; i < 8; ++i) + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 64, + i, true), + "execution slot %" PRIu32 " is available", i); + CHECK(!sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 64, + 8, true), + "one execution beyond the configured pool is rejected"); + sequencer_reset(); +} + // examples.c calls this; the platform normally provides it. void delay_ms(uint32_t ms) { (void)ms; } @@ -187,6 +335,12 @@ int main(void) { test_one_n_and_infinite_repeats(); test_atomic_revision_lifetime(); test_root_launches_local_zero_on_same_tick(); + test_c_event_uses_fourth_ticks_field(); + test_quantized_gate_preserves_phase(); + test_quantized_stop_precedes_boundary_event(); + test_group_control_cannot_recurse(); + test_resets_keep_definitions_only(); + test_configured_bounds(); amy_stop(); if (failures) { From 83ae7f1d0adfeea2b2ef565bf11ca15133d28494 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 20:10:28 +0200 Subject: [PATCH 03/12] Document sequencer group API --- amy/constants.py | 5 ++ docs/api.md | 6 +- docs/sequencer-groups.md | 132 +++++++++++++++++++++++++++++++++++++++ docs/synth.md | 13 +++- godot/amy.gd | 44 ++++++------- src/amy.h | 6 ++ src/amy_api.generated.js | 6 ++ src/api.c | 4 +- src/sequencer.h | 5 -- 9 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 docs/sequencer-groups.md diff --git a/amy/constants.py b/amy/constants.py index ecf4bb28..4820ffbe 100644 --- a/amy/constants.py +++ b/amy/constants.py @@ -125,6 +125,11 @@ TICKS_PERIOD=1 TICKS_TAG=2 TICKS_GROUP=3 +SEQUENCE_CONTROL_STOP=0 +SEQUENCE_CONTROL_START=1 +SEQUENCE_CONTROL_GATE=2 +SEQUENCE_CONTROL_PUBLISH=3 +SEQUENCE_CONTROL_CLEAR=4 RESET_SEQUENCER=4096 RESET_ALL_OSCS=8192 RESET_TIMEBASE=16384 diff --git a/docs/api.md b/docs/api.md index 0a19e45f..16f07b90 100644 --- a/docs/api.md +++ b/docs/api.md @@ -204,6 +204,9 @@ amy_start(amy_config); | `max_oscs` | Int | 180 | How many oscillators to support | | `max_buses` | Int | 4 | How many FX buses to support. No compile-time ceiling — every bus-indexed table is allocated from this at `amy_start`. Each bus costs a few KB of mix buffers even when idle, plus whatever its effects allocate once switched on | | `max_sequencer_tags` | Int | 256 | How many sequencer items to handle | +| `max_sequence_groups` | Int | 32 | Number of persistent sequencer groups; group tags are 1 through this value | +| `max_sequence_group_tags` | Int | 64 | Addressable local event tags in each allocated group definition | +| `max_sequence_group_executions` | Int | 32 | Maximum active or quantized-pending group 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,8 +506,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[4]` | `ticks` | int[,int[,tag[,group]]] | Tick, period and tag for root sequencing. A nonzero fourth value instead addresses a persistent [sequencer group](sequencer-groups.md), with the third value as its local event tag. `tag` omitted at root: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. **If used in a wire string message**, the `H` **must** be the first character of the message. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | +| `zQ` | — | `sequence_control` | group,action,value,quantize[,execution_tag] | Publish, start, stop, gate or clear a [sequencer group](sequencer-groups.md). | | `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. | | `N` | `latency_ms`| `latency_ms` | uint | Sets latency in ms. default 0 (see LATENCY) | diff --git a/docs/sequencer-groups.md b/docs/sequencer-groups.md new file mode 100644 index 00000000..a5303238 --- /dev/null +++ b/docs/sequencer-groups.md @@ -0,0 +1,132 @@ +# Sequencer groups + +Sequencer groups are reusable collections of ordinary AMY sequencer events. +They add one bounded level below the existing root sequencer: a root event may +start a group, but a group cannot start another group. + +This is useful when a musical controller needs to trigger a complete phrase +as one operation. Examples include a drum fill, a short arpeggio with its own +note-on and note-off, or a repeating percussion layer. The controller can +preload these phrases and later send one small, quantized control message. It +does not need to reproduce AMY's clock or resend every event at performance +time. + +## Defining and publishing a group + +The normal `ticks` tuple accepts an optional fourth value: + +```text +tick,period,event_tag,group_tag +``` + +`group_tag` values start at 1. An absent or zero group tag uses the existing +root sequencer without changing any of its semantics. + +This wire sequence stages a four-beat phrase in group 1 and then publishes it +atomically with a length of 192 ticks: + +```text +H0,192,0,1i2n60l1Z +H24,192,1,1i2n60l0Z +H48,192,2,1i2n64l1Z +H72,192,3,1i2n64l0Z +zQ1,3,192Z +``` + +The equivalent Python calls are: + +```python +amy.send(ticks="0,192,0,1", synth=2, note=60, vel=1) +amy.send(ticks="24,192,1,1", synth=2, note=60, vel=0) +amy.send(ticks="48,192,2,1", synth=2, note=64, vel=1) +amy.send(ticks="72,192,3,1", synth=2, note=64, vel=0) +amy.send(sequence_control=[1, amy.SEQUENCE_CONTROL_PUBLISH, 192]) +``` + +Grouped `ticks` commands update a private staging revision. Publishing is one +action in the generic control family rather than a separate begin/add/commit +API. It makes all staged local-tag replacements visible together, so a launch +can never observe a half-updated phrase. As at the root, `tick=0,period=0` +clears the specified event tag. Use a nonzero period for an event at local tick +zero. + +The published length is explicit and bounded; AMY does not derive it using an +LCM of event periods. Within each phrase, a nonzero event period repeats by +local modulo and a zero period fires once at its local tick. + +## Controlling executions + +The control layout is fixed: + +```text +group,action,value,quantize[,execution_tag] +``` + +| Action | Number | Meaning of `value` | +|---|---:|---| +| stop | 0 | reserved; use 0 | +| start | 1 | repeat count: 1 once, N exactly N times, 0 indefinitely | +| gate | 2 | suppress group-event firings for this many ticks; 0 releases a gate | +| publish | 3 | explicit group length in ticks | +| clear | 4 | reserved; use 0 | + +`quantize=0` means the next sequencer tick for a direct command. Otherwise the +control takes effect at the next multiple of that many ticks. When a root +sequencer event issues the control on the boundary itself, it takes effect on +that same tick, including the group's local tick-zero events. + +For example, start group 1 indefinitely at the next 192-tick boundary, assign +execution tag 100, and later stop that execution at a boundary: + +```text +zQ1,1,0,192,100Z +zQ1,0,0,192,100Z +``` + +```python +amy.send(sequence_control=[1, amy.SEQUENCE_CONTROL_START, 0, 192, 100]) +amy.send(sequence_control=[1, amy.SEQUENCE_CONTROL_STOP, 0, 192, 100]) +``` + +Omit `execution_tag` to address every active execution of the group for stop +or gate operations. Supplying a tag to start makes a later start with the same +group and execution tag replace it on the requested boundary. Untagged starts +may overlap, which is useful for one-shot note phrases whose releases must be +allowed to finish independently. + +A finite gate advances the execution's local clock but suppresses its event +firings. Audio already sounding is not stopped, and the first event after the +gate occurs at its original phase. A gate can itself be placed in another +group as a leaf control; start, publish and clear are rejected while a group +payload is firing, preventing recursive nesting. + +## Scheduling a launch at the root + +Because `sequence_control` is an ordinary wire command, it can be the payload +of a normal root `ticks` event. This starts group 1 once at absolute tick 960: + +```text +H960,0,40zQ1,1,1,0Z +``` + +A repeating root entry can launch the same group sparsely without copying its +events. Clear that future launch with the unchanged root operation +`H0,0,40Z`; an execution already started from it keeps running. + +## Lifetime and memory guarantees + +An active execution retains the immutable published revision it started with. +Editing, publishing or clearing the group affects future starts only. This is +important for phrases containing releases: an old note-off cannot disappear +because a new definition was loaded while it was sounding. + +`RESET_SEQUENCER` and `RESET_TIMEBASE` discard active and quantized-pending +executions but preserve published group definitions. Full AMY shutdown frees +them. + +Storage and work are bounded by `max_sequence_groups`, +`max_sequence_group_tags` and `max_sequence_group_executions` in +`amy_config_t`. Group event arrays and wire payloads are allocated only for +definitions that are authored. The tick path scans only the fixed active +execution pool; inactive stored groups are not visited, and starting an +execution does not allocate memory. diff --git a/docs/synth.md b/docs/synth.md index cf0e6e39..0b22eb26 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -241,6 +241,18 @@ For pattern sequencers like drum machines, you will also want to use `tick` alon If you are including AMY in a program, you can set the [hook `void (*amy_external_sequencer_hook)(uint32_t)`](docs/api.md) to any function. This will be called at every tick with the current tick number as an argument. +### Reusable sequencer groups + +A fourth `ticks` value stores an event in a reusable group instead of the root +sequencer: `tick,period,event_tag,group_tag`. Group tag zero is reserved for +the root sequencer, so existing one-, two- and three-value `ticks` messages +retain their original behavior. Groups are controlled through the single +`sequence_control` parameter; they can run once, a fixed number of times, or +continuously, and start/stop can be quantized to AMY's tick clock. + +See [Sequencer groups](sequencer-groups.md) for the wire format, lifecycle, +examples and implementation guarantees. + ## Core oscillators We support bandlimited saw, pulse/square and triangle waves, alongside sine and noise. Use the wave parameter: 0=SINE, PULSE, SAW_DOWN, SAW_UP, TRIANGLE, NOISE. Each oscillator can have a frequency (or set by midi note), amplitude and phase (set in 0-1.). You can also set `duty` for the pulse type. We also have a karplus-strong type (KS=6), plus `WAVETABLE` when compiled with `AMY_WAVETABLE` that plays back 16,384 sample long wavetable packs, such as those hosted on [waveeditonline.com](http://waveeditonline.com). @@ -478,4 +490,3 @@ amy.send(osc=1, wave=amy.PCM_RIGHT, preset=1024, pan=1, note=72, vel=1) - diff --git a/godot/amy.gd b/godot/amy.gd index 7c8980af..435e3693 100644 --- a/godot/amy.gd +++ b/godot/amy.gd @@ -343,6 +343,7 @@ var _KW_MAP: Dictionary = { "portamento": ["m", "I"], "tempo": ["j", "F"], "sequencer_run": ["zY", "I"], + "sequence_control": ["zQ", "L"], "external_midi_sync": ["zC", "I"], "synth": ["i", "I"], "pedal": ["ip", "I"], @@ -418,27 +419,28 @@ var _KW_PRIORITY: Dictionary = { "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_control": 51, + "external_midi_sync": 52, + "synth": 53, + "pedal": 54, + "synth_flags": 55, + "num_voices": 56, + "oscs_per_voice": 57, + "synth_level": 58, + "to_synth": 59, + "grab_midi_notes": 60, + "note_source_channel": 61, + "synth_delay": 62, + "preset": 63, + "num_partials": 64, + "start_sample": 65, + "stop_sample": 66, + "bus": 67, + "mode": 68, + "midi_cc": 69, + "midi_note_cmd": 70, + "cv_trigger": 71, + "patch_string": 72, } ## The control coefficient inputs, in wire order. Prefer naming these in a diff --git a/src/amy.h b/src/amy.h index 11deb495..37a71d03 100644 --- a/src/amy.h +++ b/src/amy.h @@ -365,6 +365,12 @@ enum coefs{ #define TICKS_TAG 2 #define TICKS_GROUP 3 +#define SEQUENCE_CONTROL_STOP 0 +#define SEQUENCE_CONTROL_START 1 +#define SEQUENCE_CONTROL_GATE 2 +#define SEQUENCE_CONTROL_PUBLISH 3 +#define SEQUENCE_CONTROL_CLEAR 4 + // Reset masks #define RESET_SEQUENCER 4096 #define RESET_ALL_OSCS 8192 diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index 4f92c050..230f8876 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -408,6 +408,12 @@ var AMY = { TICKS_TICK: 0, TICKS_PERIOD: 1, TICKS_TAG: 2, + TICKS_GROUP: 3, + SEQUENCE_CONTROL_STOP: 0, + SEQUENCE_CONTROL_START: 1, + SEQUENCE_CONTROL_GATE: 2, + SEQUENCE_CONTROL_PUBLISH: 3, + SEQUENCE_CONTROL_CLEAR: 4, RESET_SEQUENCER: 4096, RESET_ALL_OSCS: 8192, RESET_TIMEBASE: 16384, diff --git a/src/api.c b/src/api.c index 8b0371cd..faad006c 100644 --- a/src/api.c +++ b/src/api.c @@ -49,8 +49,8 @@ amy_config_t amy_default_config() { c.max_buses = AMY_DEFAULT_NUM_BUSES; c.max_sequencer_tags = 256; c.max_sequence_groups = 32; - c.max_sequence_group_tags = 32; - c.max_sequence_group_executions = 16; + c.max_sequence_group_tags = 64; + c.max_sequence_group_executions = 32; c.max_voices = 64; c.max_synths = 64; c.max_memory_patches = 32; diff --git a/src/sequencer.h b/src/sequencer.h index 2bb038da..82eda5ba 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -30,11 +30,6 @@ uint8_t sequencer_group_add_wire(uint32_t tick, uint32_t period, // sequence_control actions. The wire/API representation is always // [group, action, value, quantize, optional execution_tag]. -#define SEQUENCE_CONTROL_STOP 0 -#define SEQUENCE_CONTROL_START 1 -#define SEQUENCE_CONTROL_GATE 2 -#define SEQUENCE_CONTROL_PUBLISH 3 -#define SEQUENCE_CONTROL_CLEAR 4 uint8_t sequencer_group_control(uint32_t group, uint32_t action, uint32_t value, uint32_t quantize, uint32_t execution_tag, From 109852803bd1385100448e49965dff949d3ba5dd Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 21:01:54 +0200 Subject: [PATCH 04/12] Cover sequencer group identity and rollover --- tests/test_sequence_groups.c | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c index 05c19392..13e9a3d5 100644 --- a/tests/test_sequence_groups.c +++ b/tests/test_sequence_groups.c @@ -83,6 +83,35 @@ static void test_legacy_ticks_are_unchanged(void) { clock_to(target); CHECK(!marks_named("old") && mark_at("new", target), "legacy root tags still replace by tag"); + + clear_marks(); + uint32_t group_zero = next_boundary(sequencer_ticks(), 4); + amy_add_message("H0,4,5,0zPgroup-zero-rootZ"); + clock_to(group_zero); + CHECK(mark_at("group-zero-root", group_zero), + "an explicit group tag zero follows the legacy root path"); + amy_add_message("H0,0,5Z"); +} + +static void test_group_local_tags_are_independent(void) { + printf("event tags are local to each sequencer group\n"); + sequencer_reset(); + clear_group(6); + clear_group(7); + clear_marks(); + amy_add_message("H0,4,0,6zPgroup-six-tag-zeroZ"); + amy_add_message("H0,4,0,7zPgroup-seven-tag-zeroZ"); + amy_add_message("zQ6,3,4Z"); + amy_add_message("zQ7,3,4Z"); + + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("zQ6,1,1,4Z"); + amy_add_message("zQ7,1,1,4Z"); + clock_to(start); + CHECK(mark_at("group-six-tag-zero", start), + "group 6 owns its event tag zero"); + CHECK(mark_at("group-seven-tag-zero", start), + "group 7 independently owns event tag zero"); } static void test_one_n_and_infinite_repeats(void) { @@ -293,6 +322,24 @@ static void test_resets_keep_definitions_only(void) { CHECK(marks_named("survivor") == 1, "definition survives RESET_TIMEBASE"); } +static void test_group_start_crosses_clock_rollover(void) { + printf("group phase remains correct across the 32-bit tick rollover\n"); + sequencer_reset(); + clear_group(5); + clear_marks(); + amy_add_message("H0,4,0,5zPwrap-zeroZ"); + amy_add_message("H1,0,1,5zPwrap-oneZ"); + amy_add_message("zQ5,3,4Z"); + + amy_global.sequencer_tick_count = UINT32_MAX - 2; + amy_add_message("zQ5,1,1,4Z"); + clock_to(1); + CHECK(mark_at("wrap-zero", 0), + "quantized local tick zero fired after rollover"); + CHECK(mark_at("wrap-one", 1), + "local elapsed time advanced across rollover"); +} + static void test_configured_bounds(void) { printf("configured group, local-tag and execution bounds are enforced\n"); sequencer_reset(); @@ -332,6 +379,7 @@ int main(void) { amy_start(config); test_legacy_ticks_are_unchanged(); + test_group_local_tags_are_independent(); test_one_n_and_infinite_repeats(); test_atomic_revision_lifetime(); test_root_launches_local_zero_on_same_tick(); @@ -340,6 +388,7 @@ int main(void) { test_quantized_stop_precedes_boundary_event(); test_group_control_cannot_recurse(); test_resets_keep_definitions_only(); + test_group_start_crosses_clock_rollover(); test_configured_bounds(); amy_stop(); From 45fc871f949d93d62fa10eb2a926a78751042a3c Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 21:40:22 +0200 Subject: [PATCH 05/12] Add sequencer group usage guides --- docs/sequencer-groups-abstractions.md | 150 ++++++++++++ docs/sequencer-groups-howto.md | 258 +++++++++++++++++++++ docs/sequencer-groups-musical-use-cases.md | 100 ++++++++ docs/sequencer-groups.md | 6 + 4 files changed, 514 insertions(+) create mode 100644 docs/sequencer-groups-abstractions.md create mode 100644 docs/sequencer-groups-howto.md create mode 100644 docs/sequencer-groups-musical-use-cases.md diff --git a/docs/sequencer-groups-abstractions.md b/docs/sequencer-groups-abstractions.md new file mode 100644 index 00000000..264fafce --- /dev/null +++ b/docs/sequencer-groups-abstractions.md @@ -0,0 +1,150 @@ +# Sequencer-group abstractions and implementation + +AMY's root sequencer stores ordinary events on one global musical timeline. +Sequencer groups add one reusable, bounded phrase level below that timeline: a +root event can start a finite or repeating group of ordinary AMY events. They +do not add a drum machine, arpeggiator, song model, or recursive scheduler. + +For concrete applications, see the [musical use cases](sequencer-groups-musical-use-cases.md). +For exact messages, see the [step-by-step how-to](sequencer-groups-howto.md). +The concise argument reference is in [Sequencer groups](sequencer-groups.md). + +## The model + +The model separates stored content, scheduled starts, and active playback: + +| Object | Purpose | Lifetime | +| --- | --- | --- | +| Root sequencer event | Decides when a group starts | Existing `H` tick/period/tag semantics | +| Group tag | Selects one reusable definition slot | From 1 through the configured group capacity | +| Staging revision | Receives local event edits privately | Until published or cleared | +| Published revision | Supplies immutable content to future starts | Until replaced or cleared | +| Execution | Plays one captured revision | Until its repeat count completes or it is stopped | +| Execution tag | Optionally addresses live or pending executions | Supplied by the start operation | +| Local event tag | Replaces or clears one event in one group's staging revision | Scoped to that group only | + +Root tags, group tags, execution tags, and local event tags are separate +identities. For example, replacing a tagged root event changes which phrase +will start in the future. It does not edit the phrase definition or shorten an +execution that has already started. + +## Authoring and publication + +The existing `ticks` tuple accepts an optional fourth value: + +```text +tick,period,event_tag,group_tag +``` + +With a nonzero `group_tag`, the `H` message edits that group's private staging +revision instead of the root sequencer. The first edit after publication clones +the current published revision, so a host can replace only the local tags that +changed. A local tag is cleared with `tick=0,period=0`, exactly like a tagged +root event. + +Because that pair means clear, an event at local tick zero must use a nonzero +period. Using the group length as its period is usually the clearest choice; a +finite execution still fires it only once per repetition. + +Publication uses action 3 of the `sequence_control` family: + +```text +zQ,3,Z +``` + +The length is explicit. AMY validates every staged event against it, then +publishes the complete revision atomically. Playback therefore never observes +a partly rewritten phrase. AMY does not infer a potentially expensive least +common multiple from event periods. + +## Execution lifetime + +A start captures the currently published revision. Its repeat value is: + +- `1` for one performance; +- `N` for exactly N performances; +- `0` for indefinite repetition. + +Editing, publishing, or clearing the group afterward affects future starts +only. Every active execution retains a reference to the revision it captured +and can deliver the note-offs or other closing events already stored in that +revision. This is the key guarantee for glitch-free live phrase changes. + +Starts and stops can be quantized to the next multiple of a sequencer tick +interval. A zero quantization value means the next sequencer tick for a direct +command. When a root event starts a group, local tick zero is processed on that +same root tick. + +An optional execution tag gives live playback a stable control identity. A new +start with the same group and execution tag replaces the matching execution at +the requested boundary. Untagged starts may overlap. Stop and gate operations +can address one execution tag or, when the tag is omitted, all executions of a +group. + +## Finite event gates + +Gate action 2 suppresses event dispatch for a duration while the execution's +local clock continues advancing. It does not stop already-sounding audio. When +the gate ends, the next event occurs at its original phase rather than at a +restarted phase. A zero duration releases a current gate. + +A group may contain a gate control as a leaf event. This lets one finite phrase +temporarily suppress events from another tagged repeating layer. AMY assigns no +musical meaning to either layer; the controller owns that policy. + +## Bounded scheduling + +The root sequencer may start a group. A group may contain ordinary AMY events +and finite gate controls, but it cannot start, publish, or clear a group. This +provides the two useful musical levels—global arrangement and reusable +phrase—without cycles or unbounded recursive work. + +The configured limits independently bound: + +- persistent group slots; +- local event tags in each allocated definition; +- active or quantized-pending executions. + +The portable defaults are 32 groups, 64 local tags per group, and 32 active or +pending executions. Definition storage is allocated only when a group is +authored. The audio-time tick path scans only the fixed execution pool, not all +stored groups, so an application can choose a larger definition catalogue +without making every inactive definition part of per-tick work. + +## Implementation outline + +The implementation in [`src/sequencer.c`](../src/sequencer.c) deliberately +reuses the normal event path: + +- grouped `H` messages store the same wire payloads AMY already parses; +- staged and published definitions use fixed-capacity local-tag tables; +- published revisions are reference-counted and remain alive while captured by + an execution; +- an independently bounded execution pool owns start phase, repeat count, + execution identity, pending stop, and gate state; +- root events are processed before group events, which makes a root launch and + its local tick-zero payload sample-clock coherent; +- recursive group lifecycle operations are rejected while a grouped payload is + firing. + +The public configuration fields and constants are declared in +[`src/amy.h`](../src/amy.h). The group engine entry points are in +[`src/sequencer.h`](../src/sequencer.h), and Python uses the existing +`amy.send(ticks=...)` and `amy.send(sequence_control=...)` interface. + +## Compatibility contract + +An absent or zero fourth `ticks` value follows the existing root-sequencer path. +Existing three-field `H` messages, anonymous root events, tag replacement and +clear behavior, modulo periods, and `amy_add_event()` scheduling are unchanged. + +`RESET_SEQUENCER` and `RESET_TIMEBASE` discard active and pending executions +but preserve published group definitions. Full AMY shutdown releases the +definitions. + +The native group regression test exercises legacy root behavior and group +behavior in the same process. It covers root compatibility, local tag +namespaces, one/N/infinite repetition, quantization, atomic publication, +immutable active revisions, same-tick root launches, finite phase-preserving +gates, recursion rejection, resets, 32-bit clock rollover, and configured +bounds. diff --git a/docs/sequencer-groups-howto.md b/docs/sequencer-groups-howto.md new file mode 100644 index 00000000..9c747a52 --- /dev/null +++ b/docs/sequencer-groups-howto.md @@ -0,0 +1,258 @@ +# Sequencer-group how-to: switchable arpeggios and a percussion gate + +This example sends complete AMY wire messages, including the final `Z`. AMY's +sequencer uses 48 ticks per quarter note, so the arpeggios use 24 ticks per +eighth note and a 96-tick phrase length. + +The examples use `amy.send()` as the Python API. Each expandable section emits +the same wire message shown directly above it. + +## 1. Configure a simple sound + +Use oscillator 0 with a sine wave so the example does not depend on a stored +patch bank: + +```text +v0w0Z +``` + +
+Python API equivalent + +```python +import amy + +amy.send(osc=0, wave=amy.SINE) +``` + +
+ +## 2. Preload an ascending arpeggio + +Group 10 plays C4, E4, G4, and C5. Each note begins 24 ticks after the previous +one and has an 18-tick gate: + +```text +H0,96,0,10v0n60l1Z +H18,96,1,10v0l0Z +H24,96,2,10v0n64l1Z +H42,96,3,10v0l0Z +H48,96,4,10v0n67l1Z +H66,96,5,10v0l0Z +H72,96,6,10v0n72l1Z +H90,96,7,10v0l0Z +zQ10,3,96Z +``` + +The fourth `H` value selects group 10. The third value is a local event tag, +not a root tag. These messages update private staging storage; publish action 3 +makes the complete 96-tick revision visible atomically. + +
+Python API equivalent + +```python +amy.send(ticks=[0, 96, 0, 10], osc=0, note=60, vel=1) +amy.send(ticks=[18, 96, 1, 10], osc=0, vel=0) +amy.send(ticks=[24, 96, 2, 10], osc=0, note=64, vel=1) +amy.send(ticks=[42, 96, 3, 10], osc=0, vel=0) +amy.send(ticks=[48, 96, 4, 10], osc=0, note=67, vel=1) +amy.send(ticks=[66, 96, 5, 10], osc=0, vel=0) +amy.send(ticks=[72, 96, 6, 10], osc=0, note=72, vel=1) +amy.send(ticks=[90, 96, 7, 10], osc=0, vel=0) +amy.send(sequence_control=[10, amy.SEQUENCE_CONTROL_PUBLISH, 96]) +``` + +
+ +## 3. Preload a descending arpeggio + +Group 11 uses the same timing and reverses the pitches: + +```text +H0,96,0,11v0n72l1Z +H18,96,1,11v0l0Z +H24,96,2,11v0n67l1Z +H42,96,3,11v0l0Z +H48,96,4,11v0n64l1Z +H66,96,5,11v0l0Z +H72,96,6,11v0n60l1Z +H90,96,7,11v0l0Z +zQ11,3,96Z +``` + +
+Python API equivalent + +```python +amy.send(ticks=[0, 96, 0, 11], osc=0, note=72, vel=1) +amy.send(ticks=[18, 96, 1, 11], osc=0, vel=0) +amy.send(ticks=[24, 96, 2, 11], osc=0, note=67, vel=1) +amy.send(ticks=[42, 96, 3, 11], osc=0, vel=0) +amy.send(ticks=[48, 96, 4, 11], osc=0, note=64, vel=1) +amy.send(ticks=[66, 96, 5, 11], osc=0, vel=0) +amy.send(ticks=[72, 96, 6, 11], osc=0, note=60, vel=1) +amy.send(ticks=[90, 96, 7, 11], osc=0, vel=0) +amy.send(sequence_control=[11, amy.SEQUENCE_CONTROL_PUBLISH, 96]) +``` + +
+ +## 4. Turn on the ascending arpeggio + +Install a normal repeating root event. Every 96 ticks it starts group 10 once. +Root tag 200 gives that future schedule a replaceable identity: + +```text +H0,96,200zQ10,1,1,0Z +zY1Z +``` + +The embedded control arguments are: + +```text +zQ group,action,repeats,quantize Z + 10 1 1 0 +``` + +Action 1 means start, and repeat value 1 makes each execution finite. The root +event supplies the repetition. Quantization is zero because the root event +already fires on the exact musical boundary; the group's local tick-zero event +is delivered on that same tick. + +
+Python API equivalent + +```python +amy.send( + ticks=[0, 96, 200], + sequence_control=[10, amy.SEQUENCE_CONTROL_START, 1, 0], +) +amy.send(sequencer_run=1) +``` + +
+ +## 5. Switch to the descending arpeggio + +Replace root tag 200 with a start for group 11: + +```text +H0,96,200zQ11,1,1,0Z +``` + +The next matching root boundary starts the descending revision. An ascending +execution that already began keeps its captured revision and reaches every +original note-off normally. + +
+Python API equivalent + +```python +amy.send( + ticks=[0, 96, 200], + sequence_control=[11, amy.SEQUENCE_CONTROL_START, 1, 0], +) +``` + +
+ +## 6. Turn the arpeggio off and on + +Clear root tag 200 with the unchanged root-sequencer operation: + +```text +H0,0,200Z +``` + +This prevents future starts. It does not stop an execution that has already +begun, so the current phrase finishes with its normal note gates. Re-send the +root message from step 4 or 5 to turn the selected arpeggio on again. + +
+Python API equivalent + +```python +amy.send(ticks=[0, 0, 200]) +``` + +
+ +To play group 10 only once instead of installing a root schedule, start one +execution at the next 96-tick boundary: + +```text +zQ10,1,1,96Z +``` + +
+Python API equivalent + +```python +amy.send( + sequence_control=[10, amy.SEQUENCE_CONTROL_START, 1, 96] +) +``` + +
+ +## 7. Gate one percussion instrument from a controller + +An independently controllable percussion role needs its own group execution. +Assume synth 10 is already configured as a percussion instrument and MIDI note +42 produces the desired closed hi-hat. Group 20 triggers that hit every 24 +ticks, and execution tag 300 is its live control address: + +```text +H0,24,0,20i10n42l1Z +zQ20,3,24Z +zQ20,1,0,24,300Z +``` + +The start repeat value is zero, so the execution repeats indefinitely. Other +percussion roles should use separate groups and execution tags when they need +independent control. + +Suppose a MIDI foot controller, switch, or other input has already been mapped +by the sending application. On press, it can apply a long finite event gate: + +```text +zQ20,2,2147483647,0,300Z +``` + +On release, duration zero removes the gate immediately: + +```text +zQ20,2,0,0,300Z +``` + +The gate suppresses future events from execution 300. It does not cut off a +sample that is already sounding, and the execution's clock continues. When the +gate is released, the hi-hat resumes on its original 24-tick phase. Reading the +controller and mapping it to these messages remain outside AMY. + +
+Python API equivalent + +```python +# Define and start the independently controllable hi-hat layer. +amy.send(ticks=[0, 24, 0, 20], synth=10, note=42, vel=1) +amy.send(sequence_control=[20, amy.SEQUENCE_CONTROL_PUBLISH, 24]) +amy.send( + sequence_control=[20, amy.SEQUENCE_CONTROL_START, 0, 24, 300] +) + +# Controller press, then controller release. +amy.send( + sequence_control=[20, amy.SEQUENCE_CONTROL_GATE, 2147483647, 0, 300] +) +amy.send( + sequence_control=[20, amy.SEQUENCE_CONTROL_GATE, 0, 0, 300] +) +``` + +
+ +When the silence has a known musical duration, send that duration directly. +For example, `zQ20,2,192,0,300Z` suppresses four quarter notes at 48 PPQ and +then releases automatically without another controller message. diff --git a/docs/sequencer-groups-musical-use-cases.md b/docs/sequencer-groups-musical-use-cases.md new file mode 100644 index 00000000..1db6b7a8 --- /dev/null +++ b/docs/sequencer-groups-musical-use-cases.md @@ -0,0 +1,100 @@ +# Musical use cases for sequencer groups + +Sequencer groups are useful when a musical phrase must remain a coherent unit +while a controller changes what will play next. Two representative applications +are an interactive rhythm engine with selectable drum fills and an arpeggiator +whose timing, direction, or notes can change during playback. Both are expressed +as ordinary AMY events on a local timeline; AMY contains no policy specific to +either application. + +## Dynamic drum fills + +Consider a rhythm engine that combines repeating percussion layers with a +selectable fill and a fill density. It may offer hundreds of short fills, let a +player change the active selection while transport continues, and temporarily +silence some background layers during a fill while allowing others to continue. + +A flat root sequence can represent one final arrangement. Live editing is more +complicated: the host must expand every chosen fill into root events, identify +which future events are safe to replace, coordinate the background boundaries, +avoid truncating a fill already in progress, and resend a large schedule whenever +selection or density changes. Combining fills, densities, and independently +controlled background layers multiplies that state even though every individual +phrase is small. + +Sequencer groups preserve the useful phrase boundary: + +1. The controller preloads each fill once as a finite group. +2. A small tagged root event starts the selected group at a musical boundary. +3. Independently controllable background roles run as tagged repeating group + executions. +4. A fill can contain finite gate events for background executions that should + not dispatch events during that fill. +5. Replacing or clearing the root event changes future fills only. A fill that + already started retains its immutable revision and finishes normally. + +The controller still owns every musical choice: fill selection, density, +instrument roles, and which roles continue. AMY only provides reusable phrase +storage, coherent execution, and generic event gating. Live control therefore +changes a small reference instead of rewriting the expanded leaf-event schedule. + +Stored definitions and active executions have independent limits. A rhythm +engine can configure enough group slots for a large fill catalogue without +creating hundreds of live players or scanning every stored fill on each tick. + +## Arpeggios with clean live changes + +An arpeggio can also be expanded into the root sequencer. The difficult part is +changing rate, direction, pitch, or voicing while notes are already in flight. +Deleting old root entries can remove a future note-off and leave a note hanging. +Sending an immediate all-off prevents the hang but shortens a valid note. A +host-side timer can defer the edit, but then the host must mirror AMY's musical +clock and track the lifetimes of overlapping phrases. + +Instead, one group revision stores the complete arpeggio phrase, including every +note-on and its matching note-off. Tagged root events determine when that phrase +starts. When a player changes the arpeggio: + +- the controller stages and atomically publishes the complete replacement; +- future starts capture the new published revision; +- an execution already sounding retains its previous immutable revision; +- every release in that execution therefore occurs at its original gate; +- quantized root starts preserve the musical boundary; +- untagged executions may overlap when a new phrase starts before an older one + has finished. + +The result avoids both abrupt releases and delayed hanging notes. AMY does not +know that the event collection is an arpeggio; the same lifetime guarantee +applies to any finite musical gesture. + +## Independently controlled repeating layers + +A drum voice, ostinato, control phrase, or other repeating part can run as an +independently tagged group execution. A controller can stop it at a quantized +boundary or gate future event dispatch without stopping the sequencer, changing +the phase, or affecting unrelated layers. + +For example, a foot controller can gate the event stream that triggers one +percussion instrument. Pedal-down suppresses future hits for that tagged +execution, while a sample already sounding ends naturally. Pedal-up releases the +gate and the next hit occurs on the layer's original phase. Reading the pedal and +choosing the execution tag remain responsibilities of the controller application. + +## The common abstraction + +All three applications share the same structure: + +```text +root timeline: decide when a stored phrase starts +group definition: store a coherent local event sequence +group execution: play one immutable revision with a bounded lifetime +execution control: start, stop, or temporarily gate that playback +``` + +A flat sequence can ultimately represent the same notes. The group boundary is +valuable because it makes live changes atomic, compact, and independent of host +timing. It moves phrase completion and release ownership into AMY without moving +application-specific musical policy into the synthesizer. + +See the [step-by-step arpeggio and percussion-gate example](sequencer-groups-howto.md) +for the corresponding wire commands and Python calls. diff --git a/docs/sequencer-groups.md b/docs/sequencer-groups.md index a5303238..ee6e8698 100644 --- a/docs/sequencer-groups.md +++ b/docs/sequencer-groups.md @@ -11,6 +11,12 @@ preload these phrases and later send one small, quantized control message. It does not need to reproduce AMY's clock or resend every event at performance time. +Related guides: + +- [Abstractions and implementation](sequencer-groups-abstractions.md) +- [Musical use cases](sequencer-groups-musical-use-cases.md) +- [Step-by-step wire and Python how-to](sequencer-groups-howto.md) + ## Defining and publishing a group The normal `ticks` tuple accepts an optional fourth value: From b1e995fba8430a4210102a877df23143ff237761 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:23:49 +0200 Subject: [PATCH 06/12] Align sequencer group terminology and links --- README.md | 2 ++ docs/sequencer-groups-abstractions.md | 10 +++++----- docs/sequencer-groups.md | 2 +- docs/synth.md | 10 ++++++---- tests/test_sequence_groups.c | 10 +++++----- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 77d9e83e..eea0daf0 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 Sequencer Groups**](docs/sequencer-groups.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 Sequencer Groups**](docs/sequencer-groups.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/docs/sequencer-groups-abstractions.md b/docs/sequencer-groups-abstractions.md index 264fafce..96ad6d4b 100644 --- a/docs/sequencer-groups-abstractions.md +++ b/docs/sequencer-groups-abstractions.md @@ -3,7 +3,7 @@ AMY's root sequencer stores ordinary events on one global musical timeline. Sequencer groups add one reusable, bounded phrase level below that timeline: a root event can start a finite or repeating group of ordinary AMY events. They -do not add a drum machine, arpeggiator, song model, or recursive scheduler. +do not add a drum machine, arpeggiator, song model, or scheduler hierarchy. For concrete applications, see the [musical use cases](sequencer-groups-musical-use-cases.md). For exact messages, see the [step-by-step how-to](sequencer-groups-howto.md). @@ -97,7 +97,7 @@ musical meaning to either layer; the controller owns that policy. The root sequencer may start a group. A group may contain ordinary AMY events and finite gate controls, but it cannot start, publish, or clear a group. This provides the two useful musical levels—global arrangement and reusable -phrase—without cycles or unbounded recursive work. +phrase—without cycles or variable scheduling depth. The configured limits independently bound: @@ -124,7 +124,7 @@ reuses the normal event path: execution identity, pending stop, and gate state; - root events are processed before group events, which makes a root launch and its local tick-zero payload sample-clock coherent; -- recursive group lifecycle operations are rejected while a grouped payload is +- group-to-group lifecycle operations are rejected while a grouped payload is firing. The public configuration fields and constants are declared in @@ -146,5 +146,5 @@ The native group regression test exercises legacy root behavior and group behavior in the same process. It covers root compatibility, local tag namespaces, one/N/infinite repetition, quantization, atomic publication, immutable active revisions, same-tick root launches, finite phase-preserving -gates, recursion rejection, resets, 32-bit clock rollover, and configured -bounds. +gates, group-to-group lifecycle rejection, resets, 32-bit clock rollover, and +configured bounds. diff --git a/docs/sequencer-groups.md b/docs/sequencer-groups.md index ee6e8698..ba79613e 100644 --- a/docs/sequencer-groups.md +++ b/docs/sequencer-groups.md @@ -104,7 +104,7 @@ A finite gate advances the execution's local clock but suppresses its event firings. Audio already sounding is not stopped, and the first event after the gate occurs at its original phase. A gate can itself be placed in another group as a leaf control; start, publish and clear are rejected while a group -payload is firing, preventing recursive nesting. +payload is firing. A group therefore never launches or edits another group. ## Scheduling a launch at the root diff --git a/docs/synth.md b/docs/synth.md index 0b22eb26..d3159403 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -239,7 +239,7 @@ For pattern sequencers like drum machines, you will also want to use `tick` alon `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. -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 sequencer groups @@ -250,8 +250,11 @@ retain their original behavior. Groups are controlled through the single `sequence_control` parameter; they can run once, a fixed number of times, or continuously, and start/stop can be quantized to AMY's tick clock. -See [Sequencer groups](sequencer-groups.md) for the wire format, lifecycle, -examples and implementation guarantees. +See [Sequencer groups](sequencer-groups.md) for the concise wire format and +lifecycle reference. The accompanying guides explain the +[abstractions and implementation](sequencer-groups-abstractions.md), +[musical use cases](sequencer-groups-musical-use-cases.md), and a +[step-by-step wire and Python example](sequencer-groups-howto.md). ## Core oscillators @@ -489,4 +492,3 @@ amy.send(osc=1, wave=amy.PCM_RIGHT, preset=1024, pan=1, note=72, vel=1) ``` - diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c index 13e9a3d5..8b89201e 100644 --- a/tests/test_sequence_groups.c +++ b/tests/test_sequence_groups.c @@ -271,13 +271,13 @@ static void test_quantized_stop_precedes_boundary_event(void) { CHECK(!mark_at("stopped", stop), "stop suppresses the boundary event"); } -static void test_group_control_cannot_recurse(void) { - printf("a group cannot launch a third sequencer level\n"); +static void test_group_to_group_control_is_rejected(void) { + printf("a group payload cannot launch another group\n"); sequencer_reset(); clear_group(7); clear_group(8); clear_marks(); - amy_add_message("H0,4,0,8zPgrandchildZ"); + amy_add_message("H0,4,0,8zPforbiddenZ"); amy_add_message("zQ8,3,4Z"); amy_add_message("H0,4,0,7zQ8,1,1,0Z"); amy_add_message("zQ7,3,4Z"); @@ -285,7 +285,7 @@ static void test_group_control_cannot_recurse(void) { uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("zQ7,1,1,4Z"); clock_to(start + 4); - CHECK(!marks_named("grandchild"), "nested group launch is rejected"); + CHECK(!marks_named("forbidden"), "group-to-group launch is rejected"); } static void test_resets_keep_definitions_only(void) { @@ -386,7 +386,7 @@ int main(void) { test_c_event_uses_fourth_ticks_field(); test_quantized_gate_preserves_phase(); test_quantized_stop_precedes_boundary_event(); - test_group_control_cannot_recurse(); + test_group_to_group_control_is_rejected(); test_resets_keep_definitions_only(); test_group_start_crosses_clock_rollover(); test_configured_bounds(); From c3ebcaefbc4bea5a6631e568dca307e5c483e70d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:38:57 +0200 Subject: [PATCH 07/12] Harden sequencer group validation and diagnostics --- src/parse.c | 4 +- src/sequencer.c | 118 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 95 insertions(+), 27 deletions(-) diff --git a/src/parse.c b/src/parse.c index 25c0cef5..8c8b94f8 100644 --- a/src/parse.c +++ b/src/parse.c @@ -664,7 +664,9 @@ uint16_t amy_parse_transfer_layer_message(char *message) { uint32_t values[5] = {0, 0, 0, 0, 0}; int count = parse_list_uint32_t(message, values, 5, 0); if (count < 2) { - fprintf(stderr, "sequence_control needs at least group and action\n"); + fprintf(stderr, + "invalid sequence_control: expected " + "zQgroup,action[,value,quantize,execution_tag]\n"); } else { sequencer_group_control(values[0], values[1], values[2], values[3], values[4], count >= 5); diff --git a/src/sequencer.c b/src/sequencer.c index 1aa02cc1..da7c4863 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -89,8 +89,16 @@ static sequence_group_execution_t *group_executions = NULL; static uint32_t max_sequence_groups = 0; static uint32_t max_sequence_group_tags = 0; static uint32_t max_sequence_group_executions = 0; +static size_t sequence_group_event_bytes = 0; static volatile bool group_wire_firing = false; +static bool checked_array_size(uint32_t count, size_t element_size, + size_t *bytes) { + if (count > SIZE_MAX / element_size) return false; + *bytes = (size_t)count * element_size; + return true; +} + static void group_definition_release(sequence_group_definition_t *definition) { if (definition == NULL || definition->refs == 0) return; definition->refs--; @@ -107,14 +115,12 @@ static sequence_group_definition_t *group_definition_new(void) { amy_global.config.ram_caps_synth); if (definition == NULL) return NULL; definition->events = (sequence_group_event_t *)malloc_caps( - sizeof(sequence_group_event_t) * max_sequence_group_tags, - amy_global.config.ram_caps_synth); + sequence_group_event_bytes, amy_global.config.ram_caps_synth); if (definition->events == NULL) { free(definition); return NULL; } - memset(definition->events, 0, - sizeof(sequence_group_event_t) * max_sequence_group_tags); + memset(definition->events, 0, sequence_group_event_bytes); definition->length_ticks = 0; definition->refs = 1; return definition; @@ -177,6 +183,7 @@ static void sequence_groups_deinit(void) { max_sequence_groups = 0; max_sequence_group_tags = 0; max_sequence_group_executions = 0; + sequence_group_event_bytes = 0; } static void sequence_groups_init(uint32_t groups, uint32_t tags, @@ -186,16 +193,31 @@ static void sequence_groups_init(uint32_t groups, uint32_t tags, max_sequence_group_executions = executions; group_wire_firing = false; if (groups == 0 || tags == 0 || executions == 0) return; + + size_t group_bytes = 0; + size_t execution_bytes = 0; + if (!checked_array_size(groups, sizeof(sequence_group_slot_t), &group_bytes) + || !checked_array_size(tags, sizeof(sequence_group_event_t), + &sequence_group_event_bytes) + || !checked_array_size(executions, + sizeof(sequence_group_execution_t), + &execution_bytes)) { + fprintf(stderr, + "sequencer group configuration exceeds addressable memory: " + "groups=%" PRIu32 ", event_tags=%" PRIu32 + ", executions=%" PRIu32 "\n", + groups, tags, executions); + sequence_groups_deinit(); + return; + } sequence_groups = (sequence_group_slot_t *)malloc_caps( - sizeof(sequence_group_slot_t) * groups, amy_global.config.ram_caps_synth); + group_bytes, amy_global.config.ram_caps_synth); if (sequence_groups != NULL) - memset(sequence_groups, 0, sizeof(sequence_group_slot_t) * groups); + memset(sequence_groups, 0, group_bytes); group_executions = (sequence_group_execution_t *)malloc_caps( - sizeof(sequence_group_execution_t) * executions, - amy_global.config.ram_caps_synth); + execution_bytes, amy_global.config.ram_caps_synth); if (group_executions != NULL) - memset(group_executions, 0, - sizeof(sequence_group_execution_t) * executions); + memset(group_executions, 0, execution_bytes); if (sequence_groups == NULL || group_executions == NULL) { amy_oom("sequencer groups"); sequence_groups_deinit(); @@ -417,14 +439,36 @@ static sequence_group_slot_t *group_slot(uint32_t group) { uint8_t sequencer_group_add_wire(uint32_t tick, uint32_t period, uint32_t tag, uint32_t group, char *wire) { sequence_group_slot_t *slot = group_slot(group); - if (slot == NULL || tag >= max_sequence_group_tags) { - fprintf(stderr, "sequencer group/event tag out of range: group %" PRIu32 - ", tag %" PRIu32 "\n", group, tag); + if (slot == NULL) { + if (sequence_groups == NULL) + fprintf(stderr, "cannot add event to sequencer group %" PRIu32 + ": sequencer groups are disabled\n", group); + else + fprintf(stderr, "cannot add event: sequencer group %" PRIu32 + " is outside the configured range [1, %" PRIu32 "]\n", + group, max_sequence_groups); free(wire); return 0; } + if (tag >= max_sequence_group_tags) { + fprintf(stderr, "cannot add event tag %" PRIu32 + " to sequencer group %" PRIu32 + ": valid event tags are [0, %" PRIu32 "]\n", + tag, group, max_sequence_group_tags - 1); + free(wire); + return 0; + } + if (wire == NULL) { + fprintf(stderr, "cannot add event tag %" PRIu32 + " to sequencer group %" PRIu32 ": wire is NULL\n", + tag, group); + return 0; + } if (wire[0] == 'H') { - fprintf(stderr, "a grouped ticks event cannot contain another ticks event\n"); + fprintf(stderr, "cannot add event tag %" PRIu32 + " to sequencer group %" PRIu32 + ": a grouped event cannot contain another ticks command\n", + tag, group); free(wire); return 0; } @@ -476,9 +520,11 @@ static bool group_execution_matches(const sequence_group_execution_t *execution, && execution->execution_tag == execution_tag); } -static uint8_t group_publish(sequence_group_slot_t *slot, uint32_t length) { +static uint8_t group_publish(sequence_group_slot_t *slot, uint32_t group, + uint32_t length) { if (length == 0) { - fprintf(stderr, "a sequencer group must have a nonzero length\n"); + fprintf(stderr, "cannot publish sequencer group %" PRIu32 + ": length must be greater than zero\n", group); return 0; } if (slot->staging == NULL) { @@ -491,11 +537,18 @@ static uint8_t group_publish(sequence_group_slot_t *slot, uint32_t length) { for (uint32_t i = 0; i < max_sequence_group_tags; ++i) { sequence_group_event_t *event = &slot->staging->events[i]; if (event->wire == NULL) continue; - if (event->tick >= length - || (event->period != 0 && event->tick >= event->period)) { - fprintf(stderr, "sequencer group event %" PRIu32 - " has tick %" PRIu32 " outside its period/group length\n", - i, event->tick); + if (event->tick >= length) { + fprintf(stderr, "cannot publish sequencer group %" PRIu32 + ": event tag %" PRIu32 " has tick %" PRIu32 + ", which must be below group length %" PRIu32 "\n", + group, i, event->tick, length); + return 0; + } + if (event->period != 0 && event->tick >= event->period) { + fprintf(stderr, "cannot publish sequencer group %" PRIu32 + ": event tag %" PRIu32 " has tick %" PRIu32 + ", which must be below its period %" PRIu32 "\n", + group, i, event->tick, event->period); return 0; } } @@ -513,21 +566,30 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, bool has_execution_tag) { sequence_group_slot_t *slot = group_slot(group); if (slot == NULL) { - fprintf(stderr, "sequencer group %" PRIu32 " is out of range\n", group); + if (sequence_groups == NULL) + fprintf(stderr, "cannot control sequencer group %" PRIu32 + ": sequencer groups are disabled\n", group); + else + fprintf(stderr, "cannot control sequencer group %" PRIu32 + ": valid groups are [1, %" PRIu32 "]\n", + group, max_sequence_groups); return 0; } if (group_wire_firing && (action == SEQUENCE_CONTROL_START || action == SEQUENCE_CONTROL_PUBLISH || action == SEQUENCE_CONTROL_CLEAR)) { - fprintf(stderr, "a sequencer group cannot launch or edit a group\n"); + fprintf(stderr, "sequencer group %" PRIu32 + " cannot perform lifecycle action %" PRIu32 + ": grouped events may only stop or gate executions\n", + group, action); return 0; } uint8_t result = 0; amy_grab_lock(); if (action == SEQUENCE_CONTROL_PUBLISH) { - result = group_publish(slot, value); + result = group_publish(slot, group, value); } else if (action == SEQUENCE_CONTROL_CLEAR) { group_definition_release(slot->published); group_definition_release(slot->staging); @@ -546,7 +608,9 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, if (!execution->occupied && available == NULL) available = execution; } if (available == NULL) { - fprintf(stderr, "sequencer group execution pool is full\n"); + fprintf(stderr, "cannot start sequencer group %" PRIu32 + ": all %" PRIu32 " execution slots are occupied\n", + group, max_sequence_group_executions); } else { if (has_execution_tag) { for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { @@ -588,7 +652,9 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, result = 1; } } else { - fprintf(stderr, "unknown sequencer group action %" PRIu32 "\n", action); + fprintf(stderr, "cannot control sequencer group %" PRIu32 + ": action %" PRIu32 " is unknown; valid actions are [0, 4]\n", + group, action); } amy_release_lock(); return result; From ac8ea86a1d097567e7a779c62e5e70eb56704b8f Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:44:25 +0200 Subject: [PATCH 08/12] Expand sequencer group edge-case coverage --- tests/test_sequence_groups.c | 256 ++++++++++++++++++++++++++++++++++- 1 file changed, 250 insertions(+), 6 deletions(-) diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c index 8b89201e..fce89b19 100644 --- a/tests/test_sequence_groups.c +++ b/tests/test_sequence_groups.c @@ -55,6 +55,13 @@ static int marks_named(const char *name) { return count; } +static int marks_named_at(const char *name, uint32_t tick) { + int count = 0; + for (int i = 0; i < mark_count; ++i) + if (!strcmp(marks[i].name, name) && marks[i].tick == tick) count++; + return count; +} + static void clear_group(uint32_t group) { char wire[32]; snprintf(wire, sizeof(wire), "zQ%" PRIu32 ",4Z", group); @@ -93,6 +100,27 @@ static void test_legacy_ticks_are_unchanged(void) { amy_add_message("H0,0,5Z"); } +static void test_legacy_c_event_wire_is_unchanged(void) { + printf("legacy C events keep their three-value ticks wire format\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 + && strncmp(wire, "H3,8,7,", 7) != 0, + "an unset group field adds no fourth ticks value: %s", wire); + + event.ticks[TICKS_GROUP] = 2; + sprint_event(&event, wire, sizeof(wire), true); + CHECK(strncmp(wire, "H3,8,7,2", 8) == 0, + "a grouped C event adds exactly one ticks value: %s", wire); +} + static void test_group_local_tags_are_independent(void) { printf("event tags are local to each sequencer group\n"); sequencer_reset(); @@ -199,6 +227,49 @@ static void test_root_launches_local_zero_on_same_tick(void) { CHECK(mark_at("child", start), "root launch and group local zero coincide"); } +static void test_direct_start_begins_on_next_tick(void) { + printf("an unquantized direct start begins on the next tick\n"); + sequencer_reset(); + clear_group(1); + clear_marks(); + amy_add_message("H0,4,0,1zPnext-tickZ"); + amy_add_message("zQ1,3,4Z"); + + uint32_t start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(1, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "unquantized direct start is accepted"); + CHECK(!marks_named("next-tick"), "start does not fire synchronously"); + sequencer_midi_clock_tick(); + CHECK(mark_at("next-tick", start), "local tick zero fires on the next tick"); +} + +static void test_tagged_start_replaces_at_activation(void) { + printf("a tagged start replaces its predecessor at the activation boundary\n"); + sequencer_reset(); + clear_group(2); + clear_marks(); + amy_add_message("H0,2,0,2zPold-executionZ"); + amy_add_message("zQ2,3,2Z"); + uint32_t predecessor_start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_START, 0, 0, 41, true), + "the predecessor starts"); + sequencer_midi_clock_tick(); + CHECK(mark_at("old-execution", predecessor_start), + "the predecessor is running before replacement"); + + amy_add_message("H0,2,0,2zPnew-executionZ"); + amy_add_message("zQ2,3,2Z"); + clear_marks(); + uint32_t replacement = next_boundary(sequencer_ticks(), 4); + CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_START, 1, 4, 41, true), + "the tagged replacement is accepted"); + clock_to(replacement); + CHECK(!mark_at("old-execution", replacement), + "the predecessor does not fire at the replacement boundary"); + CHECK(marks_named_at("new-execution", replacement) == 1, + "exactly one replacement fires at the boundary"); +} + static void test_c_event_uses_fourth_ticks_field(void) { printf("the C event API defines grouped events through ticks[3]\n"); sequencer_reset(); @@ -271,21 +342,157 @@ static void test_quantized_stop_precedes_boundary_event(void) { CHECK(!mark_at("stopped", stop), "stop suppresses the boundary event"); } -static void test_group_to_group_control_is_rejected(void) { - printf("a group payload cannot launch another group\n"); +static void test_tagged_gate_and_stop_are_selective(void) { + printf("execution tags make gate and stop selective\n"); + sequencer_reset(); + clear_group(3); + clear_group(4); + clear_marks(); + amy_add_message("H0,1,0,3zPsharedZ"); + amy_add_message("zQ3,3,8Z"); + amy_add_message("H0,1,0,4zPother-groupZ"); + amy_add_message("zQ4,3,8Z"); + amy_add_message("zQ3,1,0,0,101Z"); + amy_add_message("zQ3,1,0,0,102Z"); + amy_add_message("zQ4,1,0,0,101Z"); + sequencer_midi_clock_tick(); + CHECK(marks_named_at("shared", sequencer_ticks()) == 2, + "two tagged executions of one group can overlap"); + CHECK(marks_named_at("other-group", sequencer_ticks()) == 1, + "the same execution tag is independent in another group"); + + clear_marks(); + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_GATE, 2, 0, 101, true), + "a matching tagged gate is accepted"); + uint32_t gate_tick = sequencer_ticks() + 1; + clock_to(gate_tick + 2); + CHECK(marks_named_at("shared", gate_tick) == 1 + && marks_named_at("shared", gate_tick + 1) == 1, + "only the selected execution is gated"); + CHECK(marks_named_at("shared", gate_tick + 2) == 2, + "the selected execution resumes after the exact duration"); + CHECK(marks_named_at("other-group", gate_tick) == 1, + "a tagged gate does not cross group boundaries"); + + clear_marks(); + uint32_t tagged_stop_tick = sequencer_ticks() + 1; + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 102, true), + "a matching tagged stop is accepted"); + clock_to(tagged_stop_tick); + CHECK(marks_named_at("shared", tagged_stop_tick) == 1, + "only the selected execution stops"); + CHECK(!sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 999, true), + "a nonmatching execution tag reports no affected execution"); + uint32_t all_stop_tick = sequencer_ticks() + 1; + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 0, false), + "an untagged stop selects every remaining execution in the group"); + clock_to(all_stop_tick); + int remaining = marks_named_at("shared", all_stop_tick); + CHECK(remaining == 0, + "the untagged stop removed the remaining execution (got %d events)", + remaining); + CHECK(mark_at("other-group", all_stop_tick), + "the untagged stop remains scoped to its group"); + amy_add_message("zQ4,0Z"); + sequencer_midi_clock_tick(); +} + +static void test_group_lifecycle_control_is_not_recursive(void) { + printf("a group payload cannot start, publish or clear a group\n"); sequencer_reset(); clear_group(7); clear_group(8); clear_marks(); - amy_add_message("H0,4,0,8zPforbiddenZ"); + amy_add_message("H0,4,0,8zPpublished-revisionZ"); amy_add_message("zQ8,3,4Z"); + amy_add_message("H0,4,0,8zPstaged-revisionZ"); amy_add_message("H0,4,0,7zQ8,1,1,0Z"); + amy_add_message("H0,4,1,7zQ8,3,4Z"); + amy_add_message("H0,4,2,7zQ8,4Z"); amy_add_message("zQ7,3,4Z"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("zQ7,1,1,4Z"); - clock_to(start + 4); - CHECK(!marks_named("forbidden"), "group-to-group launch is rejected"); + clock_to(start); + CHECK(!marks_named("published-revision") && !marks_named("staged-revision"), + "group-to-group start is rejected"); + + uint32_t old_revision_start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "the target group can still be started directly"); + sequencer_midi_clock_tick(); + CHECK(mark_at("published-revision", old_revision_start), + "nested clear was rejected and the published revision remains"); + CHECK(!mark_at("staged-revision", old_revision_start), + "nested publish was rejected and staged edits remain private"); + + amy_add_message("zQ8,3,4Z"); + uint32_t new_revision_start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "the newly published target group starts"); + sequencer_midi_clock_tick(); + CHECK(mark_at("staged-revision", new_revision_start), + "the rejected nested publish did not discard staged edits"); +} + +static void test_invalid_edits_are_repairable(void) { + printf("invalid definitions fail without losing staged edits\n"); + sequencer_reset(); + clear_group(5); + CHECK(!sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 0, 0, 0, false), + "zero-length publication is rejected"); + CHECK(!sequencer_group_add_wire(0, 1, 0, 5, NULL), + "a NULL wire is rejected safely"); + CHECK(!sequencer_group_add_wire(0, 1, 0, 5, strdup("H0zPnestedZ")), + "a second ticks command is rejected"); + + CHECK(sequencer_group_add_wire(3, 2, 0, 5, strdup("zPbad-periodZ")), + "an invalid-period edit can be staged"); + CHECK(!sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "publication rejects tick >= period"); + CHECK(sequencer_group_add_wire(1, 2, 0, 5, strdup("zPrepairedZ")), + "the invalid staged event can be replaced"); + CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "the repaired definition publishes"); + + CHECK(sequencer_group_add_wire(4, 0, 1, 5, strdup("zPtoo-lateZ")), + "an out-of-length event can be staged"); + CHECK(!sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "publication rejects tick >= group length"); + CHECK(sequencer_group_add_wire(0, 0, 1, 5, strdup("")), + "the invalid local tag can be cleared"); + CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "publication succeeds after clearing the invalid tag"); + + CHECK(!sequencer_group_control(5, 99, 0, 0, 0, false), + "an unknown lifecycle action is rejected"); + CHECK(!sequencer_group_control(0, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "reserved group zero is rejected by group control"); + CHECK(!sequencer_group_control(9, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "a group beyond the configured range is rejected"); + clear_group(6); + CHECK(!sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "start without a published definition is rejected"); + CHECK(!sequencer_group_control(6, SEQUENCE_CONTROL_GATE, 1, 0, 0, false), + "gate with no active execution reports no affected execution"); +} + +static void test_clear_preserves_active_revision(void) { + printf("clearing storage does not invalidate an active revision\n"); + sequencer_reset(); + clear_group(6); + clear_marks(); + amy_add_message("H0,4,0,6zPactive-after-clearZ"); + amy_add_message("zQ6,3,4Z"); + uint32_t start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "the execution starts before storage is cleared"); + clear_group(6); + sequencer_midi_clock_tick(); + CHECK(mark_at("active-after-clear", start), + "an active execution retains its published revision"); + CHECK(!sequencer_group_control(6, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "clear prevents future starts until another publication"); } static void test_resets_keep_definitions_only(void) { @@ -309,6 +516,20 @@ static void test_resets_keep_definitions_only(void) { clock_to(second); CHECK(mark_at("survivor", second), "definition survives RESET_SEQUENCER"); + clear_marks(); + amy_add_message("zQ8,1,0,0Z"); + sequencer_midi_clock_tick(); + amy_add_message("S4096Z"); + amy_execute_deltas(); + clear_marks(); + clock_to(sequencer_ticks() + 4); + CHECK(!marks_named("survivor"), + "the public RESET_SEQUENCER wire stops group executions"); + amy_add_message("zQ8,1,1,0Z"); + sequencer_midi_clock_tick(); + CHECK(marks_named("survivor") == 1, + "the public RESET_SEQUENCER wire preserves definitions"); + clear_marks(); amy_add_message("zQ8,1,0,0Z"); clock_to(sequencer_ticks() + 2); @@ -365,6 +586,22 @@ static void test_configured_bounds(void) { sequencer_reset(); } +static void test_disabled_configuration(void) { + printf("zero capacities disable sequencer groups safely\n"); + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.max_sequence_groups = 0; + config.max_sequence_group_tags = 0; + config.max_sequence_group_executions = 0; + amy_start(config); + CHECK(!sequencer_group_add_wire(0, 1, 0, 1, strdup("zPdisabledZ")), + "group storage rejects events while disabled"); + CHECK(!sequencer_group_control(1, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "group control rejects operations while disabled"); + amy_stop(); +} + // examples.c calls this; the platform normally provides it. void delay_ms(uint32_t ms) { (void)ms; } @@ -379,19 +616,26 @@ int main(void) { amy_start(config); test_legacy_ticks_are_unchanged(); + test_legacy_c_event_wire_is_unchanged(); test_group_local_tags_are_independent(); test_one_n_and_infinite_repeats(); test_atomic_revision_lifetime(); test_root_launches_local_zero_on_same_tick(); + test_direct_start_begins_on_next_tick(); + test_tagged_start_replaces_at_activation(); test_c_event_uses_fourth_ticks_field(); test_quantized_gate_preserves_phase(); test_quantized_stop_precedes_boundary_event(); - test_group_to_group_control_is_rejected(); + test_tagged_gate_and_stop_are_selective(); + test_group_lifecycle_control_is_not_recursive(); + test_invalid_edits_are_repairable(); + test_clear_preserves_active_revision(); test_resets_keep_definitions_only(); test_group_start_crosses_clock_rollover(); test_configured_bounds(); amy_stop(); + test_disabled_configuration(); if (failures) { printf("\n%d check(s) FAILED\n", failures); return 1; From 6c9829c632dfb76e56ac7d73e5d31ab5f5a9e0e4 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:44:25 +0200 Subject: [PATCH 09/12] Unify sequencer group tick processing --- src/sequencer.c | 63 +++++++++++++------------------------------------ 1 file changed, 16 insertions(+), 47 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index da7c4863..d53a7c95 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -678,7 +678,7 @@ static void group_play_wire(const char *wire) { group_wire_firing = previous; } -static void group_process_control_events(uint32_t tick) { +static void group_process_pass(uint32_t tick, bool controls) { for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { amy_grab_lock(); sequence_group_execution_t *execution = &group_executions[i]; @@ -695,57 +695,26 @@ static void group_process_control_events(uint32_t tick) { amy_release_lock(); continue; } - definition->refs++; - uint32_t local_tick = elapsed % definition->length_ticks; - amy_release_lock(); - - for (uint32_t tag = 0; tag < max_sequence_group_tags; ++tag) { - sequence_group_event_t *event = &definition->events[tag]; - if (group_event_is_control(event) && group_event_hits(event, local_tick)) - group_play_wire(event->wire); - } - - amy_grab_lock(); - group_definition_release(definition); - amy_release_lock(); - } -} - -static void group_process_events(uint32_t tick) { - for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { - amy_grab_lock(); - sequence_group_execution_t *execution = &group_executions[i]; - if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { - amy_release_lock(); - continue; - } - uint32_t elapsed = tick - execution->start_tick; - sequence_group_definition_t *definition = execution->definition; - if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) - || (execution->repeats != 0 - && elapsed / definition->length_ticks >= execution->repeats)) { - group_execution_release(execution); - amy_release_lock(); - continue; - } - 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 (!controls) { + 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; } - if (execution->gated && AMY_TIME_GEQ(tick, execution->gate_end_tick)) - execution->gated = false; - bool gated = execution->gated; + bool suppress = !controls && execution->gated; definition->refs++; uint32_t local_tick = elapsed % definition->length_ticks; amy_release_lock(); - if (!gated) { + if (!suppress) { for (uint32_t tag = 0; tag < max_sequence_group_tags; ++tag) { sequence_group_event_t *event = &definition->events[tag]; - if (!group_event_is_control(event) + if (group_event_is_control(event) == controls && group_event_hits(event, local_tick)) group_play_wire(event->wire); } @@ -819,8 +788,8 @@ static void sequencer_process_tick(void) { } // Controls embedded in a group are leaf operations (stop/gate only) and // take effect before any ordinary group event on the same tick. - group_process_control_events(amy_global.sequencer_tick_count); - group_process_events(amy_global.sequencer_tick_count); + group_process_pass(amy_global.sequencer_tick_count, true); + group_process_pass(amy_global.sequencer_tick_count, false); 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); From 2c1c052695702fa7b9c62c2db87bbfffd04ff0c1 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:46:45 +0200 Subject: [PATCH 10/12] Cover sequencer group control boundaries --- tests/test_sequence_groups.c | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c index fce89b19..bb5454a6 100644 --- a/tests/test_sequence_groups.c +++ b/tests/test_sequence_groups.c @@ -129,6 +129,7 @@ static void test_group_local_tags_are_independent(void) { clear_marks(); amy_add_message("H0,4,0,6zPgroup-six-tag-zeroZ"); amy_add_message("H0,4,0,7zPgroup-seven-tag-zeroZ"); + amy_add_message("H0,4,0zProot-tag-zeroZ"); amy_add_message("zQ6,3,4Z"); amy_add_message("zQ7,3,4Z"); @@ -140,6 +141,9 @@ static void test_group_local_tags_are_independent(void) { "group 6 owns its event tag zero"); CHECK(mark_at("group-seven-tag-zero", start), "group 7 independently owns event tag zero"); + CHECK(mark_at("root-tag-zero", start), + "root tag zero remains independent of every group-local tag zero"); + amy_add_message("H0,0,0Z"); } static void test_one_n_and_infinite_repeats(void) { @@ -374,6 +378,20 @@ static void test_tagged_gate_and_stop_are_selective(void) { CHECK(marks_named_at("other-group", gate_tick) == 1, "a tagged gate does not cross group boundaries"); + clear_marks(); + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_GATE, 100, 0, 101, true), + "a longer tagged gate is accepted"); + uint32_t long_gate_tick = sequencer_ticks() + 1; + clock_to(long_gate_tick); + CHECK(marks_named_at("shared", long_gate_tick) == 1, + "a positive gate duration suppresses the selected execution"); + uint32_t ungate_tick = sequencer_ticks() + 1; + CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_GATE, 0, 0, 101, true), + "gate duration zero requests an early ungate"); + clock_to(ungate_tick); + CHECK(marks_named_at("shared", ungate_tick) == 2, + "gate duration zero resumes the selected execution on its phase"); + clear_marks(); uint32_t tagged_stop_tick = sequencer_ticks() + 1; CHECK(sequencer_group_control(3, SEQUENCE_CONTROL_STOP, 0, 0, 102, true), @@ -435,6 +453,27 @@ static void test_group_lifecycle_control_is_not_recursive(void) { "the rejected nested publish did not discard staged edits"); } +static void test_group_stop_control_is_a_supported_leaf(void) { + printf("a group payload may stop an existing group execution\n"); + sequencer_reset(); + clear_group(7); + clear_group(8); + clear_marks(); + amy_add_message("H0,1,0,8zPmust-be-stoppedZ"); + amy_add_message("zQ8,3,4Z"); + amy_add_message("H0,4,0,7zQ8,0,0,0,55Z"); + amy_add_message("zQ7,3,4Z"); + + uint32_t boundary = next_boundary(sequencer_ticks(), 4); + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 0, 4, 55, true), + "the target execution is queued"); + CHECK(sequencer_group_control(7, SEQUENCE_CONTROL_START, 1, 4, 0, false), + "the stopping group is queued on the same boundary"); + clock_to(boundary); + CHECK(!mark_at("must-be-stopped", boundary), + "the leaf stop takes effect before ordinary events on that tick"); +} + static void test_invalid_edits_are_repairable(void) { printf("invalid definitions fail without losing staged edits\n"); sequencer_reset(); @@ -583,6 +622,14 @@ static void test_configured_bounds(void) { CHECK(!sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 64, 8, true), "one execution beyond the configured pool is rejected"); + clear_marks(); + uint32_t start = next_boundary(sequencer_ticks(), 64); + clock_to(start); + CHECK(marks_named_at("last", start) == 8, + "a rejected ninth start does not disturb the eight queued executions"); + clock_to(start + 4); + CHECK(sequencer_group_control(8, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "completed one-shots return their execution slots to the pool"); sequencer_reset(); } @@ -628,6 +675,7 @@ int main(void) { test_quantized_stop_precedes_boundary_event(); test_tagged_gate_and_stop_are_selective(); test_group_lifecycle_control_is_not_recursive(); + test_group_stop_control_is_a_supported_leaf(); test_invalid_edits_are_repairable(); test_clear_preserves_active_revision(); test_resets_keep_definitions_only(); From 2aa432308592816de68cc4510e34c258f7b21567 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:53:10 +0200 Subject: [PATCH 11/12] Clarify sequencer group contracts and errors --- docs/sequencer-groups-abstractions.md | 13 ++++++++----- docs/sequencer-groups.md | 8 +++++--- src/sequencer.c | 18 +++++++++++++----- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/docs/sequencer-groups-abstractions.md b/docs/sequencer-groups-abstractions.md index 96ad6d4b..f3a3026a 100644 --- a/docs/sequencer-groups-abstractions.md +++ b/docs/sequencer-groups-abstractions.md @@ -143,8 +143,11 @@ but preserve published group definitions. Full AMY shutdown releases the definitions. The native group regression test exercises legacy root behavior and group -behavior in the same process. It covers root compatibility, local tag -namespaces, one/N/infinite repetition, quantization, atomic publication, -immutable active revisions, same-tick root launches, finite phase-preserving -gates, group-to-group lifecycle rejection, resets, 32-bit clock rollover, and -configured bounds. +behavior in the same process. It covers the unchanged three-value C and wire +formats, root/group namespace isolation, one/N/infinite repetition, +quantization, tagged replacement, selective stop and gate, early ungate, +atomic publication, repair after rejected publication, immutable active +revisions, same-tick root launches, non-recursive lifecycle controls, allowed +leaf controls, resets, 32-bit clock rollover, disabled configuration, and +configured storage and execution bounds. The existing AMY C and audio suites +remain the broader backward-compatibility tests. diff --git a/docs/sequencer-groups.md b/docs/sequencer-groups.md index ba79613e..8708659b 100644 --- a/docs/sequencer-groups.md +++ b/docs/sequencer-groups.md @@ -133,6 +133,8 @@ them. Storage and work are bounded by `max_sequence_groups`, `max_sequence_group_tags` and `max_sequence_group_executions` in `amy_config_t`. Group event arrays and wire payloads are allocated only for -definitions that are authored. The tick path scans only the fixed active -execution pool; inactive stored groups are not visited, and starting an -execution does not allocate memory. +definitions that are authored. Setting any of the three capacities to zero +disables sequencer groups. The tick path scans only the fixed execution pool, +not all stored groups, so a larger definition catalogue does not make inactive +definitions part of per-tick work. Starting an execution does not allocate +memory. diff --git a/src/sequencer.c b/src/sequencer.c index d53a7c95..96a3522e 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -520,6 +520,13 @@ static bool group_execution_matches(const sequence_group_execution_t *execution, && execution->execution_tag == execution_tag); } +static const char *group_action_name(uint32_t action) { + if (action == SEQUENCE_CONTROL_START) return "start"; + if (action == SEQUENCE_CONTROL_PUBLISH) return "publish"; + if (action == SEQUENCE_CONTROL_CLEAR) return "clear"; + return "unknown"; +} + static uint8_t group_publish(sequence_group_slot_t *slot, uint32_t group, uint32_t length) { if (length == 0) { @@ -580,9 +587,9 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, || action == SEQUENCE_CONTROL_PUBLISH || action == SEQUENCE_CONTROL_CLEAR)) { fprintf(stderr, "sequencer group %" PRIu32 - " cannot perform lifecycle action %" PRIu32 + " cannot perform lifecycle action %s (%" PRIu32 ")" ": grouped events may only stop or gate executions\n", - group, action); + group, group_action_name(action), action); return 0; } @@ -598,8 +605,8 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, result = 1; } else if (action == SEQUENCE_CONTROL_START) { if (slot->published == NULL || slot->published->length_ticks == 0) { - fprintf(stderr, "sequencer group %" PRIu32 " has no published definition\n", - group); + fprintf(stderr, "cannot start sequencer group %" PRIu32 + ": no definition has been published\n", group); } else { uint32_t start_tick = group_control_tick(quantize); sequence_group_execution_t *available = NULL; @@ -653,7 +660,8 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, } } else { fprintf(stderr, "cannot control sequencer group %" PRIu32 - ": action %" PRIu32 " is unknown; valid actions are [0, 4]\n", + ": action %" PRIu32 " is unknown; valid actions are " + "stop=0, start=1, gate=2, publish=3, clear=4\n", group, action); } amy_release_lock(); From b791eb2028eb335d89924566ef1b1c94952161e2 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 11:57:10 +0200 Subject: [PATCH 12/12] Cover sequencer group republish and tag edges --- tests/test_sequence_groups.c | 67 +++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c index bb5454a6..0f078ba0 100644 --- a/tests/test_sequence_groups.c +++ b/tests/test_sequence_groups.c @@ -415,6 +415,28 @@ static void test_tagged_gate_and_stop_are_selective(void) { sequencer_midi_clock_tick(); } +static void test_tagged_control_does_not_select_untagged_execution(void) { + printf("tagged controls do not select untagged executions\n"); + sequencer_reset(); + clear_group(2); + clear_marks(); + amy_add_message("H0,1,0,2zPuntaggedZ"); + amy_add_message("zQ2,3,1Z"); + CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_START, 0, 0, 0, false), + "an untagged execution starts"); + sequencer_midi_clock_tick(); + + clear_marks(); + CHECK(!sequencer_group_control(2, SEQUENCE_CONTROL_STOP, 0, 0, 77, true), + "a tagged stop reports no match for an untagged execution"); + sequencer_midi_clock_tick(); + CHECK(marks_named("untagged") == 2, + "the unmatched tagged stop leaves the untagged execution running"); + CHECK(sequencer_group_control(2, SEQUENCE_CONTROL_STOP, 0, 0, 0, false), + "an untagged stop still selects the execution"); + sequencer_midi_clock_tick(); +} + static void test_group_lifecycle_control_is_not_recursive(void) { printf("a group payload cannot start, publish or clear a group\n"); sequencer_reset(); @@ -503,6 +525,17 @@ static void test_invalid_edits_are_repairable(void) { CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), "publication succeeds after clearing the invalid tag"); + CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_PUBLISH, 4, 0, 0, false), + "publishing without new edits clones the published definition"); + clear_marks(); + uint32_t cloned_start = sequencer_ticks() + 1; + CHECK(sequencer_group_control(5, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "the cloned definition can be started"); + sequencer_midi_clock_tick(); + CHECK(mark_at("repaired", cloned_start + 1), + "the cloned definition retains its event wire"); + sequencer_reset(); + CHECK(!sequencer_group_control(5, 99, 0, 0, 0, false), "an unknown lifecycle action is rejected"); CHECK(!sequencer_group_control(0, SEQUENCE_CONTROL_START, 1, 0, 0, false), @@ -635,18 +668,27 @@ static void test_configured_bounds(void) { static void test_disabled_configuration(void) { printf("zero capacities disable sequencer groups safely\n"); - amy_config_t config = amy_default_config(); - config.features.startup_bleep = 0; - config.audio = AMY_AUDIO_IS_NONE; - config.max_sequence_groups = 0; - config.max_sequence_group_tags = 0; - config.max_sequence_group_executions = 0; - amy_start(config); - CHECK(!sequencer_group_add_wire(0, 1, 0, 1, strdup("zPdisabledZ")), - "group storage rejects events while disabled"); - CHECK(!sequencer_group_control(1, SEQUENCE_CONTROL_START, 1, 0, 0, false), - "group control rejects operations while disabled"); - amy_stop(); + const uint32_t capacities[][3] = { + {0, 8, 8}, + {8, 0, 8}, + {8, 8, 0}, + }; + 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_sequence_groups = capacities[i][0]; + config.max_sequence_group_tags = capacities[i][1]; + config.max_sequence_group_executions = capacities[i][2]; + amy_start(config); + CHECK(!sequencer_group_add_wire(0, 1, 0, 1, strdup("zPdisabledZ")), + "group storage is disabled when capacity set %zu contains zero", + i + 1); + CHECK(!sequencer_group_control(1, SEQUENCE_CONTROL_START, 1, 0, 0, false), + "group control is disabled when capacity set %zu contains zero", + i + 1); + amy_stop(); + } } // examples.c calls this; the platform normally provides it. @@ -674,6 +716,7 @@ int main(void) { test_quantized_gate_preserves_phase(); test_quantized_stop_precedes_boundary_event(); test_tagged_gate_and_stop_are_selective(); + test_tagged_control_does_not_select_untagged_execution(); test_group_lifecycle_control_is_not_recursive(); test_group_stop_control_is_a_supported_leaf(); test_invalid_edits_are_repairable();