From 74bed0483367dd4bf374f1c9b0abf5f45f997f76 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Thu, 3 Sep 2026 20:04:08 +0200 Subject: [PATCH 01/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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(); From 90b62c6f1395cd7923b013342aaf09bc3231a63f Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 16:49:57 +0200 Subject: [PATCH 13/52] Simplify reusable sequences around sequencer tags --- Makefile | 3 +- amy/__init__.py | 66 ++- amy/constants.py | 3 - godot/amy.gd | 152 +++---- src/amy.c | 7 +- src/amy.h | 11 +- src/amy_api.generated.js | 155 +++---- src/api.c | 8 +- src/parse.c | 91 ++-- src/patches.c | 6 +- src/pyamy.c | 21 +- src/sequencer.c | 574 ++++++++++++------------ src/sequencer.h | 30 +- tests/test_sequence_api.py | 59 +++ tests/test_sequence_groups.c | 736 ------------------------------- tests/test_sequencer_sequences.c | 377 ++++++++++++++++ 16 files changed, 1028 insertions(+), 1271 deletions(-) create mode 100644 tests/test_sequence_api.py delete mode 100644 tests/test_sequence_groups.c create mode 100644 tests/test_sequencer_sequences.c diff --git a/Makefile b/Makefile index 2e465c0f..47cac0b5 100644 --- a/Makefile +++ b/Makefile @@ -124,7 +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_sequencer_sequences \ 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 \ @@ -145,6 +145,7 @@ amy-module: amy-example ${EXTRA_PIP_ENV} ${PYTHON} -m pip install -r requirements.txt; touch src/amy.c; ${EXTRA_PIP_ENV} ${PYTHON} -m pip install . --force-reinstall --no-deps; cd .. test: amy-module + ${PYTHON} tests/test_sequence_api.py ${PYTHON} -m amy.test qtest: amy-module diff --git a/amy/__init__.py b/amy/__init__.py index 7cb08362..7239cca7 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -241,7 +241,10 @@ def str_of_int(arg): _KW_MAP_LIST = [ # Order matters because patch_string must come last. - # 'ticks' must come first: 'H' is recognized only as first char in wire message. + # Sequence/ticks headers must come first: 'H' is only recognized as the + # first wire character. sequence_control follows a ticks/sequence_event + # header when it is used as that scheduled event's payload. + ('sequence_event', 'HAL'), ('ticks', 'HL'), ('osc', 'vI'), ('wave', 'wI'), ('note', 'nF'), ('vel', 'lF'), ('amp', 'aC'), ('freq', 'fC'), ('duty', 'dC'), ('feedback', 'bF'), ('reset', 'SI'), ('phase', 'PF'), ('sample_offset', 'poI'), ('fit', 'pFF'), ('fit_search', 'pSI'), ('pan', 'QC'), ('client', 'gI'), @@ -253,8 +256,9 @@ def str_of_int(arg): ('dist_clip', 'GCI'), ('dist_fold', 'GFI'), ('dist_crush', 'GHL'), ('dist_drive', 'GDC'), ('dist_mix', 'GMC'), ('algo_source', 'OL'), ('load_sample', 'zL'), ('transfer_file', 'zTL'), ('disk_sample', 'zFL'), ('algorithm', 'oI'), ('chorus', 'kL'), ('reverb', 'hL'), ('echo', 'ML'), ('patch', 'KI'), + ('sequence_reset', 'HRI'), + ('sequence_control', 'HCL'), ('external_channel', 'WI'), ('portamento', 'mI'), ('tempo', 'jF'), ('sequencer_run', 'zYI'), - ('sequence_control', 'zQL'), ('external_midi_sync', 'zCI'), ('synth', 'iI'), ('pedal', 'ipI'), ('synth_flags', 'ifI'), ('num_voices', 'ivI'), ('oscs_per_voice', 'inI'), ('synth_level', 'iVF'), @@ -297,6 +301,15 @@ def message(**kwargs): if 'wave' not in kwargs or kwargs['wave'] != BYO_PARTIALS: raise ValueError('\'num_partials\' must be used with \'wave\'=BYO_PARTIALS.') + outer_sequence_keys = {'sequence_event', 'ticks', 'sequence_reset'} & kwargs.keys() + if len(outer_sequence_keys) > 1: + raise ValueError('Use only one of sequence_event, sequence_reset, or ticks in a message.') + if 'sequence_reset' in kwargs and len(kwargs) != 1: + raise ValueError('sequence_reset must be sent as a standalone message.') + if ('sequence_control' in kwargs and len(kwargs) != 1 + and not ({'sequence_event', 'ticks'} & kwargs.keys())): + raise ValueError('sequence_control can only be combined with ticks or sequence_event.') + # Validity check all the passed args. prioritized_keys = [] for key, arg in kwargs.items(): @@ -374,6 +387,55 @@ def send(**kwargs): send_raw(m) +def _sequence_ticks(value): + """Normalize a stored-sequence event's local (tick, period) tuple.""" + if isinstance(value, str): + values = value.split(',') + elif isinstance(value, (list, tuple)): + values = list(value) + else: + values = [value] + if not 1 <= len(values) <= 2: + raise ValueError('A stored sequence event needs ticks=(tick,) or ticks=(tick, period).') + tick = int(values[0]) + period = int(values[1]) if len(values) == 2 else 0 + if tick < 0 or period < 0: + raise ValueError('Stored sequence tick and period must be non-negative.') + if period and tick >= period: + raise ValueError('A stored sequence tick must be below its nonzero period.') + return tick, period + + +def define_sequence(tag, events): + """Replace one reusable tagged sequence with ordinary AMY events. + + Each event is a mapping accepted by :func:`message` and must contain a + local ``ticks`` value with one or two fields. All event messages are + validated before the reset is sent, then the definition is written as a + per-tag reset followed by explicit cumulative event appends. Executions + which already started keep their previous immutable definition. + """ + sequence_tag = int(tag) + if sequence_tag < 0: + raise ValueError('Sequence tag must be non-negative.') + event_messages = [] + for event in events: + values = dict(event) + if 'ticks' not in values: + raise ValueError('Every stored sequence event needs a ticks value.') + if {'sequence_event', 'sequence_reset'} & values.keys(): + raise ValueError('Stored sequence events cannot contain sequence authoring commands.') + tick, period = _sequence_ticks(values.pop('ticks')) + if not values: + raise ValueError('Every stored sequence event needs an AMY payload.') + event_messages.append(message( + sequence_event=(sequence_tag, tick, period), **values)) + + send_raw(message(sequence_reset=sequence_tag)) + for event_message in event_messages: + send_raw(event_message) + + # Plots a time domain and spectra of audio def show(data): import matplotlib.pyplot as plt diff --git a/amy/constants.py b/amy/constants.py index 4820ffbe..9f85e94d 100644 --- a/amy/constants.py +++ b/amy/constants.py @@ -124,12 +124,9 @@ 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/godot/amy.gd b/godot/amy.gd index 435e3693..618d6cb4 100644 --- a/godot/amy.gd +++ b/godot/amy.gd @@ -292,6 +292,7 @@ func _format_ctrl(val: Variant) -> String: # ============================================================ # BEGIN GENERATED - scripts/gen_amy_gd_api.py var _KW_MAP: Dictionary = { + "sequence_event": ["HA", "L"], "ticks": ["H", "L"], "osc": ["v", "I"], "wave": ["w", "I"], @@ -339,11 +340,12 @@ var _KW_MAP: Dictionary = { "reverb": ["h", "L"], "echo": ["M", "L"], "patch": ["K", "I"], + "sequence_reset": ["HR", "I"], + "sequence_control": ["HC", "L"], "external_channel": ["W", "I"], "portamento": ["m", "I"], "tempo": ["j", "F"], "sequencer_run": ["zY", "I"], - "sequence_control": ["zQ", "L"], "external_midi_sync": ["zC", "I"], "synth": ["i", "I"], "pedal": ["ip", "I"], @@ -368,79 +370,81 @@ var _KW_MAP: Dictionary = { } var _KW_PRIORITY: Dictionary = { - "ticks": 0, - "osc": 1, - "wave": 2, - "note": 3, - "vel": 4, - "amp": 5, - "freq": 6, - "duty": 7, - "feedback": 8, - "reset": 9, - "phase": 10, - "sample_offset": 11, - "fit": 12, - "fit_search": 13, - "pan": 14, - "client": 15, - "volume": 16, - "pitch_bend": 17, - "filter_freq": 18, - "resonance": 19, - "bp0": 20, - "bp1": 21, - "eg0": 22, - "eg1": 23, - "eg0_type": 24, - "eg1_type": 25, - "debug": 26, - "chained_osc": 27, - "mod_source": 28, - "eq": 29, - "filter_type": 30, - "ratio": 31, - "latency_ms": 32, - "dist_clip": 33, - "dist_fold": 34, - "dist_crush": 35, - "dist_drive": 36, - "dist_mix": 37, - "algo_source": 38, - "load_sample": 39, - "transfer_file": 40, - "disk_sample": 41, - "algorithm": 42, - "chorus": 43, - "reverb": 44, - "echo": 45, - "patch": 46, - "external_channel": 47, - "portamento": 48, - "tempo": 49, - "sequencer_run": 50, - "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, + "sequence_event": 0, + "ticks": 1, + "osc": 2, + "wave": 3, + "note": 4, + "vel": 5, + "amp": 6, + "freq": 7, + "duty": 8, + "feedback": 9, + "reset": 10, + "phase": 11, + "sample_offset": 12, + "fit": 13, + "fit_search": 14, + "pan": 15, + "client": 16, + "volume": 17, + "pitch_bend": 18, + "filter_freq": 19, + "resonance": 20, + "bp0": 21, + "bp1": 22, + "eg0": 23, + "eg1": 24, + "eg0_type": 25, + "eg1_type": 26, + "debug": 27, + "chained_osc": 28, + "mod_source": 29, + "eq": 30, + "filter_type": 31, + "ratio": 32, + "latency_ms": 33, + "dist_clip": 34, + "dist_fold": 35, + "dist_crush": 36, + "dist_drive": 37, + "dist_mix": 38, + "algo_source": 39, + "load_sample": 40, + "transfer_file": 41, + "disk_sample": 42, + "algorithm": 43, + "chorus": 44, + "reverb": 45, + "echo": 46, + "patch": 47, + "sequence_reset": 48, + "sequence_control": 49, + "external_channel": 50, + "portamento": 51, + "tempo": 52, + "sequencer_run": 53, + "external_midi_sync": 54, + "synth": 55, + "pedal": 56, + "synth_flags": 57, + "num_voices": 58, + "oscs_per_voice": 59, + "synth_level": 60, + "to_synth": 61, + "grab_midi_notes": 62, + "note_source_channel": 63, + "synth_delay": 64, + "preset": 65, + "num_partials": 66, + "start_sample": 67, + "stop_sample": 68, + "bus": 69, + "mode": 70, + "midi_cc": 71, + "midi_note_cmd": 72, + "cv_trigger": 73, + "patch_string": 74, } ## The control coefficient inputs, in wire order. Prefer naming these in a diff --git a/src/amy.c b/src/amy.c index d20187da..75cc47b0 100644 --- a/src/amy.c +++ b/src/amy.c @@ -1299,9 +1299,8 @@ int8_t oscs_init() { patches_init(amy_global.config.max_memory_patches); instruments_init(amy_global.config.max_synths); 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); + amy_global.config.max_sequence_events, + amy_global.config.max_sequence_executions); if(pcm_samples) pcm_init(); if(AMY_HAS_CUSTOM) custom_init(); // synth and msynth are now pointers to arrays of pointers to dynamically-allocated synth structures. @@ -2479,7 +2478,7 @@ int16_t * amy_fill_buffer() { amy_global.total_blocks = 0; amy_global.total_samples = 0; amy_global.time = 0; - sequencer_group_reset_timebase(); + sequencer_sequence_reset_timebase(); amy_global.sequencer_tick_count = 0; sequencer_recompute(); amy_global.reset_timebase_pending = 0; diff --git a/src/amy.h b/src/amy.h index 37a71d03..44803f57 100644 --- a/src/amy.h +++ b/src/amy.h @@ -363,13 +363,10 @@ enum coefs{ #define TICKS_TICK 0 #define TICKS_PERIOD 1 #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 @@ -674,7 +671,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[4]; // tick, period, tag, optional group tag + uint32_t ticks[3]; // tick, period, 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; @@ -894,10 +891,8 @@ 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_sequence_events; + uint32_t max_sequence_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 230f8876..99f391cf 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -4,6 +4,7 @@ "use strict"; var AMY_KW_MAP = { + sequence_event: {wire: "HA", type: "L"}, ticks: {wire: "H", type: "L"}, osc: {wire: "v", type: "I"}, wave: {wire: "w", type: "I"}, @@ -51,11 +52,12 @@ var AMY_KW_MAP = { reverb: {wire: "h", type: "L"}, echo: {wire: "M", type: "L"}, patch: {wire: "K", type: "I"}, + sequence_reset: {wire: "HR", type: "I"}, + sequence_control: {wire: "HC", type: "L"}, external_channel: {wire: "W", type: "I"}, portamento: {wire: "m", type: "I"}, tempo: {wire: "j", type: "F"}, 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"}, @@ -80,79 +82,81 @@ var AMY_KW_MAP = { }; var AMY_KW_PRIORITY = { - ticks: 0, - osc: 1, - wave: 2, - note: 3, - vel: 4, - amp: 5, - freq: 6, - duty: 7, - feedback: 8, - reset: 9, - phase: 10, - sample_offset: 11, - fit: 12, - fit_search: 13, - pan: 14, - client: 15, - volume: 16, - pitch_bend: 17, - filter_freq: 18, - resonance: 19, - bp0: 20, - bp1: 21, - eg0: 22, - eg1: 23, - eg0_type: 24, - eg1_type: 25, - debug: 26, - chained_osc: 27, - mod_source: 28, - eq: 29, - filter_type: 30, - ratio: 31, - latency_ms: 32, - dist_clip: 33, - dist_fold: 34, - dist_crush: 35, - dist_drive: 36, - dist_mix: 37, - algo_source: 38, - load_sample: 39, - transfer_file: 40, - disk_sample: 41, - algorithm: 42, - chorus: 43, - reverb: 44, - echo: 45, - patch: 46, - external_channel: 47, - portamento: 48, - tempo: 49, - sequencer_run: 50, - 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 + sequence_event: 0, + ticks: 1, + osc: 2, + wave: 3, + note: 4, + vel: 5, + amp: 6, + freq: 7, + duty: 8, + feedback: 9, + reset: 10, + phase: 11, + sample_offset: 12, + fit: 13, + fit_search: 14, + pan: 15, + client: 16, + volume: 17, + pitch_bend: 18, + filter_freq: 19, + resonance: 20, + bp0: 21, + bp1: 22, + eg0: 23, + eg1: 24, + eg0_type: 25, + eg1_type: 26, + debug: 27, + chained_osc: 28, + mod_source: 29, + eq: 30, + filter_type: 31, + ratio: 32, + latency_ms: 33, + dist_clip: 34, + dist_fold: 35, + dist_crush: 36, + dist_drive: 37, + dist_mix: 38, + algo_source: 39, + load_sample: 40, + transfer_file: 41, + disk_sample: 42, + algorithm: 43, + chorus: 44, + reverb: 45, + echo: 46, + patch: 47, + sequence_reset: 48, + sequence_control: 49, + external_channel: 50, + portamento: 51, + tempo: 52, + sequencer_run: 53, + external_midi_sync: 54, + synth: 55, + pedal: 56, + synth_flags: 57, + num_voices: 58, + oscs_per_voice: 59, + synth_level: 60, + to_synth: 61, + grab_midi_notes: 62, + note_source_channel: 63, + synth_delay: 64, + preset: 65, + num_partials: 66, + start_sample: 67, + stop_sample: 68, + bus: 69, + mode: 70, + midi_cc: 71, + midi_note_cmd: 72, + cv_trigger: 73, + patch_string: 74 }; var AMY_COEF_FIELDS = ["const", "note", "vel", "eg0", "eg1", "mod0", "bend", "ext0", "ext1", "mod1"]; @@ -408,12 +412,9 @@ 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 faad006c..978112f5 100644 --- a/src/api.c +++ b/src/api.c @@ -48,9 +48,8 @@ amy_config_t amy_default_config() { c.max_oscs = 250; c.max_buses = AMY_DEFAULT_NUM_BUSES; c.max_sequencer_tags = 256; - c.max_sequence_groups = 32; - c.max_sequence_group_tags = 64; - c.max_sequence_group_executions = 32; + c.max_sequence_events = 64; + c.max_sequence_executions = 32; c.max_voices = 64; c.max_synths = 64; c.max_memory_patches = 32; @@ -190,7 +189,6 @@ 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); @@ -324,7 +322,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]) || AMY_IS_SET(e->ticks[TICKS_GROUP])) { + if(AMY_IS_SET(e->ticks[TICKS_TICK]) || AMY_IS_SET(e->ticks[TICKS_PERIOD]) || AMY_IS_SET(e->ticks[TICKS_TAG])) { // 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 8c8b94f8..fdc20c68 100644 --- a/src/parse.c +++ b/src/parse.c @@ -659,20 +659,6 @@ 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, - "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); - } - 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). @@ -724,8 +710,66 @@ 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[4] = {0, 0, 0, 0}; - int num_vals = parse_list_uint32_t(message + 1, ticks, 4, 0); + if (message[1] == 'A') { + // HAsequence_tag,tick,period: explicitly append one ordinary + // event to a reusable sequence. The sequence tag is the same public + // identity space used by legacy root ticks events. + uint32_t values[3] = {0, 0, 0}; + int count = parse_list_uint32_t(message + 2, values, 3, 0); + uint16_t header_len = 2 + _next_alpha(message + 2); + if (count != 3) { + fprintf(stderr, + "invalid sequence event: expected " + "HAsequence_tag,tick,period\n"); + return; + } + char *payload = message + header_len; + size_t payload_len = strlen(payload); + char *copy = (char *)malloc_caps(payload_len + 1, + amy_global.config.ram_caps_events); + if (copy == NULL) amy_oom("sequence_event"); + else { + memcpy(copy, payload, payload_len + 1); + sequencer_sequence_add_wire(values[0], values[1], values[2], copy); + } + return; + } + if (message[1] == 'C') { + // HCtag,start_or_stop[,alignment_period] + // HCtag,gate,duration[,alignment_period] + uint32_t values[4] = {0, 0, 0, 0}; + int count = parse_list_uint32_t(message + 2, values, 4, 0); + if (count < 2) { + fprintf(stderr, + "invalid sequence_control: expected " + "HCtag,start_or_stop[,alignment_period] or " + "HCtag,gate,duration[,alignment_period]\n"); + } else if (values[1] == SEQUENCE_CONTROL_GATE && count < 3) { + fprintf(stderr, + "invalid sequence_control gate: duration is required\n"); + } else { + uint32_t value = values[1] == SEQUENCE_CONTROL_GATE + ? values[2] : 0; + uint32_t alignment = values[1] == SEQUENCE_CONTROL_GATE + ? values[3] : values[2]; + sequencer_sequence_control(values[0], values[1], value, alignment); + } + return; + } + if (message[1] == 'R') { + // HRtag: clear future root/stored events for this tag. Already-active + // immutable sequence executions are intentionally unaffected. + uint32_t values[1] = {0}; + int count = parse_list_uint32_t(message + 2, values, 1, 0); + if (count != 1) + fprintf(stderr, "invalid sequence reset: expected HRtag\n"); + else + sequencer_sequence_reset(values[0]); + return; + } + + uint32_t ticks[3] = {0, 0, 0}; + int num_vals = parse_list_uint32_t(message + 1, ticks, 3, 0); uint16_t schedule_len = 1 + _next_alpha(message + 1); char *payload = message + schedule_len; uint16_t payload_len = (uint16_t)strlen(payload); @@ -734,17 +778,10 @@ void handle_ticks_message(char *message) { amy_oom("ticks_message"); } else { memcpy(stripped, payload, payload_len + 1); - 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); - } + // 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); } } diff --git a/src/patches.c b/src/patches.c index 9aa7e50a..63c07b5d 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", 4, "H"); // tick, period, tag, optional group + _EPRINT_U_SEQ(ticks, "ticks", 3, "H"); // tick, period, tag } 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", 4, "H"); // tick, period, tag, optional group + _EPRINT_U_SEQ(ticks, "ticks", 3, "H"); // tick, period, tag _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, 4); // tick, period, tag, optional group + _RET_TRUE_IF_SET_SEQ(ticks, 3); // tick, period, tag // //_RET_TRUE_IF_SET(status, "status"); _RET_TRUE_IF_SET(reset_osc); diff --git a/src/pyamy.c b/src/pyamy.c index cee64e20..d7b69fef 100644 --- a/src/pyamy.c +++ b/src/pyamy.c @@ -97,32 +97,23 @@ 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) { + } else if (strcmp(key, "max_sequence_events") == 0) { llv = PyLong_AsLongLong(value); if (PyErr_Occurred()) return -1; if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { - PyErr_SetString(PyExc_ValueError, "max_sequence_groups must be in range [0, 4294967295]"); + PyErr_SetString(PyExc_ValueError, "max_sequence_events must be in range [0, 4294967295]"); return -1; } - cfg->max_sequence_groups = (uint32_t)llv; + cfg->max_sequence_events = (uint32_t)llv; return 0; - } else if (strcmp(key, "max_sequence_group_tags") == 0) { + } else if (strcmp(key, "max_sequence_executions") == 0) { llv = PyLong_AsLongLong(value); if (PyErr_Occurred()) return -1; if (llv < 0 || (unsigned long long)llv > UINT32_MAX) { - PyErr_SetString(PyExc_ValueError, "max_sequence_group_tags must be in range [0, 4294967295]"); + PyErr_SetString(PyExc_ValueError, "max_sequence_executions 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; + cfg->max_sequence_executions = (uint32_t)llv; return 0; } else if (strcmp(key, "max_voices") == 0) { llv = PyLong_AsLongLong(value); diff --git a/src/sequencer.c b/src/sequencer.c index 96a3522e..f812ef49 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -47,50 +47,47 @@ 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; -// 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 { +// Reusable sequences use the same public tag space as legacy root events. A +// definition is copy-on-write: executions retain the exact event list they +// started with while cumulative edits become the definition for future starts. +typedef struct stored_sequence_event_t { char *wire; uint32_t tick; uint32_t period; -} sequence_group_event_t; +} stored_sequence_event_t; -typedef struct sequence_group_definition_t { - sequence_group_event_t *events; - uint32_t length_ticks; +typedef struct stored_sequence_definition_t { + stored_sequence_event_t *events; + uint32_t event_count; + uint32_t last_one_shot_tick; + bool has_periodic_event; uint32_t refs; -} sequence_group_definition_t; +} stored_sequence_definition_t; -typedef struct sequence_group_slot_t { - sequence_group_definition_t *published; - sequence_group_definition_t *staging; -} sequence_group_slot_t; +typedef struct stored_sequence_slot_t { + stored_sequence_definition_t *definition; +} stored_sequence_slot_t; -typedef struct sequence_group_execution_t { - sequence_group_definition_t *definition; - uint32_t group; +typedef struct stored_sequence_execution_t { + stored_sequence_definition_t *definition; + uint32_t tag; 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; +} stored_sequence_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 size_t sequence_group_event_bytes = 0; -static volatile bool group_wire_firing = false; +static stored_sequence_slot_t *stored_sequences = NULL; +static stored_sequence_execution_t *sequence_executions = NULL; +static uint32_t max_stored_sequence_events = 0; +static uint32_t max_stored_sequence_executions = 0; +static size_t stored_sequence_event_bytes = 0; +static volatile bool stored_sequence_wire_firing = false; static bool checked_array_size(uint32_t count, size_t element_size, size_t *bytes) { @@ -99,52 +96,57 @@ static bool checked_array_size(uint32_t count, size_t element_size, return true; } -static void group_definition_release(sequence_group_definition_t *definition) { +static void stored_sequence_definition_release( + stored_sequence_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) + for (uint32_t i = 0; i < definition->event_count; ++i) if (definition->events[i].wire != NULL) free(definition->events[i].wire); free(definition->events); free(definition); } -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); +static stored_sequence_definition_t *stored_sequence_definition_new(void) { + stored_sequence_definition_t *definition = + (stored_sequence_definition_t *)malloc_caps( + sizeof(stored_sequence_definition_t), + amy_global.config.ram_caps_synth); if (definition == NULL) return NULL; - definition->events = (sequence_group_event_t *)malloc_caps( - sequence_group_event_bytes, amy_global.config.ram_caps_synth); + definition->events = (stored_sequence_event_t *)malloc_caps( + stored_sequence_event_bytes, amy_global.config.ram_caps_synth); if (definition->events == NULL) { free(definition); return NULL; } - memset(definition->events, 0, sequence_group_event_bytes); - definition->length_ticks = 0; + memset(definition->events, 0, stored_sequence_event_bytes); + definition->event_count = 0; + definition->last_one_shot_tick = 0; + definition->has_periodic_event = false; definition->refs = 1; return definition; } -static char *group_wire_copy(const char *wire) { +static char *stored_sequence_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(); +static stored_sequence_definition_t *stored_sequence_definition_clone( + const stored_sequence_definition_t *source) { + stored_sequence_definition_t *copy = stored_sequence_definition_new(); if (copy == NULL) return NULL; if (source == NULL) return copy; - copy->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); + copy->event_count = source->event_count; + copy->last_one_shot_tick = source->last_one_shot_tick; + copy->has_periodic_event = source->has_periodic_event; + for (uint32_t i = 0; i < source->event_count; ++i) { + const stored_sequence_event_t *from = &source->events[i]; + copy->events[i].wire = stored_sequence_wire_copy(from->wire); if (copy->events[i].wire == NULL) { - group_definition_release(copy); + stored_sequence_definition_release(copy); return NULL; } copy->events[i].tick = from->tick; @@ -153,80 +155,84 @@ static sequence_group_definition_t *group_definition_clone( return copy; } -static void group_execution_release(sequence_group_execution_t *execution) { +static void stored_sequence_execution_release( + stored_sequence_execution_t *execution) { if (!execution->occupied) return; - sequence_group_definition_t *definition = execution->definition; + stored_sequence_definition_t *definition = execution->definition; memset(execution, 0, sizeof(*execution)); - group_definition_release(definition); + stored_sequence_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 stored_sequence_executions_reset(void) { + if (sequence_executions == NULL) return; + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) + stored_sequence_execution_release(&sequence_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; +static void stored_sequences_clear_definitions(void) { + if (stored_sequences == NULL) return; + for (int32_t i = 0; i < max_sequences; ++i) { + stored_sequence_definition_release(stored_sequences[i].definition); + stored_sequences[i].definition = NULL; + } +} + +static void stored_sequences_deinit(void) { + stored_sequence_executions_reset(); + stored_sequences_clear_definitions(); + if (stored_sequences != NULL) { + free(stored_sequences); + stored_sequences = NULL; } - if (group_executions != NULL) { - free(group_executions); - group_executions = NULL; + if (sequence_executions != NULL) { + free(sequence_executions); + sequence_executions = NULL; } - max_sequence_groups = 0; - max_sequence_group_tags = 0; - max_sequence_group_executions = 0; - sequence_group_event_bytes = 0; + max_stored_sequence_events = 0; + max_stored_sequence_executions = 0; + stored_sequence_event_bytes = 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; +static void stored_sequences_init(uint32_t events, uint32_t executions) { + max_stored_sequence_events = events; + max_stored_sequence_executions = executions; + stored_sequence_wire_firing = false; + if (max_sequences == 0 || events == 0 || executions == 0) return; - size_t group_bytes = 0; + size_t slot_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) + if (!checked_array_size((uint32_t)max_sequences, + sizeof(stored_sequence_slot_t), &slot_bytes) + || !checked_array_size(events, sizeof(stored_sequence_event_t), + &stored_sequence_event_bytes) || !checked_array_size(executions, - sizeof(sequence_group_execution_t), + sizeof(stored_sequence_execution_t), &execution_bytes)) { fprintf(stderr, - "sequencer group configuration exceeds addressable memory: " - "groups=%" PRIu32 ", event_tags=%" PRIu32 + "stored sequence configuration exceeds addressable memory: " + "tags=%" PRIi32 ", events=%" PRIu32 ", executions=%" PRIu32 "\n", - groups, tags, executions); - sequence_groups_deinit(); + max_sequences, events, executions); + stored_sequences_deinit(); return; } - sequence_groups = (sequence_group_slot_t *)malloc_caps( - group_bytes, amy_global.config.ram_caps_synth); - if (sequence_groups != NULL) - memset(sequence_groups, 0, group_bytes); - group_executions = (sequence_group_execution_t *)malloc_caps( + stored_sequences = (stored_sequence_slot_t *)malloc_caps( + slot_bytes, amy_global.config.ram_caps_synth); + if (stored_sequences != NULL) + memset(stored_sequences, 0, slot_bytes); + sequence_executions = (stored_sequence_execution_t *)malloc_caps( execution_bytes, amy_global.config.ram_caps_synth); - if (group_executions != NULL) - memset(group_executions, 0, execution_bytes); - if (sequence_groups == NULL || group_executions == NULL) { - amy_oom("sequencer groups"); - sequence_groups_deinit(); + if (sequence_executions != NULL) + memset(sequence_executions, 0, execution_bytes); + if (stored_sequences == NULL || sequence_executions == NULL) { + amy_oom("stored sequences"); + stored_sequences_deinit(); return; } } -void sequencer_init(int max_sequencer_tags, uint32_t groups, - uint32_t group_tags, uint32_t group_execution_count) { +void sequencer_init(int max_sequencer_tags, uint32_t sequence_events, + uint32_t sequence_execution_count) { // These are statics, so a stop/start of AMY within one process needs them // put back to their boot state (internal clock, running). sequencer_running = true; @@ -244,7 +250,7 @@ void sequencer_init(int max_sequencer_tags, uint32_t groups, sequences[i].next_active = -1; } first_active = -1; - sequence_groups_init(groups, group_tags, group_execution_count); + stored_sequences_init(sequence_events, sequence_execution_count); // We are read to go. sequencer_recompute(); } @@ -262,9 +268,8 @@ 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(); + stored_sequence_executions_reset(); + stored_sequences_clear_definitions(); } void sequencer_deinit() { @@ -274,13 +279,13 @@ void sequencer_deinit() { sequences = NULL; // sequencer_check_and_fill guards on this } max_sequences = 0; - sequence_groups_deinit(); + stored_sequences_deinit(); } -void sequencer_group_reset_timebase() { +void sequencer_sequence_reset_timebase() { // Absolute activation/control ticks cannot be meaningfully rebased across - // a timebase reset. Persistent definitions remain available for relaunch. - group_executions_reset(); + // a timebase reset. Stored definitions remain available for relaunch. + stored_sequence_executions_reset(); } void sequencer_debug() { @@ -391,6 +396,13 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha anon_cursor = (anon_cursor + 1) % AMY_ANON_SEQUENCE_SLOTS; } amy_grab_lock(); + // A public tag identifies one future sequencer object. A legacy tagged + // write therefore replaces any reusable definition at the same tag; an + // execution which already retained that definition can still finish. + if (has_tag && stored_sequences != NULL) { + stored_sequence_definition_release(stored_sequences[tag].definition); + stored_sequences[tag].definition = NULL; + } // Release any existing message for this tag, even if we're just going to rewrite it. if (sequences[tag].wire) free(sequences[tag].wire); sequences[tag].wire = NULL; @@ -430,223 +442,181 @@ 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]; +static stored_sequence_slot_t *stored_sequence_slot(uint32_t tag) { + if (stored_sequences == NULL || tag >= (uint32_t)max_sequences) return NULL; + return &stored_sequences[tag]; } -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); +uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, + uint32_t period, char *wire) { + stored_sequence_slot_t *slot = stored_sequence_slot(tag); if (slot == NULL) { - if (sequence_groups == NULL) - fprintf(stderr, "cannot add event to sequencer group %" PRIu32 - ": sequencer groups are disabled\n", group); + if (stored_sequences == NULL) + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": stored sequences are disabled\n", tag); else - fprintf(stderr, "cannot add event: sequencer group %" PRIu32 - " is outside the configured range [1, %" PRIu32 "]\n", - group, max_sequence_groups); + fprintf(stderr, "cannot append event: sequence tag %" PRIu32 + " is outside the configured range [0, %" PRIi32 "]\n", + tag, max_sequences - 1); 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); + if (wire == NULL || wire[0] == '\0' || wire[0] == 'Z') { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": event payload is empty\n", tag); free(wire); return 0; } - if (wire == NULL) { - fprintf(stderr, "cannot add event tag %" PRIu32 - " to sequencer group %" PRIu32 ": wire is NULL\n", - tag, group); + if (wire[0] == 'H' && wire[1] != 'C') { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": only H sequence-control payloads may be nested\n", tag); + free(wire); return 0; } - if (wire[0] == 'H') { - fprintf(stderr, "cannot add event tag %" PRIu32 - " to sequencer group %" PRIu32 - ": a grouped event cannot contain another ticks command\n", - tag, group); + if (period != 0 && tick >= period) { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": tick %" PRIu32 " must be below period %" PRIu32 "\n", + tag, tick, period); free(wire); return 0; } 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; - } + // Explicit cumulative sequence authoring and legacy root scheduling share + // one tag identity. Appending a stored event removes any future root event + // at that tag, while unrelated tags are untouched. + if (sequences[tag].wire != NULL) free(sequences[tag].wire); + sequences[tag].wire = NULL; + sequences[tag].tick = 0; + sequences[tag].period = 0; + active_unlink((int32_t)tag); + stored_sequence_definition_t *definition = slot->definition; + if (definition == NULL) { + definition = stored_sequence_definition_new(); + } else if (definition->refs > 1) { + definition = stored_sequence_definition_clone(definition); + } + if (definition == NULL) { + amy_release_lock(); + amy_oom("stored sequence 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; + if (definition != slot->definition) { + stored_sequence_definition_release(slot->definition); + slot->definition = definition; } + if (definition->event_count >= max_stored_sequence_events) { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": configured limit of %" PRIu32 " events is full\n", + tag, max_stored_sequence_events); + amy_release_lock(); + free(wire); + return 0; + } + stored_sequence_event_t *event = + &definition->events[definition->event_count++]; + event->wire = wire; + event->tick = tick; + event->period = period; + if (period != 0) definition->has_periodic_event = true; + else if (tick > definition->last_one_shot_tick) + definition->last_one_shot_tick = tick; amy_release_lock(); - if (wire != NULL) free(wire); return 1; } -static uint32_t group_control_tick(uint32_t quantize) { +uint8_t sequencer_sequence_reset(uint32_t tag) { + stored_sequence_slot_t *slot = stored_sequence_slot(tag); + if (slot == NULL) { + if (stored_sequences == NULL) + fprintf(stderr, "cannot reset sequence %" PRIu32 + ": stored sequences are disabled\n", tag); + else + fprintf(stderr, "cannot reset sequence: tag %" PRIu32 + " is outside the configured range [0, %" PRIi32 "]\n", + tag, max_sequences - 1); + return 0; + } + if (stored_sequence_wire_firing) { + fprintf(stderr, "sequence %" PRIu32 + " cannot reset definitions from a stored sequence event\n", + tag); + return 0; + } + + amy_grab_lock(); + if (sequences[tag].wire != NULL) free(sequences[tag].wire); + sequences[tag].wire = NULL; + sequences[tag].tick = 0; + sequences[tag].period = 0; + active_unlink((int32_t)tag); + stored_sequence_definition_release(slot->definition); + slot->definition = NULL; + amy_release_lock(); + return 1; +} + +static uint32_t sequence_control_tick(uint32_t alignment_period) { // 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; + if (alignment_period != 0) { + uint32_t remainder = tick % alignment_period; + if (remainder != 0) tick += alignment_period - 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 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) { - fprintf(stderr, "cannot publish sequencer group %" PRIu32 - ": length must be greater than zero\n", group); - 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) { - 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; - } - } - 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); +uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period) { + stored_sequence_slot_t *slot = stored_sequence_slot(tag); if (slot == NULL) { - if (sequence_groups == NULL) - fprintf(stderr, "cannot control sequencer group %" PRIu32 - ": sequencer groups are disabled\n", group); + if (stored_sequences == NULL) + fprintf(stderr, "cannot control sequence %" PRIu32 + ": stored sequences are disabled\n", tag); 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, "sequencer group %" PRIu32 - " cannot perform lifecycle action %s (%" PRIu32 ")" - ": grouped events may only stop or gate executions\n", - group, group_action_name(action), action); + fprintf(stderr, "cannot control sequence %" PRIu32 + ": valid tags are [0, %" PRIi32 "]\n", + tag, max_sequences - 1); return 0; } uint8_t result = 0; amy_grab_lock(); - if (action == SEQUENCE_CONTROL_PUBLISH) { - result = group_publish(slot, group, 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, "cannot start sequencer group %" PRIu32 - ": no definition has been published\n", group); + if (action == SEQUENCE_CONTROL_START) { + if (slot->definition == NULL || slot->definition->event_count == 0) { + fprintf(stderr, "cannot start sequence %" PRIu32 + ": its definition is empty\n", tag); } 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]; + uint32_t start_tick = sequence_control_tick(alignment_period); + stored_sequence_execution_t *available = NULL; + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { + stored_sequence_execution_t *execution = &sequence_executions[i]; if (!execution->occupied && available == NULL) available = execution; } if (available == NULL) { - fprintf(stderr, "cannot start sequencer group %" PRIu32 + fprintf(stderr, "cannot start sequence %" PRIu32 ": all %" PRIu32 " execution slots are occupied\n", - group, max_sequence_group_executions); + tag, max_stored_sequence_executions); } 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 = slot->definition; available->definition->refs++; - available->group = group; + available->tag = tag; 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)) + uint32_t control_tick = sequence_control_tick(alignment_period); + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { + stored_sequence_execution_t *execution = &sequence_executions[i]; + if (!execution->occupied || execution->tag != tag) continue; if (action == SEQUENCE_CONTROL_STOP) { execution->stop_tick = control_tick; @@ -659,47 +629,46 @@ uint8_t sequencer_group_control(uint32_t group, uint32_t action, result = 1; } } else { - fprintf(stderr, "cannot control sequencer group %" PRIu32 + fprintf(stderr, "cannot control sequence %" PRIu32 ": action %" PRIu32 " is unknown; valid actions are " - "stop=0, start=1, gate=2, publish=3, clear=4\n", - group, action); + "stop=0, start=1, gate=2\n", tag, 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; +static bool stored_sequence_event_hits(const stored_sequence_event_t *event, + uint32_t local_tick) { return event->period != 0 ? local_tick % event->period == event->tick : local_tick == event->tick; } -static bool group_event_is_control(const sequence_group_event_t *event) { - return event->wire != NULL && strncmp(event->wire, "zQ", 2) == 0; +static bool stored_sequence_event_is_control( + const stored_sequence_event_t *event) { + return strncmp(event->wire, "HC", 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 stored_sequence_play_wire(const char *wire) { + bool previous = stored_sequence_wire_firing; + stored_sequence_wire_firing = true; + amy_add_message((char *)wire); + stored_sequence_wire_firing = previous; } -static void group_process_pass(uint32_t tick, bool controls) { - for (uint32_t i = 0; i < max_sequence_group_executions; ++i) { +static void stored_sequence_process_pass(uint32_t tick, bool controls) { + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { amy_grab_lock(); - sequence_group_execution_t *execution = &group_executions[i]; + stored_sequence_execution_t *execution = &sequence_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; + stored_sequence_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); + || (!definition->has_periodic_event + && elapsed > definition->last_one_shot_tick)) { + stored_sequence_execution_release(execution); amy_release_lock(); continue; } @@ -716,20 +685,21 @@ static void group_process_pass(uint32_t tick, bool controls) { } bool suppress = !controls && execution->gated; definition->refs++; - uint32_t local_tick = elapsed % definition->length_ticks; amy_release_lock(); 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) == controls - && group_event_hits(event, local_tick)) - group_play_wire(event->wire); + for (uint32_t event_index = 0; + event_index < definition->event_count; ++event_index) { + stored_sequence_event_t *event = + &definition->events[event_index]; + if (stored_sequence_event_is_control(event) == controls + && stored_sequence_event_hits(event, elapsed)) + stored_sequence_play_wire(event->wire); } } amy_grab_lock(); - group_definition_release(definition); + stored_sequence_definition_release(definition); amy_release_lock(); } } @@ -787,17 +757,17 @@ static void sequencer_process_tick(void) { amy_release_lock(); if (wire != NULL) { // Parse and play now; the deltas play back within this block. - amy_play_message(wire); + amy_add_message(wire); free(wire); } } } 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_pass(amy_global.sequencer_tick_count, true); - group_process_pass(amy_global.sequencer_tick_count, false); + // Nested controls take effect before ordinary stored-sequence events on + // the same tick. This lets a parent stop a child without one extra onset. + stored_sequence_process_pass(amy_global.sequencer_tick_count, true); + stored_sequence_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); diff --git a/src/sequencer.h b/src/sequencer.h index 82eda5ba..876fd047 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -5,8 +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, uint32_t max_groups, - uint32_t max_group_tags, uint32_t max_group_executions); +void sequencer_init(int max_num_sequences, uint32_t max_sequence_events, + uint32_t max_sequence_executions); void sequencer_deinit(); void sequencer_reset(); void sequencer_debug(); @@ -23,18 +23,20 @@ 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]. -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(); +// Append one ordinary ticks event to the reusable sequence identified by tag. +// Takes ownership of wire. Unlike the legacy root ticks syntax, tick=period=0 +// is a valid one-shot event here. +uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, + uint32_t period, char *wire); +// Clear the future root event and reusable definition at tag. Executions which +// already started retain their immutable definition and may finish. +uint8_t sequencer_sequence_reset(uint32_t tag); +// sequence_control is [tag, start_or_stop, alignment_period] or +// [tag, gate, duration, alignment_period]. +uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period); +void sequencer_sequence_reset_timebase(); void sequencer_midi_clock_tick(); void sequencer_midi_start(); void sequencer_midi_stop(); diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py new file mode 100644 index 00000000..144354dc --- /dev/null +++ b/tests/test_sequence_api.py @@ -0,0 +1,59 @@ +"""Small, audio-independent checks for the reusable-sequence Python API.""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +import amy + + +def expect_error(fragment, fn): + try: + fn() + except ValueError as exc: + assert fragment in str(exc), str(exc) + else: + raise AssertionError("expected ValueError containing %r" % fragment) + + +def main(): + assert amy.message(sequence_event=(7, 0, 0), synth=1, note=60, vel=1) \ + == "HA7,0,0n60l1i1Z" + assert amy.message(sequence_control=(7, amy.SEQUENCE_CONTROL_START, 48)) \ + == "HC7,1,48Z" + assert amy.message(ticks=(0, 48, 3), + sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ + == "H0,48,3HC7,1,1Z" + assert amy.message(sequence_reset=7) == "HR7Z" + assert amy.message(ticks=(1, 4, 2), synth=1, note=60, vel=1) \ + == "H1,4,2n60l1i1Z" + + sent = [] + old_override = amy.override_send + amy.override_send = sent.append + try: + amy.define_sequence(7, [ + {"ticks": (0,), "synth": 1, "note": 60, "vel": 1}, + {"ticks": (3, 8), "synth": 1, "note": 60, "vel": 0}, + ]) + finally: + amy.override_send = old_override + assert sent == [ + "HR7Z", + "HA7,0,0n60l1i1Z", + "HA7,3,8n60l0i1Z", + ] + + expect_error("only one", lambda: amy.message( + ticks=(0, 4, 1), sequence_event=(2, 0, 0), synth=1)) + expect_error("standalone", lambda: amy.message(sequence_reset=2, synth=1)) + expect_error("only be combined", lambda: amy.message( + sequence_control=(2, 1), synth=1)) + expect_error("needs a ticks", lambda: amy.define_sequence(2, [{"synth": 1}])) + expect_error("needs an AMY payload", lambda: amy.define_sequence( + 2, [{"ticks": (0,)}])) + + +if __name__ == "__main__": + main() diff --git a/tests/test_sequence_groups.c b/tests/test_sequence_groups.c deleted file mode 100644 index 0f078ba0..00000000 --- a/tests/test_sequence_groups.c +++ /dev/null @@ -1,736 +0,0 @@ -// 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 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); - 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"); - - 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_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(); - 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("H0,4,0zProot-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"); - 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) { - 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"); -} - -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(); - 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_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(); - 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), - "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_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(); - clear_group(7); - clear_group(8); - clear_marks(); - 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); - 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_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(); - 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, 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), - "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) { - 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"); - 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); - 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_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(); - 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"); - 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(); -} - -static void test_disabled_configuration(void) { - printf("zero capacities disable sequencer groups safely\n"); - 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. -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_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_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(); - 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; - } - printf("\nall sequencer group checks passed\n"); - return 0; -} diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c new file mode 100644 index 00000000..9d057b37 --- /dev/null +++ b/tests/test_sequencer_sequences.c @@ -0,0 +1,377 @@ +// Regression and behavior tests for reusable tagged sequencer sequences. + +#include +#include +#include + +#include "amy.h" +#include "sequencer.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +typedef struct mark_t { + char name[32]; + uint32_t tick; +} mark_t; + +static mark_t marks[256]; +static int mark_count = 0; + +static void mark_hook(const char *code) { + if (mark_count >= (int)(sizeof(marks) / sizeof(marks[0]))) return; + snprintf(marks[mark_count].name, sizeof(marks[mark_count].name), "%s", code); + marks[mark_count].tick = sequencer_ticks(); + mark_count++; +} + +static void clear_marks(void) { + mark_count = 0; + memset(marks, 0, sizeof(marks)); +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); +} + +static uint32_t next_boundary(uint32_t now, uint32_t quantum) { + uint32_t remainder = now % quantum; + return now + (remainder == 0 ? quantum : quantum - remainder); +} + +static int mark_at(const char *name, uint32_t tick) { + for (int i = 0; i < mark_count; ++i) + if (!strcmp(marks[i].name, name) && marks[i].tick == tick) return 1; + return 0; +} + +static int marks_named(const char *name) { + int count = 0; + for (int i = 0; i < mark_count; ++i) + if (!strcmp(marks[i].name, name)) count++; + return count; +} + +static void test_legacy_ticks_are_unchanged(void) { + printf("legacy root ticks remain 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), "periodic root event fires at global modulo"); + CHECK(mark_at("root", first + 4), "periodic root 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 tagged writes still replace rather than accumulate"); +} + +static void test_legacy_c_event_wire_is_unchanged(void) { + printf("legacy C events retain three-value ticks\n"); + amy_event event = amy_default_event(); + event.osc = 2; + event.wave = TRIANGLE; + event.ticks[TICKS_TICK] = 3; + event.ticks[TICKS_PERIOD] = 8; + event.ticks[TICKS_TAG] = 7; + char wire[MAX_MESSAGE_LEN]; + sprint_event(&event, wire, sizeof(wire), true); + CHECK(strncmp(wire, "H3,8,7", 6) == 0, + "C ticks serialization remains three values: %s", wire); +} + +static void test_explicit_append_and_one_shot_lifetime(void) { + printf("explicit sequence events accumulate and finite events retire\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA10,0,0zPzeroZ"); + amy_add_message("HA10,2,0zPtwoZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC10,1,4Z"); + clock_to(start + 4); + CHECK(mark_at("zero", start), "local tick zero fires at activation"); + CHECK(mark_at("two", start + 2), "a second event shares the same tag"); + CHECK(marks_named("zero") == 1 && marks_named("two") == 1, + "period-zero sequence events fire once and execution retires"); +} + +static void test_root_and_stored_forms_share_one_tag_identity(void) { + printf("legacy and reusable forms share one public tag identity\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,4,10zProot-replacedZ"); + amy_add_message("HA10,0,0zPstoredZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC10,1,4Z"); + clock_to(start + 4); + CHECK(mark_at("stored", start) && !marks_named("root-replaced"), + "explicit append replaces the root object at the same tag"); + + amy_add_message("H0,4,10zProotZ"); + CHECK(!sequencer_sequence_control(10, SEQUENCE_CONTROL_START, 0, 0), + "legacy replacement removes the future stored definition"); + clear_marks(); + uint32_t root = next_boundary(sequencer_ticks(), 4); + clock_to(root); + CHECK(mark_at("root", root), "the replacement legacy event remains active"); +} + +static void test_active_definition_is_immutable(void) { + printf("active executions retain the definition they started with\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA11,0,0zPold-headZ"); + amy_add_message("HA11,4,0zPold-tailZ"); + uint32_t old_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC11,1,4Z"); + clock_to(old_start + 2); + + amy_add_message("HR11Z"); + amy_add_message("HA11,0,0zPnew-headZ"); + clock_to(old_start + 4); + CHECK(mark_at("old-tail", old_start + 4), + "resetting future contents does not remove an old note release"); + + clear_marks(); + uint32_t new_start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC11,1,4Z"); + clock_to(new_start + 2); + CHECK(mark_at("new-head", new_start) && !marks_named("old-head") + && !marks_named("old-tail"), + "a later start uses only the replacement definition"); +} + +static void test_root_launches_local_zero_on_same_tick(void) { + printf("root events can launch stored sequences\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA12,0,0zPchild-zeroZ"); + uint32_t start = sequencer_ticks() + 4; + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,1HC12,1,0Z", start); + amy_add_message(wire); + clock_to(start); + CHECK(mark_at("child-zero", start), + "a root launch includes the child's local tick zero"); +} + +static void test_overlapping_executions_need_no_host_identity(void) { + printf("one sequence tag supports bounded overlapping executions\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA13,0,0zPonZ"); + amy_add_message("HA13,4,0zPoffZ"); + uint32_t first = next_boundary(sequencer_ticks(), 4); + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,2HC13,1,0Z", first); + amy_add_message(wire); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,3HC13,1,0Z", first + 2); + amy_add_message(wire); + clock_to(first + 6); + CHECK(mark_at("on", first) && mark_at("on", first + 2), + "two starts of one tag can overlap"); + CHECK(mark_at("off", first + 4) && mark_at("off", first + 6), + "each overlap retains its own scheduled release"); +} + +static void test_parent_stop_leaves_started_child_to_finish(void) { + printf("stopping a parent prevents future children without truncating one\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA15,0,0zPnote-onZ"); + amy_add_message("HA15,4,0zPnote-offZ"); + amy_add_message("HA14,0,4HC15,1,0Z"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC14,1,4Z"); + clock_to(start + 2); + amy_add_message("HC14,0,0Z"); + clock_to(start + 8); + CHECK(mark_at("note-on", start), "parent starts its child"); + CHECK(mark_at("note-off", start + 4), + "the already-started child delivers its own note-off"); + CHECK(marks_named("note-on") == 1, + "the stopped parent launches no later child"); +} + +static void test_controller_sequence_bounds_repetition(void) { + printf("a finite controller sequence can bound a periodic child\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA8,0,4zPpulseZ"); + amy_add_message("HA7,0,0HC8,1,0Z"); + amy_add_message("HA7,12,0HC8,0,0Z"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC7,1,4Z"); + clock_to(start + 14); + CHECK(mark_at("pulse", start) && mark_at("pulse", start + 4) + && mark_at("pulse", start + 8), + "controller permits exactly three periods"); + CHECK(!mark_at("pulse", start + 12) && marks_named("pulse") == 3, + "same-tick stop precedes the child's ordinary event"); +} + +static void test_finite_gate_preserves_phase(void) { + printf("finite event gating preserves the target phase\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA6,0,4zPbeatZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC6,1,4Z"); + clock_to(start); + CHECK(mark_at("beat", start), "loop begins on its aligned boundary"); + CHECK(sequencer_sequence_control(6, SEQUENCE_CONTROL_GATE, 6, 0), + "finite gate is accepted without a host timer"); + clock_to(start + 8); + CHECK(!mark_at("beat", start + 4), "event inside gate is suppressed"); + CHECK(mark_at("beat", start + 8), + "event resumes on the original phase after gate expiry"); +} + +static void test_per_tag_and_global_reset_semantics(void) { + printf("per-tag replacement and global reset have distinct scopes\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA5,0,0zPsurvivorZ"); + amy_add_message("HC5,1,0Z"); + uint32_t start = sequencer_ticks() + 1; + amy_add_message("HR5Z"); + clock_to(start); + CHECK(mark_at("survivor", start), + "per-tag reset leaves an already-started snapshot alive"); + CHECK(!sequencer_sequence_control(5, SEQUENCE_CONTROL_START, 0, 0), + "per-tag reset removed the future definition"); + + amy_add_message("HA5,0,4zPclearedZ"); + amy_add_message("HC5,1,0Z"); + sequencer_reset(); + CHECK(!sequencer_sequence_control(5, SEQUENCE_CONTROL_START, 0, 0), + "global RESET_SEQUENCER clears stored definitions"); +} + +static void test_timebase_reset_keeps_definitions(void) { + printf("timebase reset drops runtime but keeps definitions\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA4,0,0zPafter-rebaseZ"); + amy_add_message("HC4,1,0Z"); + sequencer_sequence_reset_timebase(); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("after-rebase"), "pending execution is discarded"); + CHECK(sequencer_sequence_control(4, SEQUENCE_CONTROL_START, 0, 0), + "definition remains available after timebase reset"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("after-rebase", start), "definition can be relaunched"); +} + +static void test_bounds_and_validation(void) { + printf("tag, event and execution bounds fail deterministically\n"); + sequencer_reset(); + CHECK(!sequencer_sequence_add_wire(16, 0, 0, strdup("zPbad-tagZ")), + "first tag beyond max_sequencer_tags is rejected"); + CHECK(!sequencer_sequence_add_wire(3, 4, 4, strdup("zPbad-periodZ")), + "tick equal to period is rejected"); + CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("")), + "empty payload is rejected"); + CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("HA1,0,0zPbadZ")), + "stored sequences cannot edit definitions recursively"); + + for (uint32_t i = 0; i < 8; ++i) { + char *payload = strdup("zPfullZ"); + CHECK(sequencer_sequence_add_wire(3, i, 0, payload), + "event slot %" PRIu32 " is available", i); + } + CHECK(!sequencer_sequence_add_wire(3, 9, 0, strdup("zPoverflowZ")), + "one event beyond configured capacity is rejected"); + + for (uint32_t i = 0; i < 8; ++i) + CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 64), + "execution slot %" PRIu32 " is available", i); + CHECK(!sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 64), + "one execution beyond configured capacity is rejected"); + CHECK(!sequencer_sequence_control(3, 99, 0, 0), + "unknown control action is rejected"); +} + +static void test_start_crosses_clock_rollover(void) { + printf("relative sequence phase crosses uint32 clock rollover\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("HA2,0,0zPwrap-zeroZ"); + amy_add_message("HA2,2,0zPwrap-twoZ"); + amy_global.sequencer_tick_count = UINT32_MAX - 2; + amy_add_message("HC2,1,4Z"); + clock_to(2); + CHECK(mark_at("wrap-zero", 0), "aligned local zero fires after rollover"); + CHECK(mark_at("wrap-two", 2), "elapsed local time crosses rollover"); +} + +static void test_disabled_configuration(void) { + printf("zero reusable-sequence capacities disable the feature safely\n"); + const uint32_t capacities[][2] = {{0, 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_events = capacities[i][0]; + config.max_sequence_executions = capacities[i][1]; + amy_start(config); + CHECK(!sequencer_sequence_add_wire(1, 0, 0, strdup("zPdisabledZ")), + "append is disabled for zero capacity set %zu", i + 1); + CHECK(!sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "control is disabled for zero capacity set %zu", i + 1); + amy_stop(); + } +} + +// examples.c calls this; the platform normally provides it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + config.max_sequencer_tags = 16; + config.max_sequence_events = 8; + config.max_sequence_executions = 8; + amy_start(config); + + test_legacy_ticks_are_unchanged(); + test_legacy_c_event_wire_is_unchanged(); + test_explicit_append_and_one_shot_lifetime(); + test_root_and_stored_forms_share_one_tag_identity(); + test_active_definition_is_immutable(); + test_root_launches_local_zero_on_same_tick(); + test_overlapping_executions_need_no_host_identity(); + test_parent_stop_leaves_started_child_to_finish(); + test_controller_sequence_bounds_repetition(); + test_finite_gate_preserves_phase(); + test_per_tag_and_global_reset_semantics(); + test_timebase_reset_keeps_definitions(); + test_start_crosses_clock_rollover(); + test_bounds_and_validation(); + + amy_stop(); + test_disabled_configuration(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall reusable sequencer sequence checks passed\n"); + return 0; +} From bc3c85b03e9496188166809c1d45aecdd7372338 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 16:54:17 +0200 Subject: [PATCH 14/52] Document reusable tagged sequences --- ...groups-abstractions.md => sequencer-sequences-abstractions.md} | 0 docs/{sequencer-groups-howto.md => sequencer-sequences-howto.md} | 0 ...ical-use-cases.md => sequencer-sequences-musical-use-cases.md} | 0 docs/{sequencer-groups.md => sequencer-sequences.md} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename docs/{sequencer-groups-abstractions.md => sequencer-sequences-abstractions.md} (100%) rename docs/{sequencer-groups-howto.md => sequencer-sequences-howto.md} (100%) rename docs/{sequencer-groups-musical-use-cases.md => sequencer-sequences-musical-use-cases.md} (100%) rename docs/{sequencer-groups.md => sequencer-sequences.md} (100%) diff --git a/docs/sequencer-groups-abstractions.md b/docs/sequencer-sequences-abstractions.md similarity index 100% rename from docs/sequencer-groups-abstractions.md rename to docs/sequencer-sequences-abstractions.md diff --git a/docs/sequencer-groups-howto.md b/docs/sequencer-sequences-howto.md similarity index 100% rename from docs/sequencer-groups-howto.md rename to docs/sequencer-sequences-howto.md diff --git a/docs/sequencer-groups-musical-use-cases.md b/docs/sequencer-sequences-musical-use-cases.md similarity index 100% rename from docs/sequencer-groups-musical-use-cases.md rename to docs/sequencer-sequences-musical-use-cases.md diff --git a/docs/sequencer-groups.md b/docs/sequencer-sequences.md similarity index 100% rename from docs/sequencer-groups.md rename to docs/sequencer-sequences.md From 16c7e51b0fd0f7aff4d468bcc3d7ff3e8d2343ff Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 16:54:27 +0200 Subject: [PATCH 15/52] Rewrite sequence guides for the simplified API --- README.md | 4 +- docs/api.md | 13 +- docs/sequencer-sequences-abstractions.md | 220 +++++-------- docs/sequencer-sequences-howto.md | 289 +++++------------- docs/sequencer-sequences-musical-use-cases.md | 162 ++++------ docs/sequencer-sequences.md | 194 +++++------- docs/synth.md | 25 +- 7 files changed, 319 insertions(+), 588 deletions(-) diff --git a/README.md b/README.md index eea0daf0..e0dfa8df 100644 --- a/README.md +++ b/README.md @@ -35,7 +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) + * [**AMY Reusable Sequences**](docs/sequencer-sequences.md) * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) @@ -172,7 +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) + * [**AMY Reusable Sequences**](docs/sequencer-sequences.md) * [**Distortion in AMY**](docs/distortions.md) * [**AMY's MIDI specification**](docs/midi.md) * [**AMY in Arduino Getting Started**](docs/arduino.md) diff --git a/docs/api.md b/docs/api.md index 16f07b90..a68cdbbc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -203,10 +203,9 @@ amy_start(amy_config); | `write_samples_fn` | fn ptr | `NULL` | If provided, `amy_update` will call this with each new block of samples | | `max_oscs` | Int | 180 | How many oscillators to support | | `max_buses` | Int | 4 | How many FX buses to support. No compile-time ceiling — every bus-indexed table is allocated from this at `amy_start`. Each bus costs a few KB of mix buffers even when idle, plus whatever its effects allocate once switched on | -| `max_sequencer_tags` | Int | 256 | How many sequencer items to handle | -| `max_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_sequencer_tags` | Int | 256 | Size of the tag space shared by legacy root events and reusable sequences | +| `max_sequence_events` | Int | 64 | Maximum ordinary events in one reusable tagged sequence | +| `max_sequence_executions` | Int | 32 | Maximum active or alignment-pending reusable-sequence executions | | `max_voices` | Int | 64 | How many voices | | `max_synths` | Int | 64 | How many synths | | `max_memory_patches` | Int | 32 | How many in memory patches to supprot | @@ -506,9 +505,11 @@ 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[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. | +| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | Existing tick, period and tag scheduling. `tag` omitted: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. A legacy tagged write keeps its replace-by-tag behavior. **If used in a wire string message**, the `H` **must** be the first character of the message. | +| `HA` | — | `sequence_event` | tag,tick,period | Explicitly append an ordinary event to a [reusable tagged sequence](sequencer-sequences.md). Prefer `amy.define_sequence()` in Python. | +| `HR` | — | `sequence_reset` | tag | Clear the future root event and reusable definition at one tag; already-started immutable executions may finish. | +| `HC` | — | `sequence_control` | tag,start-or-stop[,alignment] or tag,gate,duration[,alignment] | Start, stop, align, or temporarily gate a reusable tagged sequence. | | `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-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index f3a3026a..310d1c16 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -1,153 +1,87 @@ -# Sequencer-group abstractions and implementation +# Reusable sequence 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 scheduler hierarchy. +## Public model -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 public model has two ways to use the existing sequencer tag identity: -## The model +1. `ticks=(tick, period, tag)` keeps the established single-event behavior; +2. `define_sequence(tag, events)` explicitly gives that tag multiple local + events which can be started and stopped as a reusable sequence. -The model separates stored content, scheduled starts, and active playback: +There is no second public group ID, no local event-tag namespace, no fourth +`ticks` field, no explicit length, and no publish/revision command. -| 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 | +`sequence_control` supplies the three generic runtime operations: -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. +- start, optionally aligned to an AMY sequencer period; +- stop every active execution of the tag at an optional alignment boundary; +- gate ordinary events for a finite duration without resetting local phase. -## Authoring and publication - -The existing `ticks` tuple accepts an optional fourth value: +Sequences may start or stop other sequences. A finite controller sequence can +therefore express a fixed repeat count, and a parent can stop launching new +note-pair children while children already in progress deliver their note-offs. -```text -tick,period,event_tag,group_tag -``` +## Why executions still exist internally -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. +A stored definition and an active execution have different lifetimes even +though that distinction is not a second public API. An execution needs a local +start tick and must retain the event data it began with. Without that internal +separation, changing a future phrase could remove a note-off or alter a fill +which is already sounding. -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 variable scheduling depth. - -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; -- group-to-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 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. +AMY therefore uses a small bounded execution pool and reference-counted, +copy-on-write definitions. Appending to a definition which an execution still +uses first clones it. The active execution keeps the old snapshot; later starts +see the updated contents. No revision number is exposed to callers. + +Multiple finite executions of one tag may overlap. This is important for +ordinary musical phrases whose gate time is longer than the interval between +starts. The execution pool, rather than a caller-managed ID scheme, is the +bound. + +## Lifetime inference + +The component events define lifetime: + +- if every event has `period=0`, the execution retires after its greatest local + tick has been processed; +- if any event has a nonzero period, the execution remains active and evaluates + that event against elapsed local time until stopped. + +This avoids an independent length that could disagree with the ordinary +sequencer periods. A fixed number of repeats is composition: a finite parent +starts a periodic child and stops it at the required local tick. + +## Tick processing + +Only active root entries and active sequence executions are visited per tick. +Stored but inactive definitions have no per-tick cost. + +Sequence controls are processed before ordinary events for a tick. Consequently +a stop scheduled at a period boundary prevents the event on that boundary, and +a parent launch can make a child's local tick-zero event run on the launch tick. + +Temporary gating suppresses ordinary payload dispatch but advances elapsed +local time normally. Control events are not gated; otherwise a controller could +mute its own future stop or recovery operation. + +## Bounds and recovery + +All storage is configured at startup: + +- `max_sequencer_tags`: shared public identities; +- `max_sequence_events`: maximum events in one stored definition; +- `max_sequence_executions`: active and pending executions. + +Definitions allocate event storage only when first used. The render path does +not perform unbounded allocation. A recursive or cyclic control graph can fill +the execution pool, but cannot grow past it; further starts fail and the caller +can stop a tag or reset the sequencer. + +## Compatibility boundary + +The legacy parser, C event layout, anonymous-event pool, modulo timing, +same-tag replacement, MIDI/external-clock behavior, and root active-list order +are unchanged. Reusable accumulation only occurs through the explicit sequence +API. Tests cover both the old path and the interaction between legacy and +reusable forms. diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index 9c747a52..c2bf891b 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -1,258 +1,121 @@ -# Sequencer-group how-to: switchable arpeggios and a percussion gate +# Reusable sequence how-to -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. +This example preloads two simple arpeggios, launches them from the root +sequencer, and changes which one will launch without cutting short a note which +already started. -The examples use `amy.send()` as the Python API. Each expandable section emits -the same wire message shown directly above it. +## 1. Define note-pair sequences -## 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 +Each finite child owns its note-on and note-off: ```python -import amy - -amy.send(osc=0, wave=amy.SINE) +amy.define_sequence(20, [ + dict(ticks=(0,), synth=1, note=60, vel=1), + dict(ticks=(18,), synth=1, note=60, vel=0), +]) +amy.define_sequence(21, [ + dict(ticks=(0,), synth=1, note=64, vel=1), + dict(ticks=(18,), synth=1, note=64, vel=0), +]) ``` -
+## 2. Define two arpeggio parents -## 2. Preload an ascending arpeggio +The parents contain only starts of their note-pair children: -Group 10 plays C4, E4, G4, and C5. Each note begins 24 ticks after the previous -one and has an 18-tick gate: +```python +amy.define_sequence(30, [ + dict(ticks=(0, 48), + sequence_control=(20, amy.SEQUENCE_CONTROL_START, 1)), + dict(ticks=(24, 48), + sequence_control=(21, amy.SEQUENCE_CONTROL_START, 1)), +]) -```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 +amy.define_sequence(31, [ + dict(ticks=(0, 24), + sequence_control=(20, amy.SEQUENCE_CONTROL_START, 1)), + dict(ticks=(12, 24), + sequence_control=(21, amy.SEQUENCE_CONTROL_START, 1)), +]) ``` -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. +Because these parents contain periodic events, they run until stopped. -
-Python API equivalent +## 3. Start the first arpeggio ```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]) +amy.send(sequence_control=(30, amy.SEQUENCE_CONTROL_START, 48)) ``` -
- -## 3. Preload a descending arpeggio +The start is aligned to the next 48-tick boundary. -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 +## 4. Switch parents ```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 +amy.send(sequence_control=(30, amy.SEQUENCE_CONTROL_STOP, 48)) +amy.send(sequence_control=(31, amy.SEQUENCE_CONTROL_START, 48)) ``` -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. +Both controls select the same next boundary. The old parent starts no more +children there. A note-pair child which started earlier remains independent and +still sends its tick-18 note-off.
-Python API equivalent +Equivalent low-level wire messages -```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: +The Python API above emits these sequence-authoring messages: ```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], -) -``` +HR20Z +HA20,0,0n60l1i1Z +HA20,18,0n60l0i1Z +HR21Z +HA21,0,0n64l1i1Z +HA21,18,0n64l0i1Z +HR30Z +HA30,0,48HC20,1,1Z +HA30,24,48HC21,1,1Z +HR31Z +HA31,0,24HC20,1,1Z +HA31,12,24HC21,1,1Z +HC30,1,48Z +HC30,0,48Z +HC31,1,48Z +``` + +`HA` is the explicit cumulative event form, `HR` resets the future contents of +one tag, and `HC` controls a tagged sequence. They are all part of the +sequencer-oriented `H` family. Existing `Htick,period,tag...` messages retain +their original replace-by-tag behavior.
-## 6. Turn the arpeggio off and on - -Clear root tag 200 with the unchanged root-sequencer operation: +## Temporarily gate one percussion layer -```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 +Suppose tag `50` is already running a periodic percussion sequence. A caller +can suppress its events for one quarter note at 48 PPQ without stopping its +clock: ```python -amy.send(ticks=[0, 0, 200]) +amy.send(sequence_control=(50, amy.SEQUENCE_CONTROL_GATE, 48, 1)) ``` -
- -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 +After 48 ticks the gate expires automatically and events resume on their +original phase. Duration zero removes a current gate explicitly: ```python -amy.send( - sequence_control=[10, amy.SEQUENCE_CONTROL_START, 1, 96] -) +amy.send(sequence_control=(50, amy.SEQUENCE_CONTROL_GATE, 0, 1)) ``` -
- -## 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] -) +Equivalent low-level wire messages -# 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] -) +```text +HC50,2,48,1Z +HC50,2,0,1Z ```
-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. +The source of these commands could be a foot pedal, UI, network controller, or +another sequence. AMY only sees generic tagged sequence control. diff --git a/docs/sequencer-sequences-musical-use-cases.md b/docs/sequencer-sequences-musical-use-cases.md index 1db6b7a8..4f1f087a 100644 --- a/docs/sequencer-sequences-musical-use-cases.md +++ b/docs/sequencer-sequences-musical-use-cases.md @@ -1,100 +1,62 @@ -# 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. +# Musical use cases for reusable sequences + +Reusable sequences reduce controller complexity when a musical phrase contains +several events but should be launched as one unit. The examples below describe +generic rhythm-engine behavior; AMY assigns no musical meaning to a tag. + +## Preloaded fills + +A rhythm engine can preload each fill once as a finite tagged sequence. Its +root schedule then stores only sequence starts. Selecting or deselecting a fill +changes future root launches, not the complete fill body. + +An already-started fill holds its immutable definition and finishes even if its +future launches are removed. The controller does not calculate an end time, +stream the phrase repeatedly, or maintain an active-fill state machine. + +## Arpeggios and note lifetime + +A short child sequence can contain one note-on and its matching note-off. A +parent sequence starts these children in an arpeggio pattern. Stopping or +replacing the parent prevents future child starts; children which already +started keep their scheduled release. + +This makes live rate, direction, voicing, or chord changes predictable without +requiring the controller to mirror AMY's clock or remember which note-offs are +still pending. Starting the same finite child again may overlap with an older +execution; each execution retains its own event snapshot. + +An explicit stop of the child tag has the different, generic meaning of +terminating every active execution of that child. A caller can therefore choose +between stopping future launches at a parent and deliberately truncating the +leaf itself. + +## Temporarily reducing a rhythm + +A repeating percussion layer can be represented by a periodic sequence. A +finite gate suppresses its ordinary events for a chosen number of ticks while +its local phase keeps advancing. Once the gate expires, it resumes at the point +it would otherwise have reached; already-ringing audio is unaffected. + +The controller decides which musical layer a tag represents and which layers +to gate. AMY implements only generic event dispatch, duration, and phase. + +## Fixed repeat counts + +Component periods define looping. When a phrase should repeat exactly `N` +times, a finite controller sequence can start the periodic phrase at tick zero +and stop it at `N * period`. Control processing precedes ordinary events, so the +event on the stop boundary is not dispatched. + +This composes existing concepts instead of adding a separate repeat-mode or +published-length state. + +## Live definition changes + +A controller can remove future launches, reset and append the replacement +definition, then install new launches. Executions which started before the +change keep the old snapshot. Future starts use the new contents. + +The controller still owns musical policy and transaction ordering, but it does +not own active execution revisions, note lifetime, phrase completion, or the +sequencer clock. diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index 8708659b..37bcb289 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -1,140 +1,112 @@ -# Sequencer groups +# Reusable sequencer sequences -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. +AMY's existing sequencer tags can also identify reusable sequences. A reusable +sequence is a collection of ordinary AMY events with local `tick` and `period` +values. It can be started from Python, from the wire protocol, or from another +sequenced event. -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. +The ordinary three-value `ticks=(tick, period, tag)` API remains unchanged. A +legacy tagged write replaces the event at that tag. Multi-event accumulation is +always explicit. -Related guides: +## Defining a sequence -- [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) +The Python convenience API replaces all future contents at a tag: -## Defining and publishing a group - -The normal `ticks` tuple accepts an optional fourth value: - -```text -tick,period,event_tag,group_tag +```python +amy.define_sequence(40, [ + dict(ticks=(0,), synth=2, note=60, vel=1), + dict(ticks=(12,), synth=2, note=60, vel=0), +]) ``` -`group_tag` values start at 1. An absent or zero group tag uses the existing -root sequencer without changing any of its semantics. +Each event uses the normal AMY keyword arguments. Its `ticks` value is local to +the start of the sequence and contains `tick` plus an optional `period`. -This wire sequence stages a four-beat phrase in group 1 and then publishes it -atomically with a length of 192 ticks: +`define_sequence()` validates every event before sending anything. It then +performs a per-tag reset followed by explicit cumulative writes. If a sequence +may be launched while it is being rewritten, first remove or stop those future +launches. An execution which already started is safe: it retains the immutable +definition it started with, including later note-offs. -```text -H0,192,0,1i2n60l1Z -H24,192,1,1i2n60l0Z -H48,192,2,1i2n64l1Z -H72,192,3,1i2n64l0Z -zQ1,3,192Z +Low-level callers can use `sequence_reset` and `sequence_event` directly: + +```python +amy.send(sequence_reset=40) +amy.send(sequence_event=(40, 0, 0), synth=2, note=60, vel=1) +amy.send(sequence_event=(40, 12, 0), synth=2, note=60, vel=0) ``` -The equivalent Python calls are: +The sequence tag and legacy root tag are one identity space. Writing a legacy +tagged `ticks` event replaces the future reusable definition at that tag; +explicitly appending a reusable event removes the future legacy root event at +that tag. Applications should assign distinct tags to stored phrases and root +launch events. + +## Starting and stopping ```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]) +amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_START, 1)) +amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_STOP, 48)) ``` -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 optional final value is `alignment_period`. `0` or `1` acts at the next +available sequencer tick for a direct command. A larger value selects the next +tick divisible by that period. When a root sequencer event fires a start on a +tick, the child sequence's local tick zero participates in that same tick. -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. +A start creates a bounded execution. More than one execution of a finite +sequence may overlap; no caller-generated execution ID is required. Stop +targets every active execution of the tag. Stopping a parent prevents its +future child starts but does not stop child sequences which already started. +This lets a note-on/note-off child own its complete lifetime. -## Controlling executions +## Finite and repeating lifetime -The control layout is fixed: +No explicit sequence length or publish action is needed: -```text -group,action,value,quantize[,execution_tag] -``` +- a definition containing only `period=0` events is finite and retires after + its last event; +- an event with nonzero `period` repeats on its local period, and keeps that + execution alive until it is stopped; +- a controlling finite sequence can start a periodic child at local tick zero + and stop it after a chosen number of periods. -| 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 -``` +## Temporary event gating ```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]) +amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_GATE, 24, 1)) ``` -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. +This suppresses ordinary event dispatch from active executions of tag `40` for +24 ticks. Their local phase continues and dispatch resumes on the original +phase. Audio which is already ringing is not cut off. Nested sequence controls +remain active while ordinary payload events are gated, so controller sequences +can still complete their lifecycle. -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. A group therefore never launches or edits another group. +Gate duration `0` removes a gate at the selected alignment boundary. -## Scheduling a launch at the root +## Reset behavior -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: +- `amy.send(sequence_reset=tag)` removes the future legacy/root event and the + future reusable definition for that tag. Active immutable executions finish. +- `RESET_TIMEBASE` discards active/pending executions because their absolute + activation ticks cannot be rebased, but retains stored definitions. +- `RESET_SEQUENCER` retains its global meaning: it clears root events, reusable + definitions, and active/pending executions. -```text -H960,0,40zQ1,1,1,0Z -``` +## Capacity and realtime behavior + +`max_sequencer_tags` bounds the shared tag space. `max_sequence_events` bounds +the number of events in one reusable definition, and +`max_sequence_executions` independently bounds active or alignment-pending +executions. Definitions are allocated only for tags which use them, and +inactive definitions are not scanned on each tick. + +Starts fail clearly when the execution pool is full. Cyclic sequence launches +cannot allocate beyond that fixed pool and can be recovered with targeted stop +commands or `RESET_SEQUENCER`. -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. 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. +See the [implementation model](sequencer-sequences-abstractions.md), +[musical use cases](sequencer-sequences-musical-use-cases.md), and +[step-by-step examples](sequencer-sequences-howto.md). diff --git a/docs/synth.md b/docs/synth.md index d3159403..6f2ec886 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -241,20 +241,20 @@ 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)`](api.md) to any function. This will be called at every tick with the current tick number as an argument. -### Reusable sequencer groups +### Reusable tagged sequences -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. +An existing sequencer tag can explicitly hold several ordinary events with +local tick values. `amy.define_sequence(tag, events)` replaces that reusable +definition, while legacy three-value `ticks=(tick, period, tag)` retains its +single-event replace behavior. `sequence_control` starts, stops, aligns, or +temporarily gates an active tagged sequence. Component periods define looping; +a definition containing only period-zero events finishes after its last event. -See [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). +See [Reusable sequencer sequences](sequencer-sequences.md) for the concise API +and lifecycle reference. The accompanying guides explain the +[abstractions and implementation](sequencer-sequences-abstractions.md), +[musical use cases](sequencer-sequences-musical-use-cases.md), and a +[step-by-step Python example](sequencer-sequences-howto.md). ## Core oscillators @@ -491,4 +491,3 @@ amy.send(osc=0, wave=amy.PCM_LEFT, preset=1024, pan=0, note=72, vel=1) # play ba amy.send(osc=1, wave=amy.PCM_RIGHT, preset=1024, pan=1, note=72, vel=1) ``` - From 29aa50a8d2f35ff1b1e2d355f30cf8f022833ba4 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 18:18:07 +0200 Subject: [PATCH 16/52] Make sequencer tags cumulative sequences --- amy/__init__.py | 16 ++-- src/amy_api.generated.js | 150 +++++++++++++++---------------- src/parse.c | 26 +----- src/sequencer.c | 70 +++++++-------- src/sequencer.h | 18 ++-- tests/test_sequence_api.py | 10 +-- tests/test_sequencer_active.c | 33 +++---- tests/test_sequencer_bounds.c | 21 ++--- tests/test_sequencer_sequences.c | 101 ++++++++++----------- 9 files changed, 193 insertions(+), 252 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index 7239cca7..7ae89f94 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -242,9 +242,8 @@ def str_of_int(arg): _KW_MAP_LIST = [ # Order matters because patch_string must come last. # Sequence/ticks headers must come first: 'H' is only recognized as the - # first wire character. sequence_control follows a ticks/sequence_event + # first wire character. sequence_control follows a ticks # header when it is used as that scheduled event's payload. - ('sequence_event', 'HAL'), ('ticks', 'HL'), ('osc', 'vI'), ('wave', 'wI'), ('note', 'nF'), ('vel', 'lF'), ('amp', 'aC'), ('freq', 'fC'), ('duty', 'dC'), ('feedback', 'bF'), ('reset', 'SI'), ('phase', 'PF'), ('sample_offset', 'poI'), ('fit', 'pFF'), ('fit_search', 'pSI'), ('pan', 'QC'), ('client', 'gI'), @@ -301,14 +300,14 @@ def message(**kwargs): if 'wave' not in kwargs or kwargs['wave'] != BYO_PARTIALS: raise ValueError('\'num_partials\' must be used with \'wave\'=BYO_PARTIALS.') - outer_sequence_keys = {'sequence_event', 'ticks', 'sequence_reset'} & kwargs.keys() + outer_sequence_keys = {'ticks', 'sequence_reset'} & kwargs.keys() if len(outer_sequence_keys) > 1: - raise ValueError('Use only one of sequence_event, sequence_reset, or ticks in a message.') + raise ValueError('Use only one of sequence_reset or ticks in a message.') if 'sequence_reset' in kwargs and len(kwargs) != 1: raise ValueError('sequence_reset must be sent as a standalone message.') if ('sequence_control' in kwargs and len(kwargs) != 1 - and not ({'sequence_event', 'ticks'} & kwargs.keys())): - raise ValueError('sequence_control can only be combined with ticks or sequence_event.') + and 'ticks' not in kwargs): + raise ValueError('sequence_control can only be combined with ticks.') # Validity check all the passed args. prioritized_keys = [] @@ -423,13 +422,12 @@ def define_sequence(tag, events): values = dict(event) if 'ticks' not in values: raise ValueError('Every stored sequence event needs a ticks value.') - if {'sequence_event', 'sequence_reset'} & values.keys(): + if 'sequence_reset' in values: raise ValueError('Stored sequence events cannot contain sequence authoring commands.') tick, period = _sequence_ticks(values.pop('ticks')) if not values: raise ValueError('Every stored sequence event needs an AMY payload.') - event_messages.append(message( - sequence_event=(sequence_tag, tick, period), **values)) + event_messages.append(message(ticks=(tick, period, sequence_tag), **values)) send_raw(message(sequence_reset=sequence_tag)) for event_message in event_messages: diff --git a/src/amy_api.generated.js b/src/amy_api.generated.js index 99f391cf..f8a7311e 100644 --- a/src/amy_api.generated.js +++ b/src/amy_api.generated.js @@ -4,7 +4,6 @@ "use strict"; var AMY_KW_MAP = { - sequence_event: {wire: "HA", type: "L"}, ticks: {wire: "H", type: "L"}, osc: {wire: "v", type: "I"}, wave: {wire: "w", type: "I"}, @@ -82,81 +81,80 @@ var AMY_KW_MAP = { }; var AMY_KW_PRIORITY = { - sequence_event: 0, - ticks: 1, - osc: 2, - wave: 3, - note: 4, - vel: 5, - amp: 6, - freq: 7, - duty: 8, - feedback: 9, - reset: 10, - phase: 11, - sample_offset: 12, - fit: 13, - fit_search: 14, - pan: 15, - client: 16, - volume: 17, - pitch_bend: 18, - filter_freq: 19, - resonance: 20, - bp0: 21, - bp1: 22, - eg0: 23, - eg1: 24, - eg0_type: 25, - eg1_type: 26, - debug: 27, - chained_osc: 28, - mod_source: 29, - eq: 30, - filter_type: 31, - ratio: 32, - latency_ms: 33, - dist_clip: 34, - dist_fold: 35, - dist_crush: 36, - dist_drive: 37, - dist_mix: 38, - algo_source: 39, - load_sample: 40, - transfer_file: 41, - disk_sample: 42, - algorithm: 43, - chorus: 44, - reverb: 45, - echo: 46, - patch: 47, - sequence_reset: 48, - sequence_control: 49, - external_channel: 50, - portamento: 51, - tempo: 52, - sequencer_run: 53, - external_midi_sync: 54, - synth: 55, - pedal: 56, - synth_flags: 57, - num_voices: 58, - oscs_per_voice: 59, - synth_level: 60, - to_synth: 61, - grab_midi_notes: 62, - note_source_channel: 63, - synth_delay: 64, - preset: 65, - num_partials: 66, - start_sample: 67, - stop_sample: 68, - bus: 69, - mode: 70, - midi_cc: 71, - midi_note_cmd: 72, - cv_trigger: 73, - patch_string: 74 + ticks: 0, + osc: 1, + wave: 2, + note: 3, + vel: 4, + amp: 5, + freq: 6, + duty: 7, + feedback: 8, + reset: 9, + phase: 10, + sample_offset: 11, + fit: 12, + fit_search: 13, + pan: 14, + client: 15, + volume: 16, + pitch_bend: 17, + filter_freq: 18, + resonance: 19, + bp0: 20, + bp1: 21, + eg0: 22, + eg1: 23, + eg0_type: 24, + eg1_type: 25, + debug: 26, + chained_osc: 27, + mod_source: 28, + eq: 29, + filter_type: 30, + ratio: 31, + latency_ms: 32, + dist_clip: 33, + dist_fold: 34, + dist_crush: 35, + dist_drive: 36, + dist_mix: 37, + algo_source: 38, + load_sample: 39, + transfer_file: 40, + disk_sample: 41, + algorithm: 42, + chorus: 43, + reverb: 44, + echo: 45, + patch: 46, + sequence_reset: 47, + sequence_control: 48, + external_channel: 49, + portamento: 50, + tempo: 51, + sequencer_run: 52, + external_midi_sync: 53, + synth: 54, + pedal: 55, + synth_flags: 56, + num_voices: 57, + oscs_per_voice: 58, + synth_level: 59, + to_synth: 60, + grab_midi_notes: 61, + note_source_channel: 62, + synth_delay: 63, + preset: 64, + num_partials: 65, + start_sample: 66, + stop_sample: 67, + bus: 68, + mode: 69, + midi_cc: 70, + midi_note_cmd: 71, + cv_trigger: 72, + patch_string: 73 }; var AMY_COEF_FIELDS = ["const", "note", "vel", "eg0", "eg1", "mod0", "bend", "ext0", "ext1", "mod1"]; diff --git a/src/parse.c b/src/parse.c index fdc20c68..f5cfb6d1 100644 --- a/src/parse.c +++ b/src/parse.c @@ -711,27 +711,9 @@ size_t yield_event_from_message(char *message, amy_event *e, size_t pos) { void handle_ticks_message(char *message) { assert(message[0] == 'H'); if (message[1] == 'A') { - // HAsequence_tag,tick,period: explicitly append one ordinary - // event to a reusable sequence. The sequence tag is the same public - // identity space used by legacy root ticks events. - uint32_t values[3] = {0, 0, 0}; - int count = parse_list_uint32_t(message + 2, values, 3, 0); - uint16_t header_len = 2 + _next_alpha(message + 2); - if (count != 3) { - fprintf(stderr, - "invalid sequence event: expected " - "HAsequence_tag,tick,period\n"); - return; - } - char *payload = message + header_len; - size_t payload_len = strlen(payload); - char *copy = (char *)malloc_caps(payload_len + 1, - amy_global.config.ram_caps_events); - if (copy == NULL) amy_oom("sequence_event"); - else { - memcpy(copy, payload, payload_len + 1); - sequencer_sequence_add_wire(values[0], values[1], values[2], copy); - } + fprintf(stderr, + "invalid ticks command: HA is not needed; append with " + "Htick,period,tag\n"); return; } if (message[1] == 'C') { @@ -757,7 +739,7 @@ void handle_ticks_message(char *message) { return; } if (message[1] == 'R') { - // HRtag: clear future root/stored events for this tag. Already-active + // HRtag: clear the future stored events for this tag. Already-active // immutable sequence executions are intentionally unaffected. uint32_t values[1] = {0}; int count = parse_list_uint32_t(message + 2, values, 1, 0); diff --git a/src/sequencer.c b/src/sequencer.c index f812ef49..ffde3932 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -21,10 +21,10 @@ typedef struct sequence_info_t { int32_t next_active; } sequence_info_t; -struct sequence_info_t *sequences = NULL; // An array indexed by tag. +struct sequence_info_t *sequences = NULL; // Anonymous direct-schedule slots. int32_t max_sequences = 0; // Number of user-addressable tags. -// Head of the ascending list of occupied slots (user tags and anonymous -// entries alike); -1 when nothing is scheduled. This replaces `highest_tag`, +// Head of the ascending list of occupied anonymous slots; -1 when nothing is +// scheduled. This replaces `highest_tag`, // which was a HIGH-WATER MARK: it only ever grew, so one event at a high tag // made every tick scan that far for the rest of the session, long after that // sequence was cleared. The anonymous pool made that the common case, not a @@ -33,11 +33,8 @@ int32_t max_sequences = 0; // Number of user-addressable tags. // end of the table permanently. The cost is proportional to what is // scheduled now. int32_t first_active = -1; -// Anonymous (no-tag) entries live past the user-addressable tag range, at -// indices [max_sequences .. max_sequences+AMY_ANON_SEQUENCE_SLOTS), so a -// user-supplied tag (bounds-checked against max_sequences) can never reach -// or clobber one. Allocated round-robin; a new anonymous entry silently -// evicts the oldest one once the pool wraps around. +// Anonymous (no-tag) entries have their own fixed pool. Allocated round-robin; +// a new anonymous entry silently evicts the oldest once the pool wraps. #define AMY_ANON_SEQUENCE_SLOTS 256 static int32_t anon_cursor = 0; static volatile bool sequencer_running = true; @@ -240,10 +237,9 @@ void sequencer_init(int max_sequencer_tags, uint32_t sequence_events, wire_firing = false; anon_cursor = 0; max_sequences = max_sequencer_tags; - int32_t total_slots = max_sequences + AMY_ANON_SEQUENCE_SLOTS; - sequences = (struct sequence_info_t *)malloc_caps(total_slots * sizeof(struct sequence_info_t), + sequences = (struct sequence_info_t *)malloc_caps(AMY_ANON_SEQUENCE_SLOTS * sizeof(struct sequence_info_t), amy_global.config.ram_caps_synth); - for (int32_t i = 0; i < total_slots; ++i) { + for (int32_t i = 0; i < AMY_ANON_SEQUENCE_SLOTS; ++i) { sequences[i].wire = NULL; sequences[i].tick = 0; sequences[i].period = 0; @@ -258,7 +254,7 @@ void sequencer_init(int max_sequencer_tags, uint32_t sequence_events, void sequencer_reset() { // Remove all events (tagged and anonymous). No lock here: this is called // from play_delta() (RESET_SEQUENCER), which already runs under the amy lock. - for (int32_t i = 0; i < max_sequences + AMY_ANON_SEQUENCE_SLOTS; ++i) { + for (int32_t i = 0; i < AMY_ANON_SEQUENCE_SLOTS; ++i) { if (sequences[i].wire) { free(sequences[i].wire); sequences[i].wire = NULL; @@ -294,8 +290,10 @@ void sequencer_debug() { fprintf(stderr, "sequencer: max_sequences %" PRIi32" active %" PRIi32 "\n", max_sequences, n_active); for (int32_t tag = first_active; tag != -1; tag = sequences[tag].next_active) { if (sequences[tag].wire) { - fprintf(stderr, "sequence tag %" PRIi32"%s tick %" PRIu32 " period %"PRIu32 " wire \"%s\"\n", - tag, tag >= max_sequences ? " (anon)" : "", sequences[tag].tick, sequences[tag].period, sequences[tag].wire); + fprintf(stderr, "anonymous sequence slot %" PRIi32 " tick %" PRIu32 + " period %" PRIu32 " wire \"%s\"\n", + tag, sequences[tag].tick, sequences[tag].period, + sequences[tag].wire); } } } @@ -366,10 +364,10 @@ void sequencer_recompute() { // Store a wire message in the sequencer. Takes ownership of wire (malloc'd). // // has_tag false means tag wasn't supplied by the caller (a 1- or 2-value -// ticks= form): the entry is allocated round-robin from the anonymous pool -// instead of the given tag value, so it's stored but not addressable or -// individually cancelable. has_tag true is the normal tag-indexed form: tick -// and period both zero clears that tag's entry (the only way to cancel one). +// ticks= form): the entry is allocated round-robin from the anonymous pool, so +// it is stored but not addressable or individually cancelable. has_tag true +// appends to the reusable definition at that tag; an empty tick-zero message +// resets the definition. // // A one-off whose tick is already due or overdue is not stored at all -- it // plays immediately, before returning. See the comment at that branch. @@ -385,6 +383,18 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha free(wire); return 0; } + // Tagged ticks are the events of the reusable sequence identified by + // that tag. Repeating a tag therefore accumulates events, matching + // the way repeated synth= messages build one synth. The historical + // empty H0,0,tag form remains a convenient spelling for per-tag reset; + // with a payload, tick zero is an ordinary (and essential) local + // one-shot event. + if (tick == 0 && period == 0 + && (wire == NULL || wire[0] == '\0' || wire[0] == 'Z')) { + free(wire); + return sequencer_sequence_reset(tag); + } + return sequencer_sequence_add_wire(tag, tick, period, wire); } else { // Anonymous: tick==0 && period==0 has nothing to cancel (no tag was // given), so just drop it rather than allocating a slot for a no-op. @@ -392,18 +402,11 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha free(wire); return 0; } - tag = (uint32_t)(max_sequences + anon_cursor); + tag = (uint32_t)anon_cursor; anon_cursor = (anon_cursor + 1) % AMY_ANON_SEQUENCE_SLOTS; } amy_grab_lock(); - // A public tag identifies one future sequencer object. A legacy tagged - // write therefore replaces any reusable definition at the same tag; an - // execution which already retained that definition can still finish. - if (has_tag && stored_sequences != NULL) { - stored_sequence_definition_release(stored_sequences[tag].definition); - stored_sequences[tag].definition = NULL; - } - // Release any existing message for this tag, even if we're just going to rewrite it. + // Reuse the selected anonymous slot, evicting its previous message. if (sequences[tag].wire) free(sequences[tag].wire); sequences[tag].wire = NULL; sequences[tag].tick = 0; @@ -482,14 +485,6 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, } amy_grab_lock(); - // Explicit cumulative sequence authoring and legacy root scheduling share - // one tag identity. Appending a stored event removes any future root event - // at that tag, while unrelated tags are untouched. - if (sequences[tag].wire != NULL) free(sequences[tag].wire); - sequences[tag].wire = NULL; - sequences[tag].tick = 0; - sequences[tag].period = 0; - active_unlink((int32_t)tag); stored_sequence_definition_t *definition = slot->definition; if (definition == NULL) { definition = stored_sequence_definition_new(); @@ -546,11 +541,6 @@ uint8_t sequencer_sequence_reset(uint32_t tag) { } amy_grab_lock(); - if (sequences[tag].wire != NULL) free(sequences[tag].wire); - sequences[tag].wire = NULL; - sequences[tag].tick = 0; - sequences[tag].period = 0; - active_unlink((int32_t)tag); stored_sequence_definition_release(slot->definition); slot->definition = NULL; amy_release_lock(); diff --git a/src/sequencer.h b/src/sequencer.h index 876fd047..37a40c4e 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -17,19 +17,19 @@ void sequencer_check_and_fill(); // called once per block from amy_execute_delt void sequencer_check_and_call_js_hook(); // called from the browser main loop #endif // Store a wire message (with its leading 'H' already stripped) in the -// sequencer. If has_tag is true, it's stored under tag (replacing/clearing -// any existing entry there, addressable later by that same tag); clears the -// tag if tick and period are both 0. If has_tag is false, it's stored -// anonymously (round-robin in a small reserved pool) and can't be addressed -// or cancelled by any tag. Takes ownership of wire. +// sequencer. If has_tag is true, append it to the reusable sequence identified +// by tag. An empty tick=period=0 command clears that sequence; the same timing +// with a payload appends a local tick-zero event. If has_tag is false, store it +// anonymously (round-robin in a small reserved pool) for immediate sequencer +// playback. Takes ownership of wire. uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire); // Append one ordinary ticks event to the reusable sequence identified by tag. -// Takes ownership of wire. Unlike the legacy root ticks syntax, tick=period=0 -// is a valid one-shot event here. +// Takes ownership of wire. A tick=period=0 event is a valid one-shot when its +// wire payload is nonempty. uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, uint32_t period, char *wire); -// Clear the future root event and reusable definition at tag. Executions which -// already started retain their immutable definition and may finish. +// Clear the future definition at tag. Executions which already started retain +// their immutable definition and may finish. uint8_t sequencer_sequence_reset(uint32_t tag); // sequence_control is [tag, start_or_stop, alignment_period] or // [tag, gate, duration, alignment_period]. diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index 144354dc..7230994d 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -18,8 +18,8 @@ def expect_error(fragment, fn): def main(): - assert amy.message(sequence_event=(7, 0, 0), synth=1, note=60, vel=1) \ - == "HA7,0,0n60l1i1Z" + assert amy.message(ticks=(0, 0, 7), synth=1, note=60, vel=1) \ + == "H0,0,7n60l1i1Z" assert amy.message(sequence_control=(7, amy.SEQUENCE_CONTROL_START, 48)) \ == "HC7,1,48Z" assert amy.message(ticks=(0, 48, 3), @@ -41,12 +41,10 @@ def main(): amy.override_send = old_override assert sent == [ "HR7Z", - "HA7,0,0n60l1i1Z", - "HA7,3,8n60l0i1Z", + "H0,0,7n60l1i1Z", + "H3,8,7n60l0i1Z", ] - expect_error("only one", lambda: amy.message( - ticks=(0, 4, 1), sequence_event=(2, 0, 0), synth=1)) expect_error("standalone", lambda: amy.message(sequence_reset=2, synth=1)) expect_error("only be combined", lambda: amy.message( sequence_control=(2, 1), synth=1)) diff --git a/tests/test_sequencer_active.c b/tests/test_sequencer_active.c index 4fd7087b..e0895ca1 100644 --- a/tests/test_sequencer_active.c +++ b/tests/test_sequencer_active.c @@ -1,21 +1,8 @@ -// The sequencer's per-tick cost should track what is SCHEDULED, not what -// tag number happened to be used. -// -// sequencer_process_tick() used to sweep 0..highest_tag, and highest_tag -// was a high-water mark that only ever grew — cleared sequences never -// brought it down. So one event parked at a high tag made every tick -// scan that far for the rest of the session, and raising -// max_sequencer_tags made the worst case proportionally worse. The -// anonymous pool made this the common case, not a corner: anonymous -// ticks= entries are allocated round-robin at indices past -// max_sequences, so a burst of one-shots pinned the mark at the very -// end of the table permanently. The occupied slots are threaded through -// the table as an ascending list now. -// -// The headline check here is an INVARIANT rather than a benchmark: one -// sequence at tag 0 and one sequence at tag max-1 must cost the same, -// because both are one sequence. Under the old sweep the second cost -// ~max times the first. +// The sequencer's per-tick cost should track active work, not the numeric value +// of a public tag. Tagged definitions are stored separately from the small +// anonymous direct-scheduling pool, and active executions occupy a bounded +// pool. Consequently one sequence at tag 0 and one at tag max-1 have the same +// scan cost. // // Build/run with `make ctest`. @@ -53,10 +40,12 @@ static void seq_note_on(int32_t tag, int osc) { e.ticks[TICKS_PERIOD] = 16; e.ticks[TICKS_TAG] = (uint32_t)tag; amy_add_event(&e); + sequencer_sequence_control((uint32_t)tag, SEQUENCE_CONTROL_START, 0, 0); } -// Clearing is a send to the same tag with neither tick nor period. +// Stop active playback, then clear the future definition. static void seq_clear(int32_t tag) { + sequencer_sequence_control((uint32_t)tag, SEQUENCE_CONTROL_STOP, 0, 0); amy_event e = amy_default_event(); e.ticks[TICKS_TICK] = 0; e.ticks[TICKS_PERIOD] = 0; @@ -105,10 +94,8 @@ static void test_out_of_order_and_clear(void) { all_off(); } -// Anonymous entries (1- or 2-value ticks=, no tag) live past the user tag -// range. They should fire once, disappear, and — with the active list — -// leave no lasting per-tick cost behind. Under the old sweep, one -// anonymous entry pinned the scan at the far end of the table forever. +// Anonymous entries (1- or 2-value ticks=, no tag) use a separate pool. They +// should fire once, disappear, and leave no lasting per-tick cost behind. static void test_anonymous_one_shots(void) { printf("anonymous one-shots fire once and leave the list empty\n"); sequencer_reset(); diff --git a/tests/test_sequencer_bounds.c b/tests/test_sequencer_bounds.c index 960e3989..ba886364 100644 --- a/tests/test_sequencer_bounds.c +++ b/tests/test_sequencer_bounds.c @@ -1,13 +1,12 @@ // Regression test for the sequencer tag bounds check. // -// User-addressable tags index `sequences[0 .. max_sequences-1]`, and the -// anonymous pool lives immediately after, at -// [max_sequences .. max_sequences+AMY_ANON_SEQUENCE_SLOTS). An earlier +// User-addressable tags once indexed `sequences[0 .. max_sequences-1]`, with +// the anonymous pool immediately after it. An earlier // version of the sequencer guarded with `tag > max_sequences` (and read // the tag into an int32_t), which let tag == max_sequences write one // entry past the user range — in those days one element past the whole -// allocation, a heap overflow; today it would silently clobber an -// anonymous entry instead. sequencer_add_wire() now checks +// allocation, a heap overflow. Tagged definitions and anonymous direct events +// now use separate storage, and sequencer_add_wire() still checks // `tag >= (uint32_t)max_sequences` unsigned, which also disposes of the // negative-reindex case: a tag past INT32_MAX stays a huge unsigned // value and fails the same compare, so it can never index backwards. @@ -76,16 +75,14 @@ static int audible(int osc) { return synth[osc] != NULL && synth[osc]->status == SYNTH_AUDIBLE; } -// Whether a tag was accepted is observable two ways: the sequence fires -// (osc goes audible), and something is in the active list at all. -extern int32_t first_active; - static int accepted(uint32_t tag) { sequencer_reset(); seq_note_on_at_tag(tag, 0); + int scheduled = sequencer_sequence_control( + tag, SEQUENCE_CONTROL_START, 0, 0); advance_secs(0.5); int fired = audible(0); - int scheduled = (first_active != -1); + sequencer_sequence_control(tag, SEQUENCE_CONTROL_STOP, 0, 0); seq_clear(tag); all_off(); sequencer_reset(); @@ -110,9 +107,7 @@ static void test_tag_bounds(void) { CHECK(!accepted(0x80000000u), "a tag past INT32_MAX is rejected"); } -// An out-of-range user tag must not clobber the anonymous pool that sits -// right past the user range. Occupy anonymous slot 0 (the entry a -// too-lenient check would land tag==max on), then try to overwrite it. +// An out-of-range user tag must not affect the separate anonymous pool. static void test_no_anon_clobber(void) { printf("an out-of-range tag can't clobber an anonymous entry\n"); sequencer_reset(); diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 9d057b37..644d2cfd 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -56,28 +56,26 @@ static int marks_named(const char *name) { return count; } -static void test_legacy_ticks_are_unchanged(void) { - printf("legacy root ticks remain unchanged\n"); +static void test_untagged_ticks_and_cumulative_tags(void) { + printf("untagged root ticks and cumulative tagged sequences\n"); sequencer_reset(); clear_marks(); uint32_t first = next_boundary(sequencer_ticks(), 4); - amy_add_message("H0,4,0zProotZ"); + amy_add_message("H0,4zProotZ"); clock_to(first + 4); CHECK(mark_at("root", first), "periodic root event fires at global modulo"); CHECK(mark_at("root", first + 4), "periodic root event keeps looping"); - amy_add_message("H0,0,0Z"); + sequencer_reset(); 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 tagged writes still replace rather than accumulate"); + amy_add_message("H0,0,9zPfirstZ"); + amy_add_message("H2,0,9zPsecondZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC9,1,4Z"); + clock_to(start + 2); + CHECK(mark_at("first", start) && mark_at("second", start + 2), + "repeating a tag cumulates ordinary events into one sequence"); } static void test_legacy_c_event_wire_is_unchanged(void) { @@ -98,8 +96,8 @@ static void test_explicit_append_and_one_shot_lifetime(void) { printf("explicit sequence events accumulate and finite events retire\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA10,0,0zPzeroZ"); - amy_add_message("HA10,2,0zPtwoZ"); + amy_add_message("H0,0,10zPzeroZ"); + amy_add_message("H2,0,10zPtwoZ"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC10,1,4Z"); clock_to(start + 4); @@ -109,39 +107,34 @@ static void test_explicit_append_and_one_shot_lifetime(void) { "period-zero sequence events fire once and execution retires"); } -static void test_root_and_stored_forms_share_one_tag_identity(void) { - printf("legacy and reusable forms share one public tag identity\n"); +static void test_empty_tick_zero_is_reset_but_payload_is_an_event(void) { + printf("empty tick-zero reset remains distinct from a tick-zero event\n"); sequencer_reset(); clear_marks(); - amy_add_message("H0,4,10zProot-replacedZ"); - amy_add_message("HA10,0,0zPstoredZ"); + amy_add_message("H0,0,10zPstoredZ"); + amy_add_message("H0,0,10Z"); + CHECK(!sequencer_sequence_control(10, SEQUENCE_CONTROL_START, 0, 0), + "an empty H0,0,tag resets that tag"); + amy_add_message("H0,0,10zPstoredZ"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC10,1,4Z"); - clock_to(start + 4); - CHECK(mark_at("stored", start) && !marks_named("root-replaced"), - "explicit append replaces the root object at the same tag"); - - amy_add_message("H0,4,10zProotZ"); - CHECK(!sequencer_sequence_control(10, SEQUENCE_CONTROL_START, 0, 0), - "legacy replacement removes the future stored definition"); - clear_marks(); - uint32_t root = next_boundary(sequencer_ticks(), 4); - clock_to(root); - CHECK(mark_at("root", root), "the replacement legacy event remains active"); + clock_to(start); + CHECK(mark_at("stored", start), + "H0,0,tag with a payload is a local tick-zero event"); } static void test_active_definition_is_immutable(void) { printf("active executions retain the definition they started with\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA11,0,0zPold-headZ"); - amy_add_message("HA11,4,0zPold-tailZ"); + amy_add_message("H0,0,11zPold-headZ"); + amy_add_message("H4,0,11zPold-tailZ"); uint32_t old_start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC11,1,4Z"); clock_to(old_start + 2); amy_add_message("HR11Z"); - amy_add_message("HA11,0,0zPnew-headZ"); + amy_add_message("H0,0,11zPnew-headZ"); clock_to(old_start + 4); CHECK(mark_at("old-tail", old_start + 4), "resetting future contents does not remove an old note release"); @@ -159,10 +152,10 @@ static void test_root_launches_local_zero_on_same_tick(void) { printf("root events can launch stored sequences\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA12,0,0zPchild-zeroZ"); + amy_add_message("H0,0,12zPchild-zeroZ"); uint32_t start = sequencer_ticks() + 4; char wire[96]; - snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,1HC12,1,0Z", start); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HC12,1,0Z", start); amy_add_message(wire); clock_to(start); CHECK(mark_at("child-zero", start), @@ -173,13 +166,13 @@ static void test_overlapping_executions_need_no_host_identity(void) { printf("one sequence tag supports bounded overlapping executions\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA13,0,0zPonZ"); - amy_add_message("HA13,4,0zPoffZ"); + amy_add_message("H0,0,13zPonZ"); + amy_add_message("H4,0,13zPoffZ"); uint32_t first = next_boundary(sequencer_ticks(), 4); char wire[96]; - snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,2HC13,1,0Z", first); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HC13,1,0Z", first); amy_add_message(wire); - snprintf(wire, sizeof(wire), "H%" PRIu32 ",0,3HC13,1,0Z", first + 2); + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HC13,1,0Z", first + 2); amy_add_message(wire); clock_to(first + 6); CHECK(mark_at("on", first) && mark_at("on", first + 2), @@ -192,9 +185,9 @@ static void test_parent_stop_leaves_started_child_to_finish(void) { printf("stopping a parent prevents future children without truncating one\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA15,0,0zPnote-onZ"); - amy_add_message("HA15,4,0zPnote-offZ"); - amy_add_message("HA14,0,4HC15,1,0Z"); + amy_add_message("H0,0,15zPnote-onZ"); + amy_add_message("H4,0,15zPnote-offZ"); + amy_add_message("H0,4,14HC15,1,0Z"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC14,1,4Z"); clock_to(start + 2); @@ -211,9 +204,9 @@ static void test_controller_sequence_bounds_repetition(void) { printf("a finite controller sequence can bound a periodic child\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA8,0,4zPpulseZ"); - amy_add_message("HA7,0,0HC8,1,0Z"); - amy_add_message("HA7,12,0HC8,0,0Z"); + amy_add_message("H0,4,8zPpulseZ"); + amy_add_message("H0,0,7HC8,1,0Z"); + amy_add_message("H12,0,7HC8,0,0Z"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC7,1,4Z"); clock_to(start + 14); @@ -228,7 +221,7 @@ static void test_finite_gate_preserves_phase(void) { printf("finite event gating preserves the target phase\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA6,0,4zPbeatZ"); + amy_add_message("H0,4,6zPbeatZ"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC6,1,4Z"); clock_to(start); @@ -245,7 +238,7 @@ static void test_per_tag_and_global_reset_semantics(void) { printf("per-tag replacement and global reset have distinct scopes\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA5,0,0zPsurvivorZ"); + amy_add_message("H0,0,5zPsurvivorZ"); amy_add_message("HC5,1,0Z"); uint32_t start = sequencer_ticks() + 1; amy_add_message("HR5Z"); @@ -255,7 +248,7 @@ static void test_per_tag_and_global_reset_semantics(void) { CHECK(!sequencer_sequence_control(5, SEQUENCE_CONTROL_START, 0, 0), "per-tag reset removed the future definition"); - amy_add_message("HA5,0,4zPclearedZ"); + amy_add_message("H0,4,5zPclearedZ"); amy_add_message("HC5,1,0Z"); sequencer_reset(); CHECK(!sequencer_sequence_control(5, SEQUENCE_CONTROL_START, 0, 0), @@ -266,7 +259,7 @@ static void test_timebase_reset_keeps_definitions(void) { printf("timebase reset drops runtime but keeps definitions\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA4,0,0zPafter-rebaseZ"); + amy_add_message("H0,0,4zPafter-rebaseZ"); amy_add_message("HC4,1,0Z"); sequencer_sequence_reset_timebase(); clock_to(sequencer_ticks() + 2); @@ -287,7 +280,7 @@ static void test_bounds_and_validation(void) { "tick equal to period is rejected"); CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("")), "empty payload is rejected"); - CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("HA1,0,0zPbadZ")), + CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("H0,0,1zPbadZ")), "stored sequences cannot edit definitions recursively"); for (uint32_t i = 0; i < 8; ++i) { @@ -311,8 +304,8 @@ static void test_start_crosses_clock_rollover(void) { printf("relative sequence phase crosses uint32 clock rollover\n"); sequencer_reset(); clear_marks(); - amy_add_message("HA2,0,0zPwrap-zeroZ"); - amy_add_message("HA2,2,0zPwrap-twoZ"); + amy_add_message("H0,0,2zPwrap-zeroZ"); + amy_add_message("H2,0,2zPwrap-twoZ"); amy_global.sequencer_tick_count = UINT32_MAX - 2; amy_add_message("HC2,1,4Z"); clock_to(2); @@ -351,10 +344,10 @@ int main(void) { config.max_sequence_executions = 8; amy_start(config); - test_legacy_ticks_are_unchanged(); + test_untagged_ticks_and_cumulative_tags(); test_legacy_c_event_wire_is_unchanged(); test_explicit_append_and_one_shot_lifetime(); - test_root_and_stored_forms_share_one_tag_identity(); + test_empty_tick_zero_is_reset_but_payload_is_an_event(); test_active_definition_is_immutable(); test_root_launches_local_zero_on_same_tick(); test_overlapping_executions_need_no_host_identity(); From 06309fa988cdec69c8e72362146704798ec24637 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 18:18:15 +0200 Subject: [PATCH 17/52] Document cumulative sequencer tags --- docs/api.md | 7 +- docs/billie_jean.md | 2 +- docs/midi.md | 8 +- docs/sequencer-sequences-abstractions.md | 107 +++++++++-------------- docs/sequencer-sequences-howto.md | 68 +++++--------- docs/sequencer-sequences.md | 89 ++++++++----------- docs/synth.md | 21 ++--- docs/tutorial.html | 17 ++-- 8 files changed, 128 insertions(+), 191 deletions(-) diff --git a/docs/api.md b/docs/api.md index a68cdbbc..b59b8e9c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -203,7 +203,7 @@ amy_start(amy_config); | `write_samples_fn` | fn ptr | `NULL` | If provided, `amy_update` will call this with each new block of samples | | `max_oscs` | Int | 180 | How many oscillators to support | | `max_buses` | Int | 4 | How many FX buses to support. No compile-time ceiling — every bus-indexed table is allocated from this at `amy_start`. Each bus costs a few KB of mix buffers even when idle, plus whatever its effects allocate once switched on | -| `max_sequencer_tags` | Int | 256 | Size of the tag space shared by legacy root events and reusable sequences | +| `max_sequencer_tags` | Int | 256 | Number of reusable sequencer tag identities | | `max_sequence_events` | Int | 64 | Maximum ordinary events in one reusable tagged sequence | | `max_sequence_executions` | Int | 32 | Maximum active or alignment-pending reusable-sequence executions | | `max_voices` | Int | 64 | How many voices | @@ -505,9 +505,8 @@ 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]] | Existing tick, period and tag scheduling. `tag` omitted: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. A legacy tagged write keeps its replace-by-tag behavior. **If used in a wire string message**, the `H` **must** be the first character of the message. | -| `HA` | — | `sequence_event` | tag,tick,period | Explicitly append an ordinary event to a [reusable tagged sequence](sequencer-sequences.md). Prefer `amy.define_sequence()` in Python. | -| `HR` | — | `sequence_reset` | tag | Clear the future root event and reusable definition at one tag; already-started immutable executions may finish. | +| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | `tag` omitted: schedule directly on the global clock. `tag` supplied: append to that reusable sequence using local ticks; repeating a tag cumulates. **If used in a wire string message**, the `H` **must** be the first character of the message. | +| `HR` | — | `sequence_reset` | tag | Clear the future definition at one tag; already-started immutable executions may finish. | | `HC` | — | `sequence_control` | tag,start-or-stop[,alignment] or tag,gate,duration[,alignment] | Start, stop, align, or temporarily gate a reusable tagged sequence. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | | `zY` | **TODO** | `sequencer_run` | 0/1 | Sequencer transport: `zY1` starts the sequencer, `zY0` stops it. Lets a host drive playback without MIDI clock sync (see `external_midi_sync`). | diff --git a/docs/billie_jean.md b/docs/billie_jean.md index 56a38cad..3ec4ea56 100644 --- a/docs/billie_jean.md +++ b/docs/billie_jean.md @@ -293,7 +293,7 @@ timed_note chord_notes[] = { }; ``` -We have a new function that takes an entire table of `timed_notes` along with a starting sequencer tick and a channel (synth), and schedules them all, including note-offs if the table includes nonzero note durations. The scheduling itself is the `ticks` field of the `amy_event` structure: setting `e.ticks[0]` to an absolute sequencer tick makes AMY hold the event and play it when its clock reaches that tick. (The `ticks` field can also describe repeating patterns - `e.ticks[1]` is a repeat period and `e.ticks[2]` a tag you can use to replace or cancel an entry - but here we only need the one-shot absolute-tick form.) The sequencer counts 48 ticks per quarter note, and each “tick” of our pattern tables is an eighth note, so we convert between the two with `amy_ticks_per_tick = 24`. +We have a new function that takes an entire table of `timed_notes` along with a starting sequencer tick and a channel (synth), and schedules them all, including note-offs if the table includes nonzero note durations. The scheduling itself is the `ticks` field of the `amy_event` structure: setting `e.ticks[0]` to an absolute sequencer tick makes AMY hold the event and play it when its clock reaches that tick. (The `ticks` field can also describe repeating patterns with `e.ticks[1]`, while `e.ticks[2]` adds the event to a reusable tagged sequence; here we only need the untagged one-shot absolute-tick form.) The sequencer counts 48 ticks per quarter note, and each “tick” of our pattern tables is an eighth note, so we convert between the two with `amy_ticks_per_tick = 24`. ```C float amy_ticks_per_tick = 24.0f; diff --git a/docs/midi.md b/docs/midi.md index 6ef8988e..0dcd6643 100644 --- a/docs/midi.md +++ b/docs/midi.md @@ -81,9 +81,9 @@ Because an `AMY_MIDI` osc emits MIDI in response to ordinary note events, you ca amy.send(osc=0, wave=amy.AMY_MIDI) # set up the MIDI sender once # Send a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note. -amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # note on at tick 0 of each 48-tick period -amy.send(osc=0, note=60, vel=0, ticks="24,48,2") # note off at tick 24 of each 48-tick period +amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # both events accumulate behind tag 1 +amy.send(osc=0, note=60, vel=0, ticks="24,48,1") +amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 48)) ``` -AMY keeps sending those MIDI messages out the port at the configured tempo until you remove them (by their `tag`) or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. - +AMY keeps sending those MIDI messages out the port at the configured tempo until you stop tag 1 or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 310d1c16..266dbd70 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -2,86 +2,61 @@ ## Public model -The public model has two ways to use the existing sequencer tag identity: +The existing sequencer tag is the sequence identity. Every ordinary +`ticks=(tick, period, tag)` message appends an event to that tag. The tag is +reset explicitly and controlled with one start/stop operation. There is no +second group namespace, separate append command, fourth `ticks` field, +explicit length, or publish/revision operation. -1. `ticks=(tick, period, tag)` keeps the established single-event behavior; -2. `define_sequence(tag, events)` explicitly gives that tag multiple local - events which can be started and stopped as a reusable sequence. - -There is no second public group ID, no local event-tag namespace, no fourth -`ticks` field, no explicit length, and no publish/revision command. - -`sequence_control` supplies the three generic runtime operations: +`sequence_control` provides: - start, optionally aligned to an AMY sequencer period; -- stop every active execution of the tag at an optional alignment boundary; +- stop all active executions of a tag at an optional boundary; - gate ordinary events for a finite duration without resetting local phase. -Sequences may start or stop other sequences. A finite controller sequence can -therefore express a fixed repeat count, and a parent can stop launching new -note-pair children while children already in progress deliver their note-offs. - -## Why executions still exist internally - -A stored definition and an active execution have different lifetimes even -though that distinction is not a second public API. An execution needs a local -start tick and must retain the event data it began with. Without that internal -separation, changing a future phrase could remove a note-off or alter a fill -which is already sounding. - -AMY therefore uses a small bounded execution pool and reference-counted, -copy-on-write definitions. Appending to a definition which an execution still -uses first clones it. The active execution keeps the old snapshot; later starts -see the updated contents. No revision number is exposed to callers. +Sequences may start or stop other sequences. A finite controller can therefore +express a fixed repeat count, and a parent can stop launching new note-pair +children while children already in progress deliver their note-offs. -Multiple finite executions of one tag may overlap. This is important for -ordinary musical phrases whose gate time is longer than the interval between -starts. The execution pool, rather than a caller-managed ID scheme, is the -bound. +## Why executions exist internally -## Lifetime inference +A stored definition and an active execution have different lifetimes without +being different public abstractions. An execution needs a local start tick and +must retain the event data it began with. Otherwise editing a future phrase +could remove a note-off or alter a fill already sounding. -The component events define lifetime: +AMY therefore uses a bounded execution pool and reference-counted copy-on-write +definitions. Editing a definition used by an execution clones it. The active +execution keeps its old snapshot; later starts see the new contents. No +revision number or execution ID is exposed. -- if every event has `period=0`, the execution retires after its greatest local - tick has been processed; -- if any event has a nonzero period, the execution remains active and evaluates - that event against elapsed local time until stopped. +Finite executions of one tag may overlap. This supports phrases whose note +gate exceeds their trigger interval without transferring note state to the +caller. -This avoids an independent length that could disagree with the ordinary -sequencer periods. A fixed number of repeats is composition: a finite parent -starts a periodic child and stops it at the required local tick. +## Lifetime inference and tick processing -## Tick processing +If every event has `period=0`, the execution retires after its greatest local +tick. If any event has a nonzero period, it remains active until stopped. -Only active root entries and active sequence executions are visited per tick. -Stored but inactive definitions have no per-tick cost. +Only untagged scheduled entries and active sequence executions are visited per +tick. Stored inactive definitions have no per-tick cost. Sequence controls are +processed before ordinary events, so a boundary stop prevents an event on that +boundary and a child start can include local tick zero on the same tick. -Sequence controls are processed before ordinary events for a tick. Consequently -a stop scheduled at a period boundary prevents the event on that boundary, and -a parent launch can make a child's local tick-zero event run on the launch tick. - -Temporary gating suppresses ordinary payload dispatch but advances elapsed -local time normally. Control events are not gated; otherwise a controller could -mute its own future stop or recovery operation. +Gating suppresses ordinary payload dispatch while elapsed local time advances. +Control events are not gated, preventing a controller from muting its own +recovery operation. ## Bounds and recovery -All storage is configured at startup: - -- `max_sequencer_tags`: shared public identities; -- `max_sequence_events`: maximum events in one stored definition; -- `max_sequence_executions`: active and pending executions. - -Definitions allocate event storage only when first used. The render path does -not perform unbounded allocation. A recursive or cyclic control graph can fill -the execution pool, but cannot grow past it; further starts fail and the caller -can stop a tag or reset the sequencer. - -## Compatibility boundary +Startup configuration bounds tags, events per definition, and simultaneous +executions. A cyclic control graph may fill the execution pool, but cannot grow +beyond it; later starts fail clearly and the caller can stop a tag or reset the +sequencer. -The legacy parser, C event layout, anonymous-event pool, modulo timing, -same-tag replacement, MIDI/external-clock behavior, and root active-list order -are unchanged. Reusable accumulation only occurs through the explicit sequence -API. Tests cover both the old path and the interaction between legacy and -reusable forms. +The ordinary three-field C event layout remains unchanged. Untagged one-off +and periodic scheduling, MIDI/external-clock behavior, and global reset retain +their existing behavior. The intentional API change is that a supplied tag now +creates a stopped reusable sequence and repeated writes cumulate instead of +replacing one scheduled event. diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index c2bf891b..88c49dab 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -1,13 +1,10 @@ # Reusable sequence how-to -This example preloads two simple arpeggios, launches them from the root -sequencer, and changes which one will launch without cutting short a note which -already started. +This example preloads two arpeggios and switches between them without cutting +short a note which already started. ## 1. Define note-pair sequences -Each finite child owns its note-on and note-off: - ```python amy.define_sequence(20, [ dict(ticks=(0,), synth=1, note=60, vel=1), @@ -21,8 +18,6 @@ amy.define_sequence(21, [ ## 2. Define two arpeggio parents -The parents contain only starts of their note-pair children: - ```python amy.define_sequence(30, [ dict(ticks=(0, 48), @@ -39,54 +34,44 @@ amy.define_sequence(31, [ ]) ``` -Because these parents contain periodic events, they run until stopped. +The parents contain periodic events and run until stopped. -## 3. Start the first arpeggio +## 3. Start and switch ```python amy.send(sequence_control=(30, amy.SEQUENCE_CONTROL_START, 48)) -``` - -The start is aligned to the next 48-tick boundary. -## 4. Switch parents - -```python +# Later, switch both parents at the same boundary. amy.send(sequence_control=(30, amy.SEQUENCE_CONTROL_STOP, 48)) amy.send(sequence_control=(31, amy.SEQUENCE_CONTROL_START, 48)) ``` -Both controls select the same next boundary. The old parent starts no more -children there. A note-pair child which started earlier remains independent and -still sends its tick-18 note-off. +The old parent starts no more children at that boundary. A note-pair child +started earlier remains independent and still sends its tick-18 note-off.
Equivalent low-level wire messages -The Python API above emits these sequence-authoring messages: - ```text HR20Z -HA20,0,0n60l1i1Z -HA20,18,0n60l0i1Z +H0,0,20n60l1i1Z +H18,0,20n60l0i1Z HR21Z -HA21,0,0n64l1i1Z -HA21,18,0n64l0i1Z +H0,0,21n64l1i1Z +H18,0,21n64l0i1Z HR30Z -HA30,0,48HC20,1,1Z -HA30,24,48HC21,1,1Z +H0,48,30HC20,1,1Z +H24,48,30HC21,1,1Z HR31Z -HA31,0,24HC20,1,1Z -HA31,12,24HC21,1,1Z +H0,24,31HC20,1,1Z +H12,24,31HC21,1,1Z HC30,1,48Z HC30,0,48Z HC31,1,48Z ``` -`HA` is the explicit cumulative event form, `HR` resets the future contents of -one tag, and `HC` controls a tagged sequence. They are all part of the -sequencer-oriented `H` family. Existing `Htick,period,tag...` messages retain -their original replace-by-tag behavior. +Ordinary `Htick,period,tag...` messages cumulate behind the tag. `HR` resets +one definition and `HC` controls its executions.
@@ -100,22 +85,13 @@ clock: amy.send(sequence_control=(50, amy.SEQUENCE_CONTROL_GATE, 48, 1)) ``` -After 48 ticks the gate expires automatically and events resume on their -original phase. Duration zero removes a current gate explicitly: +After 48 ticks the gate expires and events resume on their original phase. +Duration zero removes a current gate explicitly: ```python amy.send(sequence_control=(50, amy.SEQUENCE_CONTROL_GATE, 0, 1)) ``` -
-Equivalent low-level wire messages - -```text -HC50,2,48,1Z -HC50,2,0,1Z -``` - -
- -The source of these commands could be a foot pedal, UI, network controller, or -another sequence. AMY only sees generic tagged sequence control. +The equivalent wire messages are `HC50,2,48,1Z` and `HC50,2,0,1Z`. Their +source may be a foot pedal, UI, network controller, or another sequence; AMY +only sees generic tagged sequence control. diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index 37bcb289..fc006c3a 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -1,13 +1,12 @@ # Reusable sequencer sequences -AMY's existing sequencer tags can also identify reusable sequences. A reusable -sequence is a collection of ordinary AMY events with local `tick` and `period` -values. It can be started from Python, from the wire protocol, or from another -sequenced event. +A sequencer tag identifies a reusable sequence of ordinary AMY events. Sending +more than one event with the same tag accumulates those events, in the same way +that repeated `synth=` messages configure one synth. Tagged events use local +ticks and remain inactive until the sequence is started. -The ordinary three-value `ticks=(tick, period, tag)` API remains unchanged. A -legacy tagged write replaces the event at that tag. Multi-event accumulation is -always explicit. +Untagged `ticks` events keep their direct scheduling behavior on the global +sequencer clock. ## Defining a sequence @@ -20,28 +19,20 @@ amy.define_sequence(40, [ ]) ``` -Each event uses the normal AMY keyword arguments. Its `ticks` value is local to -the start of the sequence and contains `tick` plus an optional `period`. - -`define_sequence()` validates every event before sending anything. It then -performs a per-tag reset followed by explicit cumulative writes. If a sequence -may be launched while it is being rewritten, first remove or stop those future -launches. An execution which already started is safe: it retains the immutable -definition it started with, including later note-offs. - -Low-level callers can use `sequence_reset` and `sequence_event` directly: +Each event uses normal AMY keyword arguments. Its `ticks` value is local to the +start of the sequence and contains `tick` plus an optional `period`. +`define_sequence()` validates every event, resets the tag, then sends ordinary +tagged `ticks` messages: ```python amy.send(sequence_reset=40) -amy.send(sequence_event=(40, 0, 0), synth=2, note=60, vel=1) -amy.send(sequence_event=(40, 12, 0), synth=2, note=60, vel=0) +amy.send(ticks=(0, 0, 40), synth=2, note=60, vel=1) +amy.send(ticks=(12, 0, 40), synth=2, note=60, vel=0) ``` -The sequence tag and legacy root tag are one identity space. Writing a legacy -tagged `ticks` event replaces the future reusable definition at that tag; -explicitly appending a reusable event removes the future legacy root event at -that tag. Applications should assign distinct tags to stored phrases and root -launch events. +Repeating tag `40` accumulates both events. `sequence_reset=40` explicitly +replaces the definition; the empty wire form `H0,0,40Z` is an equivalent reset. +With an event payload, `ticks=(0, 0, 40)` is a valid local tick-zero event. ## Starting and stopping @@ -52,14 +43,13 @@ amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_STOP, 48)) The optional final value is `alignment_period`. `0` or `1` acts at the next available sequencer tick for a direct command. A larger value selects the next -tick divisible by that period. When a root sequencer event fires a start on a -tick, the child sequence's local tick zero participates in that same tick. +global tick divisible by that period. When a sequenced parent starts a child, +the child's local tick zero participates in the same tick. -A start creates a bounded execution. More than one execution of a finite -sequence may overlap; no caller-generated execution ID is required. Stop -targets every active execution of the tag. Stopping a parent prevents its -future child starts but does not stop child sequences which already started. -This lets a note-on/note-off child own its complete lifetime. +A start creates a bounded execution. Finite executions of one tag may overlap, +so callers do not need execution IDs or note-lifetime bookkeeping. Stop targets +all active executions of that tag. Stopping a parent prevents future child +starts, while children already started retain their own event pairs. ## Finite and repeating lifetime @@ -67,10 +57,9 @@ No explicit sequence length or publish action is needed: - a definition containing only `period=0` events is finite and retires after its last event; -- an event with nonzero `period` repeats on its local period, and keeps that - execution alive until it is stopped; -- a controlling finite sequence can start a periodic child at local tick zero - and stop it after a chosen number of periods. +- an event with nonzero `period` repeats on its local period until stopped; +- a finite controller sequence can start a periodic child and stop it after a + chosen number of periods. ## Temporary event gating @@ -79,34 +68,26 @@ amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_GATE, 24, 1)) ``` This suppresses ordinary event dispatch from active executions of tag `40` for -24 ticks. Their local phase continues and dispatch resumes on the original -phase. Audio which is already ringing is not cut off. Nested sequence controls -remain active while ordinary payload events are gated, so controller sequences -can still complete their lifecycle. - -Gate duration `0` removes a gate at the selected alignment boundary. +24 ticks. Local phase continues, and dispatch resumes on the original phase. +Audio already ringing is not cut off. Nested sequence controls remain active, +so a controller sequence can still complete its lifecycle. Duration zero +removes a gate at the selected boundary. ## Reset behavior -- `amy.send(sequence_reset=tag)` removes the future legacy/root event and the - future reusable definition for that tag. Active immutable executions finish. -- `RESET_TIMEBASE` discards active/pending executions because their absolute +- `amy.send(sequence_reset=tag)` removes the future definition. Active + executions retain the snapshot they started with and may finish. +- `RESET_TIMEBASE` discards active or pending executions because their absolute activation ticks cannot be rebased, but retains stored definitions. -- `RESET_SEQUENCER` retains its global meaning: it clears root events, reusable - definitions, and active/pending executions. +- `RESET_SEQUENCER` clears untagged events, tagged definitions, and executions. ## Capacity and realtime behavior -`max_sequencer_tags` bounds the shared tag space. `max_sequence_events` bounds -the number of events in one reusable definition, and -`max_sequence_executions` independently bounds active or alignment-pending -executions. Definitions are allocated only for tags which use them, and +`max_sequencer_tags` bounds public tag identities. `max_sequence_events` bounds +the number of events in one definition, and `max_sequence_executions` bounds +active or alignment-pending executions. Definitions allocate only when used; inactive definitions are not scanned on each tick. -Starts fail clearly when the execution pool is full. Cyclic sequence launches -cannot allocate beyond that fixed pool and can be recovered with targeted stop -commands or `RESET_SEQUENCER`. - See the [implementation model](sequencer-sequences-abstractions.md), [musical use cases](sequencer-sequences-musical-use-cases.md), and [step-by-step examples](sequencer-sequences-howto.md). diff --git a/docs/synth.md b/docs/synth.md index 6f2ec886..9b999f27 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -225,8 +225,6 @@ You can schedule an event with `amy.send(..., ticks="tick,period,tag")`. All thr ```python amy.send(osc=0, wave=amy.SAW_UP, eg0="0,1,500,0,500,0") # Pluck tone amy.send(osc=0, note=50, vel=1, ticks=amy.sequencer_ticks() + 96) # one-off: fires once, ~1s from now -amy.send(osc=0, note=38, vel=1, ticks="0,24,7") # repeating, cancelable via tag 7 -amy.send(osc=0, ticks="0,0,7") # cancel tag 7 amy.send(osc=0, note=72, vel=1, ticks="0,24") # repeating, not individually cancelable amy.reset() # Stop everything ``` @@ -237,18 +235,22 @@ You can schedule repeating events (like a step sequencer or drum machine) with ` For pattern sequencers like drum machines, you will also want to use `tick` alongside `period`. If both are given and `period` is nonzero, `tick` is assumed to be an offset on the `period`. For example, for a 16-step drum machine pattern running on eighth notes (PPQ/2), you would use a `period` of `16 * 24 = 384`. The first slot of the drum machine would have a `tick` of 0, the 2nd would have a `tick` offset of 24, and so on. -`tag` is optional. If you give one, you can cancel that event later by sending `ticks="0,0,tag"` with the same `tag`. If you omitted `tag` when setting up the sequence (a 1- or 2-value `ticks=`), the event is still scheduled and still fires, but it isn't addressable by any tag -- there's no way to cancel or replace it individually (only by something like `amy.reset()`, discarding all sequenced events), so only omit `tag` for events you don't need to manage later. +`tag` is optional. Without one, an event is scheduled directly on the global +sequencer clock and cannot be addressed individually. With a tag, the event is +added to a reusable sequence and its tick becomes local to each start of that +sequence. Repeating a tag accumulates events; reset the tag explicitly before +replacing its contents. If you are including AMY in a program, you can set the [hook `void (*amy_external_sequencer_hook)(uint32_t)`](api.md) to any function. This will be called at every tick with the current tick number as an argument. ### Reusable tagged sequences -An existing sequencer tag can explicitly hold several ordinary events with -local tick values. `amy.define_sequence(tag, events)` replaces that reusable -definition, while legacy three-value `ticks=(tick, period, tag)` retains its -single-event replace behavior. `sequence_control` starts, stops, aligns, or -temporarily gates an active tagged sequence. Component periods define looping; -a definition containing only period-zero events finishes after its last event. +A sequencer tag holds one or more ordinary events with local tick values. +Repeated three-value `ticks=(tick, period, tag)` messages cumulate behind the +same tag. `amy.define_sequence(tag, events)` is the convenient replace-as-a-list +operation. `sequence_control` starts, stops, aligns, or temporarily gates an +active tagged sequence. Component periods define looping; a definition +containing only period-zero events finishes after its last event. See [Reusable sequencer sequences](sequencer-sequences.md) for the concise API and lifecycle reference. The accompanying guides explain the @@ -490,4 +492,3 @@ amy.start_sample(preset=1024, source=amy.SAMPLE_FROM_OUTPUT, max_frames=11025, m amy.send(osc=0, wave=amy.PCM_LEFT, preset=1024, pan=0, note=72, vel=1) # play back AUDIO_IN sample an octave higher amy.send(osc=1, wave=amy.PCM_RIGHT, preset=1024, pan=1, note=72, vel=1) ``` - diff --git a/docs/tutorial.html b/docs/tutorial.html index 6f0bdcae..eb6df9cf 100644 --- a/docs/tutorial.html +++ b/docs/tutorial.html @@ -162,16 +162,23 @@

AMY sequencer

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

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

+

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

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

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

+amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_STOP, 1)) +amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks="0,384,1") # first slot of a 16 1/8th note drum machine -amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,2") # ninth slot of a 16 1/8th note drum machine +amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,1") # ninth slot in the same tagged sequence +amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 384))
@@ -287,5 +294,3 @@ < - - From 21395160b76c224d11d0e6c3c9d2e2adef443235 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 18:35:17 +0200 Subject: [PATCH 18/52] Align sequence test terminology --- tests/test_sequencer_sequences.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 644d2cfd..816edffa 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -92,8 +92,8 @@ static void test_legacy_c_event_wire_is_unchanged(void) { "C ticks serialization remains three values: %s", wire); } -static void test_explicit_append_and_one_shot_lifetime(void) { - printf("explicit sequence events accumulate and finite events retire\n"); +static void test_repeated_tag_and_one_shot_lifetime(void) { + printf("repeated tagged events accumulate and finite events retire\n"); sequencer_reset(); clear_marks(); amy_add_message("H0,0,10zPzeroZ"); @@ -346,7 +346,7 @@ int main(void) { test_untagged_ticks_and_cumulative_tags(); test_legacy_c_event_wire_is_unchanged(); - test_explicit_append_and_one_shot_lifetime(); + test_repeated_tag_and_one_shot_lifetime(); test_empty_tick_zero_is_reset_but_payload_is_an_event(); test_active_definition_is_immutable(); test_root_launches_local_zero_on_same_tick(); From fca1579591cb4301d0f0583cc5ae8d8d2cb531aa Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 18:39:42 +0200 Subject: [PATCH 19/52] Remove retired sequence append from Godot --- godot/amy.gd | 150 +++++++++++++++++++++++++-------------------------- 1 file changed, 74 insertions(+), 76 deletions(-) diff --git a/godot/amy.gd b/godot/amy.gd index 618d6cb4..165a0d31 100644 --- a/godot/amy.gd +++ b/godot/amy.gd @@ -292,7 +292,6 @@ func _format_ctrl(val: Variant) -> String: # ============================================================ # BEGIN GENERATED - scripts/gen_amy_gd_api.py var _KW_MAP: Dictionary = { - "sequence_event": ["HA", "L"], "ticks": ["H", "L"], "osc": ["v", "I"], "wave": ["w", "I"], @@ -370,81 +369,80 @@ var _KW_MAP: Dictionary = { } var _KW_PRIORITY: Dictionary = { - "sequence_event": 0, - "ticks": 1, - "osc": 2, - "wave": 3, - "note": 4, - "vel": 5, - "amp": 6, - "freq": 7, - "duty": 8, - "feedback": 9, - "reset": 10, - "phase": 11, - "sample_offset": 12, - "fit": 13, - "fit_search": 14, - "pan": 15, - "client": 16, - "volume": 17, - "pitch_bend": 18, - "filter_freq": 19, - "resonance": 20, - "bp0": 21, - "bp1": 22, - "eg0": 23, - "eg1": 24, - "eg0_type": 25, - "eg1_type": 26, - "debug": 27, - "chained_osc": 28, - "mod_source": 29, - "eq": 30, - "filter_type": 31, - "ratio": 32, - "latency_ms": 33, - "dist_clip": 34, - "dist_fold": 35, - "dist_crush": 36, - "dist_drive": 37, - "dist_mix": 38, - "algo_source": 39, - "load_sample": 40, - "transfer_file": 41, - "disk_sample": 42, - "algorithm": 43, - "chorus": 44, - "reverb": 45, - "echo": 46, - "patch": 47, - "sequence_reset": 48, - "sequence_control": 49, - "external_channel": 50, - "portamento": 51, - "tempo": 52, - "sequencer_run": 53, - "external_midi_sync": 54, - "synth": 55, - "pedal": 56, - "synth_flags": 57, - "num_voices": 58, - "oscs_per_voice": 59, - "synth_level": 60, - "to_synth": 61, - "grab_midi_notes": 62, - "note_source_channel": 63, - "synth_delay": 64, - "preset": 65, - "num_partials": 66, - "start_sample": 67, - "stop_sample": 68, - "bus": 69, - "mode": 70, - "midi_cc": 71, - "midi_note_cmd": 72, - "cv_trigger": 73, - "patch_string": 74, + "ticks": 0, + "osc": 1, + "wave": 2, + "note": 3, + "vel": 4, + "amp": 5, + "freq": 6, + "duty": 7, + "feedback": 8, + "reset": 9, + "phase": 10, + "sample_offset": 11, + "fit": 12, + "fit_search": 13, + "pan": 14, + "client": 15, + "volume": 16, + "pitch_bend": 17, + "filter_freq": 18, + "resonance": 19, + "bp0": 20, + "bp1": 21, + "eg0": 22, + "eg1": 23, + "eg0_type": 24, + "eg1_type": 25, + "debug": 26, + "chained_osc": 27, + "mod_source": 28, + "eq": 29, + "filter_type": 30, + "ratio": 31, + "latency_ms": 32, + "dist_clip": 33, + "dist_fold": 34, + "dist_crush": 35, + "dist_drive": 36, + "dist_mix": 37, + "algo_source": 38, + "load_sample": 39, + "transfer_file": 40, + "disk_sample": 41, + "algorithm": 42, + "chorus": 43, + "reverb": 44, + "echo": 45, + "patch": 46, + "sequence_reset": 47, + "sequence_control": 48, + "external_channel": 49, + "portamento": 50, + "tempo": 51, + "sequencer_run": 52, + "external_midi_sync": 53, + "synth": 54, + "pedal": 55, + "synth_flags": 56, + "num_voices": 57, + "oscs_per_voice": 58, + "synth_level": 59, + "to_synth": 60, + "grab_midi_notes": 61, + "note_source_channel": 62, + "synth_delay": 63, + "preset": 64, + "num_partials": 65, + "start_sample": 66, + "stop_sample": 67, + "bus": 68, + "mode": 69, + "midi_cc": 70, + "midi_note_cmd": 71, + "cv_trigger": 72, + "patch_string": 73, } ## The control coefficient inputs, in wire order. Prefer naming these in a From 2669c3aecd7a762763a5c45e66c41b64fd1dde48 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 19:30:24 +0200 Subject: [PATCH 20/52] Cover concurrent sequence definition generations --- tests/test_sequencer_sequences.c | 64 ++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 816edffa..0d465f02 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -148,6 +148,68 @@ static void test_active_definition_is_immutable(void) { "a later start uses only the replacement definition"); } +static void test_append_while_active_uses_copy_on_write(void) { + printf("appending while active publishes a future definition\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,11zPbaseZ"); + amy_add_message("H6,0,11zPold-tailZ"); + amy_add_message("HC11,1,0Z"); + uint32_t old_start = sequencer_ticks() + 1; + clock_to(old_start + 1); + + amy_add_message("H2,0,11zPappendedZ"); + clock_to(old_start + 6); + CHECK(mark_at("base", old_start) && mark_at("old-tail", old_start + 6), + "the active execution retains its original events"); + CHECK(!mark_at("appended", old_start + 2), + "an append cannot enter an already-running snapshot"); + + clear_marks(); + amy_add_message("HC11,1,0Z"); + uint32_t new_start = sequencer_ticks() + 1; + clock_to(new_start + 6); + CHECK(mark_at("base", new_start) + && mark_at("appended", new_start + 2) + && mark_at("old-tail", new_start + 6), + "a later execution sees the cumulative appended definition"); +} + +static void test_three_definition_generations_overlap(void) { + printf("three immutable definition generations can overlap\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,12zPbaseZ"); + amy_add_message("H12,0,12zPtailZ"); + + amy_add_message("HC12,1,0Z"); + uint32_t first_start = sequencer_ticks() + 1; + clock_to(first_start); + + amy_add_message("H2,0,12zPsecondZ"); + amy_add_message("HC12,1,0Z"); + uint32_t second_start = sequencer_ticks() + 1; + clock_to(second_start); + + amy_add_message("H4,0,12zPthirdZ"); + amy_add_message("HC12,1,0Z"); + uint32_t third_start = sequencer_ticks() + 1; + clock_to(third_start + 12); + + CHECK(mark_at("base", first_start) + && !mark_at("second", first_start + 2) + && !mark_at("third", first_start + 4), + "the first execution keeps generation one"); + CHECK(mark_at("base", second_start) + && mark_at("second", second_start + 2) + && !mark_at("third", second_start + 4), + "the second execution keeps generation two"); + CHECK(mark_at("base", third_start) + && mark_at("second", third_start + 2) + && mark_at("third", third_start + 4), + "the third execution sees generation three"); +} + static void test_root_launches_local_zero_on_same_tick(void) { printf("root events can launch stored sequences\n"); sequencer_reset(); @@ -349,6 +411,8 @@ int main(void) { test_repeated_tag_and_one_shot_lifetime(); test_empty_tick_zero_is_reset_but_payload_is_an_event(); test_active_definition_is_immutable(); + test_append_while_active_uses_copy_on_write(); + test_three_definition_generations_overlap(); test_root_launches_local_zero_on_same_tick(); test_overlapping_executions_need_no_host_identity(); test_parent_stop_leaves_started_child_to_finish(); From b2a88659ac683cef58a38316c86a216c4ae40687 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 19:35:15 +0200 Subject: [PATCH 21/52] Move sequence version reclamation off render path --- docs/sequencer-sequences-abstractions.md | 15 ++ src/sequencer.c | 215 ++++++++++++++++++----- 2 files changed, 186 insertions(+), 44 deletions(-) diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 266dbd70..0e5101ea 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -30,6 +30,21 @@ definitions. Editing a definition used by an execution clones it. The active execution keeps its old snapshot; later starts see the new contents. No revision number or execution ID is exposed. +The copy is constructed while the old definition is pinned, but outside the +queue lock also used by rendering. Publication is a short checked pointer swap. +When the last execution releases an obsolete definition, the render path links +it onto an intrusive retirement list; a later non-rendering control call +detaches that list and performs the variable-time string and heap frees. The +audio path therefore neither copies nor frees a definition. + +This is reference-counted deferred reclamation, not a tracing garbage +collector. A fixed two-buffer ping-pong is insufficient because overlapping or +repeating executions can retain more than two generations at once. Allocating +versions only when an active definition is edited keeps the normal preload path +linear and bounds retained generations through the configured execution pool. +This matters in particular on embedded targets, where allocator and external- +memory/cache latency must not extend a render-thread critical section. + Finite executions of one tag may overlap. This supports phrases whose note gate exceeds their trigger interval without transferring note state to the caller. diff --git a/src/sequencer.c b/src/sequencer.c index ffde3932..e7ad12ed 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -1,6 +1,8 @@ #include "sequencer.h" #include "amy.h" +#include + #ifdef __EMSCRIPTEN__ #include #endif @@ -59,6 +61,10 @@ typedef struct stored_sequence_definition_t { uint32_t last_one_shot_tick; bool has_periodic_event; uint32_t refs; + // Zero-reference definitions are linked here by the render path. A + // non-rendering sequence API call detaches the complete list under the + // queue lock and performs the variable-time frees after releasing it. + struct stored_sequence_definition_t *next_retired; } stored_sequence_definition_t; typedef struct stored_sequence_slot_t { @@ -85,6 +91,7 @@ static uint32_t max_stored_sequence_events = 0; static uint32_t max_stored_sequence_executions = 0; static size_t stored_sequence_event_bytes = 0; static volatile bool stored_sequence_wire_firing = false; +static stored_sequence_definition_t *retired_sequence_definitions = NULL; static bool checked_array_size(uint32_t count, size_t element_size, size_t *bytes) { @@ -93,17 +100,57 @@ static bool checked_array_size(uint32_t count, size_t element_size, return true; } -static void stored_sequence_definition_release( +static void stored_sequence_definition_destroy( stored_sequence_definition_t *definition) { - if (definition == NULL || definition->refs == 0) return; - definition->refs--; - if (definition->refs != 0) return; + if (definition == NULL) return; for (uint32_t i = 0; i < definition->event_count; ++i) if (definition->events[i].wire != NULL) free(definition->events[i].wire); free(definition->events); free(definition); } +// References are changed only while amy_queue_lock is held. Return the object +// which reached zero so the caller can either retire it (render path) or free +// it after dropping the lock (control path). +static stored_sequence_definition_t *stored_sequence_definition_unref_locked( + stored_sequence_definition_t *definition) { + if (definition == NULL) return NULL; + assert(definition->refs != 0); + definition->refs--; + return definition->refs == 0 ? definition : NULL; +} + +static void stored_sequence_definition_retire_locked( + stored_sequence_definition_t *definition) { + stored_sequence_definition_t *retired = + stored_sequence_definition_unref_locked(definition); + if (retired == NULL) return; + retired->next_retired = retired_sequence_definitions; + retired_sequence_definitions = retired; +} + +static void stored_sequence_definition_destroy_list( + stored_sequence_definition_t *definition) { + while (definition != NULL) { + stored_sequence_definition_t *next = definition->next_retired; + stored_sequence_definition_destroy(definition); + definition = next; + } +} + +// This may be called from sequence API entry points which can also be fired by +// the render thread through a stored HC payload. Reclaim only when this is an +// external/control-side call. Keeping retired objects until the next such call +// is bounded by the tag and execution pools and never delays audio rendering. +static void stored_sequence_reclaim_retired(void) { + if (wire_firing || stored_sequence_wire_firing) return; + amy_grab_lock(); + stored_sequence_definition_t *retired = retired_sequence_definitions; + retired_sequence_definitions = NULL; + amy_release_lock(); + stored_sequence_definition_destroy_list(retired); +} + static stored_sequence_definition_t *stored_sequence_definition_new(void) { stored_sequence_definition_t *definition = (stored_sequence_definition_t *)malloc_caps( @@ -121,6 +168,7 @@ static stored_sequence_definition_t *stored_sequence_definition_new(void) { definition->last_one_shot_tick = 0; definition->has_periodic_event = false; definition->refs = 1; + definition->next_retired = NULL; return definition; } @@ -143,7 +191,7 @@ static stored_sequence_definition_t *stored_sequence_definition_clone( const stored_sequence_event_t *from = &source->events[i]; copy->events[i].wire = stored_sequence_wire_copy(from->wire); if (copy->events[i].wire == NULL) { - stored_sequence_definition_release(copy); + stored_sequence_definition_destroy(copy); return NULL; } copy->events[i].tick = from->tick; @@ -152,24 +200,25 @@ static stored_sequence_definition_t *stored_sequence_definition_clone( return copy; } -static void stored_sequence_execution_release( +static void stored_sequence_execution_release_deferred( stored_sequence_execution_t *execution) { if (!execution->occupied) return; stored_sequence_definition_t *definition = execution->definition; memset(execution, 0, sizeof(*execution)); - stored_sequence_definition_release(definition); + stored_sequence_definition_retire_locked(definition); } static void stored_sequence_executions_reset(void) { if (sequence_executions == NULL) return; for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) - stored_sequence_execution_release(&sequence_executions[i]); + stored_sequence_execution_release_deferred(&sequence_executions[i]); } static void stored_sequences_clear_definitions(void) { if (stored_sequences == NULL) return; for (int32_t i = 0; i < max_sequences; ++i) { - stored_sequence_definition_release(stored_sequences[i].definition); + stored_sequence_definition_retire_locked( + stored_sequences[i].definition); stored_sequences[i].definition = NULL; } } @@ -188,6 +237,9 @@ static void stored_sequences_deinit(void) { max_stored_sequence_events = 0; max_stored_sequence_executions = 0; stored_sequence_event_bytes = 0; + stored_sequence_definition_t *retired = retired_sequence_definitions; + retired_sequence_definitions = NULL; + stored_sequence_definition_destroy_list(retired); } static void stored_sequences_init(uint32_t events, uint32_t executions) { @@ -450,6 +502,35 @@ static stored_sequence_slot_t *stored_sequence_slot(uint32_t tag) { return &stored_sequences[tag]; } +static void stored_sequence_definition_append_owned( + stored_sequence_definition_t *definition, uint32_t tick, + uint32_t period, char *wire) { + stored_sequence_event_t *event = + &definition->events[definition->event_count++]; + event->wire = wire; + event->tick = tick; + event->period = period; + if (period != 0) definition->has_periodic_event = true; + else if (tick > definition->last_one_shot_tick) + definition->last_one_shot_tick = tick; +} + +// A candidate owns the incoming wire in its final event. If publication loses +// a race, detach that event before destroying the private candidate so the +// same caller-owned wire can be retried against the newly published version. +static void stored_sequence_candidate_discard( + stored_sequence_definition_t *candidate, char *wire) { + if (candidate != NULL && candidate->event_count != 0) { + stored_sequence_event_t *event = + &candidate->events[candidate->event_count - 1]; + if (event->wire == wire) { + event->wire = NULL; + candidate->event_count--; + } + } + stored_sequence_definition_destroy(candidate); +} + uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, uint32_t period, char *wire) { stored_sequence_slot_t *slot = stored_sequence_slot(tag); @@ -484,41 +565,80 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, return 0; } - amy_grab_lock(); - stored_sequence_definition_t *definition = slot->definition; - if (definition == NULL) { - definition = stored_sequence_definition_new(); - } else if (definition->refs > 1) { - definition = stored_sequence_definition_clone(definition); - } - if (definition == NULL) { + stored_sequence_reclaim_retired(); + for (;;) { + amy_grab_lock(); + stored_sequence_definition_t *source = slot->definition; + if (source != NULL + && source->event_count >= max_stored_sequence_events) { + fprintf(stderr, "cannot append event to sequence %" PRIu32 + ": configured limit of %" PRIu32 " events is full\n", + tag, max_stored_sequence_events); + amy_release_lock(); + free(wire); + return 0; + } + + // No execution or other writer can observe a refs==1 definition, so + // appending the already-allocated incoming wire is a bounded mutation. + // This keeps bulk preload O(n) instead of cloning on every event. + if (source != NULL && source->refs == 1) { + stored_sequence_definition_append_owned(source, tick, period, + wire); + amy_release_lock(); + stored_sequence_reclaim_retired(); + return 1; + } + + // Pin a shared source before leaving the lock. From this point it is + // immutable, so allocation and all copying can happen without holding + // up the render thread. + if (source != NULL) source->refs++; amy_release_lock(); - amy_oom("stored sequence edit"); - free(wire); - return 0; - } - if (definition != slot->definition) { - stored_sequence_definition_release(slot->definition); - slot->definition = definition; - } - if (definition->event_count >= max_stored_sequence_events) { - fprintf(stderr, "cannot append event to sequence %" PRIu32 - ": configured limit of %" PRIu32 " events is full\n", - tag, max_stored_sequence_events); + + stored_sequence_definition_t *candidate = source == NULL + ? stored_sequence_definition_new() + : stored_sequence_definition_clone(source); + if (candidate == NULL) { + stored_sequence_definition_t *dead = NULL; + if (source != NULL) { + amy_grab_lock(); + dead = stored_sequence_definition_unref_locked(source); + amy_release_lock(); + } + stored_sequence_definition_destroy(dead); + amy_oom("stored sequence edit"); + free(wire); + return 0; + } + stored_sequence_definition_append_owned(candidate, tick, period, wire); + + amy_grab_lock(); + if (slot->definition == source) { + slot->definition = candidate; + stored_sequence_definition_t *dead = NULL; + if (source != NULL) { + // Drop the old slot ownership and our temporary writer pin. + dead = stored_sequence_definition_unref_locked(source); + stored_sequence_definition_t *after_pin = + stored_sequence_definition_unref_locked(source); + if (after_pin != NULL) dead = after_pin; + } + amy_release_lock(); + stored_sequence_definition_destroy(dead); + stored_sequence_reclaim_retired(); + return 1; + } + + // Another writer published first. Keep the caller's wire, release our + // source pin, discard the private candidate outside the lock, and retry + // against the new cumulative definition. + stored_sequence_definition_t *dead = source == NULL ? NULL + : stored_sequence_definition_unref_locked(source); amy_release_lock(); - free(wire); - return 0; + stored_sequence_candidate_discard(candidate, wire); + stored_sequence_definition_destroy(dead); } - stored_sequence_event_t *event = - &definition->events[definition->event_count++]; - event->wire = wire; - event->tick = tick; - event->period = period; - if (period != 0) definition->has_periodic_event = true; - else if (tick > definition->last_one_shot_tick) - definition->last_one_shot_tick = tick; - amy_release_lock(); - return 1; } uint8_t sequencer_sequence_reset(uint32_t tag) { @@ -540,10 +660,15 @@ uint8_t sequencer_sequence_reset(uint32_t tag) { return 0; } + stored_sequence_reclaim_retired(); amy_grab_lock(); - stored_sequence_definition_release(slot->definition); + stored_sequence_definition_t *definition = slot->definition; slot->definition = NULL; + stored_sequence_definition_t *dead = + stored_sequence_definition_unref_locked(definition); amy_release_lock(); + stored_sequence_definition_destroy(dead); + stored_sequence_reclaim_retired(); return 1; } @@ -574,6 +699,7 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, return 0; } + stored_sequence_reclaim_retired(); uint8_t result = 0; amy_grab_lock(); if (action == SEQUENCE_CONTROL_START) { @@ -624,6 +750,7 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, "stop=0, start=1, gate=2\n", tag, action); } amy_release_lock(); + stored_sequence_reclaim_retired(); return result; } @@ -658,7 +785,7 @@ static void stored_sequence_process_pass(uint32_t tick, bool controls) { if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) || (!definition->has_periodic_event && elapsed > definition->last_one_shot_tick)) { - stored_sequence_execution_release(execution); + stored_sequence_execution_release_deferred(execution); amy_release_lock(); continue; } @@ -689,7 +816,7 @@ static void stored_sequence_process_pass(uint32_t tick, bool controls) { } amy_grab_lock(); - stored_sequence_definition_release(definition); + stored_sequence_definition_retire_locked(definition); amy_release_lock(); } } From 7c98ad2bae4d6934bd097de9052a55a47d2ee9b6 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 19:39:53 +0200 Subject: [PATCH 22/52] Reclaim sequence versions at control boundary --- docs/sequencer-sequences-abstractions.md | 5 ++++- src/api.c | 6 ++++++ src/sequencer.c | 25 ++++++++++++++++-------- src/sequencer.h | 3 +++ 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 0e5101ea..da4eec73 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -35,7 +35,10 @@ queue lock also used by rendering. Publication is a short checked pointer swap. When the last execution releases an obsolete definition, the render path links it onto an intrusive retirement list; a later non-rendering control call detaches that list and performs the variable-time string and heap frees. The -audio path therefore neither copies nor frees a definition. +audio path therefore neither copies nor frees a definition. Internally fired +wire payloads bypass the public wire-ingest boundary, while that public boundary +drains the retirement list after parsing. This makes reclamation a structural +control-path property rather than a best-effort test of concurrent render state. This is reference-counted deferred reclamation, not a tracing garbage collector. A fixed two-buffer ping-pong is insufficient because overlapping or diff --git a/src/api.c b/src/api.c index 978112f5..60f9dd0f 100644 --- a/src/api.c +++ b/src/api.c @@ -2,6 +2,7 @@ // C callable entry points to amy #include "amy.h" +#include "sequencer.h" amy_config_t amy_default_config() { amy_config_t c; @@ -293,6 +294,7 @@ void amy_add_message_with_sysex_flag(char *message, bool sysex) { // Transfer status can't change mid-message, so the whole string is // one chunk of transfer payload. parse_transfer_message(message, (uint16_t)strlen(message)); + sequencer_reclaim_retired(); return; } // Fast pre-check of this message for a leading 'H' (ticks) scheduling @@ -303,6 +305,10 @@ void amy_add_message_with_sysex_flag(char *message, bool sysex) { // Not scheduled: parse and play every command in the message now. amy_play_message(message); } + // Public wire ingestion is a control-side boundary. Sequence playback uses + // amy_play_message()/handle_ticks_message() directly, so it can never enter + // this reclamation path from the render thread. + sequencer_reclaim_retired(); } // given a wire message string play / schedule the event directly (WIRE API) diff --git a/src/sequencer.c b/src/sequencer.c index e7ad12ed..381aa487 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -138,12 +138,11 @@ static void stored_sequence_definition_destroy_list( } } -// This may be called from sequence API entry points which can also be fired by -// the render thread through a stored HC payload. Reclaim only when this is an -// external/control-side call. Keeping retired objects until the next such call -// is bounded by the tag and execution pools and never delays audio rendering. -static void stored_sequence_reclaim_retired(void) { - if (wire_firing || stored_sequence_wire_firing) return; +// The public wire boundary calls this unconditionally after parsing. Sequence +// entry points also use it opportunistically, except while a render-fired wire +// is active. Keeping the actual destruction here makes that distinction +// explicit instead of trying to infer the caller from concurrent global state. +void sequencer_reclaim_retired(void) { amy_grab_lock(); stored_sequence_definition_t *retired = retired_sequence_definitions; retired_sequence_definitions = NULL; @@ -151,6 +150,11 @@ static void stored_sequence_reclaim_retired(void) { stored_sequence_definition_destroy_list(retired); } +static void stored_sequence_reclaim_retired(void) { + if (wire_firing || stored_sequence_wire_firing) return; + sequencer_reclaim_retired(); +} + static stored_sequence_definition_t *stored_sequence_definition_new(void) { stored_sequence_definition_t *definition = (stored_sequence_definition_t *)malloc_caps( @@ -765,10 +769,15 @@ static bool stored_sequence_event_is_control( return strncmp(event->wire, "HC", 2) == 0; } +static void sequence_play_wire_now(char *wire) { + if (wire[0] == 'H') handle_ticks_message(wire); + else amy_play_message(wire); +} + static void stored_sequence_play_wire(const char *wire) { bool previous = stored_sequence_wire_firing; stored_sequence_wire_firing = true; - amy_add_message((char *)wire); + sequence_play_wire_now((char *)wire); stored_sequence_wire_firing = previous; } @@ -874,7 +883,7 @@ static void sequencer_process_tick(void) { amy_release_lock(); if (wire != NULL) { // Parse and play now; the deltas play back within this block. - amy_add_message(wire); + sequence_play_wire_now(wire); free(wire); } } diff --git a/src/sequencer.h b/src/sequencer.h index 37a40c4e..efc5309a 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -13,6 +13,9 @@ void sequencer_debug(); void sequencer_recompute(); void sequencer_check_and_fill(); // called once per block from amy_execute_deltas() +// Destroy zero-reference immutable sequence definitions retired by the render +// path. The caller must be a control/non-render thread. +void sequencer_reclaim_retired(); #ifdef __EMSCRIPTEN__ void sequencer_check_and_call_js_hook(); // called from the browser main loop #endif From b6f559a55d67f2d1dc4509a3e59fabca4fbfbd70 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 19:52:48 +0200 Subject: [PATCH 23/52] Defer render-fired sequence reset cleanup --- src/sequencer.c | 5 +++-- tests/test_sequencer_sequences.c | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index 381aa487..b0d97987 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -668,8 +668,9 @@ uint8_t sequencer_sequence_reset(uint32_t tag) { amy_grab_lock(); stored_sequence_definition_t *definition = slot->definition; slot->definition = NULL; - stored_sequence_definition_t *dead = - stored_sequence_definition_unref_locked(definition); + stored_sequence_definition_t *dead = NULL; + if (wire_firing) stored_sequence_definition_retire_locked(definition); + else dead = stored_sequence_definition_unref_locked(definition); amy_release_lock(); stored_sequence_definition_destroy(dead); stored_sequence_reclaim_retired(); diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 0d465f02..de3a04c0 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -224,6 +224,19 @@ static void test_root_launches_local_zero_on_same_tick(void) { "a root launch includes the child's local tick zero"); } +static void test_root_can_reset_a_future_definition(void) { + printf("root events can reset future stored definitions\n"); + sequencer_reset(); + amy_add_message("H0,0,12zPfutureZ"); + uint32_t reset_tick = sequencer_ticks() + 2; + char wire[96]; + snprintf(wire, sizeof(wire), "H%" PRIu32 ",0HR12Z", reset_tick); + amy_add_message(wire); + clock_to(reset_tick); + CHECK(!sequencer_sequence_control(12, SEQUENCE_CONTROL_START, 0, 0), + "a render-fired reset removes the future definition"); +} + static void test_overlapping_executions_need_no_host_identity(void) { printf("one sequence tag supports bounded overlapping executions\n"); sequencer_reset(); @@ -414,6 +427,7 @@ int main(void) { test_append_while_active_uses_copy_on_write(); test_three_definition_generations_overlap(); test_root_launches_local_zero_on_same_tick(); + test_root_can_reset_a_future_definition(); test_overlapping_executions_need_no_host_identity(); test_parent_stop_leaves_started_child_to_finish(); test_controller_sequence_bounds_repetition(); From 0198b50e64a79ac8bcda21d79ecbb988399fbe70 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:23:22 +0200 Subject: [PATCH 24/52] Make sequence triggers note-like and strict --- amy/__init__.py | 76 ++++++++++++++++++++++++++++++-- src/parse.c | 33 ++++++++++---- tests/test_sequence_api.py | 17 +++++++ tests/test_sequencer_sequences.c | 26 +++++++++++ 4 files changed, 141 insertions(+), 11 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index 7ae89f94..cb714867 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -240,6 +240,74 @@ def str_of_int(arg): return str(int(arg)) +def _list_values(value): + """Return a wire-list argument as individual values for validation.""" + if isinstance(value, str): + return value.split(',') + if isinstance(value, (list, tuple)): + return list(value) + return [value] + + +def _sequence_control_values(value): + """Validate the low-level ``HC`` payload without blocking templates.""" + values = _list_values(value) + if len(values) < 2: + raise ValueError('sequence_control needs at least tag and action.') + try: + action = int(values[1]) + except (TypeError, ValueError): + # Command templates substitute tokens such as %v before AMY parses HC. + if not (isinstance(values[1], str) and values[1].startswith('%')): + raise ValueError('sequence_control action must be stop=0, start=1, gate=2, or a template token.') + if len(values) not in (2, 3): + raise ValueError('A templated sequence_control needs tag, action, and optional alignment_period.') + return values + if action in (SEQUENCE_CONTROL_STOP, SEQUENCE_CONTROL_START): + if len(values) not in (2, 3): + raise ValueError('A start/stop sequence_control needs tag, action, and optional alignment_period.') + elif action == SEQUENCE_CONTROL_GATE: + if len(values) not in (3, 4): + raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.') + else: + raise ValueError('sequence_control action must be stop=0, start=1, or gate=2.') + return values + + +def _normalize_sequence_note(kwargs): + """Translate note-like sequence control into the existing HC primitive.""" + if 'sequence' not in kwargs: + if 'alignment_period' in kwargs: + raise ValueError('alignment_period is only valid with sequence.') + return kwargs + if 'sequence_control' in kwargs or 'sequence_reset' in kwargs: + raise ValueError('sequence cannot be combined with sequence_control or sequence_reset.') + extra = set(kwargs) - {'sequence', 'vel', 'alignment_period', 'ticks'} + if extra: + raise ValueError('sequence can only be combined with vel, alignment_period, and ticks.') + if 'vel' not in kwargs: + raise ValueError('sequence needs vel: use a value above zero to start and zero to stop.') + tag = int(kwargs['sequence']) + if tag < 0: + raise ValueError('Sequence tag must be non-negative.') + alignment = int(kwargs.get('alignment_period', 0)) + if alignment < 0: + raise ValueError('Sequence alignment_period must be non-negative.') + velocity = kwargs['vel'] + if isinstance(velocity, str) and velocity.startswith('%'): + action = velocity + else: + velocity = float(velocity) + if velocity < 0: + raise ValueError('Sequence vel must be non-negative.') + action = SEQUENCE_CONTROL_START if velocity > 0 else SEQUENCE_CONTROL_STOP + normalized = {} + if 'ticks' in kwargs: + normalized['ticks'] = kwargs['ticks'] + normalized['sequence_control'] = (tag, action, alignment) + return normalized + + _KW_MAP_LIST = [ # Order matters because patch_string must come last. # Sequence/ticks headers must come first: 'H' is only recognized as the # first wire character. sequence_control follows a ticks @@ -281,6 +349,7 @@ def message(**kwargs): # Each keyword maps to two or three chars, first one or two are the wire protocol prefix, last is an arg type code # I=int, F=float, S=str, L=list, C=ctrl_coefs global show_warnings, _KW_MAP, _KW_PRIORITY, _ARG_HANDLERS + kwargs = _normalize_sequence_note(kwargs) if show_warnings: # Check for possible user confusions. if 'voices' in kwargs and 'preset' in kwargs and 'osc' not in kwargs: @@ -305,9 +374,10 @@ def message(**kwargs): raise ValueError('Use only one of sequence_reset or ticks in a message.') if 'sequence_reset' in kwargs and len(kwargs) != 1: raise ValueError('sequence_reset must be sent as a standalone message.') - if ('sequence_control' in kwargs and len(kwargs) != 1 - and 'ticks' not in kwargs): - raise ValueError('sequence_control can only be combined with ticks.') + if 'sequence_control' in kwargs: + if set(kwargs) - {'sequence_control', 'ticks'}: + raise ValueError('sequence_control can only be combined with ticks.') + _sequence_control_values(kwargs['sequence_control']) # Validity check all the passed args. prioritized_keys = [] diff --git a/src/parse.c b/src/parse.c index f5cfb6d1..7f5f11da 100644 --- a/src/parse.c +++ b/src/parse.c @@ -719,16 +719,32 @@ void handle_ticks_message(char *message) { if (message[1] == 'C') { // HCtag,start_or_stop[,alignment_period] // HCtag,gate,duration[,alignment_period] - uint32_t values[4] = {0, 0, 0, 0}; - int count = parse_list_uint32_t(message + 2, values, 4, 0); - if (count < 2) { + uint32_t values[5] = {0, 0, 0, 0, 0}; + int count = parse_list_uint32_t(message + 2, values, 5, 0); + char terminator = message[2 + _next_alpha(message + 2)]; + if (terminator != '\0' && terminator != 'Z') { + fprintf(stderr, + "invalid sequence_control: HC must not contain an " + "ordinary AMY payload\n"); + } else if (count < 2) { fprintf(stderr, "invalid sequence_control: expected " "HCtag,start_or_stop[,alignment_period] or " "HCtag,gate,duration[,alignment_period]\n"); - } else if (values[1] == SEQUENCE_CONTROL_GATE && count < 3) { + } else if ((values[1] == SEQUENCE_CONTROL_START + || values[1] == SEQUENCE_CONTROL_STOP) + && count != 2 && count != 3) { + fprintf(stderr, + "invalid sequence_control start/stop: expected " + "HCtag,start_or_stop[,alignment_period]\n"); + } else if (values[1] == SEQUENCE_CONTROL_GATE + && count != 3 && count != 4) { + fprintf(stderr, + "invalid sequence_control gate: expected " + "HCtag,gate,duration[,alignment_period]\n"); + } else if (count > 4) { fprintf(stderr, - "invalid sequence_control gate: duration is required\n"); + "invalid sequence_control: expected at most four values\n"); } else { uint32_t value = values[1] == SEQUENCE_CONTROL_GATE ? values[2] : 0; @@ -741,9 +757,10 @@ void handle_ticks_message(char *message) { if (message[1] == 'R') { // HRtag: clear the future stored events for this tag. Already-active // immutable sequence executions are intentionally unaffected. - uint32_t values[1] = {0}; - int count = parse_list_uint32_t(message + 2, values, 1, 0); - if (count != 1) + uint32_t values[2] = {0, 0}; + int count = parse_list_uint32_t(message + 2, values, 2, 0); + char terminator = message[2 + _next_alpha(message + 2)]; + if ((terminator != '\0' && terminator != 'Z') || count != 1) fprintf(stderr, "invalid sequence reset: expected HRtag\n"); else sequencer_sequence_reset(values[0]); diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index 7230994d..aa2c7735 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -25,6 +25,13 @@ def main(): assert amy.message(ticks=(0, 48, 3), sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ == "H0,48,3HC7,1,1Z" + assert amy.message(sequence=7, vel=1) == "HC7,1,0Z" + assert amy.message(sequence=7, vel=0, alignment_period=48) \ + == "HC7,0,48Z" + assert amy.message(ticks=(0, 48, 3), sequence=7, vel=1, + alignment_period=1) == "H0,48,3HC7,1,1Z" + assert amy.message(sequence=7, vel="%v", alignment_period=1) \ + == "HC7,%v,1Z" assert amy.message(sequence_reset=7) == "HR7Z" assert amy.message(ticks=(1, 4, 2), synth=1, note=60, vel=1) \ == "H1,4,2n60l1i1Z" @@ -48,6 +55,16 @@ def main(): expect_error("standalone", lambda: amy.message(sequence_reset=2, synth=1)) expect_error("only be combined", lambda: amy.message( sequence_control=(2, 1), synth=1)) + expect_error("only be combined", lambda: amy.message( + ticks=(0,), sequence_control=(2, 1), synth=1)) + expect_error("start/stop", lambda: amy.message(sequence_control=(2, 1, 3, 4))) + expect_error("duration", lambda: amy.message(sequence_control=(2, 2))) + expect_error("action", lambda: amy.message(sequence_control=(2, 99))) + expect_error("needs vel", lambda: amy.message(sequence=2)) + expect_error("can only be combined", lambda: amy.message( + sequence=2, vel=1, synth=1)) + expect_error("only valid", lambda: amy.message(alignment_period=4, synth=1)) + expect_error("non-negative", lambda: amy.message(sequence=2, vel=-1)) expect_error("needs a ticks", lambda: amy.define_sequence(2, [{"synth": 1}])) expect_error("needs an AMY payload", lambda: amy.define_sequence( 2, [{"ticks": (0,)}])) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index de3a04c0..b6c28d29 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -375,6 +375,31 @@ static void test_bounds_and_validation(void) { "unknown control action is rejected"); } +static void test_wire_control_shape_is_strict(void) { + printf("sequence control and reset wire shapes are strict\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,3zPdefinedZ"); + + amy_add_message("HC3,1,0,99Z"); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("defined"), + "a start with an extra field is rejected"); + + amy_add_message("HC3,2Z"); + amy_add_message("HC3,1,0zPignoredZ"); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("defined") && !marks_named("ignored"), + "a missing gate duration and trailing payload are rejected"); + + amy_add_message("HR3,4Z"); + CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), + "a reset with an extra field leaves the definition intact"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("defined", start), "the intact definition still starts"); +} + static void test_start_crosses_clock_rollover(void) { printf("relative sequence phase crosses uint32 clock rollover\n"); sequencer_reset(); @@ -436,6 +461,7 @@ int main(void) { test_timebase_reset_keeps_definitions(); test_start_crosses_clock_rollover(); test_bounds_and_validation(); + test_wire_control_shape_is_strict(); amy_stop(); test_disabled_configuration(); From 361ac4049df07802901a3a8d26caaf4a6dbf255f Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:26:23 +0200 Subject: [PATCH 25/52] Migrate first-party sequencer callers --- amy/examples.py | 20 ++++++++------------ amy/test.py | 11 +++++------ docs/midi.md | 2 +- docs/sequencer-sequences-abstractions.md | 4 +++- docs/sequencer-sequences-howto.md | 18 +++++++----------- docs/sequencer-sequences.md | 10 +++++++--- docs/tutorial.html | 12 ++++++------ experiments/sampler/play_cleanbreaks.py | 4 +--- experiments/sampler/play_sampler.py | 4 ++-- 9 files changed, 40 insertions(+), 45 deletions(-) diff --git a/amy/examples.py b/amy/examples.py index 3cf35772..81896a5e 100644 --- a/amy/examples.py +++ b/amy/examples.py @@ -257,18 +257,14 @@ def example_sequencer_drums(): # Update high cowbell amy.send(osc=4, note=70) - # Add patterns - # Hi hat every 1/8th note - amy.send(ticks=[0, 24, 0], osc=2, vel=2.0) - - # Bass drum every quarter note - amy.send(ticks=[0, 96, 1], osc=0, vel=1.0) - - # Snare every quarter note, counterphase to BD - amy.send(ticks=[24, 96, 2], osc=1, vel=1.0) - - # Cow once every other cycle - amy.send(ticks=[0, 192, 3], osc=3, vel=1.0) + # Store all parts as one reusable pattern, then start it like a note. + amy.define_sequence(0, [ + dict(ticks=(0, 24), osc=2, vel=2.0), # hi-hat every eighth note + dict(ticks=(0, 96), osc=0, vel=1.0), # bass drum every quarter + dict(ticks=(24, 96), osc=1, vel=1.0), # counterphase snare + dict(ticks=(0, 192), osc=3, vel=1.0), # cowbell every other cycle + ]) + amy.send(sequence=0, vel=1, alignment_period=1) def example_fm(): amy.reset() diff --git a/amy/test.py b/amy/test.py index 2539b32e..116440ff 100644 --- a/amy/test.py +++ b/amy/test.py @@ -2034,7 +2034,7 @@ def __init__(self): self.default_synths = True def run(self): - amy_send_at(time=100, ticks='20,24,0', synth=1, note=64, vel=1) + amy_send_at(time=100, ticks='20,24', synth=1, note=64, vel=1) class TestSequencedSynthDrums(AmyTest): @@ -2046,7 +2046,7 @@ def __init__(self): def run(self): # The sequencer working on the SYNTH_FLAGS_NOTES_VIA_MIDI synth 10 (38 = Acoustic Snare). - amy_send_at(time=100, ticks='20,24,0', synth=10, note=38, vel=1) + amy_send_at(time=100, ticks='20,24', synth=10, note=38, vel=1) class TestSequencerOsc(AmyTest): @@ -2058,10 +2058,10 @@ class TestSequencerOsc(AmyTest): def run(self): amy_send_at(time=0, osc=0, wave=amy.SINE, freq=1000) # Absolute-tick events: note on at tick 20 (~231 ms), off at tick 40 (~463 ms). - amy.send(osc=0, vel=1, ticks="20,0,1") - amy.send(osc=0, vel=0, ticks="40,0,2") + amy.send(osc=0, vel=1, ticks="20") + amy.send(osc=0, vel=0, ticks="40") # Periodic event: a lower note every 60 ticks, lands once at ~694 ms. - amy.send(osc=1, wave=amy.SINE, freq=500, vel=1, ticks="0,60,3") + amy.send(osc=1, wave=amy.SINE, freq=500, vel=1, ticks="0,60") amy_send_at(time=900, osc=1, vel=0) @@ -2341,4 +2341,3 @@ def main(argv): if __name__ == "__main__": main(sys.argv) - diff --git a/docs/midi.md b/docs/midi.md index 0dcd6643..7cfe0727 100644 --- a/docs/midi.md +++ b/docs/midi.md @@ -83,7 +83,7 @@ amy.send(osc=0, wave=amy.AMY_MIDI) # set up the MIDI sender o # Send a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note. amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # both events accumulate behind tag 1 amy.send(osc=0, note=60, vel=0, ticks="24,48,1") -amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 48)) +amy.send(sequence=1, vel=1, alignment_period=48) ``` AMY keeps sending those MIDI messages out the port at the configured tempo until you stop tag 1 or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index da4eec73..9776531d 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -8,7 +8,9 @@ reset explicitly and controlled with one start/stop operation. There is no second group namespace, separate append command, fourth `ticks` field, explicit length, or publish/revision operation. -`sequence_control` provides: +At the Python API, `amy.send(sequence=tag, vel=...)` makes start and stop look +like note-on and note-off. Internally its compact `sequence_control` operation +provides: - start, optionally aligned to an AMY sequencer period; - stop all active executions of a tag at an optional boundary; diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index 88c49dab..af9acfa1 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -20,17 +20,13 @@ amy.define_sequence(21, [ ```python amy.define_sequence(30, [ - dict(ticks=(0, 48), - sequence_control=(20, amy.SEQUENCE_CONTROL_START, 1)), - dict(ticks=(24, 48), - sequence_control=(21, amy.SEQUENCE_CONTROL_START, 1)), + dict(ticks=(0, 48), sequence=20, vel=1, alignment_period=1), + dict(ticks=(24, 48), sequence=21, vel=1, alignment_period=1), ]) amy.define_sequence(31, [ - dict(ticks=(0, 24), - sequence_control=(20, amy.SEQUENCE_CONTROL_START, 1)), - dict(ticks=(12, 24), - sequence_control=(21, amy.SEQUENCE_CONTROL_START, 1)), + dict(ticks=(0, 24), sequence=20, vel=1, alignment_period=1), + dict(ticks=(12, 24), sequence=21, vel=1, alignment_period=1), ]) ``` @@ -39,11 +35,11 @@ The parents contain periodic events and run until stopped. ## 3. Start and switch ```python -amy.send(sequence_control=(30, amy.SEQUENCE_CONTROL_START, 48)) +amy.send(sequence=30, vel=1, alignment_period=48) # Later, switch both parents at the same boundary. -amy.send(sequence_control=(30, amy.SEQUENCE_CONTROL_STOP, 48)) -amy.send(sequence_control=(31, amy.SEQUENCE_CONTROL_START, 48)) +amy.send(sequence=30, vel=0, alignment_period=48) +amy.send(sequence=31, vel=1, alignment_period=48) ``` The old parent starts no more children at that boundary. A note-pair child diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index fc006c3a..b6c68268 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -37,11 +37,15 @@ With an event payload, `ticks=(0, 0, 40)` is a valid local tick-zero event. ## Starting and stopping ```python -amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_START, 1)) -amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_STOP, 48)) +amy.send(sequence=40, vel=1, alignment_period=1) +amy.send(sequence=40, vel=0, alignment_period=48) ``` -The optional final value is `alignment_period`. `0` or `1` acts at the next +This deliberately resembles note-on/note-off: positive `vel` starts the +sequence and zero stops it. The wire representation remains the lower-level +`sequence_control` operation, so existing command templates can substitute +their value into `HCtag,%v,alignment`. The optional `alignment_period` is the +alignment quantum. `0` or `1` acts at the next available sequencer tick for a direct command. A larger value selects the next global tick divisible by that period. When a sequenced parent starts a child, the child's local tick zero participates in the same tick. diff --git a/docs/tutorial.html b/docs/tutorial.html index eb6df9cf..12dbfabc 100644 --- a/docs/tutorial.html +++ b/docs/tutorial.html @@ -162,23 +162,23 @@

AMY sequencer

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

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

-amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_STOP, 1)) +amy.send(sequence=1, vel=0, alignment_period=1) amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, note=70, ticks=",48,1") -amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 1)) +amy.send(sequence=1, vel=1, alignment_period=1)

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

-amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_STOP, 1)) +amy.send(sequence=1, vel=0, alignment_period=1) amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks="0,384,1") # first slot of a 16 1/8th note drum machine amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,1") # ninth slot in the same tagged sequence -amy.send(sequence_control=(1, amy.SEQUENCE_CONTROL_START, 384)) +amy.send(sequence=1, vel=1, alignment_period=384)
diff --git a/experiments/sampler/play_cleanbreaks.py b/experiments/sampler/play_cleanbreaks.py index e094b0b4..34f170de 100644 --- a/experiments/sampler/play_cleanbreaks.py +++ b/experiments/sampler/play_cleanbreaks.py @@ -73,7 +73,6 @@ def main(): # Line tick 0 up with the first note-on (loading above consumed time). amy.send(reset=amy.RESET_TIMEBASE) t = 0 # ticks - tag = 1 print(f"\n when bars native break") for i, e in enumerate(picks): fit = e['bars'] * BAR_TICKS @@ -85,8 +84,7 @@ def main(): # past by the first render); play the opener directly. amy.send(**kw) else: - amy.send(ticks=[t, 0, tag], **kw) - tag += 1 + amy.send(ticks=[t], **kw) t += fit us_per_tick = int(60000000.0 / (args.bpm * PPQ)) # matches sequencer.c total = int(t * us_per_tick / 1e6 * SR) diff --git a/experiments/sampler/play_sampler.py b/experiments/sampler/play_sampler.py index a5e189dc..6cf4f527 100644 --- a/experiments/sampler/play_sampler.py +++ b/experiments/sampler/play_sampler.py @@ -187,7 +187,7 @@ def demo_hits(args): # Quantized to sequencer ticks live (PPQ/4 ticks per 16th). amy.send(tempo=args.bpm) for i, k in enumerate(order): - amy.send(ticks=[int(i * PPQ / 4), 0, i + 1], osc=(i % 24) + 1, + amy.send(ticks=[int(i * PPQ / 4)], osc=(i % 24) + 1, wave=amy.PCM, preset=presets[k], vel=1) time.sleep(len(order) * step + 2) return @@ -233,7 +233,7 @@ def demo_loops(args): amy.send(**kw) # ...and let the sequencer re-trigger every `fit` ticks after that. if args.loops > 1: - amy.send(ticks=[0, fit, i + 1], **kw) + amy.send(ticks=[0, fit], **kw) # "N loops" = N cycles of the longest break. total_ticks = max(l[4] for l in loops) * args.loops total = int(total_ticks * tick_samples(args.bpm)) From 3060cc0b48ddf65a21e1f862ab422de8db35d327 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:28:50 +0200 Subject: [PATCH 26/52] Test sequence publication allocation failures --- Makefile | 18 ++++++-- src/sequencer.c | 26 +++++++++-- src/sequencer.h | 3 ++ tests/test_sequencer_oom.c | 89 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 tests/test_sequencer_oom.c diff --git a/Makefile b/Makefile index 47cac0b5..a673b960 100644 --- a/Makefile +++ b/Makefile @@ -124,7 +124,8 @@ amy-message: $(OBJECTS) src/amy-message.o # Plain C tests for things the audio-rendering suite can't reach -- e.g. clock # rollovers 50 days out, which you can only hit by fast-forwarding the counters. CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds \ - tests/test_sequencer_sequences \ + tests/test_sequencer_sequences \ + tests/test_sequencer_oom \ 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 \ @@ -132,12 +133,23 @@ CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_ # Static pattern rules, so these win over the generic %.o: %.c above (which # would compile without -Isrc and fail to find amy.h). -$(addsuffix .o,$(CTESTS)): %.o: %.c $(HEADERS) src/patches.h +$(addsuffix .o,$(filter-out tests/test_sequencer_oom,$(CTESTS))): %.o: %.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -Isrc -c $< -o $@ -$(CTESTS): %: %.o $(OBJECTS) +$(filter-out tests/test_sequencer_oom,$(CTESTS)): %: %.o $(OBJECTS) $(CC) $(CFLAGS) $(OBJECTS) $< -Wall $(LIBS) -o $@ +# Build only the sequencer and its OOM test with the test-only allocation hook; +# every other test and every production target uses the ordinary object. +tests/sequencer_oom_impl.o: src/sequencer.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -c $< -o $@ + +tests/test_sequencer_oom.o: tests/test_sequencer_oom.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -Isrc -c $< -o $@ + +tests/test_sequencer_oom: tests/test_sequencer_oom.o tests/sequencer_oom_impl.o $(filter-out src/sequencer.o,$(OBJECTS)) + $(CC) $(CFLAGS) $(filter-out src/sequencer.o,$(OBJECTS)) tests/sequencer_oom_impl.o $< -Wall $(LIBS) -o $@ + ctest: $(CTESTS) @for t in $(CTESTS); do echo "== $$t"; ./$$t || exit 1; done diff --git a/src/sequencer.c b/src/sequencer.c index b0d97987..4fcc489b 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -93,6 +93,23 @@ static size_t stored_sequence_event_bytes = 0; static volatile bool stored_sequence_wire_firing = false; static stored_sequence_definition_t *retired_sequence_definitions = NULL; +#ifdef AMY_SEQUENCE_TESTING +static int32_t stored_sequence_allocations_before_failure = -1; + +void sequencer_test_fail_allocation_after(int32_t successful_allocations) { + stored_sequence_allocations_before_failure = successful_allocations; +} +#endif + +static void *stored_sequence_allocate(uint32_t size, uint32_t caps) { +#ifdef AMY_SEQUENCE_TESTING + if (stored_sequence_allocations_before_failure == 0) return NULL; + if (stored_sequence_allocations_before_failure > 0) + stored_sequence_allocations_before_failure--; +#endif + return malloc_caps(size, caps); +} + static bool checked_array_size(uint32_t count, size_t element_size, size_t *bytes) { if (count > SIZE_MAX / element_size) return false; @@ -157,11 +174,11 @@ static void stored_sequence_reclaim_retired(void) { static stored_sequence_definition_t *stored_sequence_definition_new(void) { stored_sequence_definition_t *definition = - (stored_sequence_definition_t *)malloc_caps( + (stored_sequence_definition_t *)stored_sequence_allocate( sizeof(stored_sequence_definition_t), amy_global.config.ram_caps_synth); if (definition == NULL) return NULL; - definition->events = (stored_sequence_event_t *)malloc_caps( + definition->events = (stored_sequence_event_t *)stored_sequence_allocate( stored_sequence_event_bytes, amy_global.config.ram_caps_synth); if (definition->events == NULL) { free(definition); @@ -178,7 +195,8 @@ static stored_sequence_definition_t *stored_sequence_definition_new(void) { static char *stored_sequence_wire_copy(const char *wire) { size_t len = strlen(wire); - char *copy = (char *)malloc_caps(len + 1, amy_global.config.ram_caps_events); + char *copy = (char *)stored_sequence_allocate( + len + 1, amy_global.config.ram_caps_events); if (copy != NULL) memcpy(copy, wire, len + 1); return copy; } @@ -611,7 +629,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, amy_release_lock(); } stored_sequence_definition_destroy(dead); - amy_oom("stored sequence edit"); + amy_oom("stored sequence edit: out of memory\n"); free(wire); return 0; } diff --git a/src/sequencer.h b/src/sequencer.h index efc5309a..018f92d5 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -40,6 +40,9 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint32_t value, uint32_t alignment_period); void sequencer_sequence_reset_timebase(); +#ifdef AMY_SEQUENCE_TESTING +void sequencer_test_fail_allocation_after(int32_t successful_allocations); +#endif void sequencer_midi_clock_tick(); void sequencer_midi_start(); void sequencer_midi_stop(); diff --git a/tests/test_sequencer_oom.c b/tests/test_sequencer_oom.c new file mode 100644 index 00000000..0fed5c32 --- /dev/null +++ b/tests/test_sequencer_oom.c @@ -0,0 +1,89 @@ +// Allocation-failure regression tests for immutable sequence publication. + +#include +#include +#include +#include + +#include "amy.h" +#include "sequencer.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +static int base_hits = 0; +static int unexpected_hits = 0; + +static void mark_hook(const char *code) { + if (!strcmp(code, "base-head") || !strcmp(code, "base-tail")) + base_hits++; + if (!strcmp(code, "must-not-publish")) unexpected_hits++; +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); +} + +static void define_base(void) { + CHECK(sequencer_sequence_add_wire(1, 0, 0, strdup("zPbase-headZ")), + "base head is defined"); + CHECK(sequencer_sequence_add_wire(1, 4, 0, strdup("zPbase-tailZ")), + "base tail is defined"); +} + +static void test_clone_allocation_failures_preserve_source(void) { + printf("every clone allocation failure preserves the published definition\n"); + // Clone allocation order: definition, event array, then two wire strings. + for (int32_t fail_after = 0; fail_after < 4; ++fail_after) { + sequencer_reset(); + define_base(); + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "source execution pins the definition (failure %" PRIi32 ")", + fail_after); + + char *incoming = strdup("zPmust-not-publishZ"); + sequencer_test_fail_allocation_after(fail_after); + uint8_t appended = sequencer_sequence_add_wire(1, 2, 0, incoming); + sequencer_test_fail_allocation_after(-1); + CHECK(!appended, "allocation failure %" PRIi32 " rejects the edit", + fail_after); + + base_hits = 0; + unexpected_hits = 0; + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "old definition remains startable"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 4); + CHECK(base_hits >= 2 && unexpected_hits == 0, + "failure %" PRIi32 " publishes neither a partial nor corrupt edit", + fail_after); + } +} + +// examples.c calls this; the platform normally provides it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + config.max_sequencer_tags = 4; + config.max_sequence_events = 8; + config.max_sequence_executions = 8; + amy_start(config); + + test_clone_allocation_failures_preserve_source(); + + amy_stop(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall sequence allocation-failure checks passed\n"); + return 0; +} From f22307a3803f40af6aeba2194aaa6a6d8ce2f726 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:30:25 +0200 Subject: [PATCH 27/52] Cover concurrent sequence publication retry --- Makefile | 16 ++-- src/sequencer.c | 17 +++++ src/sequencer.h | 1 + tests/test_sequencer_concurrency.c | 117 +++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 tests/test_sequencer_concurrency.c diff --git a/Makefile b/Makefile index a673b960..d351750e 100644 --- a/Makefile +++ b/Makefile @@ -126,6 +126,7 @@ amy-message: $(OBJECTS) src/amy-message.o CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds \ tests/test_sequencer_sequences \ tests/test_sequencer_oom \ + tests/test_sequencer_concurrency \ tests/test_bus_config tests/test_patch_slots \ tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \ tests/test_timebase_reset tests/test_osc_free_on_release \ @@ -133,22 +134,27 @@ CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_ # Static pattern rules, so these win over the generic %.o: %.c above (which # would compile without -Isrc and fail to find amy.h). -$(addsuffix .o,$(filter-out tests/test_sequencer_oom,$(CTESTS))): %.o: %.c $(HEADERS) src/patches.h +SEQUENCE_SPECIAL_TESTS = tests/test_sequencer_oom tests/test_sequencer_concurrency + +$(addsuffix .o,$(filter-out $(SEQUENCE_SPECIAL_TESTS),$(CTESTS))): %.o: %.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -Isrc -c $< -o $@ -$(filter-out tests/test_sequencer_oom,$(CTESTS)): %: %.o $(OBJECTS) +$(filter-out $(SEQUENCE_SPECIAL_TESTS),$(CTESTS)): %: %.o $(OBJECTS) $(CC) $(CFLAGS) $(OBJECTS) $< -Wall $(LIBS) -o $@ # Build only the sequencer and its OOM test with the test-only allocation hook; # every other test and every production target uses the ordinary object. -tests/sequencer_oom_impl.o: src/sequencer.c $(HEADERS) src/patches.h +tests/sequencer_testing_impl.o: src/sequencer.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -c $< -o $@ tests/test_sequencer_oom.o: tests/test_sequencer_oom.c $(HEADERS) src/patches.h $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -Isrc -c $< -o $@ -tests/test_sequencer_oom: tests/test_sequencer_oom.o tests/sequencer_oom_impl.o $(filter-out src/sequencer.o,$(OBJECTS)) - $(CC) $(CFLAGS) $(filter-out src/sequencer.o,$(OBJECTS)) tests/sequencer_oom_impl.o $< -Wall $(LIBS) -o $@ +tests/test_sequencer_concurrency.o: tests/test_sequencer_concurrency.c $(HEADERS) src/patches.h + $(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -Isrc -c $< -o $@ + +$(SEQUENCE_SPECIAL_TESTS): %: %.o tests/sequencer_testing_impl.o $(filter-out src/sequencer.o,$(OBJECTS)) + $(CC) $(CFLAGS) $(filter-out src/sequencer.o,$(OBJECTS)) tests/sequencer_testing_impl.o $< -Wall $(LIBS) -o $@ ctest: $(CTESTS) @for t in $(CTESTS); do echo "== $$t"; ./$$t || exit 1; done diff --git a/src/sequencer.c b/src/sequencer.c index 4fcc489b..7557d198 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -95,10 +95,15 @@ static stored_sequence_definition_t *retired_sequence_definitions = NULL; #ifdef AMY_SEQUENCE_TESTING static int32_t stored_sequence_allocations_before_failure = -1; +static void (*stored_sequence_after_pin_hook)(void) = NULL; void sequencer_test_fail_allocation_after(int32_t successful_allocations) { stored_sequence_allocations_before_failure = successful_allocations; } + +void sequencer_test_set_after_pin_hook(void (*hook)(void)) { + stored_sequence_after_pin_hook = hook; +} #endif static void *stored_sequence_allocate(uint32_t size, uint32_t caps) { @@ -588,6 +593,9 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, } stored_sequence_reclaim_retired(); +#ifdef AMY_SEQUENCE_TESTING + bool test_pin_hook_called = false; +#endif for (;;) { amy_grab_lock(); stored_sequence_definition_t *source = slot->definition; @@ -618,6 +626,15 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, if (source != NULL) source->refs++; amy_release_lock(); +#ifdef AMY_SEQUENCE_TESTING + // Tests use this one-shot rendezvous to make two writers clone the + // same pinned generation. It is absent from production builds. + if (!test_pin_hook_called && stored_sequence_after_pin_hook != NULL) { + test_pin_hook_called = true; + stored_sequence_after_pin_hook(); + } +#endif + stored_sequence_definition_t *candidate = source == NULL ? stored_sequence_definition_new() : stored_sequence_definition_clone(source); diff --git a/src/sequencer.h b/src/sequencer.h index 018f92d5..91834aa0 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -42,6 +42,7 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, void sequencer_sequence_reset_timebase(); #ifdef AMY_SEQUENCE_TESTING void sequencer_test_fail_allocation_after(int32_t successful_allocations); +void sequencer_test_set_after_pin_hook(void (*hook)(void)); #endif void sequencer_midi_clock_tick(); void sequencer_midi_start(); diff --git a/tests/test_sequencer_concurrency.c b/tests/test_sequencer_concurrency.c new file mode 100644 index 00000000..2fa696ea --- /dev/null +++ b/tests/test_sequencer_concurrency.c @@ -0,0 +1,117 @@ +// Deterministic two-writer publication/retry regression test. + +#include +#include +#include +#include +#include + +#include "amy.h" +#include "sequencer.h" + +static int failures = 0; + +#define CHECK(cond, fmt, ...) do { \ + if (cond) { printf(" ok " fmt "\n", ##__VA_ARGS__); } \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); failures++; } \ +} while (0) + +static pthread_mutex_t rendezvous_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t rendezvous_changed = PTHREAD_COND_INITIALIZER; +static int writers_at_pin = 0; +static int release_writers = 0; +static int a_hits = 0; +static int b_hits = 0; + +static void after_source_pin(void) { + pthread_mutex_lock(&rendezvous_lock); + writers_at_pin++; + if (writers_at_pin == 2) { + release_writers = 1; + pthread_cond_broadcast(&rendezvous_changed); + } else { + while (!release_writers) + pthread_cond_wait(&rendezvous_changed, &rendezvous_lock); + } + pthread_mutex_unlock(&rendezvous_lock); +} + +typedef struct writer_args_t { + uint32_t tick; + const char *wire; + uint8_t result; +} writer_args_t; + +static void *append_event(void *opaque) { + writer_args_t *args = (writer_args_t *)opaque; + args->result = sequencer_sequence_add_wire( + 1, args->tick, 0, strdup(args->wire)); + return NULL; +} + +static void mark_hook(const char *code) { + if (!strcmp(code, "writer-a")) a_hits++; + if (!strcmp(code, "writer-b")) b_hits++; +} + +static void clock_to(uint32_t target) { + while (!AMY_TIME_GEQ(sequencer_ticks(), target)) sequencer_midi_clock_tick(); +} + +static void test_losing_writer_retries_cumulatively(void) { + printf("two writers publishing from one generation both survive\n"); + sequencer_reset(); + CHECK(sequencer_sequence_add_wire(1, 0, 0, strdup("zPbaseZ")), + "base definition exists"); + CHECK(sequencer_sequence_add_wire(1, 6, 0, strdup("zPtailZ")), + "base definition has a finite tail"); + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "an execution pins the shared source generation"); + + writer_args_t a = {2, "zPwriter-aZ", 0}; + writer_args_t b = {4, "zPwriter-bZ", 0}; + pthread_t a_thread; + pthread_t b_thread; + sequencer_test_set_after_pin_hook(after_source_pin); + CHECK(pthread_create(&a_thread, NULL, append_event, &a) == 0, + "writer A starts"); + CHECK(pthread_create(&b_thread, NULL, append_event, &b) == 0, + "writer B starts"); + pthread_join(a_thread, NULL); + pthread_join(b_thread, NULL); + sequencer_test_set_after_pin_hook(NULL); + CHECK(a.result && b.result, "both competing edits report success"); + + a_hits = 0; + b_hits = 0; + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "the cumulatively published generation starts"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start + 6); + CHECK(a_hits == 1 && b_hits == 1, + "the losing compare/retry path loses and duplicates no event"); +} + +// examples.c calls this; the platform normally provides it. +void delay_ms(uint32_t ms) { (void)ms; } + +int main(void) { + amy_config_t config = amy_default_config(); + config.features.startup_bleep = 0; + config.audio = AMY_AUDIO_IS_NONE; + config.amy_external_exec_hook = mark_hook; + config.max_sequencer_tags = 4; + config.max_sequence_events = 8; + config.max_sequence_executions = 8; + amy_start(config); + + test_losing_writer_retries_cumulatively(); + + amy_stop(); + if (failures) { + printf("\n%d check(s) FAILED\n", failures); + return 1; + } + printf("\nall concurrent sequence publication checks passed\n"); + return 0; +} From dcbd2842be2f67f828f1ca1636519700a3c3ea6f Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:36:19 +0200 Subject: [PATCH 28/52] Define bounded sequence composition semantics --- docs/sequencer-sequences-abstractions.md | 5 ++ docs/sequencer-sequences.md | 7 ++- tests/test_sequencer_sequences.c | 73 ++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 9776531d..18ab3a00 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -75,6 +75,11 @@ executions. A cyclic control graph may fill the execution pool, but cannot grow beyond it; later starts fail clearly and the caller can stop a tag or reset the sequencer. +Aligned stop and gate commands capture the executions active when the command +is sent. An execution started later does not inherit previously pending control +state merely because its tag matches. This keeps control ownership on explicit +executions rather than creating a hidden per-tag automation timeline. + The ordinary three-field C event layout remains unchanged. Untagged one-off and periodic scheduling, MIDI/external-clock behavior, and global reset retain their existing behavior. The intentional API change is that a supplied tag now diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index b6c68268..332a5ab0 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -52,8 +52,11 @@ the child's local tick zero participates in the same tick. A start creates a bounded execution. Finite executions of one tag may overlap, so callers do not need execution IDs or note-lifetime bookkeeping. Stop targets -all active executions of that tag. Stopping a parent prevents future child -starts, while children already started retain their own event pairs. +all executions of that tag which are active when the command is sent. If the +stop is aligned to a future boundary, a separate execution started after that +command does not inherit its pending stop. This avoids hidden per-tag control +state. Stopping a parent prevents future child starts, while children already +started retain their own event pairs. ## Finite and repeating lifetime diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index b6c28d29..25e45427 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -309,6 +309,50 @@ static void test_finite_gate_preserves_phase(void) { "event resumes on the original phase after gate expiry"); } +static void test_quantized_stop_targets_current_executions(void) { + printf("quantized controls capture the current execution set\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,4,6zPpulseZ"); + amy_add_message("HC6,1,1Z"); + uint32_t first_start = sequencer_ticks() + 1; + clock_to(first_start); + CHECK(mark_at("pulse", first_start), "first execution begins"); + + CHECK(sequencer_sequence_control(6, SEQUENCE_CONTROL_STOP, 0, 8), + "first execution accepts a future aligned stop"); + uint32_t stop_boundary = next_boundary(sequencer_ticks(), 8); + amy_add_message("HC6,1,1Z"); + uint32_t second_start = sequencer_ticks() + 1; + clock_to(stop_boundary + 4); + CHECK(!mark_at("pulse", stop_boundary), + "the captured execution stops before its boundary event"); + CHECK(mark_at("pulse", second_start) + && mark_at("pulse", second_start + 4), + "a later start does not inherit an earlier pending stop"); +} + +static void test_cyclic_controls_are_bounded_and_recoverable(void) { + printf("cyclic sequence controls remain bounded and recoverable\n"); + sequencer_reset(); + amy_add_message("H0,1,1HC2,1,0Z"); + amy_add_message("H0,1,2HC1,1,0Z"); + amy_add_message("H0,0,3zPrecoveryZ"); + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), + "cycle root starts"); + clock_to(sequencer_ticks() + 1); + CHECK(!sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), + "the cycle fills but cannot exceed the execution pool"); + + CHECK(sequencer_sequence_control(1, SEQUENCE_CONTROL_STOP, 0, 0), + "all active A executions accept stop"); + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_STOP, 0, 0), + "all active B executions accept stop"); + clock_to(sequencer_ticks() + 1); + CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), + "stopping both cycle tags makes the pool reusable"); +} + static void test_per_tag_and_global_reset_semantics(void) { printf("per-tag replacement and global reset have distinct scopes\n"); sequencer_reset(); @@ -413,6 +457,32 @@ static void test_start_crosses_clock_rollover(void) { CHECK(mark_at("wrap-two", 2), "elapsed local time crosses rollover"); } +static void test_gate_and_stop_cross_clock_rollover(void) { + printf("pending gate and stop controls cross uint32 clock rollover\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,2,2zPwrap-pulseZ"); + amy_global.sequencer_tick_count = UINT32_MAX - 4; + amy_add_message("HC2,1,2Z"); + uint32_t start = UINT32_MAX - 3; + clock_to(start); + CHECK(mark_at("wrap-pulse", start), "loop starts before rollover"); + + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_GATE, 4, 1), + "gate spanning rollover is accepted"); + clock_to(2); + CHECK(!mark_at("wrap-pulse", UINT32_MAX - 1) + && !mark_at("wrap-pulse", 0), + "events remain gated on both sides of rollover"); + CHECK(mark_at("wrap-pulse", 2), "gate expires at its wrapped end tick"); + + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_STOP, 0, 4), + "stop aligns to a post-rollover boundary"); + clock_to(4); + CHECK(mark_at("wrap-pulse", 2) && !mark_at("wrap-pulse", 4), + "stop suppresses the event on its aligned boundary"); +} + static void test_disabled_configuration(void) { printf("zero reusable-sequence capacities disable the feature safely\n"); const uint32_t capacities[][2] = {{0, 8}, {8, 0}}; @@ -457,9 +527,12 @@ int main(void) { test_parent_stop_leaves_started_child_to_finish(); test_controller_sequence_bounds_repetition(); test_finite_gate_preserves_phase(); + test_quantized_stop_targets_current_executions(); + test_cyclic_controls_are_bounded_and_recoverable(); test_per_tag_and_global_reset_semantics(); test_timebase_reset_keeps_definitions(); test_start_crosses_clock_rollover(); + test_gate_and_stop_cross_clock_rollover(); test_bounds_and_validation(); test_wire_control_shape_is_strict(); From 092941ca4c38fdcf381fc134ce65e6f6333f7670 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:45:17 +0200 Subject: [PATCH 29/52] Accept note-like sequence control velocity --- amy/__init__.py | 8 +-- docs/sequencer-sequences.md | 9 +-- src/parse.c | 96 ++++++++++++++++++++++---------- src/sequencer.h | 2 +- tests/test_sequence_api.py | 3 + tests/test_sequencer_sequences.c | 24 ++++++++ 6 files changed, 105 insertions(+), 37 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index cb714867..1f73de2a 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -255,7 +255,7 @@ def _sequence_control_values(value): if len(values) < 2: raise ValueError('sequence_control needs at least tag and action.') try: - action = int(values[1]) + action = float(values[1]) except (TypeError, ValueError): # Command templates substitute tokens such as %v before AMY parses HC. if not (isinstance(values[1], str) and values[1].startswith('%')): @@ -263,14 +263,14 @@ def _sequence_control_values(value): if len(values) not in (2, 3): raise ValueError('A templated sequence_control needs tag, action, and optional alignment_period.') return values - if action in (SEQUENCE_CONTROL_STOP, SEQUENCE_CONTROL_START): + if 0 <= action <= 1: if len(values) not in (2, 3): - raise ValueError('A start/stop sequence_control needs tag, action, and optional alignment_period.') + raise ValueError('A start/stop sequence_control needs tag, velocity, and optional alignment_period.') elif action == SEQUENCE_CONTROL_GATE: if len(values) not in (3, 4): raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.') else: - raise ValueError('sequence_control action must be stop=0, start=1, or gate=2.') + raise ValueError('sequence_control velocity/action must be stop=0, start=(0,1], or gate=2.') return values diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index 332a5ab0..768811f1 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -41,10 +41,11 @@ amy.send(sequence=40, vel=1, alignment_period=1) amy.send(sequence=40, vel=0, alignment_period=48) ``` -This deliberately resembles note-on/note-off: positive `vel` starts the -sequence and zero stops it. The wire representation remains the lower-level -`sequence_control` operation, so existing command templates can substitute -their value into `HCtag,%v,alignment`. The optional `alignment_period` is the +This deliberately resembles note-on/note-off: `vel` in the range `(0, 1]` +starts the sequence and zero stops it. The wire representation remains the +lower-level `sequence_control` operation, where that same field has velocity +semantics. Existing command templates can therefore substitute their value +directly into `HCtag,%v,alignment`. The optional `alignment_period` is the alignment quantum. `0` or `1` acts at the next available sequencer tick for a direct command. A larger value selects the next global tick divisible by that period. When a sequenced parent starts a child, diff --git a/src/parse.c b/src/parse.c index 7f5f11da..aae53b8d 100644 --- a/src/parse.c +++ b/src/parse.c @@ -5,6 +5,7 @@ #include "transfer.h" // for amy_dump_state_to_sysex, amy_dump_file_to_sysex #include // for isalpha(). #include +#include #if defined(TULIP) || defined(AMYBOARD) #include "py/runtime.h" #endif @@ -704,6 +705,25 @@ size_t yield_event_from_message(char *message, amy_event *e, size_t pos) { return pos; } +static int sequence_control_uint_tail(const char *cursor, uint32_t *values, + int capacity) { + int count = 0; + while (*cursor == ',') { + ++cursor; + while (*cursor == ' ') ++cursor; + if (!isdigit((unsigned char)*cursor) || count == capacity) return -1; + errno = 0; + char *end = NULL; + unsigned long long parsed = strtoull(cursor, &end, 10); + if (errno == ERANGE || parsed > UINT32_MAX) return -1; + while (*end == ' ') ++end; + values[count++] = (uint32_t)parsed; + cursor = end; + } + if (*cursor != '\0' && (*cursor != 'Z' || cursor[1] != '\0')) return -1; + return count; +} + // Called from amy_add_message when the first char is 'H', indicating a ticks message. // It claims the rest of the message as its payload -- stored as a raw // wire string and only parsed when it comes due -- so a schedule command @@ -717,40 +737,60 @@ void handle_ticks_message(char *message) { return; } if (message[1] == 'C') { - // HCtag,start_or_stop[,alignment_period] + // HCtag,velocity[,alignment_period] // HCtag,gate,duration[,alignment_period] - uint32_t values[5] = {0, 0, 0, 0, 0}; - int count = parse_list_uint32_t(message + 2, values, 5, 0); - char terminator = message[2 + _next_alpha(message + 2)]; - if (terminator != '\0' && terminator != 'Z') { - fprintf(stderr, - "invalid sequence_control: HC must not contain an " - "ordinary AMY payload\n"); - } else if (count < 2) { + const char *tag_start = message + 2; + while (*tag_start == ' ') ++tag_start; + errno = 0; + char *tag_end = NULL; + unsigned long long parsed_tag = strtoull(tag_start, &tag_end, 10); + while (*tag_end == ' ') ++tag_end; + const char *velocity_start = tag_end + 1; + errno = 0; + char *velocity_end = NULL; + float velocity = strtof(velocity_start, &velocity_end); + bool velocity_valid = velocity_end != velocity_start + && errno != ERANGE && isfinite(velocity); + const char *tail = velocity_end; + while (*tail == ' ') ++tail; + uint32_t rest[2] = {0, 0}; + int rest_count = sequence_control_uint_tail(tail, rest, 2); + if (!isdigit((unsigned char)*tag_start) || tag_end == tag_start + || parsed_tag > UINT32_MAX || *tag_end != ',' + || !velocity_valid || rest_count < 0) { fprintf(stderr, "invalid sequence_control: expected " - "HCtag,start_or_stop[,alignment_period] or " - "HCtag,gate,duration[,alignment_period]\n"); - } else if ((values[1] == SEQUENCE_CONTROL_START - || values[1] == SEQUENCE_CONTROL_STOP) - && count != 2 && count != 3) { - fprintf(stderr, - "invalid sequence_control start/stop: expected " - "HCtag,start_or_stop[,alignment_period]\n"); - } else if (values[1] == SEQUENCE_CONTROL_GATE - && count != 3 && count != 4) { - fprintf(stderr, - "invalid sequence_control gate: expected " + "HCtag,velocity[,alignment_period] or " "HCtag,gate,duration[,alignment_period]\n"); - } else if (count > 4) { + return; + } + + uint32_t action = 0; + uint32_t value = 0; + uint32_t alignment = 0; + bool shape_valid = false; + if (velocity >= 0 && velocity <= 1) { + action = velocity > 0 ? SEQUENCE_CONTROL_START + : SEQUENCE_CONTROL_STOP; + shape_valid = rest_count <= 1; + if (rest_count == 1) alignment = rest[0]; + } else if (velocity == SEQUENCE_CONTROL_GATE) { + action = SEQUENCE_CONTROL_GATE; + shape_valid = rest_count >= 1 && rest_count <= 2; + value = rest[0]; + if (rest_count == 2) alignment = rest[1]; + } else { + shape_valid = false; + } + + if (!shape_valid) { fprintf(stderr, - "invalid sequence_control: expected at most four values\n"); + "invalid sequence_control: velocity must be in [0,1], " + "or use gate=2 with a duration; tag, duration, and " + "alignment must be non-negative integers\n"); } else { - uint32_t value = values[1] == SEQUENCE_CONTROL_GATE - ? values[2] : 0; - uint32_t alignment = values[1] == SEQUENCE_CONTROL_GATE - ? values[3] : values[2]; - sequencer_sequence_control(values[0], values[1], value, alignment); + sequencer_sequence_control((uint32_t)parsed_tag, action, value, + alignment); } return; } diff --git a/src/sequencer.h b/src/sequencer.h index 91834aa0..1a674029 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -34,7 +34,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, // Clear the future definition at tag. Executions which already started retain // their immutable definition and may finish. uint8_t sequencer_sequence_reset(uint32_t tag); -// sequence_control is [tag, start_or_stop, alignment_period] or +// sequence_control is [tag, velocity, alignment_period] or // [tag, gate, duration, alignment_period]. uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint32_t value, diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index aa2c7735..72aa51a2 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -22,10 +22,12 @@ def main(): == "H0,0,7n60l1i1Z" assert amy.message(sequence_control=(7, amy.SEQUENCE_CONTROL_START, 48)) \ == "HC7,1,48Z" + assert amy.message(sequence_control=(7, 0.625, 48)) == "HC7,0.625,48Z" assert amy.message(ticks=(0, 48, 3), sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ == "H0,48,3HC7,1,1Z" assert amy.message(sequence=7, vel=1) == "HC7,1,0Z" + assert amy.message(sequence=7, vel=0.625) == "HC7,1,0Z" assert amy.message(sequence=7, vel=0, alignment_period=48) \ == "HC7,0,48Z" assert amy.message(ticks=(0, 48, 3), sequence=7, vel=1, @@ -60,6 +62,7 @@ def main(): expect_error("start/stop", lambda: amy.message(sequence_control=(2, 1, 3, 4))) expect_error("duration", lambda: amy.message(sequence_control=(2, 2))) expect_error("action", lambda: amy.message(sequence_control=(2, 99))) + expect_error("action", lambda: amy.message(sequence_control=(2, -0.1))) expect_error("needs vel", lambda: amy.message(sequence=2)) expect_error("can only be combined", lambda: amy.message( sequence=2, vel=1, synth=1)) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 25e45427..243db417 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -442,6 +442,30 @@ static void test_wire_control_shape_is_strict(void) { uint32_t start = sequencer_ticks() + 1; clock_to(start); CHECK(mark_at("defined", start), "the intact definition still starts"); + + sequencer_reset(); + clear_marks(); + amy_add_message("H0,1,4zPvelocity-startZ"); + amy_add_message("HC4,0.625,1Z"); + start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("velocity-start", start), + "a positive fractional template velocity starts a sequence"); + amy_add_message("HC4,0,1Z"); + clock_to(sequencer_ticks() + 1); + CHECK(!mark_at("velocity-start", sequencer_ticks()), + "zero template velocity stops a sequence"); + + sequencer_reset(); + clear_marks(); + amy_add_message("H0,1,5zPmalformed-startZ"); + amy_add_message("HC5,-0.1,1Z"); + amy_add_message("HC5,0.5,1.5Z"); + amy_add_message("HC5,1,Z"); + amy_add_message("HC4294967296,1Z"); + clock_to(sequencer_ticks() + 2); + CHECK(!marks_named("malformed-start"), + "invalid velocity, integer, empty, and overflowing fields are rejected"); } static void test_start_crosses_clock_rollover(void) { From 33f4c01cb0aeafa8583315fd81a6d61f802096a1 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:46:20 +0200 Subject: [PATCH 30/52] Preserve existing config member offsets --- docs/upgrading.md | 35 ++++++++++++++++++++++++++++++++++- src/amy.h | 7 +++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/upgrading.md b/docs/upgrading.md index 6701af81..0b8a0946 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -3,6 +3,40 @@ Here we will post breaking APIs between releases of AMY and tips on porting. +## Reusable sequencer sequences (unreleased) + +Supplying the same tag in more than one three-value `ticks=` message now +accumulates all those events into a stopped reusable sequence. Previously, a +later event replaced the earlier event at that tag. This intentional change +makes a tag behave like a synth identity: repeated messages build it up. + +Code which only needs direct one-off or periodic scheduling should omit the +tag and keep using one- or two-value `ticks`: + +```python +amy.send(ticks=(20,), synth=1, note=60, vel=1) +amy.send(ticks=(24,), synth=1, note=60, vel=0) +``` + +To replace a tagged definition, reset it explicitly before appending its new +events. The Python helper validates the complete replacement before sending +anything: + +```python +amy.define_sequence(7, [ + dict(ticks=(0,), synth=1, note=60, vel=1), + dict(ticks=(12,), synth=1, note=60, vel=0), +]) +amy.send(sequence=7, vel=1, alignment_period=1) +``` + +The C configuration adds `max_sequence_events` and +`max_sequence_executions`. They are appended to `amy_config_t`; initialize the +structure with `amy_default_config()` and then override named fields, as in all +current AMY examples. Recompile applications together with the updated AMY +headers and library whenever the public configuration structure changes. + + ## 1.0.X -> 1.1.X This is a big change that moves a lot of stuff you used to have to do yourself into AMY itself -- voice and synth handling, note stealing, MIDI, I2S, sequencer. @@ -70,4 +104,3 @@ void loop() { patches_store_patch(&e, "v0w7f0"); // Or whatever the wire string defining your patch is. ``` - diff --git a/src/amy.h b/src/amy.h index 44803f57..6de9e5fd 100644 --- a/src/amy.h +++ b/src/amy.h @@ -891,8 +891,6 @@ typedef struct { uint16_t max_buses; uint8_t ks_oscs; uint32_t max_sequencer_tags; - uint32_t max_sequence_events; - uint32_t max_sequence_executions; uint32_t max_voices; uint32_t max_synths; uint32_t max_memory_patches; @@ -960,6 +958,11 @@ typedef struct { int8_t capture_device_id; int8_t playback_device_id; + // Append new configuration fields here so existing members retain their + // offsets for callers compiled against an earlier amy_config_t layout. + uint32_t max_sequence_events; + uint32_t max_sequence_executions; + } amy_config_t; typedef struct eq_state { From ab5f302017aa0c89fc9e5d2101cf605b26e23fa2 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:48:20 +0200 Subject: [PATCH 31/52] Document and test sequence API migration --- Makefile | 6 +++++- docs/upgrading.md | 8 +++++++- tests/test_js_api.js | 22 ++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 tests/test_js_api.js diff --git a/Makefile b/Makefile index d351750e..dbac073c 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,7 @@ EMSCRIPTEN_OPTIONS = -s WASM=1 --bind \ -s ASYNCIFY -s ASYNCIFY_STACK_SIZE=128000 PYTHON = python3 -.PHONY: default all clean amy-module test ctest web deploy-web godot-api c-api check-c-api +.PHONY: default all clean amy-module test ctest web deploy-web godot-api c-api check-c-api js-api-test default: $(TARGET) all: default @@ -82,6 +82,10 @@ check-c-api: $(PYTHON) scripts/gen_amy_js_api.py --check $(PYTHON) scripts/gen_patches_js.py --check $(PYTHON) scripts/gen_pcm_presets_js.py --check + node tests/test_js_api.js + +js-api-test: + node tests/test_js_api.js SOURCES += src/algorithms.c src/amy.c src/envelope.c src/examples.c src/parse.c \ src/filters.c src/oscillators.c src/pcm.c src/interp_partials.c src/custom.c \ diff --git a/docs/upgrading.md b/docs/upgrading.md index 0b8a0946..c301d4f8 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -10,6 +10,13 @@ accumulates all those events into a stopped reusable sequence. Previously, a later event replaced the earlier event at that tag. This intentional change makes a tag behave like a synth identity: repeated messages build it up. +Tagged events therefore no longer begin repeating merely because they were +defined. Callers which used a unique tag as the replace/remove identity of one +automatically active event must either use tagless direct scheduling or adapt +their wrapper to reset, define, and explicitly start that tag. Updating such a +wrapper should stop the old execution, reset the future definition, append the +replacement events, and start it at the required alignment boundary. + Code which only needs direct one-off or periodic scheduling should omit the tag and keep using one- or two-value `ticks`: @@ -103,4 +110,3 @@ void loop() { e.patch_number = 1024; patches_store_patch(&e, "v0w7f0"); // Or whatever the wire string defining your patch is. ``` - diff --git a/tests/test_js_api.js b/tests/test_js_api.js new file mode 100644 index 00000000..aaee955c --- /dev/null +++ b/tests/test_js_api.js @@ -0,0 +1,22 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const path = require("node:path"); + +require(path.join(__dirname, "..", "src", "amy_api.generated.js")); + +assert.equal( + amy_message({sequence_control: [7, 0.625, 48]}), + "HC7,0.625,48Z" +); +assert.equal( + amy_message({ticks: [0, 48, 3], sequence_control: [7, 1, 1]}), + "H0,48,3HC7,1,1Z" +); +assert.equal(amy_message({sequence_reset: 7}), "HR7Z"); +assert.equal( + amy_message({sequence_control: [7, AMY.SEQUENCE_CONTROL_GATE, 24, 1]}), + "HC7,2,24,1Z" +); + +console.log("JavaScript reusable-sequence API checks passed"); From 36aa150ecc66668dc687db3185210fdc6d814ad1 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:49:37 +0200 Subject: [PATCH 32/52] Simplify stored sequence slots --- src/sequencer.c | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index 7557d198..54206bd0 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -67,10 +67,6 @@ typedef struct stored_sequence_definition_t { struct stored_sequence_definition_t *next_retired; } stored_sequence_definition_t; -typedef struct stored_sequence_slot_t { - stored_sequence_definition_t *definition; -} stored_sequence_slot_t; - typedef struct stored_sequence_execution_t { stored_sequence_definition_t *definition; uint32_t tag; @@ -85,7 +81,7 @@ typedef struct stored_sequence_execution_t { bool gated; } stored_sequence_execution_t; -static stored_sequence_slot_t *stored_sequences = NULL; +static stored_sequence_definition_t **stored_sequences = NULL; static stored_sequence_execution_t *sequence_executions = NULL; static uint32_t max_stored_sequence_events = 0; static uint32_t max_stored_sequence_executions = 0; @@ -245,8 +241,8 @@ static void stored_sequences_clear_definitions(void) { if (stored_sequences == NULL) return; for (int32_t i = 0; i < max_sequences; ++i) { stored_sequence_definition_retire_locked( - stored_sequences[i].definition); - stored_sequences[i].definition = NULL; + stored_sequences[i]); + stored_sequences[i] = NULL; } } @@ -278,7 +274,7 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { size_t slot_bytes = 0; size_t execution_bytes = 0; if (!checked_array_size((uint32_t)max_sequences, - sizeof(stored_sequence_slot_t), &slot_bytes) + sizeof(*stored_sequences), &slot_bytes) || !checked_array_size(events, sizeof(stored_sequence_event_t), &stored_sequence_event_bytes) || !checked_array_size(executions, @@ -292,7 +288,7 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { stored_sequences_deinit(); return; } - stored_sequences = (stored_sequence_slot_t *)malloc_caps( + stored_sequences = (stored_sequence_definition_t **)malloc_caps( slot_bytes, amy_global.config.ram_caps_synth); if (stored_sequences != NULL) memset(stored_sequences, 0, slot_bytes); @@ -524,7 +520,7 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha return 1; } -static stored_sequence_slot_t *stored_sequence_slot(uint32_t tag) { +static stored_sequence_definition_t **stored_sequence_slot(uint32_t tag) { if (stored_sequences == NULL || tag >= (uint32_t)max_sequences) return NULL; return &stored_sequences[tag]; } @@ -560,7 +556,7 @@ static void stored_sequence_candidate_discard( uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, uint32_t period, char *wire) { - stored_sequence_slot_t *slot = stored_sequence_slot(tag); + stored_sequence_definition_t **slot = stored_sequence_slot(tag); if (slot == NULL) { if (stored_sequences == NULL) fprintf(stderr, "cannot append event to sequence %" PRIu32 @@ -598,7 +594,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, #endif for (;;) { amy_grab_lock(); - stored_sequence_definition_t *source = slot->definition; + stored_sequence_definition_t *source = *slot; if (source != NULL && source->event_count >= max_stored_sequence_events) { fprintf(stderr, "cannot append event to sequence %" PRIu32 @@ -653,8 +649,8 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, stored_sequence_definition_append_owned(candidate, tick, period, wire); amy_grab_lock(); - if (slot->definition == source) { - slot->definition = candidate; + if (*slot == source) { + *slot = candidate; stored_sequence_definition_t *dead = NULL; if (source != NULL) { // Drop the old slot ownership and our temporary writer pin. @@ -681,7 +677,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, } uint8_t sequencer_sequence_reset(uint32_t tag) { - stored_sequence_slot_t *slot = stored_sequence_slot(tag); + stored_sequence_definition_t **slot = stored_sequence_slot(tag); if (slot == NULL) { if (stored_sequences == NULL) fprintf(stderr, "cannot reset sequence %" PRIu32 @@ -701,8 +697,8 @@ uint8_t sequencer_sequence_reset(uint32_t tag) { stored_sequence_reclaim_retired(); amy_grab_lock(); - stored_sequence_definition_t *definition = slot->definition; - slot->definition = NULL; + stored_sequence_definition_t *definition = *slot; + *slot = NULL; stored_sequence_definition_t *dead = NULL; if (wire_firing) stored_sequence_definition_retire_locked(definition); else dead = stored_sequence_definition_unref_locked(definition); @@ -727,7 +723,7 @@ static uint32_t sequence_control_tick(uint32_t alignment_period) { uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint32_t value, uint32_t alignment_period) { - stored_sequence_slot_t *slot = stored_sequence_slot(tag); + stored_sequence_definition_t **slot = stored_sequence_slot(tag); if (slot == NULL) { if (stored_sequences == NULL) fprintf(stderr, "cannot control sequence %" PRIu32 @@ -743,7 +739,7 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint8_t result = 0; amy_grab_lock(); if (action == SEQUENCE_CONTROL_START) { - if (slot->definition == NULL || slot->definition->event_count == 0) { + if (*slot == NULL || (*slot)->event_count == 0) { fprintf(stderr, "cannot start sequence %" PRIu32 ": its definition is empty\n", tag); } else { @@ -759,7 +755,7 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, tag, max_stored_sequence_executions); } else { memset(available, 0, sizeof(*available)); - available->definition = slot->definition; + available->definition = *slot; available->definition->refs++; available->tag = tag; available->start_tick = start_tick; From 380f20e15260b100ffd788b8467e740f04532e9d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 20:52:04 +0200 Subject: [PATCH 33/52] Ignore generated sequence test binaries --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index db16b158..77fa66f4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ tests/tst tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds +tests/test_sequencer_sequences +tests/test_sequencer_oom +tests/test_sequencer_concurrency tests/test_bus_config tests/test_patch_slots tests/test_synth_readout From 4aab0fcb0f35d661999f42a36b536e4ba5cfb7f0 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 21:35:10 +0200 Subject: [PATCH 34/52] Use explicit run state for sequence control --- amy/__init__.py | 54 +++++++++++++++++--------------- amy/examples.py | 2 +- src/parse.c | 35 +++++++++++---------- src/sequencer.h | 2 +- tests/test_js_api.js | 4 +-- tests/test_sequence_api.py | 24 ++++++++------ tests/test_sequencer_sequences.c | 16 +++++----- 7 files changed, 73 insertions(+), 64 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index 1f73de2a..d9efe123 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -254,53 +254,57 @@ def _sequence_control_values(value): values = _list_values(value) if len(values) < 2: raise ValueError('sequence_control needs at least tag and action.') - try: - action = float(values[1]) - except (TypeError, ValueError): - # Command templates substitute tokens such as %v before AMY parses HC. - if not (isinstance(values[1], str) and values[1].startswith('%')): - raise ValueError('sequence_control action must be stop=0, start=1, gate=2, or a template token.') + raw_action = values[1] + if isinstance(raw_action, str) and raw_action.startswith('%'): + # Command templates substitute the token before AMY parses HC. The + # resulting wire value must still be the integer 0, 1, or 2. if len(values) not in (2, 3): raise ValueError('A templated sequence_control needs tag, action, and optional alignment_period.') return values - if 0 <= action <= 1: + if isinstance(raw_action, int) and not isinstance(raw_action, bool): + action = raw_action + elif isinstance(raw_action, str) and raw_action.isdigit(): + action = int(raw_action) + else: + raise ValueError('sequence_control action must be an integer: stop=0, start=1, or gate=2.') + if action in (SEQUENCE_CONTROL_STOP, SEQUENCE_CONTROL_START): if len(values) not in (2, 3): - raise ValueError('A start/stop sequence_control needs tag, velocity, and optional alignment_period.') + raise ValueError('A start/stop sequence_control needs tag, run, and optional alignment_period.') elif action == SEQUENCE_CONTROL_GATE: if len(values) not in (3, 4): raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.') else: - raise ValueError('sequence_control velocity/action must be stop=0, start=(0,1], or gate=2.') + raise ValueError('sequence_control action must be stop=0, start=1, or gate=2.') return values -def _normalize_sequence_note(kwargs): - """Translate note-like sequence control into the existing HC primitive.""" +def _normalize_sequence_run(kwargs): + """Translate boolean sequence control into the existing HC primitive.""" if 'sequence' not in kwargs: - if 'alignment_period' in kwargs: - raise ValueError('alignment_period is only valid with sequence.') + for key in ('run', 'alignment_period'): + if key in kwargs: + raise ValueError('%s is only valid with sequence.' % key) return kwargs if 'sequence_control' in kwargs or 'sequence_reset' in kwargs: raise ValueError('sequence cannot be combined with sequence_control or sequence_reset.') - extra = set(kwargs) - {'sequence', 'vel', 'alignment_period', 'ticks'} + extra = set(kwargs) - {'sequence', 'run', 'alignment_period', 'ticks'} if extra: - raise ValueError('sequence can only be combined with vel, alignment_period, and ticks.') - if 'vel' not in kwargs: - raise ValueError('sequence needs vel: use a value above zero to start and zero to stop.') + raise ValueError('sequence can only be combined with run, alignment_period, and ticks.') + if 'run' not in kwargs: + raise ValueError('sequence needs run=True to start or run=False to stop.') tag = int(kwargs['sequence']) if tag < 0: raise ValueError('Sequence tag must be non-negative.') alignment = int(kwargs.get('alignment_period', 0)) if alignment < 0: raise ValueError('Sequence alignment_period must be non-negative.') - velocity = kwargs['vel'] - if isinstance(velocity, str) and velocity.startswith('%'): - action = velocity + run = kwargs['run'] + if isinstance(run, bool): + action = SEQUENCE_CONTROL_START if run else SEQUENCE_CONTROL_STOP + elif isinstance(run, int) and run in (0, 1): + action = run else: - velocity = float(velocity) - if velocity < 0: - raise ValueError('Sequence vel must be non-negative.') - action = SEQUENCE_CONTROL_START if velocity > 0 else SEQUENCE_CONTROL_STOP + raise ValueError('Sequence run must be True/False or numeric 1/0.') normalized = {} if 'ticks' in kwargs: normalized['ticks'] = kwargs['ticks'] @@ -349,7 +353,7 @@ def message(**kwargs): # Each keyword maps to two or three chars, first one or two are the wire protocol prefix, last is an arg type code # I=int, F=float, S=str, L=list, C=ctrl_coefs global show_warnings, _KW_MAP, _KW_PRIORITY, _ARG_HANDLERS - kwargs = _normalize_sequence_note(kwargs) + kwargs = _normalize_sequence_run(kwargs) if show_warnings: # Check for possible user confusions. if 'voices' in kwargs and 'preset' in kwargs and 'osc' not in kwargs: diff --git a/amy/examples.py b/amy/examples.py index 81896a5e..66ed537e 100644 --- a/amy/examples.py +++ b/amy/examples.py @@ -264,7 +264,7 @@ def example_sequencer_drums(): dict(ticks=(24, 96), osc=1, vel=1.0), # counterphase snare dict(ticks=(0, 192), osc=3, vel=1.0), # cowbell every other cycle ]) - amy.send(sequence=0, vel=1, alignment_period=1) + amy.send(sequence=0, run=True, alignment_period=1) def example_fm(): amy.reset() diff --git a/src/parse.c b/src/parse.c index aae53b8d..5abe4cd0 100644 --- a/src/parse.c +++ b/src/parse.c @@ -737,7 +737,7 @@ void handle_ticks_message(char *message) { return; } if (message[1] == 'C') { - // HCtag,velocity[,alignment_period] + // HCtag,run[,alignment_period], where run is exactly 0 or 1. // HCtag,gate,duration[,alignment_period] const char *tag_start = message + 2; while (*tag_start == ' ') ++tag_start; @@ -745,37 +745,38 @@ void handle_ticks_message(char *message) { char *tag_end = NULL; unsigned long long parsed_tag = strtoull(tag_start, &tag_end, 10); while (*tag_end == ' ') ++tag_end; - const char *velocity_start = tag_end + 1; + const char *action_start = *tag_end == ',' ? tag_end + 1 : tag_end; + while (*action_start == ' ') ++action_start; errno = 0; - char *velocity_end = NULL; - float velocity = strtof(velocity_start, &velocity_end); - bool velocity_valid = velocity_end != velocity_start - && errno != ERANGE && isfinite(velocity); - const char *tail = velocity_end; + char *action_end = NULL; + unsigned long long parsed_action = strtoull(action_start, &action_end, + 10); + bool action_valid = isdigit((unsigned char)*action_start) + && action_end != action_start && errno != ERANGE + && parsed_action <= UINT32_MAX; + const char *tail = action_end; while (*tail == ' ') ++tail; uint32_t rest[2] = {0, 0}; int rest_count = sequence_control_uint_tail(tail, rest, 2); if (!isdigit((unsigned char)*tag_start) || tag_end == tag_start || parsed_tag > UINT32_MAX || *tag_end != ',' - || !velocity_valid || rest_count < 0) { + || !action_valid || rest_count < 0) { fprintf(stderr, "invalid sequence_control: expected " - "HCtag,velocity[,alignment_period] or " + "HCtag,run[,alignment_period] (run is 0 or 1) or " "HCtag,gate,duration[,alignment_period]\n"); return; } - uint32_t action = 0; + uint32_t action = (uint32_t)parsed_action; uint32_t value = 0; uint32_t alignment = 0; bool shape_valid = false; - if (velocity >= 0 && velocity <= 1) { - action = velocity > 0 ? SEQUENCE_CONTROL_START - : SEQUENCE_CONTROL_STOP; + if (action == SEQUENCE_CONTROL_STOP + || action == SEQUENCE_CONTROL_START) { shape_valid = rest_count <= 1; if (rest_count == 1) alignment = rest[0]; - } else if (velocity == SEQUENCE_CONTROL_GATE) { - action = SEQUENCE_CONTROL_GATE; + } else if (action == SEQUENCE_CONTROL_GATE) { shape_valid = rest_count >= 1 && rest_count <= 2; value = rest[0]; if (rest_count == 2) alignment = rest[1]; @@ -785,8 +786,8 @@ void handle_ticks_message(char *message) { if (!shape_valid) { fprintf(stderr, - "invalid sequence_control: velocity must be in [0,1], " - "or use gate=2 with a duration; tag, duration, and " + "invalid sequence_control: run must be 0 or 1, or use " + "gate=2 with a duration; tag, duration, and " "alignment must be non-negative integers\n"); } else { sequencer_sequence_control((uint32_t)parsed_tag, action, value, diff --git a/src/sequencer.h b/src/sequencer.h index 1a674029..83923fb7 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -34,7 +34,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, // Clear the future definition at tag. Executions which already started retain // their immutable definition and may finish. uint8_t sequencer_sequence_reset(uint32_t tag); -// sequence_control is [tag, velocity, alignment_period] or +// sequence_control is [tag, run, alignment_period] (run is 0 or 1) or // [tag, gate, duration, alignment_period]. uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint32_t value, diff --git a/tests/test_js_api.js b/tests/test_js_api.js index aaee955c..8382b2c0 100644 --- a/tests/test_js_api.js +++ b/tests/test_js_api.js @@ -6,8 +6,8 @@ const path = require("node:path"); require(path.join(__dirname, "..", "src", "amy_api.generated.js")); assert.equal( - amy_message({sequence_control: [7, 0.625, 48]}), - "HC7,0.625,48Z" + amy_message({sequence_control: [7, 1, 48]}), + "HC7,1,48Z" ); assert.equal( amy_message({ticks: [0, 48, 3], sequence_control: [7, 1, 1]}), diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index 72aa51a2..ffa46dcb 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -22,18 +22,16 @@ def main(): == "H0,0,7n60l1i1Z" assert amy.message(sequence_control=(7, amy.SEQUENCE_CONTROL_START, 48)) \ == "HC7,1,48Z" - assert amy.message(sequence_control=(7, 0.625, 48)) == "HC7,0.625,48Z" assert amy.message(ticks=(0, 48, 3), sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ == "H0,48,3HC7,1,1Z" - assert amy.message(sequence=7, vel=1) == "HC7,1,0Z" - assert amy.message(sequence=7, vel=0.625) == "HC7,1,0Z" - assert amy.message(sequence=7, vel=0, alignment_period=48) \ + assert amy.message(sequence=7, run=True) == "HC7,1,0Z" + assert amy.message(sequence=7, run=1) == "HC7,1,0Z" + assert amy.message(sequence=7, run=False, alignment_period=48) \ == "HC7,0,48Z" - assert amy.message(ticks=(0, 48, 3), sequence=7, vel=1, + assert amy.message(sequence=7, run=0) == "HC7,0,0Z" + assert amy.message(ticks=(0, 48, 3), sequence=7, run=True, alignment_period=1) == "H0,48,3HC7,1,1Z" - assert amy.message(sequence=7, vel="%v", alignment_period=1) \ - == "HC7,%v,1Z" assert amy.message(sequence_reset=7) == "HR7Z" assert amy.message(ticks=(1, 4, 2), synth=1, note=60, vel=1) \ == "H1,4,2n60l1i1Z" @@ -63,11 +61,17 @@ def main(): expect_error("duration", lambda: amy.message(sequence_control=(2, 2))) expect_error("action", lambda: amy.message(sequence_control=(2, 99))) expect_error("action", lambda: amy.message(sequence_control=(2, -0.1))) - expect_error("needs vel", lambda: amy.message(sequence=2)) + expect_error("integer", lambda: amy.message(sequence_control=(2, 0.625))) + expect_error("integer", lambda: amy.message(sequence_control=(2, True))) + expect_error("needs run", lambda: amy.message(sequence=2)) expect_error("can only be combined", lambda: amy.message( - sequence=2, vel=1, synth=1)) + sequence=2, run=True, synth=1)) expect_error("only valid", lambda: amy.message(alignment_period=4, synth=1)) - expect_error("non-negative", lambda: amy.message(sequence=2, vel=-1)) + expect_error("only valid", lambda: amy.message(run=True, synth=1)) + expect_error("True/False", lambda: amy.message(sequence=2, run=0.625)) + expect_error("True/False", lambda: amy.message(sequence=2, run=1.0)) + expect_error("True/False", lambda: amy.message(sequence=2, run=2)) + expect_error("True/False", lambda: amy.message(sequence=2, run="%v")) expect_error("needs a ticks", lambda: amy.define_sequence(2, [{"synth": 1}])) expect_error("needs an AMY payload", lambda: amy.define_sequence( 2, [{"ticks": (0,)}])) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 243db417..f7daab00 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -445,27 +445,27 @@ static void test_wire_control_shape_is_strict(void) { sequencer_reset(); clear_marks(); - amy_add_message("H0,1,4zPvelocity-startZ"); - amy_add_message("HC4,0.625,1Z"); + amy_add_message("H0,1,4zPrun-startZ"); + amy_add_message("HC4,1,1Z"); start = sequencer_ticks() + 1; clock_to(start); - CHECK(mark_at("velocity-start", start), - "a positive fractional template velocity starts a sequence"); + CHECK(mark_at("run-start", start), "run=1 starts a sequence"); amy_add_message("HC4,0,1Z"); clock_to(sequencer_ticks() + 1); - CHECK(!mark_at("velocity-start", sequencer_ticks()), - "zero template velocity stops a sequence"); + CHECK(!mark_at("run-start", sequencer_ticks()), + "run=0 stops a sequence"); sequencer_reset(); clear_marks(); amy_add_message("H0,1,5zPmalformed-startZ"); - amy_add_message("HC5,-0.1,1Z"); + amy_add_message("HC5,-1,1Z"); + amy_add_message("HC5,0.5,1Z"); amy_add_message("HC5,0.5,1.5Z"); amy_add_message("HC5,1,Z"); amy_add_message("HC4294967296,1Z"); clock_to(sequencer_ticks() + 2); CHECK(!marks_named("malformed-start"), - "invalid velocity, integer, empty, and overflowing fields are rejected"); + "invalid action, fractional, empty, and overflowing fields are rejected"); } static void test_start_crosses_clock_rollover(void) { From cc2407ff8898e42694c0dee9b4b67444659fdede Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 21:36:30 +0200 Subject: [PATCH 35/52] Document boolean sequence run control --- amy/examples.py | 2 +- docs/api.md | 2 +- docs/midi.md | 2 +- docs/sequencer-sequences-abstractions.md | 6 +++--- docs/sequencer-sequences-howto.md | 14 +++++++------- docs/sequencer-sequences.md | 14 +++++++------- docs/tutorial.html | 12 ++++++------ docs/upgrading.md | 6 +++++- 8 files changed, 31 insertions(+), 27 deletions(-) diff --git a/amy/examples.py b/amy/examples.py index 66ed537e..b880af8d 100644 --- a/amy/examples.py +++ b/amy/examples.py @@ -257,7 +257,7 @@ def example_sequencer_drums(): # Update high cowbell amy.send(osc=4, note=70) - # Store all parts as one reusable pattern, then start it like a note. + # Store all parts as one reusable pattern, then start it explicitly. amy.define_sequence(0, [ dict(ticks=(0, 24), osc=2, vel=2.0), # hi-hat every eighth note dict(ticks=(0, 96), osc=0, vel=1.0), # bass drum every quarter diff --git a/docs/api.md b/docs/api.md index b59b8e9c..c3283901 100644 --- a/docs/api.md +++ b/docs/api.md @@ -507,7 +507,7 @@ At bus scope only the constant term of `GD`/`GM` is used; a bus sum has no per-n | ------ | -------- | ---------- | ---------- | ------------------------------------- | | `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | `tag` omitted: schedule directly on the global clock. `tag` supplied: append to that reusable sequence using local ticks; repeating a tag cumulates. **If used in a wire string message**, the `H` **must** be the first character of the message. | | `HR` | — | `sequence_reset` | tag | Clear the future definition at one tag; already-started immutable executions may finish. | -| `HC` | — | `sequence_control` | tag,start-or-stop[,alignment] or tag,gate,duration[,alignment] | Start, stop, align, or temporarily gate a reusable tagged sequence. | +| `HC` | — | `sequence_control` | tag,run[,alignment] or tag,gate,duration[,alignment] | Start (`run=1`), stop (`run=0`), align, or temporarily gate (`gate=2`) a reusable tagged sequence. Run is strictly `0` or `1`, not a velocity or fractional value. Python callers can use `amy.send(sequence=tag, run=True/False, alignment_period=...)`. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | | `zY` | **TODO** | `sequencer_run` | 0/1 | Sequencer transport: `zY1` starts the sequencer, `zY0` stops it. Lets a host drive playback without MIDI clock sync (see `external_midi_sync`). | | `zC` | **TODO** | `external_midi_sync` | 0/1/2 | MIDI clock sync: 1 = the sequencer follows incoming MIDI realtime clock/start/stop (0xF8/0xFA/0xFC); 2 = AMY is the clock master, sending those messages (0xF8 at 24 PPQ from the internal tempo, 0xFA/0xFC on transport start/stop); 0 (default) = internal clock, neither follows nor sends. | diff --git a/docs/midi.md b/docs/midi.md index 7cfe0727..19927fd2 100644 --- a/docs/midi.md +++ b/docs/midi.md @@ -83,7 +83,7 @@ amy.send(osc=0, wave=amy.AMY_MIDI) # set up the MIDI sender o # Send a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note. amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # both events accumulate behind tag 1 amy.send(osc=0, note=60, vel=0, ticks="24,48,1") -amy.send(sequence=1, vel=1, alignment_period=48) +amy.send(sequence=1, run=True, alignment_period=48) ``` AMY keeps sending those MIDI messages out the port at the configured tempo until you stop tag 1 or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 18ab3a00..36a2441d 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -8,9 +8,9 @@ reset explicitly and controlled with one start/stop operation. There is no second group namespace, separate append command, fourth `ticks` field, explicit length, or publish/revision operation. -At the Python API, `amy.send(sequence=tag, vel=...)` makes start and stop look -like note-on and note-off. Internally its compact `sequence_control` operation -provides: +At the Python API, `amy.send(sequence=tag, run=True)` starts and +`run=False` stops. This boolean deliberately does not reuse note velocity. +Internally its compact `sequence_control` operation provides: - start, optionally aligned to an AMY sequencer period; - stop all active executions of a tag at an optional boundary; diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index af9acfa1..ad543933 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -20,13 +20,13 @@ amy.define_sequence(21, [ ```python amy.define_sequence(30, [ - dict(ticks=(0, 48), sequence=20, vel=1, alignment_period=1), - dict(ticks=(24, 48), sequence=21, vel=1, alignment_period=1), + dict(ticks=(0, 48), sequence=20, run=True, alignment_period=1), + dict(ticks=(24, 48), sequence=21, run=True, alignment_period=1), ]) amy.define_sequence(31, [ - dict(ticks=(0, 24), sequence=20, vel=1, alignment_period=1), - dict(ticks=(12, 24), sequence=21, vel=1, alignment_period=1), + dict(ticks=(0, 24), sequence=20, run=True, alignment_period=1), + dict(ticks=(12, 24), sequence=21, run=True, alignment_period=1), ]) ``` @@ -35,11 +35,11 @@ The parents contain periodic events and run until stopped. ## 3. Start and switch ```python -amy.send(sequence=30, vel=1, alignment_period=48) +amy.send(sequence=30, run=True, alignment_period=48) # Later, switch both parents at the same boundary. -amy.send(sequence=30, vel=0, alignment_period=48) -amy.send(sequence=31, vel=1, alignment_period=48) +amy.send(sequence=30, run=False, alignment_period=48) +amy.send(sequence=31, run=True, alignment_period=48) ``` The old parent starts no more children at that boundary. A note-pair child diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index 768811f1..b2a3856a 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -37,15 +37,15 @@ With an event payload, `ticks=(0, 0, 40)` is a valid local tick-zero event. ## Starting and stopping ```python -amy.send(sequence=40, vel=1, alignment_period=1) -amy.send(sequence=40, vel=0, alignment_period=48) +amy.send(sequence=40, run=True, alignment_period=1) +amy.send(sequence=40, run=False, alignment_period=48) ``` -This deliberately resembles note-on/note-off: `vel` in the range `(0, 1]` -starts the sequence and zero stops it. The wire representation remains the -lower-level `sequence_control` operation, where that same field has velocity -semantics. Existing command templates can therefore substitute their value -directly into `HCtag,%v,alignment`. The optional `alignment_period` is the +`run` is a boolean: true starts the sequence and false stops it. It is separate +from `vel`, which keeps its usual meaning of note velocity. At the lower-level +`sequence_control` API and on the wire, run is represented by the integer `1` +or `0`: `HCtag,run,alignment`. Fractional values are invalid rather than being +interpreted as a sequence state. The optional `alignment_period` is the alignment quantum. `0` or `1` acts at the next available sequencer tick for a direct command. A larger value selects the next global tick divisible by that period. When a sequenced parent starts a child, diff --git a/docs/tutorial.html b/docs/tutorial.html index 12dbfabc..25805863 100644 --- a/docs/tutorial.html +++ b/docs/tutorial.html @@ -162,23 +162,23 @@

AMY sequencer

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

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

-amy.send(sequence=1, vel=0, alignment_period=1) +amy.send(sequence=1, run=False, alignment_period=1) amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, note=70, ticks=",48,1") -amy.send(sequence=1, vel=1, alignment_period=1) +amy.send(sequence=1, run=True, alignment_period=1)

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

-amy.send(sequence=1, vel=0, alignment_period=1) +amy.send(sequence=1, run=False, alignment_period=1) amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks="0,384,1") # first slot of a 16 1/8th note drum machine amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,1") # ninth slot in the same tagged sequence -amy.send(sequence=1, vel=1, alignment_period=384) +amy.send(sequence=1, run=True, alignment_period=384)
diff --git a/docs/upgrading.md b/docs/upgrading.md index c301d4f8..91e861e5 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -34,9 +34,13 @@ amy.define_sequence(7, [ dict(ticks=(0,), synth=1, note=60, vel=1), dict(ticks=(12,), synth=1, note=60, vel=0), ]) -amy.send(sequence=7, vel=1, alignment_period=1) +amy.send(sequence=7, run=True, alignment_period=1) ``` +Sequence execution is a boolean state, not a note velocity. Use `run=True` or +`run=False` in the Python convenience API. The corresponding low-level and +wire values are the integers `1` and `0`; fractional values are rejected. + The C configuration adds `max_sequence_events` and `max_sequence_executions`. They are appended to `amy_config_t`; initialize the structure with `amy_default_config()` and then override named fields, as in all From 841ccd29e92ff7ed820ff998735f93fd785a97dc Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 21:51:56 +0200 Subject: [PATCH 36/52] Expose named sequence control actions --- amy/__init__.py | 47 +++++++++++++++++++++----------- src/parse.c | 7 +++-- src/sequencer.h | 2 +- tests/test_sequence_api.py | 27 ++++++++++-------- tests/test_sequencer_sequences.c | 8 +++--- 5 files changed, 55 insertions(+), 36 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index d9efe123..106cfe5e 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -269,7 +269,7 @@ def _sequence_control_values(value): raise ValueError('sequence_control action must be an integer: stop=0, start=1, or gate=2.') if action in (SEQUENCE_CONTROL_STOP, SEQUENCE_CONTROL_START): if len(values) not in (2, 3): - raise ValueError('A start/stop sequence_control needs tag, run, and optional alignment_period.') + raise ValueError('A start/stop sequence_control needs tag, action, and optional alignment_period.') elif action == SEQUENCE_CONTROL_GATE: if len(values) not in (3, 4): raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.') @@ -278,37 +278,52 @@ def _sequence_control_values(value): return values -def _normalize_sequence_run(kwargs): - """Translate boolean sequence control into the existing HC primitive.""" +def _normalize_sequence_action(kwargs): + """Translate a named sequence action into the existing HC primitive.""" if 'sequence' not in kwargs: - for key in ('run', 'alignment_period'): + for key in ('action', 'duration', 'alignment_period'): if key in kwargs: raise ValueError('%s is only valid with sequence.' % key) return kwargs if 'sequence_control' in kwargs or 'sequence_reset' in kwargs: raise ValueError('sequence cannot be combined with sequence_control or sequence_reset.') - extra = set(kwargs) - {'sequence', 'run', 'alignment_period', 'ticks'} + extra = set(kwargs) - { + 'sequence', 'action', 'duration', 'alignment_period', 'ticks' + } if extra: - raise ValueError('sequence can only be combined with run, alignment_period, and ticks.') - if 'run' not in kwargs: - raise ValueError('sequence needs run=True to start or run=False to stop.') + raise ValueError('sequence can only be combined with action, duration, alignment_period, and ticks.') + if 'action' not in kwargs: + raise ValueError("sequence needs action='start', 'stop', or 'gate'.") tag = int(kwargs['sequence']) if tag < 0: raise ValueError('Sequence tag must be non-negative.') alignment = int(kwargs.get('alignment_period', 0)) if alignment < 0: raise ValueError('Sequence alignment_period must be non-negative.') - run = kwargs['run'] - if isinstance(run, bool): - action = SEQUENCE_CONTROL_START if run else SEQUENCE_CONTROL_STOP - elif isinstance(run, int) and run in (0, 1): - action = run + action_name = kwargs['action'] + actions = { + 'stop': SEQUENCE_CONTROL_STOP, + 'start': SEQUENCE_CONTROL_START, + 'gate': SEQUENCE_CONTROL_GATE, + } + if not isinstance(action_name, str) or action_name not in actions: + raise ValueError("Sequence action must be 'start', 'stop', or 'gate'.") + action = actions[action_name] + if action == SEQUENCE_CONTROL_GATE: + if 'duration' not in kwargs: + raise ValueError("Sequence action='gate' needs a duration in ticks.") + duration = int(kwargs['duration']) + if duration < 0: + raise ValueError('Sequence gate duration must be non-negative.') + control = (tag, action, duration, alignment) else: - raise ValueError('Sequence run must be True/False or numeric 1/0.') + if 'duration' in kwargs: + raise ValueError('Sequence duration is only valid with action=\'gate\'.') + control = (tag, action, alignment) normalized = {} if 'ticks' in kwargs: normalized['ticks'] = kwargs['ticks'] - normalized['sequence_control'] = (tag, action, alignment) + normalized['sequence_control'] = control return normalized @@ -353,7 +368,7 @@ def message(**kwargs): # Each keyword maps to two or three chars, first one or two are the wire protocol prefix, last is an arg type code # I=int, F=float, S=str, L=list, C=ctrl_coefs global show_warnings, _KW_MAP, _KW_PRIORITY, _ARG_HANDLERS - kwargs = _normalize_sequence_run(kwargs) + kwargs = _normalize_sequence_action(kwargs) if show_warnings: # Check for possible user confusions. if 'voices' in kwargs and 'preset' in kwargs and 'osc' not in kwargs: diff --git a/src/parse.c b/src/parse.c index 5abe4cd0..6f971d03 100644 --- a/src/parse.c +++ b/src/parse.c @@ -737,7 +737,7 @@ void handle_ticks_message(char *message) { return; } if (message[1] == 'C') { - // HCtag,run[,alignment_period], where run is exactly 0 or 1. + // HCtag,action[,alignment_period], for stop=0 or start=1. // HCtag,gate,duration[,alignment_period] const char *tag_start = message + 2; while (*tag_start == ' ') ++tag_start; @@ -763,7 +763,7 @@ void handle_ticks_message(char *message) { || !action_valid || rest_count < 0) { fprintf(stderr, "invalid sequence_control: expected " - "HCtag,run[,alignment_period] (run is 0 or 1) or " + "HCtag,action[,alignment_period] (stop=0, start=1) or " "HCtag,gate,duration[,alignment_period]\n"); return; } @@ -786,7 +786,8 @@ void handle_ticks_message(char *message) { if (!shape_valid) { fprintf(stderr, - "invalid sequence_control: run must be 0 or 1, or use " + "invalid sequence_control: action must be stop=0, " + "start=1, or use " "gate=2 with a duration; tag, duration, and " "alignment must be non-negative integers\n"); } else { diff --git a/src/sequencer.h b/src/sequencer.h index 83923fb7..e837d0c8 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -34,7 +34,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, // Clear the future definition at tag. Executions which already started retain // their immutable definition and may finish. uint8_t sequencer_sequence_reset(uint32_t tag); -// sequence_control is [tag, run, alignment_period] (run is 0 or 1) or +// sequence_control is [tag, action, alignment_period] for stop/start or // [tag, gate, duration, alignment_period]. uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint32_t value, diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index ffa46dcb..393d8a15 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -25,12 +25,12 @@ def main(): assert amy.message(ticks=(0, 48, 3), sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ == "H0,48,3HC7,1,1Z" - assert amy.message(sequence=7, run=True) == "HC7,1,0Z" - assert amy.message(sequence=7, run=1) == "HC7,1,0Z" - assert amy.message(sequence=7, run=False, alignment_period=48) \ + assert amy.message(sequence=7, action="start") == "HC7,1,0Z" + assert amy.message(sequence=7, action="stop", alignment_period=48) \ == "HC7,0,48Z" - assert amy.message(sequence=7, run=0) == "HC7,0,0Z" - assert amy.message(ticks=(0, 48, 3), sequence=7, run=True, + assert amy.message(sequence=7, action="gate", duration=24, + alignment_period=1) == "HC7,2,24,1Z" + assert amy.message(ticks=(0, 48, 3), sequence=7, action="start", alignment_period=1) == "H0,48,3HC7,1,1Z" assert amy.message(sequence_reset=7) == "HR7Z" assert amy.message(ticks=(1, 4, 2), synth=1, note=60, vel=1) \ @@ -63,15 +63,18 @@ def main(): expect_error("action", lambda: amy.message(sequence_control=(2, -0.1))) expect_error("integer", lambda: amy.message(sequence_control=(2, 0.625))) expect_error("integer", lambda: amy.message(sequence_control=(2, True))) - expect_error("needs run", lambda: amy.message(sequence=2)) + expect_error("needs action", lambda: amy.message(sequence=2)) expect_error("can only be combined", lambda: amy.message( - sequence=2, run=True, synth=1)) + sequence=2, action="start", synth=1)) expect_error("only valid", lambda: amy.message(alignment_period=4, synth=1)) - expect_error("only valid", lambda: amy.message(run=True, synth=1)) - expect_error("True/False", lambda: amy.message(sequence=2, run=0.625)) - expect_error("True/False", lambda: amy.message(sequence=2, run=1.0)) - expect_error("True/False", lambda: amy.message(sequence=2, run=2)) - expect_error("True/False", lambda: amy.message(sequence=2, run="%v")) + expect_error("only valid", lambda: amy.message(action="start", synth=1)) + expect_error("start", lambda: amy.message(sequence=2, action=True)) + expect_error("start", lambda: amy.message(sequence=2, action=1)) + expect_error("duration", lambda: amy.message(sequence=2, action="gate")) + expect_error("only valid", lambda: amy.message( + sequence=2, action="start", duration=1)) + expect_error("non-negative", lambda: amy.message( + sequence=2, action="gate", duration=-1)) expect_error("needs a ticks", lambda: amy.define_sequence(2, [{"synth": 1}])) expect_error("needs an AMY payload", lambda: amy.define_sequence( 2, [{"ticks": (0,)}])) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index f7daab00..6d4eac8d 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -445,15 +445,15 @@ static void test_wire_control_shape_is_strict(void) { sequencer_reset(); clear_marks(); - amy_add_message("H0,1,4zPrun-startZ"); + amy_add_message("H0,1,4zPaction-startZ"); amy_add_message("HC4,1,1Z"); start = sequencer_ticks() + 1; clock_to(start); - CHECK(mark_at("run-start", start), "run=1 starts a sequence"); + CHECK(mark_at("action-start", start), "action start=1 starts a sequence"); amy_add_message("HC4,0,1Z"); clock_to(sequencer_ticks() + 1); - CHECK(!mark_at("run-start", sequencer_ticks()), - "run=0 stops a sequence"); + CHECK(!mark_at("action-start", sequencer_ticks()), + "action stop=0 stops a sequence"); sequencer_reset(); clear_marks(); From f03875f239d85f44d7d3d4115e0b51bf6482318d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 21:51:56 +0200 Subject: [PATCH 37/52] Document start stop and gate actions --- amy/examples.py | 2 +- docs/api.md | 2 +- docs/midi.md | 2 +- docs/sequencer-sequences-abstractions.md | 7 ++++--- docs/sequencer-sequences-howto.md | 18 +++++++++--------- docs/sequencer-sequences.md | 20 +++++++++++--------- docs/tutorial.html | 12 ++++++------ docs/upgrading.md | 10 ++++++---- 8 files changed, 39 insertions(+), 34 deletions(-) diff --git a/amy/examples.py b/amy/examples.py index b880af8d..309516ec 100644 --- a/amy/examples.py +++ b/amy/examples.py @@ -264,7 +264,7 @@ def example_sequencer_drums(): dict(ticks=(24, 96), osc=1, vel=1.0), # counterphase snare dict(ticks=(0, 192), osc=3, vel=1.0), # cowbell every other cycle ]) - amy.send(sequence=0, run=True, alignment_period=1) + amy.send(sequence=0, action='start', alignment_period=1) def example_fm(): amy.reset() diff --git a/docs/api.md b/docs/api.md index c3283901..750a7893 100644 --- a/docs/api.md +++ b/docs/api.md @@ -507,7 +507,7 @@ At bus scope only the constant term of `GD`/`GM` is used; a bus sum has no per-n | ------ | -------- | ---------- | ---------- | ------------------------------------- | | `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | `tag` omitted: schedule directly on the global clock. `tag` supplied: append to that reusable sequence using local ticks; repeating a tag cumulates. **If used in a wire string message**, the `H` **must** be the first character of the message. | | `HR` | — | `sequence_reset` | tag | Clear the future definition at one tag; already-started immutable executions may finish. | -| `HC` | — | `sequence_control` | tag,run[,alignment] or tag,gate,duration[,alignment] | Start (`run=1`), stop (`run=0`), align, or temporarily gate (`gate=2`) a reusable tagged sequence. Run is strictly `0` or `1`, not a velocity or fractional value. Python callers can use `amy.send(sequence=tag, run=True/False, alignment_period=...)`. | +| `HC` | — | `sequence_control` | tag,action[,alignment] or tag,gate,duration[,alignment] | Stop (`action=0`), start (`action=1`), align, or temporarily gate (`action=2`) a reusable tagged sequence. Actions are integers, not velocity or fractional values. Python callers use the named `action='stop'`, `'start'`, or `'gate'`; gate also requires `duration`. | | `j` | `tempo` | `tempo` | float | The tempo (BPM, quarter notes) of the sequencer. Defaults to 108.0. | | `zY` | **TODO** | `sequencer_run` | 0/1 | Sequencer transport: `zY1` starts the sequencer, `zY0` stops it. Lets a host drive playback without MIDI clock sync (see `external_midi_sync`). | | `zC` | **TODO** | `external_midi_sync` | 0/1/2 | MIDI clock sync: 1 = the sequencer follows incoming MIDI realtime clock/start/stop (0xF8/0xFA/0xFC); 2 = AMY is the clock master, sending those messages (0xF8 at 24 PPQ from the internal tempo, 0xFA/0xFC on transport start/stop); 0 (default) = internal clock, neither follows nor sends. | diff --git a/docs/midi.md b/docs/midi.md index 19927fd2..96f0f6e3 100644 --- a/docs/midi.md +++ b/docs/midi.md @@ -83,7 +83,7 @@ amy.send(osc=0, wave=amy.AMY_MIDI) # set up the MIDI sender o # Send a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note. amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # both events accumulate behind tag 1 amy.send(osc=0, note=60, vel=0, ticks="24,48,1") -amy.send(sequence=1, run=True, alignment_period=48) +amy.send(sequence=1, action='start', alignment_period=48) ``` AMY keeps sending those MIDI messages out the port at the configured tempo until you stop tag 1 or reset the sequencer. See [the sequencer docs](synth.md) for `tick` / `period` / `tag` details. diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 36a2441d..80480d4a 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -8,9 +8,10 @@ reset explicitly and controlled with one start/stop operation. There is no second group namespace, separate append command, fourth `ticks` field, explicit length, or publish/revision operation. -At the Python API, `amy.send(sequence=tag, run=True)` starts and -`run=False` stops. This boolean deliberately does not reuse note velocity. -Internally its compact `sequence_control` operation provides: +At the Python API, `amy.send(sequence=tag, action='start')`, `action='stop'`, +and `action='gate'` expose the full operation rather than presenting part of it +as a boolean. These named actions do not reuse note velocity. Internally the +compact `sequence_control` operation provides: - start, optionally aligned to an AMY sequencer period; - stop all active executions of a tag at an optional boundary; diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index ad543933..3c1d57a0 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -20,13 +20,13 @@ amy.define_sequence(21, [ ```python amy.define_sequence(30, [ - dict(ticks=(0, 48), sequence=20, run=True, alignment_period=1), - dict(ticks=(24, 48), sequence=21, run=True, alignment_period=1), + dict(ticks=(0, 48), sequence=20, action='start', alignment_period=1), + dict(ticks=(24, 48), sequence=21, action='start', alignment_period=1), ]) amy.define_sequence(31, [ - dict(ticks=(0, 24), sequence=20, run=True, alignment_period=1), - dict(ticks=(12, 24), sequence=21, run=True, alignment_period=1), + dict(ticks=(0, 24), sequence=20, action='start', alignment_period=1), + dict(ticks=(12, 24), sequence=21, action='start', alignment_period=1), ]) ``` @@ -35,11 +35,11 @@ The parents contain periodic events and run until stopped. ## 3. Start and switch ```python -amy.send(sequence=30, run=True, alignment_period=48) +amy.send(sequence=30, action='start', alignment_period=48) # Later, switch both parents at the same boundary. -amy.send(sequence=30, run=False, alignment_period=48) -amy.send(sequence=31, run=True, alignment_period=48) +amy.send(sequence=30, action='stop', alignment_period=48) +amy.send(sequence=31, action='start', alignment_period=48) ``` The old parent starts no more children at that boundary. A note-pair child @@ -78,14 +78,14 @@ can suppress its events for one quarter note at 48 PPQ without stopping its clock: ```python -amy.send(sequence_control=(50, amy.SEQUENCE_CONTROL_GATE, 48, 1)) +amy.send(sequence=50, action='gate', duration=48, alignment_period=1) ``` After 48 ticks the gate expires and events resume on their original phase. Duration zero removes a current gate explicitly: ```python -amy.send(sequence_control=(50, amy.SEQUENCE_CONTROL_GATE, 0, 1)) +amy.send(sequence=50, action='gate', duration=0, alignment_period=1) ``` The equivalent wire messages are `HC50,2,48,1Z` and `HC50,2,0,1Z`. Their diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index b2a3856a..92b17179 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -37,16 +37,18 @@ With an event payload, `ticks=(0, 0, 40)` is a valid local tick-zero event. ## Starting and stopping ```python -amy.send(sequence=40, run=True, alignment_period=1) -amy.send(sequence=40, run=False, alignment_period=48) +amy.send(sequence=40, action='start', alignment_period=1) +amy.send(sequence=40, action='stop', alignment_period=48) +amy.send(sequence=40, action='gate', duration=24, alignment_period=1) ``` -`run` is a boolean: true starts the sequence and false stops it. It is separate -from `vel`, which keeps its usual meaning of note velocity. At the lower-level -`sequence_control` API and on the wire, run is represented by the integer `1` -or `0`: `HCtag,run,alignment`. Fractional values are invalid rather than being -interpreted as a sequence state. The optional `alignment_period` is the -alignment quantum. `0` or `1` acts at the next +The named actions expose the complete control model: `start` creates an +execution, `stop` terminates the selected executions, and `gate` temporarily +suppresses their ordinary events for the required `duration`. `vel` keeps its +usual meaning of note velocity. At the lower-level `sequence_control` API and +on the wire, actions use integers: stop `0`, start `1`, and gate `2`. +Fractional values are invalid. The optional `alignment_period` is the alignment +quantum. `0` or `1` acts at the next available sequencer tick for a direct command. A larger value selects the next global tick divisible by that period. When a sequenced parent starts a child, the child's local tick zero participates in the same tick. @@ -72,7 +74,7 @@ No explicit sequence length or publish action is needed: ## Temporary event gating ```python -amy.send(sequence_control=(40, amy.SEQUENCE_CONTROL_GATE, 24, 1)) +amy.send(sequence=40, action='gate', duration=24, alignment_period=1) ``` This suppresses ordinary event dispatch from active executions of tag `40` for diff --git a/docs/tutorial.html b/docs/tutorial.html index 25805863..035cdf50 100644 --- a/docs/tutorial.html +++ b/docs/tutorial.html @@ -162,23 +162,23 @@

AMY sequencer

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

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

-amy.send(sequence=1, run=False, alignment_period=1) +amy.send(sequence=1, action='stop', alignment_period=1) amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, note=70, ticks=",48,1") -amy.send(sequence=1, run=True, alignment_period=1) +amy.send(sequence=1, action='start', alignment_period=1)

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

-amy.send(sequence=1, run=False, alignment_period=1) +amy.send(sequence=1, action='stop', alignment_period=1) amy.send(sequence_reset=1) amy.send(osc=0, vel=1, wave=amy.PCM, preset=0, ticks="0,384,1") # first slot of a 16 1/8th note drum machine amy.send(osc=1, vel=1, wave=amy.PCM, preset=3, ticks="216,384,1") # ninth slot in the same tagged sequence -amy.send(sequence=1, run=True, alignment_period=384) +amy.send(sequence=1, action='start', alignment_period=384)
diff --git a/docs/upgrading.md b/docs/upgrading.md index 91e861e5..a4a16385 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -34,12 +34,14 @@ amy.define_sequence(7, [ dict(ticks=(0,), synth=1, note=60, vel=1), dict(ticks=(12,), synth=1, note=60, vel=0), ]) -amy.send(sequence=7, run=True, alignment_period=1) +amy.send(sequence=7, action='start', alignment_period=1) ``` -Sequence execution is a boolean state, not a note velocity. Use `run=True` or -`run=False` in the Python convenience API. The corresponding low-level and -wire values are the integers `1` and `0`; fractional values are rejected. +Sequence control is an explicit action, not a note velocity. Use +`action='start'`, `action='stop'`, or `action='gate'` in the Python convenience +API; gate additionally requires `duration`. The corresponding low-level and +wire action values are the integers `1`, `0`, and `2`; fractional values are +rejected. The C configuration adds `max_sequence_events` and `max_sequence_executions`. They are appended to `amy_config_t`; initialize the From 596047be3d5a9013822660f996279356e2492abe Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:12:12 +0200 Subject: [PATCH 38/52] Document reusable sequence model and compatibility --- docs/sequencer-sequences-abstractions.md | 222 +++++++++++------- docs/sequencer-sequences-howto.md | 119 ++++++++-- docs/sequencer-sequences-musical-use-cases.md | 99 ++++---- docs/sequencer-sequences-status.md | 154 ++++++++++++ docs/sequencer-sequences.md | 19 +- docs/synth.md | 6 +- docs/upgrading.md | 4 + 7 files changed, 458 insertions(+), 165 deletions(-) create mode 100644 docs/sequencer-sequences-status.md diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index 80480d4a..b5422873 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -1,88 +1,138 @@ # Reusable sequence abstractions and implementation -## Public model - -The existing sequencer tag is the sequence identity. Every ordinary -`ticks=(tick, period, tag)` message appends an event to that tag. The tag is -reset explicitly and controlled with one start/stop operation. There is no -second group namespace, separate append command, fourth `ticks` field, -explicit length, or publish/revision operation. - -At the Python API, `amy.send(sequence=tag, action='start')`, `action='stop'`, -and `action='gate'` expose the full operation rather than presenting part of it -as a boolean. These named actions do not reuse note velocity. Internally the -compact `sequence_control` operation provides: - -- start, optionally aligned to an AMY sequencer period; -- stop all active executions of a tag at an optional boundary; -- gate ordinary events for a finite duration without resetting local phase. - -Sequences may start or stop other sequences. A finite controller can therefore -express a fixed repeat count, and a parent can stop launching new note-pair -children while children already in progress deliver their note-offs. - -## Why executions exist internally - -A stored definition and an active execution have different lifetimes without -being different public abstractions. An execution needs a local start tick and -must retain the event data it began with. Otherwise editing a future phrase -could remove a note-off or alter a fill already sounding. - -AMY therefore uses a bounded execution pool and reference-counted copy-on-write -definitions. Editing a definition used by an execution clones it. The active -execution keeps its old snapshot; later starts see the new contents. No -revision number or execution ID is exposed. - -The copy is constructed while the old definition is pinned, but outside the -queue lock also used by rendering. Publication is a short checked pointer swap. -When the last execution releases an obsolete definition, the render path links -it onto an intrusive retirement list; a later non-rendering control call -detaches that list and performs the variable-time string and heap frees. The -audio path therefore neither copies nor frees a definition. Internally fired -wire payloads bypass the public wire-ingest boundary, while that public boundary -drains the retirement list after parsing. This makes reclamation a structural -control-path property rather than a best-effort test of concurrent render state. - -This is reference-counted deferred reclamation, not a tracing garbage -collector. A fixed two-buffer ping-pong is insufficient because overlapping or -repeating executions can retain more than two generations at once. Allocating -versions only when an active definition is edited keeps the normal preload path -linear and bounds retained generations through the configured execution pool. -This matters in particular on embedded targets, where allocator and external- -memory/cache latency must not extend a render-thread critical section. - -Finite executions of one tag may overlap. This supports phrases whose note -gate exceeds their trigger interval without transferring note state to the -caller. - -## Lifetime inference and tick processing - -If every event has `period=0`, the execution retires after its greatest local -tick. If any event has a nonzero period, it remains active until stopped. - -Only untagged scheduled entries and active sequence executions are visited per -tick. Stored inactive definitions have no per-tick cost. Sequence controls are -processed before ordinary events, so a boundary stop prevents an event on that -boundary and a child start can include local tick zero on the same tick. - -Gating suppresses ordinary payload dispatch while elapsed local time advances. -Control events are not gated, preventing a controller from muting its own -recovery operation. - -## Bounds and recovery - -Startup configuration bounds tags, events per definition, and simultaneous -executions. A cyclic control graph may fill the execution pool, but cannot grow -beyond it; later starts fail clearly and the caller can stop a tag or reset the -sequencer. - -Aligned stop and gate commands capture the executions active when the command -is sent. An execution started later does not inherit previously pending control -state merely because its tag matches. This keeps control ownership on explicit -executions rather than creating a hidden per-tag automation timeline. - -The ordinary three-field C event layout remains unchanged. Untagged one-off -and periodic scheduling, MIDI/external-clock behavior, and global reset retain -their existing behavior. The intentional API change is that a supplied tag now -creates a stopped reusable sequence and repeated writes cumulate instead of -replacing one scheduled event. +## Public abstractions + +### Definition + +A three-value `ticks=(tick, period, tag)` event contributes one ordinary AMY +event to the reusable definition identified by `tag`. Repeating the tag +accumulates events. Ticks in a definition are local to each execution. + +`amy.define_sequence(tag, events)` is a Python replace-as-a-list convenience: +it validates every event, resets the future definition, and then sends the +tagged events. `sequence_reset=tag` resets only the definition used by future +starts. It does not rewrite an execution which already started. + +### Execution + +The action `start` creates an execution with its own local start tick. Several +finite executions of the same definition may overlap. The action `stop` +selects all executions of the tag which are active when the action is issued. +If the stop is aligned to a later boundary, an execution started after the +stop request is not implicitly captured by it. + +An execution containing only period-zero events is finite and retires after +its greatest local tick. If any event has a nonzero period, the execution +repeats until stopped. + +### Gate + +The action `gate` suppresses ordinary event dispatch for a duration while +local phase advances. It does not stop audio which is already ringing. +Sequence-control events continue to run while gated, allowing a finite +controller sequence to restore or change another sequence without being +blocked by its own gate. + +### Composition + +A stored payload may be an ordinary AMY event or a control for another +sequence. A finite sequence can therefore launch note gestures, control a +periodic sequence for a fixed number of repeats, or coordinate several +independent phrases. Cycles are not recursively expanded through C call +frames: each successful start occupies a slot in the bounded execution pool, +so a cyclic graph fails further starts once that pool is full and remains +recoverable through stop or reset. + +## Event ordering and ownership + +For a given tick, sequence controls are processed before ordinary events. A +stop on a boundary therefore prevents the ordinary event on that boundary, +and a child start can include the child's local tick-zero event on the same +tick. + +Stopping an execution cancels its future payloads. AMY cannot synthesize a +generic inverse for arbitrary events: a payload may change a filter, load a +patch, start another sequence, or send a note. If a phrase must complete a +release, store that release in a finite child and stop the parent which creates +future children. If the caller intentionally stops the child itself, its +remaining payloads are intentionally cancelled. + +## Immutable snapshots + +A definition and an execution have different lifetimes. Once an execution +starts, it holds a reference to the exact definition version it observed. +Changing the tag publishes a new version for future starts; existing +executions continue to read their old versions. This prevents a live edit from +removing a pending note-off or changing another payload halfway through a +phrase. + +The implementation uses copy-on-write snapshot semantics. A definition owned +only by its tag can be appended in place. If an execution or competing writer +also holds it, an editor pins that source and constructs a complete candidate +copy. This is the data-versioning rule; it is not by itself sufficient for a +real-time audio thread because copying and freeing are variable-time work. + +## RCU-like publication and deferred reclamation + +Candidate construction happens outside `amy_queue_lock`. After cloning the +events and their wire strings, the editor briefly reacquires the lock and +publishes the candidate only if the tag still points to the source it cloned. +Publication is therefore a checked pointer swap. If another writer won the +race, the losing writer discards its private candidate outside the lock and +retries from the newly published definition. Concurrent cumulative writers do +not silently lose one another's events. + +Executions act as readers by retaining references to their immutable versions. +When the render path releases the last reference, it does not free the event +array or its strings. It links the definition onto an intrusive retired list, +which requires no allocation. A later non-rendering command boundary detaches +that list under the lock and performs destruction after releasing the lock. +Internally fired sequence payloads bypass the public command boundary so they +cannot accidentally reclaim memory from the render path. + +This is an RCU-like publication and reclamation scheme with explicit reference +counts, not a tracing garbage collector. Copy-on-write still describes how a +new immutable version is created; RCU-like publication describes how readers +continue safely and how old versions are retired without waiting or freeing on +the audio path. + +Two fixed ping-pong buffers are insufficient. Multiple overlapping or +indefinitely repeating executions may retain more than two historical +generations while additional edits are published. Explicit references allow +exactly the generations which remain in use to survive. A general garbage +collector would add machinery without improving that already-known ownership. + +## Why this matters on ESP32 + +At 48 kHz with 128-sample render blocks, one block represents approximately +2.67 ms. Heap allocation, copying many variable-length wire strings, heap +coalescing, PSRAM/cache latency, and destruction of an entire definition are +not usefully bounded operations within that deadline. Performing them while +holding the lock shared with sequence rendering can turn an infrequent live +edit into an audio dropout. + +The current design limits the shared-lock publication step to reference +updates, validation, and a pointer swap. The render path releases references +and links retired objects without allocating or freeing. This removes the +known variable-time definition work from the render critical section. + +That architecture reduces and bounds the source-level risk; it is not a claim +that every ESP32 configuration is proven hard real-time. Final assurance still +requires measurement on the target board with the intended sample rate, block +size, memory capabilities, effects load, concurrent authoring traffic, heap +low-water mark, and worst observed render deadline. + +## Capacity and per-tick cost + +`max_sequencer_tags` bounds definition identities. `max_sequence_events` +bounds events in one definition, and `max_sequence_executions` bounds active +or alignment-pending executions. Definitions allocate lazily. The tick loop +visits active executions and directly scheduled entries, not every inactive +definition. + +Allocation failure, a full definition, an unavailable execution slot, an +invalid tag, and malformed action shapes fail with diagnostics. A failed +publication leaves the previously published definition intact. + +See [Status and compatibility](sequencer-sequences-status.md) for validated +behavior, platform limits, and migration guidance. diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index 3c1d57a0..c3931b7a 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -1,93 +1,160 @@ # Reusable sequence how-to -This example preloads two arpeggios and switches between them without cutting -short a note which already started. +This example preloads two arpeggios, starts one, and switches to the other on a +musical boundary. Python is the primary interface; the equivalent wire +messages are collected afterward. -## 1. Define note-pair sequences +AMY's sequencer uses 48 ticks per quarter note. The example gives every note +an 18-tick gate and uses 48 ticks as its switching boundary. + +## 1. Define complete note gestures + +Store each note-on together with its note-off in a finite sequence: ```python +import amy + amy.define_sequence(20, [ dict(ticks=(0,), synth=1, note=60, vel=1), dict(ticks=(18,), synth=1, note=60, vel=0), ]) + amy.define_sequence(21, [ dict(ticks=(0,), synth=1, note=64, vel=1), dict(ticks=(18,), synth=1, note=64, vel=0), ]) ``` -## 2. Define two arpeggio parents +Both definitions contain only period-zero events. Each start therefore creates +a finite execution which retires after its tick-18 note-off. + +## 2. Define two arpeggios + +The slower arpeggio starts the two note gestures half a quarter note apart. +The faster one starts them an eighth note apart: ```python amy.define_sequence(30, [ - dict(ticks=(0, 48), sequence=20, action='start', alignment_period=1), - dict(ticks=(24, 48), sequence=21, action='start', alignment_period=1), + dict(ticks=(0, 48), sequence=20, + action='start', alignment_period=1), + dict(ticks=(24, 48), sequence=21, + action='start', alignment_period=1), ]) amy.define_sequence(31, [ - dict(ticks=(0, 24), sequence=20, action='start', alignment_period=1), - dict(ticks=(12, 24), sequence=21, action='start', alignment_period=1), + dict(ticks=(0, 24), sequence=20, + action='start', alignment_period=1), + dict(ticks=(12, 24), sequence=21, + action='start', alignment_period=1), ]) ``` -The parents contain periodic events and run until stopped. +The nonzero periods make these parent executions repeat until explicitly +stopped. A stored sequence may contain ordinary AMY events or controls for +other sequences. ## 3. Start and switch ```python amy.send(sequence=30, action='start', alignment_period=48) -# Later, switch both parents at the same boundary. +# Later: stop the old parent and start the new one at the same boundary. amy.send(sequence=30, action='stop', alignment_period=48) amy.send(sequence=31, action='start', alignment_period=48) ``` -The old parent starts no more children at that boundary. A note-pair child -started earlier remains independent and still sends its tick-18 note-off. +The stop prevents sequence 30 from launching another child at the selected +boundary. A note gesture launched before that boundary is an independent +execution, so it still sends its original note-off. The caller does not need +to mirror AMY's tick count or remember pending releases. + +Start may be sent again while an earlier finite execution of the same tag is +active. Each execution has its own local start tick and immutable definition +snapshot. + +## 4. Stop playback + +```python +amy.send(sequence=31, action='stop', alignment_period=48) +``` + +Stopping a parent cancels its future child launches. Stopping a leaf such as +sequence 20 instead deliberately cancels the future events of every selected +active leaf execution, including any pending note-off. This lets the caller +choose between a graceful parent stop and explicit truncation.
-Equivalent low-level wire messages +Equivalent wire messages + +`H,,Z` appends a normal event to a reusable +definition. `HRZ` resets future contents. `HC` uses action `0` for stop, +`1` for start, and `2` for gate. ```text HR20Z H0,0,20n60l1i1Z H18,0,20n60l0i1Z + HR21Z H0,0,21n64l1i1Z H18,0,21n64l0i1Z + HR30Z H0,48,30HC20,1,1Z H24,48,30HC21,1,1Z + HR31Z H0,24,31HC20,1,1Z H12,24,31HC21,1,1Z + HC30,1,48Z HC30,0,48Z HC31,1,48Z +HC31,0,48Z ``` -Ordinary `Htick,period,tag...` messages cumulate behind the tag. `HR` resets -one definition and `HC` controls its executions. +The final field of each `HC` message is the alignment period. Direct controls +with alignment `0` or `1` act on the next available sequencer tick; a larger +value selects the next global tick divisible by that value.
-## Temporarily gate one percussion layer +## Temporarily gate one layer -Suppose tag `50` is already running a periodic percussion sequence. A caller -can suppress its events for one quarter note at 48 PPQ without stopping its -clock: +Suppose sequence 50 is a running periodic percussion layer. Suppress its +ordinary events for one quarter note without stopping its local clock: ```python -amy.send(sequence=50, action='gate', duration=48, alignment_period=1) +amy.send( + sequence=50, + action='gate', + duration=48, + alignment_period=1, +) ``` -After 48 ticks the gate expires and events resume on their original phase. -Duration zero removes a current gate explicitly: +After 48 ticks, ordinary event dispatch resumes on the original phase. Audio +which was already ringing is not cut off. A zero-duration gate removes the +current gate at the selected boundary: ```python -amy.send(sequence=50, action='gate', duration=0, alignment_period=1) +amy.send( + sequence=50, + action='gate', + duration=0, + alignment_period=1, +) +``` + +
+Equivalent gate wire messages + +```text +HC50,2,48,1Z +HC50,2,0,1Z ``` -The equivalent wire messages are `HC50,2,48,1Z` and `HC50,2,0,1Z`. Their -source may be a foot pedal, UI, network controller, or another sequence; AMY -only sees generic tagged sequence control. +
+ +For the complete lifecycle and reset rules, see +[Reusable sequences](sequencer-sequences.md). diff --git a/docs/sequencer-sequences-musical-use-cases.md b/docs/sequencer-sequences-musical-use-cases.md index 4f1f087a..06ad3cc9 100644 --- a/docs/sequencer-sequences-musical-use-cases.md +++ b/docs/sequencer-sequences-musical-use-cases.md @@ -1,62 +1,75 @@ # Musical use cases for reusable sequences -Reusable sequences reduce controller complexity when a musical phrase contains -several events but should be launched as one unit. The examples below describe -generic rhythm-engine behavior; AMY assigns no musical meaning to a tag. +Reusable sequences let a caller define a collection of ordinary AMY events +once and launch that collection as one musical unit. AMY gives no musical +meaning to a sequence tag: a sequence may contain notes, parameter changes, or +controls for other sequences. -## Preloaded fills +## Preloaded fills and phrases -A rhythm engine can preload each fill once as a finite tagged sequence. Its -root schedule then stores only sequence starts. Selecting or deselecting a fill -changes future root launches, not the complete fill body. +A rhythm engine can preload each fill or phrase as a finite sequence. Its live +schedule then needs only a sequence start instead of another copy of every +event in the phrase. This keeps controller traffic and controller code small +even when the phrase catalogue is large. -An already-started fill holds its immutable definition and finishes even if its -future launches are removed. The controller does not calculate an end time, -stream the phrase repeatedly, or maintain an active-fill state machine. +An execution retains the definition with which it started. Rebuilding the +stored definition affects later starts but does not alter a phrase already in +progress. The caller therefore does not need to stream the phrase repeatedly, +calculate when it ends, or track which definition version is sounding. -## Arpeggios and note lifetime +## Arpeggios with complete note ownership -A short child sequence can contain one note-on and its matching note-off. A -parent sequence starts these children in an arpeggio pattern. Stopping or -replacing the parent prevents future child starts; children which already -started keep their scheduled release. +A short finite sequence can hold a note-on together with its matching +note-off. A periodic parent sequence can start these note-pair sequences in an +arpeggio pattern. -This makes live rate, direction, voicing, or chord changes predictable without -requiring the controller to mirror AMY's clock or remember which note-offs are -still pending. Starting the same finite child again may overlap with an older -execution; each execution retains its own event snapshot. +Stopping or replacing the parent prevents later child starts. Children which +already started remain independent and deliver their original note-offs. A +live change of rate, direction, voicing, or harmony can therefore be expressed +without mirroring AMY's clock or maintaining pending-note state in the caller. -An explicit stop of the child tag has the different, generic meaning of -terminating every active execution of that child. A caller can therefore choose -between stopping future launches at a parent and deliberately truncating the -leaf itself. +Starting the same child again while an older execution is active is valid. +This permits note gates to overlap their trigger interval. If a caller instead +wants to truncate every active instance of the child, it can explicitly stop +the child's tag. -## Temporarily reducing a rhythm +## Temporarily thinning a rhythm -A repeating percussion layer can be represented by a periodic sequence. A -finite gate suppresses its ordinary events for a chosen number of ticks while -its local phase keeps advancing. Once the gate expires, it resumes at the point -it would otherwise have reached; already-ringing audio is unaffected. +A repeating percussion layer can be stored as a periodic sequence. The `gate` +action suppresses its ordinary event dispatch for a chosen number of ticks +while local phase continues. When the gate expires, the layer resumes where it +would otherwise have been. -The controller decides which musical layer a tag represents and which layers -to gate. AMY implements only generic event dispatch, duration, and phase. +This action does not silence audio which is already ringing. It controls +future event dispatch and continues to process sequence-control events, so a +controller sequence cannot gate away its own recovery. The caller decides +which tags represent musical layers; AMY implements only generic action, +duration, and phase behavior. -## Fixed repeat counts +## A fixed number of repeats -Component periods define looping. When a phrase should repeat exactly `N` -times, a finite controller sequence can start the periodic phrase at tick zero -and stop it at `N * period`. Control processing precedes ordinary events, so the -event on the stop boundary is not dispatched. +An event with a nonzero period repeats until its execution is stopped. To play +it exactly `N` times, a finite controller sequence can start the periodic +sequence at local tick zero and stop it at `N * period`. -This composes existing concepts instead of adding a separate repeat-mode or -published-length state. +Sequence controls are processed before ordinary events on the same tick, so +the event at the stop boundary is not dispatched. This composes finite and +periodic sequences without adding a separate repeat-counter state. + +## Parameter automation and compound gestures + +Stored events are not limited to notes. A finite sequence can apply filter, +amplitude, pan, effects, patch, or other AMY changes at local ticks. This can +represent a reusable automation curve or a compound control gesture. AMY does +not invent inverse events when such an execution is stopped; the definition +must contain any restoration required by the caller's musical intent. ## Live definition changes -A controller can remove future launches, reset and append the replacement -definition, then install new launches. Executions which started before the -change keep the old snapshot. Future starts use the new contents. +A controller can stop future launches, reset a tag, append a replacement +definition, and start it at a selected alignment. Executions which began before +the change keep their immutable snapshots; later starts use the replacement. -The controller still owns musical policy and transaction ordering, but it does -not own active execution revisions, note lifetime, phrase completion, or the -sequencer clock. +The controller continues to own musical policy and the ordering of the edit. +It does not need to own definition versions, phrase completion, sequence phase, +or note-release bookkeeping. diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md new file mode 100644 index 00000000..3386f375 --- /dev/null +++ b/docs/sequencer-sequences-status.md @@ -0,0 +1,154 @@ +# Reusable sequence status and compatibility + +This document records the implemented interface, the compatibility boundary, +and the validation which still depends on a particular target or downstream +application. It describes the reusable-sequence model in this source tree. + +## Implemented interface + +Python callers normally use named actions: + +```python +amy.send(sequence=40, action='start', alignment_period=48) +amy.send(sequence=40, action='stop', alignment_period=48) +amy.send(sequence=40, action='gate', duration=24, alignment_period=1) +``` + +`amy.define_sequence(tag, events)` is the validated replace-as-a-list helper. +The corresponding lower-level fields are `sequence_reset` and +`sequence_control`. JavaScript and Godot bindings expose those lower-level +fields through the generated API. + +The wire protocol uses: + +| Operation | Wire shape | Meaning | +| --- | --- | --- | +| append | `Htick,period,tagZ` | Add an ordinary event to a definition | +| reset | `HRtagZ` | Clear the definition used by future starts | +| stop | `HCtag,0,alignmentZ` | Stop the selected executions | +| start | `HCtag,1,alignmentZ` | Create an execution | +| gate | `HCtag,2,duration,alignmentZ` | Temporarily suppress ordinary events | + +The numeric action is deliberately a three-value action rather than a boolean +or a note velocity. Fractional action values are rejected. + +## Compatibility summary + +| Existing use | Status | Required action | +| --- | --- | --- | +| Untagged `ticks=(tick,)` | Compatible | None | +| Untagged `ticks=(tick, period)` | Compatible | None | +| Repeated tagged writes used to replace one event | Changed | Reset and rebuild the definition, or omit the tag for direct scheduling | +| A tagged event expected to become active immediately | Changed | Start its sequence explicitly | +| Empty `H0,0,tagZ` used as cancellation | Compatible reset spelling | It still resets the future definition; stop an active execution separately | +| C code using `amy_config_t` | Source compatible after rebuild | Initialize with `amy_default_config()` and override named fields | +| Generated JavaScript or Godot bindings | Regeneration required | Rebuild the bindings with this AMY source | + +The intentional breaking change is limited to tagged scheduling. A tag now +identifies a stopped, cumulative definition: repeated tagged writes append, +and playback begins only after an explicit start. This replaces two properties +of the earlier tagged-event behavior, where a later write replaced the event +and the tagged event was active immediately. + +## Migrating a replaceable tagged event + +If the tag was only being used as a replace/remove handle, the smallest +migration is to omit it and keep using direct one-off or periodic scheduling. + +If the contents need to remain addressable as a reusable sequence, replace +them explicitly: + +```python +amy.send(sequence=tag, action='stop', alignment_period=period) +amy.define_sequence(tag, events) +amy.send(sequence=tag, action='start', alignment_period=period) +``` + +The low-level wire equivalent is: + +```text +HC,0,Z +HRZ +H,,Z +... +HC,1,Z +``` + +An aligned stop captures the executions which exist when the command is sent. +Replacing the definition changes future starts, while an execution which +already began retains its immutable snapshot. This lets a wrapper migrate +without tracking AMY's current tick, active note state, or definition version. +The wrapper must still choose its musical update boundary: replacing on the +next full period is simple and phase-stable, but may have more latency than an +application-specific mid-cycle update. + +One known first-party consumer of the replace-on-tag behavior is Tulip's +`AMYSequenceEvent` wrapper. Its `update()` and `remove()` operations need the +explicit stop/reset/append/start lifecycle above. That migration is localized, +but its live-edit boundary is a product choice and should be tested together +with the consumers of that wrapper. + +## Other source-compatibility details + +`amy_config_t` appends `max_sequence_events` and +`max_sequence_executions`. Appending preserves the offsets of existing +members, but changing the size of a public C structure is not a binary ABI +promise. Applications should be recompiled against the matching header and +library. As with other AMY configuration, begin with `amy_default_config()` so +new fields receive supported defaults. + +Limits are explicit. `max_sequencer_tags` bounds identities, +`max_sequence_events` bounds one definition, and +`max_sequence_executions` bounds active or alignment-pending executions. +Exhaustion, invalid tags, malformed actions, publication allocation failure, +and cyclic start graphs fail without publishing a partial definition. Callers +which deliberately choose small limits should treat a rejected operation as a +normal bounded-resource failure. + +Resetting a definition does not stop an execution which already holds a +snapshot. `RESET_TIMEBASE` removes active and pending executions while +retaining definitions. `RESET_SEQUENCER` clears direct events, definitions, +and executions. + +## Automated validation + +The host test suite covers: + +- unchanged one- and two-value direct scheduling; +- cumulative definitions, explicit reset, finite and repeating executions; +- overlapping executions and more than two simultaneously retained snapshot + generations; +- same-tick control ordering, alignment, tick rollover, gate phase, and global + reset behavior; +- current-execution capture for aligned stop and gate; +- arbitrary payloads, sequence composition, bounded cycles, and exhausted + execution pools; +- allocation failure at candidate-construction stages and recovery without a + partial publication; +- two competing writers, including checked publication and retry; +- Python validation and exact wire serialization; +- executable JavaScript serialization and generated binding freshness. + +The reusable-sequence C tests run as part of `make ctest`. Python API coverage +is in `tests/test_sequence_api.py`, and generated API checks are included in +`make check-c-api`. + +## Target-dependent validation still required + +The ownership design keeps definition allocation, cloning, string copying, +and destruction off the render path and outside the shared render-lock +critical section. That is a source-level real-time property, not a substitute +for measuring a complete device. + +On an ESP32 target, validate the intended sample rate, block and DMA sizes, +memory capabilities, effects load, and authoring traffic. Record maximum +render time, missed DMA deadlines, publication critical-section time, heap +low-water mark, largest free block, and maximum retired-list depth. At 48 kHz +and 128 samples, the block deadline is approximately 2.67 ms. + +Generated Godot source is checked for freshness and syntax when the parser is +available. An executable Godot runtime behavior test remains target-dependent; +the sequence behavior itself is implemented in the common C core. + +See [Abstractions and implementation](sequencer-sequences-abstractions.md) for +the snapshot publication and deferred-reclamation design. diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index 92b17179..9f211f46 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -1,4 +1,4 @@ -# Reusable sequencer sequences +# Reusable sequences A sequencer tag identifies a reusable sequence of ordinary AMY events. Sending more than one event with the same tag accumulates those events, in the same way @@ -48,10 +48,10 @@ suppresses their ordinary events for the required `duration`. `vel` keeps its usual meaning of note velocity. At the lower-level `sequence_control` API and on the wire, actions use integers: stop `0`, start `1`, and gate `2`. Fractional values are invalid. The optional `alignment_period` is the alignment -quantum. `0` or `1` acts at the next -available sequencer tick for a direct command. A larger value selects the next -global tick divisible by that period. When a sequenced parent starts a child, -the child's local tick zero participates in the same tick. +quantum. `0` or `1` acts at the next available sequencer tick for a direct +command. A larger value selects the next global tick divisible by that period. +When a sequenced parent starts a child, the child's local tick zero participates +in the same tick. A start creates a bounded execution. Finite executions of one tag may overlap, so callers do not need execution IDs or note-lifetime bookkeeping. Stop targets @@ -63,7 +63,7 @@ started retain their own event pairs. ## Finite and repeating lifetime -No explicit sequence length or publish action is needed: +Lifetime follows directly from the periods of the stored events: - a definition containing only `period=0` events is finite and retires after its last event; @@ -79,7 +79,7 @@ amy.send(sequence=40, action='gate', duration=24, alignment_period=1) This suppresses ordinary event dispatch from active executions of tag `40` for 24 ticks. Local phase continues, and dispatch resumes on the original phase. -Audio already ringing is not cut off. Nested sequence controls remain active, +Audio already ringing is not cut off. Sequence-control payloads remain active, so a controller sequence can still complete its lifecycle. Duration zero removes a gate at the selected boundary. @@ -100,4 +100,7 @@ inactive definitions are not scanned on each tick. See the [implementation model](sequencer-sequences-abstractions.md), [musical use cases](sequencer-sequences-musical-use-cases.md), and -[step-by-step examples](sequencer-sequences-howto.md). +[step-by-step examples](sequencer-sequences-howto.md). The +[status and compatibility guide](sequencer-sequences-status.md) records the +intentional tagged-scheduling change, migration path, test coverage, and +target-dependent validation boundary. diff --git a/docs/synth.md b/docs/synth.md index 9b999f27..06a4aa96 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -252,11 +252,13 @@ operation. `sequence_control` starts, stops, aligns, or temporarily gates an active tagged sequence. Component periods define looping; a definition containing only period-zero events finishes after its last event. -See [Reusable sequencer sequences](sequencer-sequences.md) for the concise API +See [Reusable sequences](sequencer-sequences.md) for the concise API and lifecycle reference. The accompanying guides explain the [abstractions and implementation](sequencer-sequences-abstractions.md), [musical use cases](sequencer-sequences-musical-use-cases.md), and a -[step-by-step Python example](sequencer-sequences-howto.md). +[step-by-step Python example](sequencer-sequences-howto.md). See +[status and compatibility](sequencer-sequences-status.md) when migrating +existing tagged scheduling or configuring a target build. ## Core oscillators diff --git a/docs/upgrading.md b/docs/upgrading.md index a4a16385..ed09f90a 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -5,6 +5,10 @@ Here we will post breaking APIs between releases of AMY and tips on porting. ## Reusable sequencer sequences (unreleased) +For the complete compatibility matrix, migration lifecycle, validation status, +and target-dependent checks, see +[Reusable sequence status and compatibility](sequencer-sequences-status.md). + Supplying the same tag in more than one three-value `ticks=` message now accumulates all those events into a stopped reusable sequence. Previously, a later event replaced the earlier event at that tag. This intentional change From 065a2299f51e7210c1dd5e260d7be1472c7ed0e6 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:38:07 +0200 Subject: [PATCH 39/52] Separate render and external sequence dispatch --- src/amy.h | 4 ++ src/api.c | 10 +++ src/cv_trigger.c | 2 +- src/parse.c | 20 ++++-- src/sequencer.c | 173 +++++++++++++++++++++++++++++++---------------- src/sequencer.h | 29 +++++++- 6 files changed, 170 insertions(+), 68 deletions(-) diff --git a/src/amy.h b/src/amy.h index 6de9e5fd..a37ae701 100644 --- a/src/amy.h +++ b/src/amy.h @@ -1169,6 +1169,10 @@ uint32_t ms_to_samples(uint32_t ms) ; // API void amy_add_message(char *message); +// Internal render-side ingress, used by CV triggers. It deliberately avoids +// variable-time sequence reclamation and gives sequence controls the current +// render tick rather than pretending they came from an external caller. +void amy_add_message_from_render(char *message); // Parse and play a stored wire message now (a fired sequencer entry). void amy_play_message(char *message); // Like amy_add_message but the data is treated as coming from an external diff --git a/src/api.c b/src/api.c index 60f9dd0f..daa68ee3 100644 --- a/src/api.c +++ b/src/api.c @@ -316,6 +316,16 @@ void amy_add_message(char *message) { amy_add_message_with_sysex_flag(message, /* sysex */ false); } +void amy_add_message_from_render(char *message) { + if (message[0] == 'H') { + handle_ticks_message_with_origin( + message, SEQUENCER_ORIGIN_RENDER, + amy_global.sequencer_tick_count); + } else { + amy_play_message(message); + } +} + // Like amy_add_message but marks the message as coming from an external // sysex source so the transfer routing in amy_message_is_transfer_chunk() // applies. diff --git a/src/cv_trigger.c b/src/cv_trigger.c index 4f9bd6a4..3fd3ae13 100644 --- a/src/cv_trigger.c +++ b/src/cv_trigger.c @@ -116,7 +116,7 @@ void cv_trigger_generate_events(float *cv_inputs) { char message[AMY_WIRE_COMMAND_LEN]; substitute_midi_special_values(message, cv_trig->message_template, 0, 0, note); //fprintf(stderr, "update_external_cv_in: message %s\n", message); - amy_add_message(message); + amy_add_message_from_render(message); } } } else if ((polarity * cv_val) < (polarity * cv_trig->thresh_reset)) { diff --git a/src/parse.c b/src/parse.c index 6f971d03..be11c43c 100644 --- a/src/parse.c +++ b/src/parse.c @@ -728,7 +728,9 @@ static int sequence_control_uint_tail(const char *cursor, uint32_t *values, // It claims the rest of the message as its payload -- stored as a raw // wire string and only parsed when it comes due -- so a schedule command // is only ever honored as the first command of a message. -void handle_ticks_message(char *message) { +void handle_ticks_message_with_origin(char *message, + sequencer_origin_t origin, + uint32_t current_tick) { assert(message[0] == 'H'); if (message[1] == 'A') { fprintf(stderr, @@ -791,8 +793,9 @@ void handle_ticks_message(char *message) { "gate=2 with a duration; tag, duration, and " "alignment must be non-negative integers\n"); } else { - sequencer_sequence_control((uint32_t)parsed_tag, action, value, - alignment); + sequencer_sequence_control_with_origin( + (uint32_t)parsed_tag, action, value, alignment, origin, + current_tick); } return; } @@ -805,7 +808,7 @@ void handle_ticks_message(char *message) { if ((terminator != '\0' && terminator != 'Z') || count != 1) fprintf(stderr, "invalid sequence reset: expected HRtag\n"); else - sequencer_sequence_reset(values[0]); + sequencer_sequence_reset_with_origin(values[0], origin); return; } @@ -821,11 +824,16 @@ void handle_ticks_message(char *message) { memcpy(stripped, payload, payload_len + 1); // A root tag is only "given" if all 3 values were present; fewer // than that (a 1- or 2-value ticks=) stores anonymously. - sequencer_add_wire(ticks[TICKS_TICK], ticks[TICKS_PERIOD], ticks[TICKS_TAG], - num_vals >= 3, stripped); + sequencer_add_wire_with_origin( + ticks[TICKS_TICK], ticks[TICKS_PERIOD], ticks[TICKS_TAG], + num_vals >= 3, stripped, origin); } } +void handle_ticks_message(char *message) { + handle_ticks_message_with_origin(message, SEQUENCER_ORIGIN_EXTERNAL, 0); +} + // given a string return a parsed event // // Transfer payloads never reach here: amy_add_message() traps them before diff --git a/src/sequencer.c b/src/sequencer.c index 54206bd0..87bf7e01 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -24,7 +24,7 @@ typedef struct sequence_info_t { } sequence_info_t; struct sequence_info_t *sequences = NULL; // Anonymous direct-schedule slots. -int32_t max_sequences = 0; // Number of user-addressable tags. +uint32_t max_sequences = 0; // Number of user-addressable tags. // Head of the ascending list of occupied anonymous slots; -1 when nothing is // scheduled. This replaces `highest_tag`, // which was a HIGH-WATER MARK: it only ever grew, so one event at a high tag @@ -86,7 +86,6 @@ static stored_sequence_execution_t *sequence_executions = NULL; static uint32_t max_stored_sequence_events = 0; static uint32_t max_stored_sequence_executions = 0; static size_t stored_sequence_event_bytes = 0; -static volatile bool stored_sequence_wire_firing = false; static stored_sequence_definition_t *retired_sequence_definitions = NULL; #ifdef AMY_SEQUENCE_TESTING @@ -168,9 +167,18 @@ void sequencer_reclaim_retired(void) { stored_sequence_definition_destroy_list(retired); } -static void stored_sequence_reclaim_retired(void) { - if (wire_firing || stored_sequence_wire_firing) return; - sequencer_reclaim_retired(); +static bool sequence_origin_may_reclaim(sequencer_origin_t origin) { + return origin == SEQUENCER_ORIGIN_EXTERNAL; +} + +static stored_sequence_definition_t * +stored_sequence_definition_release_locked( + stored_sequence_definition_t *definition, + sequencer_origin_t origin) { + if (sequence_origin_may_reclaim(origin)) + return stored_sequence_definition_unref_locked(definition); + stored_sequence_definition_retire_locked(definition); + return NULL; } static stored_sequence_definition_t *stored_sequence_definition_new(void) { @@ -239,7 +247,7 @@ static void stored_sequence_executions_reset(void) { static void stored_sequences_clear_definitions(void) { if (stored_sequences == NULL) return; - for (int32_t i = 0; i < max_sequences; ++i) { + for (uint32_t i = 0; i < max_sequences; ++i) { stored_sequence_definition_retire_locked( stored_sequences[i]); stored_sequences[i] = NULL; @@ -268,12 +276,11 @@ static void stored_sequences_deinit(void) { static void stored_sequences_init(uint32_t events, uint32_t executions) { max_stored_sequence_events = events; max_stored_sequence_executions = executions; - stored_sequence_wire_firing = false; if (max_sequences == 0 || events == 0 || executions == 0) return; size_t slot_bytes = 0; size_t execution_bytes = 0; - if (!checked_array_size((uint32_t)max_sequences, + if (!checked_array_size(max_sequences, sizeof(*stored_sequences), &slot_bytes) || !checked_array_size(events, sizeof(stored_sequence_event_t), &stored_sequence_event_bytes) @@ -282,7 +289,7 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { &execution_bytes)) { fprintf(stderr, "stored sequence configuration exceeds addressable memory: " - "tags=%" PRIi32 ", events=%" PRIu32 + "tags=%" PRIu32 ", events=%" PRIu32 ", executions=%" PRIu32 "\n", max_sequences, events, executions); stored_sequences_deinit(); @@ -303,7 +310,7 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { } } -void sequencer_init(int max_sequencer_tags, uint32_t sequence_events, +void sequencer_init(uint32_t max_sequencer_tags, uint32_t sequence_events, uint32_t sequence_execution_count) { // These are statics, so a stop/start of AMY within one process needs them // put back to their boot state (internal clock, running). @@ -362,7 +369,7 @@ void sequencer_sequence_reset_timebase() { void sequencer_debug() { int32_t n_active = 0; for (int32_t t = first_active; t != -1; t = sequences[t].next_active) ++n_active; - fprintf(stderr, "sequencer: max_sequences %" PRIi32" active %" PRIi32 "\n", max_sequences, n_active); + fprintf(stderr, "sequencer: max_sequences %" PRIu32" active %" PRIi32 "\n", max_sequences, n_active); for (int32_t tag = first_active; tag != -1; tag = sequences[tag].next_active) { if (sequences[tag].wire) { fprintf(stderr, "anonymous sequence slot %" PRIi32 " tick %" PRIu32 @@ -446,14 +453,16 @@ void sequencer_recompute() { // // A one-off whose tick is already due or overdue is not stored at all -- it // plays immediately, before returning. See the comment at that branch. -uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire) { +uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, + uint32_t tag, bool has_tag, char *wire, + sequencer_origin_t origin) { if (sequences == NULL) { // sequencer_init hasn't run free(wire); return 0; } if (has_tag) { - if (tag >= (uint32_t)max_sequences) { - fprintf(stderr, "sequencer tag %" PRIu32" (with tick %" PRIu32", period %" PRIu32") is greater than or eq max_sequences %" PRIi32"\n", + if (tag >= max_sequences) { + fprintf(stderr, "sequencer tag %" PRIu32" (with tick %" PRIu32", period %" PRIu32") is greater than or eq max_sequences %" PRIu32"\n", tag, tick, period, max_sequences); free(wire); return 0; @@ -467,9 +476,10 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha if (tick == 0 && period == 0 && (wire == NULL || wire[0] == '\0' || wire[0] == 'Z')) { free(wire); - return sequencer_sequence_reset(tag); + return sequencer_sequence_reset_with_origin(tag, origin); } - return sequencer_sequence_add_wire(tag, tick, period, wire); + return sequencer_sequence_add_wire_with_origin( + tag, tick, period, wire, origin); } else { // Anonymous: tick==0 && period==0 has nothing to cancel (no tag was // given), so just drop it rather than allocating a slot for a no-op. @@ -520,8 +530,14 @@ uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool ha return 1; } +uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, + bool has_tag, char *wire) { + return sequencer_add_wire_with_origin( + tick, period, tag, has_tag, wire, SEQUENCER_ORIGIN_EXTERNAL); +} + static stored_sequence_definition_t **stored_sequence_slot(uint32_t tag) { - if (stored_sequences == NULL || tag >= (uint32_t)max_sequences) return NULL; + if (stored_sequences == NULL || tag >= max_sequences) return NULL; return &stored_sequences[tag]; } @@ -554,8 +570,9 @@ static void stored_sequence_candidate_discard( stored_sequence_definition_destroy(candidate); } -uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, - uint32_t period, char *wire) { +uint8_t sequencer_sequence_add_wire_with_origin( + uint32_t tag, uint32_t tick, uint32_t period, char *wire, + sequencer_origin_t origin) { stored_sequence_definition_t **slot = stored_sequence_slot(tag); if (slot == NULL) { if (stored_sequences == NULL) @@ -564,7 +581,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, else fprintf(stderr, "cannot append event: sequence tag %" PRIu32 " is outside the configured range [0, %" PRIi32 "]\n", - tag, max_sequences - 1); + tag, (int32_t)(max_sequences - 1)); free(wire); return 0; } @@ -588,7 +605,7 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, return 0; } - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); #ifdef AMY_SEQUENCE_TESTING bool test_pin_hook_called = false; #endif @@ -612,7 +629,8 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, stored_sequence_definition_append_owned(source, tick, period, wire); amy_release_lock(); - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) + sequencer_reclaim_retired(); return 1; } @@ -638,7 +656,8 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, stored_sequence_definition_t *dead = NULL; if (source != NULL) { amy_grab_lock(); - dead = stored_sequence_definition_unref_locked(source); + dead = stored_sequence_definition_release_locked(source, + origin); amy_release_lock(); } stored_sequence_definition_destroy(dead); @@ -654,14 +673,17 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, stored_sequence_definition_t *dead = NULL; if (source != NULL) { // Drop the old slot ownership and our temporary writer pin. - dead = stored_sequence_definition_unref_locked(source); + dead = stored_sequence_definition_release_locked(source, + origin); stored_sequence_definition_t *after_pin = - stored_sequence_definition_unref_locked(source); + stored_sequence_definition_release_locked(source, + origin); if (after_pin != NULL) dead = after_pin; } amy_release_lock(); stored_sequence_definition_destroy(dead); - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) + sequencer_reclaim_retired(); return 1; } @@ -669,14 +691,21 @@ uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, // source pin, discard the private candidate outside the lock, and retry // against the new cumulative definition. stored_sequence_definition_t *dead = source == NULL ? NULL - : stored_sequence_definition_unref_locked(source); + : stored_sequence_definition_release_locked(source, origin); amy_release_lock(); stored_sequence_candidate_discard(candidate, wire); stored_sequence_definition_destroy(dead); } } -uint8_t sequencer_sequence_reset(uint32_t tag) { +uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, + uint32_t period, char *wire) { + return sequencer_sequence_add_wire_with_origin( + tag, tick, period, wire, SEQUENCER_ORIGIN_EXTERNAL); +} + +uint8_t sequencer_sequence_reset_with_origin(uint32_t tag, + sequencer_origin_t origin) { stored_sequence_definition_t **slot = stored_sequence_slot(tag); if (slot == NULL) { if (stored_sequences == NULL) @@ -685,34 +714,41 @@ uint8_t sequencer_sequence_reset(uint32_t tag) { else fprintf(stderr, "cannot reset sequence: tag %" PRIu32 " is outside the configured range [0, %" PRIi32 "]\n", - tag, max_sequences - 1); + tag, (int32_t)(max_sequences - 1)); return 0; } - if (stored_sequence_wire_firing) { + if (origin == SEQUENCER_ORIGIN_STORED) { fprintf(stderr, "sequence %" PRIu32 " cannot reset definitions from a stored sequence event\n", tag); return 0; } - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); amy_grab_lock(); stored_sequence_definition_t *definition = *slot; *slot = NULL; stored_sequence_definition_t *dead = NULL; - if (wire_firing) stored_sequence_definition_retire_locked(definition); - else dead = stored_sequence_definition_unref_locked(definition); + dead = stored_sequence_definition_release_locked(definition, origin); amy_release_lock(); stored_sequence_definition_destroy(dead); - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); return 1; } -static uint32_t sequence_control_tick(uint32_t alignment_period) { +uint8_t sequencer_sequence_reset(uint32_t tag) { + return sequencer_sequence_reset_with_origin( + tag, SEQUENCER_ORIGIN_EXTERNAL); +} + +static uint32_t sequence_control_tick(uint32_t alignment_period, + sequencer_origin_t origin, + uint32_t current_tick) { // A control fired by the root sequencer participates in this tick. A // control arriving between ticks begins no earlier than the next tick. - uint32_t tick = wire_firing ? amy_global.sequencer_tick_count - : amy_global.sequencer_tick_count + 1; + uint32_t tick = origin == SEQUENCER_ORIGIN_EXTERNAL + ? amy_global.sequencer_tick_count + 1 + : current_tick; if (alignment_period != 0) { uint32_t remainder = tick % alignment_period; if (remainder != 0) tick += alignment_period - remainder; @@ -720,9 +756,10 @@ static uint32_t sequence_control_tick(uint32_t alignment_period) { return tick; } -uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, - uint32_t value, - uint32_t alignment_period) { +uint8_t sequencer_sequence_control_with_origin( + uint32_t tag, uint32_t action, uint32_t value, + uint32_t alignment_period, sequencer_origin_t origin, + uint32_t current_tick) { stored_sequence_definition_t **slot = stored_sequence_slot(tag); if (slot == NULL) { if (stored_sequences == NULL) @@ -731,11 +768,11 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, else fprintf(stderr, "cannot control sequence %" PRIu32 ": valid tags are [0, %" PRIi32 "]\n", - tag, max_sequences - 1); + tag, (int32_t)(max_sequences - 1)); return 0; } - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); uint8_t result = 0; amy_grab_lock(); if (action == SEQUENCE_CONTROL_START) { @@ -743,7 +780,8 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, fprintf(stderr, "cannot start sequence %" PRIu32 ": its definition is empty\n", tag); } else { - uint32_t start_tick = sequence_control_tick(alignment_period); + uint32_t start_tick = sequence_control_tick( + alignment_period, origin, current_tick); stored_sequence_execution_t *available = NULL; for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { stored_sequence_execution_t *execution = &sequence_executions[i]; @@ -765,7 +803,8 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, } } else if (action == SEQUENCE_CONTROL_STOP || action == SEQUENCE_CONTROL_GATE) { - uint32_t control_tick = sequence_control_tick(alignment_period); + uint32_t control_tick = sequence_control_tick( + alignment_period, origin, current_tick); for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { stored_sequence_execution_t *execution = &sequence_executions[i]; if (!execution->occupied || execution->tag != tag) @@ -786,10 +825,17 @@ uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, "stop=0, start=1, gate=2\n", tag, action); } amy_release_lock(); - stored_sequence_reclaim_retired(); + if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); return result; } +uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period) { + return sequencer_sequence_control_with_origin( + tag, action, value, alignment_period, SEQUENCER_ORIGIN_EXTERNAL, 0); +} + static bool stored_sequence_event_hits(const stored_sequence_event_t *event, uint32_t local_tick) { return event->period != 0 ? local_tick % event->period == event->tick @@ -801,16 +847,16 @@ static bool stored_sequence_event_is_control( return strncmp(event->wire, "HC", 2) == 0; } -static void sequence_play_wire_now(char *wire) { - if (wire[0] == 'H') handle_ticks_message(wire); +static void sequence_play_wire_now(char *wire, sequencer_origin_t origin, + uint32_t current_tick) { + if (wire[0] == 'H') + handle_ticks_message_with_origin(wire, origin, current_tick); else amy_play_message(wire); } -static void stored_sequence_play_wire(const char *wire) { - bool previous = stored_sequence_wire_firing; - stored_sequence_wire_firing = true; - sequence_play_wire_now((char *)wire); - stored_sequence_wire_firing = previous; +static void stored_sequence_play_wire(const char *wire, uint32_t current_tick) { + sequence_play_wire_now( + (char *)wire, SEQUENCER_ORIGIN_STORED, current_tick); } static void stored_sequence_process_pass(uint32_t tick, bool controls) { @@ -852,7 +898,7 @@ static void stored_sequence_process_pass(uint32_t tick, bool controls) { &definition->events[event_index]; if (stored_sequence_event_is_control(event) == controls && stored_sequence_event_hits(event, elapsed)) - stored_sequence_play_wire(event->wire); + stored_sequence_play_wire(event->wire, tick); } } @@ -863,7 +909,11 @@ static void stored_sequence_process_pass(uint32_t tick, bool controls) { } static void sequencer_process_tick(void) { - amy_global.sequencer_tick_count++; + // External sequence controls take their next-tick snapshot under this same + // lock, so current-tick versus next-tick activation has one ordering point. + amy_grab_lock(); + uint32_t tick = ++amy_global.sequencer_tick_count; + amy_release_lock(); midi_clock_out_tick(); // no-op unless in AMY_MIDI_SYNC_SEND mode // Guard nested check-and-fire calls (via a fired message's own parse) // while still processing this tick's fires; restore on the way out. @@ -879,7 +929,7 @@ static void sequencer_process_tick(void) { bool hit = false; bool delete = false; if(sequences[tag].period != 0) { // period set - uint32_t offset = amy_global.sequencer_tick_count % sequences[tag].period; + uint32_t offset = tick % sequences[tag].period; if (offset == sequences[tag].tick) hit = true; } else { // Test for absolute tick (no period set). <= rather than ==: @@ -890,7 +940,7 @@ static void sequencer_process_tick(void) { // playing. <= lets it fire on the next tick instead, matching // the play-it-late rule sequencer_add_wire() uses for a // one-off that is already due when it arrives. - if (sequences[tag].tick <= amy_global.sequencer_tick_count) { hit = true; delete = true; } + if (sequences[tag].tick <= tick) { hit = true; delete = true; } } if(hit) { // Take the message out (one-shot) or a copy of it (repeating) @@ -915,7 +965,8 @@ static void sequencer_process_tick(void) { amy_release_lock(); if (wire != NULL) { // Parse and play now; the deltas play back within this block. - sequence_play_wire_now(wire); + sequence_play_wire_now( + wire, SEQUENCER_ORIGIN_RENDER, tick); free(wire); } } @@ -924,11 +975,11 @@ static void sequencer_process_tick(void) { } // Nested controls take effect before ordinary stored-sequence events on // the same tick. This lets a parent stop a child without one extra onset. - stored_sequence_process_pass(amy_global.sequencer_tick_count, true); - stored_sequence_process_pass(amy_global.sequencer_tick_count, false); + stored_sequence_process_pass(tick, true); + stored_sequence_process_pass(tick, 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); + amy_global.config.amy_external_sequencer_hook(tick); } } @@ -964,7 +1015,9 @@ void sequencer_midi_start() { // If external clock was not previously enabled, keep using internal clock // so the sequencer advances on its own without needing F8 ticks. if (sequencer_external_clock) { + amy_grab_lock(); amy_global.sequencer_tick_count = 0; + amy_release_lock(); } // Reset the tick timer to now so sequencer_check_and_fill doesn't try to // catch up all the ticks that elapsed while stopped. diff --git a/src/sequencer.h b/src/sequencer.h index e837d0c8..3902e13a 100644 --- a/src/sequencer.h +++ b/src/sequencer.h @@ -5,7 +5,7 @@ #include "amy.h" #define MIDI_SEQUENCER_PPQ 24 // MIDI clocks per quarter note uint32_t sequencer_ticks(); -void sequencer_init(int max_num_sequences, uint32_t max_sequence_events, +void sequencer_init(uint32_t max_num_sequences, uint32_t max_sequence_events, uint32_t max_sequence_executions); void sequencer_deinit(); void sequencer_reset(); @@ -16,6 +16,20 @@ void sequencer_check_and_fill(); // called once per block from amy_execute_delt // Destroy zero-reference immutable sequence definitions retired by the render // path. The caller must be a control/non-render thread. void sequencer_reclaim_retired(); + +// Internal dispatch origin. External commands start no earlier than the next +// tick and may reclaim retired definitions. Render-originated commands use the +// supplied current tick and may only retire storage. A stored event is also +// prohibited from editing sequence definitions while they are being walked. +typedef enum sequencer_origin_t { + SEQUENCER_ORIGIN_EXTERNAL = 0, + SEQUENCER_ORIGIN_RENDER, + SEQUENCER_ORIGIN_STORED +} sequencer_origin_t; + +void handle_ticks_message_with_origin(char *message, + sequencer_origin_t origin, + uint32_t current_tick); #ifdef __EMSCRIPTEN__ void sequencer_check_and_call_js_hook(); // called from the browser main loop #endif @@ -26,19 +40,32 @@ void sequencer_check_and_call_js_hook(); // called from the browser main loop // anonymously (round-robin in a small reserved pool) for immediate sequencer // playback. Takes ownership of wire. uint8_t sequencer_add_wire(uint32_t tick, uint32_t period, uint32_t tag, bool has_tag, char *wire); +uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, + uint32_t tag, bool has_tag, char *wire, + sequencer_origin_t origin); // Append one ordinary ticks event to the reusable sequence identified by tag. // Takes ownership of wire. A tick=period=0 event is a valid one-shot when its // wire payload is nonempty. uint8_t sequencer_sequence_add_wire(uint32_t tag, uint32_t tick, uint32_t period, char *wire); +uint8_t sequencer_sequence_add_wire_with_origin( + uint32_t tag, uint32_t tick, uint32_t period, char *wire, + sequencer_origin_t origin); // Clear the future definition at tag. Executions which already started retain // their immutable definition and may finish. uint8_t sequencer_sequence_reset(uint32_t tag); +uint8_t sequencer_sequence_reset_with_origin(uint32_t tag, + sequencer_origin_t origin); // sequence_control is [tag, action, alignment_period] for stop/start or // [tag, gate, duration, alignment_period]. uint8_t sequencer_sequence_control(uint32_t tag, uint32_t action, uint32_t value, uint32_t alignment_period); +uint8_t sequencer_sequence_control_with_origin(uint32_t tag, uint32_t action, + uint32_t value, + uint32_t alignment_period, + sequencer_origin_t origin, + uint32_t current_tick); void sequencer_sequence_reset_timebase(); #ifdef AMY_SEQUENCE_TESTING void sequencer_test_fail_allocation_after(int32_t successful_allocations); From 98873a9e8b1f524aaa7405f094dc0ec6676ed8b6 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:39:34 +0200 Subject: [PATCH 40/52] Make same-tick sequence controls slot independent --- src/sequencer.c | 119 ++++++++++++++++++++----------- tests/test_sequencer_sequences.c | 22 ++++++ 2 files changed, 99 insertions(+), 42 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index 87bf7e01..8c7eff20 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -75,10 +75,12 @@ typedef struct stored_sequence_execution_t { uint32_t gate_change_tick; uint32_t gate_duration; uint32_t gate_end_tick; + uint32_t controls_processed_tick; bool occupied; bool stop_pending; bool gate_change_pending; bool gated; + bool controls_processed; } stored_sequence_execution_t; static stored_sequence_definition_t **stored_sequences = NULL; @@ -859,53 +861,86 @@ static void stored_sequence_play_wire(const char *wire, uint32_t current_tick) { (char *)wire, SEQUENCER_ORIGIN_STORED, current_tick); } -static void stored_sequence_process_pass(uint32_t tick, bool controls) { - for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) { - amy_grab_lock(); - stored_sequence_execution_t *execution = &sequence_executions[i]; - if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { +static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, + bool controls) { + amy_grab_lock(); + stored_sequence_execution_t *execution = &sequence_executions[slot]; + if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { + amy_release_lock(); + return false; + } + uint32_t elapsed = tick - execution->start_tick; + stored_sequence_definition_t *definition = execution->definition; + if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) + || (!definition->has_periodic_event + && elapsed > definition->last_one_shot_tick)) { + stored_sequence_execution_release_deferred(execution); + amy_release_lock(); + return false; + } + if (controls) { + if (execution->controls_processed + && execution->controls_processed_tick == tick) { amy_release_lock(); - continue; + return false; } - uint32_t elapsed = tick - execution->start_tick; - stored_sequence_definition_t *definition = execution->definition; - if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) - || (!definition->has_periodic_event - && elapsed > definition->last_one_shot_tick)) { - stored_sequence_execution_release_deferred(execution); - amy_release_lock(); - continue; + // Mark before dispatch: a control graph may stop/reuse this slot, and a + // newly created execution in that slot must remain distinguishable. + execution->controls_processed = true; + execution->controls_processed_tick = tick; + } else { + if (execution->gate_change_pending + && AMY_TIME_GEQ(tick, execution->gate_change_tick)) { + execution->gate_change_pending = false; + execution->gated = execution->gate_duration != 0; + execution->gate_end_tick = execution->gate_change_tick + + execution->gate_duration; } - if (!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 suppress = !controls && execution->gated; + definition->refs++; + amy_release_lock(); + + if (!suppress) { + for (uint32_t event_index = 0; + event_index < definition->event_count; ++event_index) { + stored_sequence_event_t *event = &definition->events[event_index]; + if (stored_sequence_event_is_control(event) == controls + && stored_sequence_event_hits(event, elapsed)) + stored_sequence_play_wire(event->wire, tick); } - bool suppress = !controls && execution->gated; - definition->refs++; - amy_release_lock(); + } - if (!suppress) { - for (uint32_t event_index = 0; - event_index < definition->event_count; ++event_index) { - stored_sequence_event_t *event = - &definition->events[event_index]; - if (stored_sequence_event_is_control(event) == controls - && stored_sequence_event_hits(event, elapsed)) - stored_sequence_play_wire(event->wire, tick); + amy_grab_lock(); + stored_sequence_definition_retire_locked(definition); + amy_release_lock(); + return true; +} + +static void stored_sequence_process_controls(uint32_t tick) { + // A control can start an execution in a lower-numbered slot already passed + // by this scan. Repeat until no due execution remains unvisited. At most one + // control visit per configured slot is allowed per tick; this both covers + // every simultaneously active execution and bounds stop/reuse cycles. + uint32_t visits_left = max_stored_sequence_executions; + bool progressed; + do { + progressed = false; + for (uint32_t i = 0; + i < max_stored_sequence_executions && visits_left != 0; ++i) { + if (stored_sequence_process_slot(i, tick, true)) { + visits_left--; + progressed = true; } } + } while (progressed && visits_left != 0); +} - amy_grab_lock(); - stored_sequence_definition_retire_locked(definition); - amy_release_lock(); - } +static void stored_sequence_process_events(uint32_t tick) { + for (uint32_t i = 0; i < max_stored_sequence_executions; ++i) + stored_sequence_process_slot(i, tick, false); } static void sequencer_process_tick(void) { @@ -973,10 +1008,10 @@ static void sequencer_process_tick(void) { } tag = next; } - // Nested controls take effect before ordinary stored-sequence events on + // Composed controls take effect before ordinary stored-sequence events on // the same tick. This lets a parent stop a child without one extra onset. - stored_sequence_process_pass(tick, true); - stored_sequence_process_pass(tick, false); + stored_sequence_process_controls(tick); + stored_sequence_process_events(tick); wire_firing = was_firing; if(amy_global.config.amy_external_sequencer_hook != NULL) { amy_global.config.amy_external_sequencer_hook(tick); diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 6d4eac8d..96fada52 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -353,6 +353,27 @@ static void test_cyclic_controls_are_bounded_and_recoverable(void) { "stopping both cycle tags makes the pool reusable"); } +static void test_same_tick_control_is_slot_order_independent(void) { + printf("same-tick controls are independent of execution slot order\n"); + sequencer_reset(); + clear_marks(); + + // The filler occupies slot 0 for tick 1 only. The parent occupies slot 1 + // from tick 2. At tick 2 slot 0 is retired before slot 1 starts child 3, + // which therefore reuses the already-visited lower slot. Child 3 must still + // run its local-zero control and start leaf 4 on that same tick. + amy_add_message("H0,0,1zPfillerZ"); + amy_add_message("H0,0,2HC3,1,1Z"); + amy_add_message("H0,0,3HC4,1,1Z"); + amy_add_message("H0,0,4zPslot-leafZ"); + amy_add_message("HC1,1,1Z"); + amy_add_message("HC2,1,2Z"); + clock_to(sequencer_ticks() + 4); + + CHECK(marks_named("slot-leaf") == 1, + "a child in a recycled lower slot receives its tick-zero control"); +} + static void test_per_tag_and_global_reset_semantics(void) { printf("per-tag replacement and global reset have distinct scopes\n"); sequencer_reset(); @@ -553,6 +574,7 @@ int main(void) { test_finite_gate_preserves_phase(); test_quantized_stop_targets_current_executions(); test_cyclic_controls_are_bounded_and_recoverable(); + test_same_tick_control_is_slot_order_independent(); test_per_tag_and_global_reset_semantics(); test_timebase_reset_keeps_definitions(); test_start_crosses_clock_rollover(); From 1b1e5279104f321e118c90b280a26e62519322ce Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:41:19 +0200 Subject: [PATCH 41/52] Test concurrent sequence render and control --- tests/test_sequencer_concurrency.c | 45 ++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_sequencer_concurrency.c b/tests/test_sequencer_concurrency.c index 2fa696ea..6e81a66b 100644 --- a/tests/test_sequencer_concurrency.c +++ b/tests/test_sequencer_concurrency.c @@ -22,6 +22,7 @@ static int writers_at_pin = 0; static int release_writers = 0; static int a_hits = 0; static int b_hits = 0; +static int control_failures = 0; static void after_source_pin(void) { pthread_mutex_lock(&rendezvous_lock); @@ -92,6 +93,49 @@ static void test_losing_writer_retries_cumulatively(void) { "the losing compare/retry path loses and duplicates no event"); } +static void *advance_render_ticks(void *opaque) { + uint32_t count = *(uint32_t *)opaque; + for (uint32_t i = 0; i < count; ++i) sequencer_midi_clock_tick(); + return NULL; +} + +static void *change_sequence_gate(void *opaque) { + uint32_t count = *(uint32_t *)opaque; + for (uint32_t i = 0; i < count; ++i) { + if (!sequencer_sequence_control( + 2, SEQUENCE_CONTROL_GATE, i & 1U, 1)) + control_failures++; + } + return NULL; +} + +static void test_render_and_control_threads_share_no_sequence_context(void) { + printf("render ticks and external controls keep separate context\n"); + sequencer_reset(); + CHECK(sequencer_sequence_add_wire(2, 0, 1, strdup("zPthread-pulseZ")), + "periodic definition exists"); + CHECK(sequencer_sequence_control(2, SEQUENCE_CONTROL_START, 0, 0), + "periodic execution starts"); + + uint32_t iterations = 2000; + pthread_t render_thread; + pthread_t control_thread; + control_failures = 0; + CHECK(pthread_create(&render_thread, NULL, advance_render_ticks, + &iterations) == 0, + "render thread starts"); + CHECK(pthread_create(&control_thread, NULL, change_sequence_gate, + &iterations) == 0, + "control thread starts"); + pthread_join(render_thread, NULL); + pthread_join(control_thread, NULL); + + CHECK(control_failures == 0, + "all concurrent controls target the active execution"); + CHECK(sequencer_sequence_reset(2), + "external reset is not confused with stored-event dispatch"); +} + // examples.c calls this; the platform normally provides it. void delay_ms(uint32_t ms) { (void)ms; } @@ -106,6 +150,7 @@ int main(void) { amy_start(config); test_losing_writer_retries_cumulatively(); + test_render_and_control_threads_share_no_sequence_context(); amy_stop(); if (failures) { From fc3c9e5b61ec42394d73bc8fea27e1dfa461f17b Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:42:44 +0200 Subject: [PATCH 42/52] Reject unrepresentable sequence capacities --- src/sequencer.c | 35 ++++++++++++++++---------------- tests/test_sequencer_sequences.c | 19 +++++++++++------ 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index 8c7eff20..f96d3395 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -103,18 +103,21 @@ void sequencer_test_set_after_pin_hook(void (*hook)(void)) { } #endif -static void *stored_sequence_allocate(uint32_t size, uint32_t caps) { +static void *stored_sequence_allocate(size_t size, uint32_t caps) { #ifdef AMY_SEQUENCE_TESTING if (stored_sequence_allocations_before_failure == 0) return NULL; if (stored_sequence_allocations_before_failure > 0) stored_sequence_allocations_before_failure--; #endif - return malloc_caps(size, caps); + if (size > UINT32_MAX) return NULL; + return malloc_caps((uint32_t)size, caps); } static bool checked_array_size(uint32_t count, size_t element_size, size_t *bytes) { - if (count > SIZE_MAX / element_size) return false; + if (element_size == 0 || element_size > UINT32_MAX + || count > UINT32_MAX / element_size) + return false; *bytes = (size_t)count * element_size; return true; } @@ -157,10 +160,8 @@ static void stored_sequence_definition_destroy_list( } } -// The public wire boundary calls this unconditionally after parsing. Sequence -// entry points also use it opportunistically, except while a render-fired wire -// is active. Keeping the actual destruction here makes that distinction -// explicit instead of trying to infer the caller from concurrent global state. +// External API boundaries call this after parsing. Render-side dispatch only +// retires definitions; it never enters this variable-time destruction path. void sequencer_reclaim_retired(void) { amy_grab_lock(); stored_sequence_definition_t *retired = retired_sequence_definitions; @@ -297,11 +298,11 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { stored_sequences_deinit(); return; } - stored_sequences = (stored_sequence_definition_t **)malloc_caps( + stored_sequences = (stored_sequence_definition_t **)stored_sequence_allocate( slot_bytes, amy_global.config.ram_caps_synth); if (stored_sequences != NULL) memset(stored_sequences, 0, slot_bytes); - sequence_executions = (stored_sequence_execution_t *)malloc_caps( + sequence_executions = (stored_sequence_execution_t *)stored_sequence_allocate( execution_bytes, amy_global.config.ram_caps_synth); if (sequence_executions != NULL) memset(sequence_executions, 0, execution_bytes); @@ -498,7 +499,7 @@ uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, sequences[tag].wire = NULL; sequences[tag].tick = 0; sequences[tag].period = 0; - active_unlink(tag); // out of the list while it has nothing in it + active_unlink((int32_t)tag); // Anonymous slots are bounded to 0..255. if (tick == 0 && period == 0) { // Non-schedulable event: just clear the tag. amy_release_lock(); free(wire); @@ -527,7 +528,7 @@ uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, sequences[tag].tick = tick; sequences[tag].period = period; sequences[tag].wire = wire; - active_link(tag); // ...and back in, now that it has a message again + active_link((int32_t)tag); // ...and back in, now that it has a message again amy_release_lock(); return 1; } @@ -582,8 +583,8 @@ uint8_t sequencer_sequence_add_wire_with_origin( ": stored sequences are disabled\n", tag); else fprintf(stderr, "cannot append event: sequence tag %" PRIu32 - " is outside the configured range [0, %" PRIi32 "]\n", - tag, (int32_t)(max_sequences - 1)); + " is outside the configured range [0, %" PRIu32 "]\n", + tag, max_sequences - 1); free(wire); return 0; } @@ -715,8 +716,8 @@ uint8_t sequencer_sequence_reset_with_origin(uint32_t tag, ": stored sequences are disabled\n", tag); else fprintf(stderr, "cannot reset sequence: tag %" PRIu32 - " is outside the configured range [0, %" PRIi32 "]\n", - tag, (int32_t)(max_sequences - 1)); + " is outside the configured range [0, %" PRIu32 "]\n", + tag, max_sequences - 1); return 0; } if (origin == SEQUENCER_ORIGIN_STORED) { @@ -769,8 +770,8 @@ uint8_t sequencer_sequence_control_with_origin( ": stored sequences are disabled\n", tag); else fprintf(stderr, "cannot control sequence %" PRIu32 - ": valid tags are [0, %" PRIi32 "]\n", - tag, (int32_t)(max_sequences - 1)); + ": valid tags are [0, %" PRIu32 "]\n", + tag, max_sequences - 1); return 0; } diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 96fada52..a9224f95 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -529,19 +529,26 @@ static void test_gate_and_stop_cross_clock_rollover(void) { } static void test_disabled_configuration(void) { - printf("zero reusable-sequence capacities disable the feature safely\n"); - const uint32_t capacities[][2] = {{0, 8}, {8, 0}}; + printf("invalid reusable-sequence capacities disable the feature safely\n"); + const uint32_t capacities[][3] = { + {256, 0, 8}, + {256, 8, 0}, + {256, UINT32_MAX, 1}, + {256, 1, UINT32_MAX}, + {UINT32_MAX, 1, 1}, + }; for (size_t i = 0; i < sizeof(capacities) / sizeof(capacities[0]); ++i) { amy_config_t config = amy_default_config(); config.features.startup_bleep = 0; config.audio = AMY_AUDIO_IS_NONE; - config.max_sequence_events = capacities[i][0]; - config.max_sequence_executions = capacities[i][1]; + config.max_sequencer_tags = capacities[i][0]; + config.max_sequence_events = capacities[i][1]; + config.max_sequence_executions = capacities[i][2]; amy_start(config); CHECK(!sequencer_sequence_add_wire(1, 0, 0, strdup("zPdisabledZ")), - "append is disabled for zero capacity set %zu", i + 1); + "append is disabled for invalid capacity set %zu", i + 1); CHECK(!sequencer_sequence_control(1, SEQUENCE_CONTROL_START, 0, 0), - "control is disabled for zero capacity set %zu", i + 1); + "control is disabled for invalid capacity set %zu", i + 1); amy_stop(); } } From 1fd5e22a5f7273c116486008001bd05a3048fa9d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:45:28 +0200 Subject: [PATCH 43/52] Validate sequence integers without truncation --- amy/__init__.py | 70 ++++++++++++++++++------- src/parse.c | 89 +++++++++++++++++--------------- tests/test_sequence_api.py | 17 ++++++ tests/test_sequencer_sequences.c | 6 ++- 4 files changed, 119 insertions(+), 63 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index 106cfe5e..e56255fd 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -249,17 +249,45 @@ def _list_values(value): return [value] +_SEQUENCE_UINT32_MAX = (1 << 32) - 1 + + +def _sequence_uint32(value, name, allow_template=False): + """Return one exact sequence integer without lossy numeric coercion.""" + if allow_template and isinstance(value, str) and value.startswith('%'): + return value + if isinstance(value, bool): + raise ValueError('%s must be a non-negative integer.' % name) + if isinstance(value, int): + result = value + elif isinstance(value, str) and value.strip().isdigit(): + result = int(value.strip()) + else: + raise ValueError('%s must be a non-negative integer.' % name) + if result < 0: + raise ValueError('%s must be non-negative.' % name) + if result > _SEQUENCE_UINT32_MAX: + raise ValueError('%s must be in uint32 range.' % name) + return result + + def _sequence_control_values(value): """Validate the low-level ``HC`` payload without blocking templates.""" values = _list_values(value) if len(values) < 2: raise ValueError('sequence_control needs at least tag and action.') + values[0] = _sequence_uint32( + values[0], 'sequence_control tag', allow_template=True) raw_action = values[1] if isinstance(raw_action, str) and raw_action.startswith('%'): # Command templates substitute the token before AMY parses HC. The # resulting wire value must still be the integer 0, 1, or 2. - if len(values) not in (2, 3): - raise ValueError('A templated sequence_control needs tag, action, and optional alignment_period.') + if not 2 <= len(values) <= 4: + raise ValueError('A templated sequence_control needs tag, action, and up to duration and alignment_period.') + for index in range(2, len(values)): + values[index] = _sequence_uint32( + values[index], 'templated sequence_control field', + allow_template=True) return values if isinstance(raw_action, int) and not isinstance(raw_action, bool): action = raw_action @@ -275,6 +303,13 @@ def _sequence_control_values(value): raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.') else: raise ValueError('sequence_control action must be stop=0, start=1, or gate=2.') + values[1] = action + field_names = ('sequence_control duration', 'sequence_control alignment_period') \ + if action == SEQUENCE_CONTROL_GATE else ('sequence_control alignment_period',) + for index, name in enumerate(field_names, start=2): + if index < len(values): + values[index] = _sequence_uint32( + values[index], name, allow_template=True) return values @@ -294,12 +329,9 @@ def _normalize_sequence_action(kwargs): raise ValueError('sequence can only be combined with action, duration, alignment_period, and ticks.') if 'action' not in kwargs: raise ValueError("sequence needs action='start', 'stop', or 'gate'.") - tag = int(kwargs['sequence']) - if tag < 0: - raise ValueError('Sequence tag must be non-negative.') - alignment = int(kwargs.get('alignment_period', 0)) - if alignment < 0: - raise ValueError('Sequence alignment_period must be non-negative.') + tag = _sequence_uint32(kwargs['sequence'], 'Sequence tag') + alignment = _sequence_uint32( + kwargs.get('alignment_period', 0), 'Sequence alignment_period') action_name = kwargs['action'] actions = { 'stop': SEQUENCE_CONTROL_STOP, @@ -312,9 +344,8 @@ def _normalize_sequence_action(kwargs): if action == SEQUENCE_CONTROL_GATE: if 'duration' not in kwargs: raise ValueError("Sequence action='gate' needs a duration in ticks.") - duration = int(kwargs['duration']) - if duration < 0: - raise ValueError('Sequence gate duration must be non-negative.') + duration = _sequence_uint32( + kwargs['duration'], 'Sequence gate duration') control = (tag, action, duration, alignment) else: if 'duration' in kwargs: @@ -393,10 +424,14 @@ def message(**kwargs): raise ValueError('Use only one of sequence_reset or ticks in a message.') if 'sequence_reset' in kwargs and len(kwargs) != 1: raise ValueError('sequence_reset must be sent as a standalone message.') + if 'sequence_reset' in kwargs: + kwargs['sequence_reset'] = _sequence_uint32( + kwargs['sequence_reset'], 'sequence_reset tag') if 'sequence_control' in kwargs: if set(kwargs) - {'sequence_control', 'ticks'}: raise ValueError('sequence_control can only be combined with ticks.') - _sequence_control_values(kwargs['sequence_control']) + kwargs['sequence_control'] = _sequence_control_values( + kwargs['sequence_control']) # Validity check all the passed args. prioritized_keys = [] @@ -485,10 +520,9 @@ def _sequence_ticks(value): values = [value] if not 1 <= len(values) <= 2: raise ValueError('A stored sequence event needs ticks=(tick,) or ticks=(tick, period).') - tick = int(values[0]) - period = int(values[1]) if len(values) == 2 else 0 - if tick < 0 or period < 0: - raise ValueError('Stored sequence tick and period must be non-negative.') + tick = _sequence_uint32(values[0], 'Stored sequence tick') + period = _sequence_uint32(values[1], 'Stored sequence period') \ + if len(values) == 2 else 0 if period and tick >= period: raise ValueError('A stored sequence tick must be below its nonzero period.') return tick, period @@ -503,9 +537,7 @@ def define_sequence(tag, events): per-tag reset followed by explicit cumulative event appends. Executions which already started keep their previous immutable definition. """ - sequence_tag = int(tag) - if sequence_tag < 0: - raise ValueError('Sequence tag must be non-negative.') + sequence_tag = _sequence_uint32(tag, 'Sequence tag') event_messages = [] for event in events: values = dict(event) diff --git a/src/parse.c b/src/parse.c index be11c43c..f806b331 100644 --- a/src/parse.c +++ b/src/parse.c @@ -705,20 +705,29 @@ size_t yield_event_from_message(char *message, amy_event *e, size_t pos) { return pos; } +static bool sequence_uint32(const char *cursor, const char **end, + uint32_t *value) { + while (*cursor == ' ') ++cursor; + if (!isdigit((unsigned char)*cursor)) return false; + errno = 0; + char *parsed_end = NULL; + unsigned long long parsed = strtoull(cursor, &parsed_end, 10); + if (errno == ERANGE || parsed > UINT32_MAX) return false; + while (*parsed_end == ' ') ++parsed_end; + *value = (uint32_t)parsed; + *end = parsed_end; + return true; +} + static int sequence_control_uint_tail(const char *cursor, uint32_t *values, int capacity) { int count = 0; while (*cursor == ',') { ++cursor; - while (*cursor == ' ') ++cursor; - if (!isdigit((unsigned char)*cursor) || count == capacity) return -1; - errno = 0; - char *end = NULL; - unsigned long long parsed = strtoull(cursor, &end, 10); - if (errno == ERANGE || parsed > UINT32_MAX) return -1; - while (*end == ' ') ++end; - values[count++] = (uint32_t)parsed; - cursor = end; + if (count == capacity + || !sequence_uint32(cursor, &cursor, &values[count])) + return -1; + count++; } if (*cursor != '\0' && (*cursor != 'Z' || cursor[1] != '\0')) return -1; return count; @@ -732,36 +741,23 @@ void handle_ticks_message_with_origin(char *message, sequencer_origin_t origin, uint32_t current_tick) { assert(message[0] == 'H'); - if (message[1] == 'A') { - fprintf(stderr, - "invalid ticks command: HA is not needed; append with " - "Htick,period,tag\n"); - return; - } if (message[1] == 'C') { // HCtag,action[,alignment_period], for stop=0 or start=1. // HCtag,gate,duration[,alignment_period] - const char *tag_start = message + 2; - while (*tag_start == ' ') ++tag_start; - errno = 0; - char *tag_end = NULL; - unsigned long long parsed_tag = strtoull(tag_start, &tag_end, 10); - while (*tag_end == ' ') ++tag_end; - const char *action_start = *tag_end == ',' ? tag_end + 1 : tag_end; - while (*action_start == ' ') ++action_start; - errno = 0; - char *action_end = NULL; - unsigned long long parsed_action = strtoull(action_start, &action_end, - 10); - bool action_valid = isdigit((unsigned char)*action_start) - && action_end != action_start && errno != ERANGE - && parsed_action <= UINT32_MAX; - const char *tail = action_end; - while (*tail == ' ') ++tail; + const char *tag_end = NULL; + uint32_t tag = 0; + bool tag_valid = sequence_uint32(message + 2, &tag_end, &tag); + const char *action_start = tag_valid && *tag_end == ',' + ? tag_end + 1 : ""; + const char *action_end = NULL; + uint32_t action = 0; + bool action_valid = sequence_uint32( + action_start, &action_end, &action); + const char *tail = action_valid ? action_end : ""; uint32_t rest[2] = {0, 0}; - int rest_count = sequence_control_uint_tail(tail, rest, 2); - if (!isdigit((unsigned char)*tag_start) || tag_end == tag_start - || parsed_tag > UINT32_MAX || *tag_end != ',' + int rest_count = action_valid + ? sequence_control_uint_tail(tail, rest, 2) : -1; + if (!tag_valid || *tag_end != ',' || !action_valid || rest_count < 0) { fprintf(stderr, "invalid sequence_control: expected " @@ -770,7 +766,6 @@ void handle_ticks_message_with_origin(char *message, return; } - uint32_t action = (uint32_t)parsed_action; uint32_t value = 0; uint32_t alignment = 0; bool shape_valid = false; @@ -794,21 +789,29 @@ void handle_ticks_message_with_origin(char *message, "alignment must be non-negative integers\n"); } else { sequencer_sequence_control_with_origin( - (uint32_t)parsed_tag, action, value, alignment, origin, - current_tick); + tag, action, value, alignment, origin, current_tick); } return; } if (message[1] == 'R') { // HRtag: clear the future stored events for this tag. Already-active // immutable sequence executions are intentionally unaffected. - uint32_t values[2] = {0, 0}; - int count = parse_list_uint32_t(message + 2, values, 2, 0); - char terminator = message[2 + _next_alpha(message + 2)]; - if ((terminator != '\0' && terminator != 'Z') || count != 1) + const char *end = NULL; + uint32_t tag = 0; + if (!sequence_uint32(message + 2, &end, &tag) + || (*end != '\0' && (*end != 'Z' || end[1] != '\0'))) fprintf(stderr, "invalid sequence reset: expected HRtag\n"); else - sequencer_sequence_reset_with_origin(values[0], origin); + sequencer_sequence_reset_with_origin(tag, origin); + return; + } + + const char *tick_start = message + 1; + while (*tick_start == ' ') ++tick_start; + if (!isdigit((unsigned char)*tick_start)) { + fprintf(stderr, + "invalid ticks command: expected Htick[,period[,tag]]payload, " + "HCtag,action, or HRtag\n"); return; } diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index 393d8a15..93d8b527 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -22,6 +22,8 @@ def main(): == "H0,0,7n60l1i1Z" assert amy.message(sequence_control=(7, amy.SEQUENCE_CONTROL_START, 48)) \ == "HC7,1,48Z" + assert amy.message(sequence_control=("%v", "%v", "%v", "%v")) \ + == "HC%v,%v,%v,%vZ" assert amy.message(ticks=(0, 48, 3), sequence_control=(7, amy.SEQUENCE_CONTROL_START, 1)) \ == "H0,48,3HC7,1,1Z" @@ -63,6 +65,17 @@ def main(): expect_error("action", lambda: amy.message(sequence_control=(2, -0.1))) expect_error("integer", lambda: amy.message(sequence_control=(2, 0.625))) expect_error("integer", lambda: amy.message(sequence_control=(2, True))) + expect_error("tag", lambda: amy.message(sequence_control=(1.5, 1))) + expect_error("alignment", lambda: amy.message(sequence_control=(2, 1, 1.5))) + expect_error("uint32", lambda: amy.message( + sequence_control=(2, 2, 1 << 32))) + expect_error("tag", lambda: amy.message(sequence_reset=1.5)) + expect_error("tag", lambda: amy.message(sequence=True, action="start")) + expect_error("tag", lambda: amy.message(sequence=1.5, action="start")) + expect_error("duration", lambda: amy.message( + sequence=2, action="gate", duration=1.5)) + expect_error("alignment", lambda: amy.message( + sequence=2, action="start", alignment_period=1.5)) expect_error("needs action", lambda: amy.message(sequence=2)) expect_error("can only be combined", lambda: amy.message( sequence=2, action="start", synth=1)) @@ -78,6 +91,10 @@ def main(): expect_error("needs a ticks", lambda: amy.define_sequence(2, [{"synth": 1}])) expect_error("needs an AMY payload", lambda: amy.define_sequence( 2, [{"ticks": (0,)}])) + expect_error("tick", lambda: amy.define_sequence( + 2, [{"ticks": (1.5,), "osc": 1}])) + expect_error("period", lambda: amy.define_sequence( + 2, [{"ticks": (1, 1 << 32), "osc": 1}])) if __name__ == "__main__": diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index a9224f95..8c8d7dc5 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -458,8 +458,12 @@ static void test_wire_control_shape_is_strict(void) { "a missing gate duration and trailing payload are rejected"); amy_add_message("HR3,4Z"); + amy_add_message("HR4294967296Z"); + amy_add_message("HR3.0Z"); + amy_add_message("HR-1Z"); + amy_add_message("HA3Z"); CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), - "a reset with an extra field leaves the definition intact"); + "malformed and overflowing resets leave the definition intact"); uint32_t start = sequencer_ticks() + 1; clock_to(start); CHECK(mark_at("defined", start), "the intact definition still starts"); From 04db76371bfc0ee9c8cf069d51098eba5045af6d Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:50:05 +0200 Subject: [PATCH 44/52] Define strict wrap-safe sequence timing --- amy/__init__.py | 35 ++++++++++++++++--- docs/sequencer-sequences-abstractions.md | 12 +++++++ docs/sequencer-sequences-howto.md | 5 +++ docs/sequencer-sequences-musical-use-cases.md | 6 ++++ docs/sequencer-sequences-status.md | 5 ++- docs/sequencer-sequences.md | 6 ++++ src/parse.c | 33 +++++++++++------ src/sequencer.c | 34 +++++++++++++++++- tests/test_sequence_api.py | 7 ++++ tests/test_sequencer_sequences.c | 35 ++++++++++++++++++- 10 files changed, 161 insertions(+), 17 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index e56255fd..aac39dda 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -250,6 +250,7 @@ def _list_values(value): _SEQUENCE_UINT32_MAX = (1 << 32) - 1 +_SEQUENCE_MAX_INTERVAL = (1 << 31) - 1 def _sequence_uint32(value, name, allow_template=False): @@ -271,6 +272,30 @@ def _sequence_uint32(value, name, allow_template=False): return result +def _sequence_interval(value, name, allow_template=False): + result = _sequence_uint32(value, name, allow_template=allow_template) + if isinstance(result, str): + return result + if result > _SEQUENCE_MAX_INTERVAL: + raise ValueError('%s must not exceed 2147483647 ticks.' % name) + return result + + +def _message_ticks(value): + values = _list_values(value) + if not 1 <= len(values) <= 3: + raise ValueError('ticks needs tick, optional period, and optional tag.') + names = ('ticks tick', 'ticks period', 'ticks tag') + normalized = [ + _sequence_uint32(item, names[index]) + for index, item in enumerate(values) + ] + if (len(normalized) >= 2 and normalized[1] + and normalized[0] >= normalized[1]): + raise ValueError('ticks tick must be below its nonzero period.') + return normalized + + def _sequence_control_values(value): """Validate the low-level ``HC`` payload without blocking templates.""" values = _list_values(value) @@ -285,7 +310,7 @@ def _sequence_control_values(value): if not 2 <= len(values) <= 4: raise ValueError('A templated sequence_control needs tag, action, and up to duration and alignment_period.') for index in range(2, len(values)): - values[index] = _sequence_uint32( + values[index] = _sequence_interval( values[index], 'templated sequence_control field', allow_template=True) return values @@ -308,7 +333,7 @@ def _sequence_control_values(value): if action == SEQUENCE_CONTROL_GATE else ('sequence_control alignment_period',) for index, name in enumerate(field_names, start=2): if index < len(values): - values[index] = _sequence_uint32( + values[index] = _sequence_interval( values[index], name, allow_template=True) return values @@ -330,7 +355,7 @@ def _normalize_sequence_action(kwargs): if 'action' not in kwargs: raise ValueError("sequence needs action='start', 'stop', or 'gate'.") tag = _sequence_uint32(kwargs['sequence'], 'Sequence tag') - alignment = _sequence_uint32( + alignment = _sequence_interval( kwargs.get('alignment_period', 0), 'Sequence alignment_period') action_name = kwargs['action'] actions = { @@ -344,7 +369,7 @@ def _normalize_sequence_action(kwargs): if action == SEQUENCE_CONTROL_GATE: if 'duration' not in kwargs: raise ValueError("Sequence action='gate' needs a duration in ticks.") - duration = _sequence_uint32( + duration = _sequence_interval( kwargs['duration'], 'Sequence gate duration') control = (tag, action, duration, alignment) else: @@ -400,6 +425,8 @@ def message(**kwargs): # I=int, F=float, S=str, L=list, C=ctrl_coefs global show_warnings, _KW_MAP, _KW_PRIORITY, _ARG_HANDLERS kwargs = _normalize_sequence_action(kwargs) + if kwargs.get('ticks') is not None: + kwargs['ticks'] = _message_ticks(kwargs['ticks']) if show_warnings: # Check for possible user confusions. if 'voices' in kwargs and 'preset' in kwargs and 'osc' not in kwargs: diff --git a/docs/sequencer-sequences-abstractions.md b/docs/sequencer-sequences-abstractions.md index b5422873..dc812fe8 100644 --- a/docs/sequencer-sequences-abstractions.md +++ b/docs/sequencer-sequences-abstractions.md @@ -33,6 +33,18 @@ Sequence-control events continue to run while gated, allowing a finite controller sequence to restore or change another sequence without being blocked by its own gate. +Suppression is deliberately event-agnostic: an ordinary event which falls in +the gated interval is skipped and is not replayed later. This includes +note-offs and parameter-restoration events. A definition which requires such +an event for cleanup should keep it outside the gated interval or put the +complete gesture in a separately started finite sequence. + +Gate duration and control alignment are limited to 2,147,483,647 ticks. This +keeps every pending boundary within the unambiguous half-range of AMY's +wrapping 32-bit tick comparisons. Once an execution has reached its start it +is latched as started, so an indefinitely running periodic sequence continues +across subsequent clock wraparounds. + ### Composition A stored payload may be an ordinary AMY event or a control for another diff --git a/docs/sequencer-sequences-howto.md b/docs/sequencer-sequences-howto.md index c3931b7a..348a6f07 100644 --- a/docs/sequencer-sequences-howto.md +++ b/docs/sequencer-sequences-howto.md @@ -137,6 +137,11 @@ After 48 ticks, ordinary event dispatch resumes on the original phase. Audio which was already ringing is not cut off. A zero-duration gate removes the current gate at the selected boundary: +Gate skips every ordinary event in the interval rather than postponing it. In +particular, a note-off or parameter reset inside the interval will not run +later. Keep state-restoring events outside the gate or package a complete +note-on/note-off gesture in its own finite sequence. + ```python amy.send( sequence=50, diff --git a/docs/sequencer-sequences-musical-use-cases.md b/docs/sequencer-sequences-musical-use-cases.md index 06ad3cc9..84a6fe78 100644 --- a/docs/sequencer-sequences-musical-use-cases.md +++ b/docs/sequencer-sequences-musical-use-cases.md @@ -46,6 +46,12 @@ controller sequence cannot gate away its own recovery. The caller decides which tags represent musical layers; AMY implements only generic action, duration, and phase behavior. +Ordinary events inside the interval are skipped, not delayed. For material +with a required note-off or parameter restoration, the author must place that +cleanup outside the gate or express the complete gesture as a separate finite +sequence. This keeps gate semantics independent of any particular instrument +or application. + ## A fixed number of repeats An event with a nonzero period repeats until its execution is stopped. To play diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md index 3386f375..89d70425 100644 --- a/docs/sequencer-sequences-status.md +++ b/docs/sequencer-sequences-status.md @@ -30,7 +30,10 @@ The wire protocol uses: | gate | `HCtag,2,duration,alignmentZ` | Temporarily suppress ordinary events | The numeric action is deliberately a three-value action rather than a boolean -or a note velocity. Fractional action values are rejected. +or a note velocity. Fractional values are rejected for every sequence tag, +tick, period, duration and alignment field. Tags, ticks and periods use uint32; +duration and alignment are capped at 2,147,483,647 ticks for wrap-safe pending +boundaries. ## Compatibility summary diff --git a/docs/sequencer-sequences.md b/docs/sequencer-sequences.md index 9f211f46..843eb398 100644 --- a/docs/sequencer-sequences.md +++ b/docs/sequencer-sequences.md @@ -83,6 +83,12 @@ Audio already ringing is not cut off. Sequence-control payloads remain active, so a controller sequence can still complete its lifecycle. Duration zero removes a gate at the selected boundary. +Gated ordinary events are skipped and are not replayed. That rule also applies +to note-offs and parameter-restoration events. Keep required cleanup outside +the interval or in a separately started finite gesture. Duration and alignment +must not exceed 2,147,483,647 ticks so their boundaries remain unambiguous +across the wrapping 32-bit tick clock. + ## Reset behavior - `amy.send(sequence_reset=tag)` removes the future definition. Active diff --git a/src/parse.c b/src/parse.c index f806b331..56587c57 100644 --- a/src/parse.c +++ b/src/parse.c @@ -733,6 +733,21 @@ static int sequence_control_uint_tail(const char *cursor, uint32_t *values, return count; } +static int sequence_ticks_prefix(const char *cursor, uint32_t values[3], + const char **payload) { + int count = 0; + while (count < 3) { + if (!sequence_uint32(cursor, &cursor, &values[count])) return -1; + count++; + if (*cursor != ',') break; + if (count == 3) return -1; + cursor++; + } + if (*cursor != '\0' && !isalpha((unsigned char)*cursor)) return -1; + *payload = cursor; + return count; +} + // Called from amy_add_message when the first char is 'H', indicating a ticks message. // It claims the rest of the message as its payload -- stored as a raw // wire string and only parsed when it comes due -- so a schedule command @@ -806,21 +821,19 @@ void handle_ticks_message_with_origin(char *message, return; } - const char *tick_start = message + 1; - while (*tick_start == ' ') ++tick_start; - if (!isdigit((unsigned char)*tick_start)) { + uint32_t ticks[3] = {0, 0, 0}; + const char *payload = NULL; + int num_vals = sequence_ticks_prefix(message + 1, ticks, &payload); + if (num_vals < 1) { fprintf(stderr, "invalid ticks command: expected Htick[,period[,tag]]payload, " "HCtag,action, or HRtag\n"); return; } - - uint32_t ticks[3] = {0, 0, 0}; - int num_vals = parse_list_uint32_t(message + 1, ticks, 3, 0); - uint16_t schedule_len = 1 + _next_alpha(message + 1); - char *payload = message + schedule_len; - uint16_t payload_len = (uint16_t)strlen(payload); - char *stripped = (char *)malloc_caps(payload_len + 1, amy_global.config.ram_caps_events); + size_t payload_len = strlen(payload); + char *stripped = payload_len >= UINT32_MAX ? NULL + : (char *)malloc_caps((uint32_t)(payload_len + 1), + amy_global.config.ram_caps_events); if (stripped == NULL) { amy_oom("ticks_message"); } else { diff --git a/src/sequencer.c b/src/sequencer.c index f96d3395..7a2d5d7b 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -77,6 +77,7 @@ typedef struct stored_sequence_execution_t { uint32_t gate_end_tick; uint32_t controls_processed_tick; bool occupied; + bool started; bool stop_pending; bool gate_change_pending; bool gated; @@ -463,6 +464,12 @@ uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, free(wire); return 0; } + if (period != 0 && tick >= period) { + fprintf(stderr, "cannot schedule event: tick %" PRIu32 + " must be below period %" PRIu32 "\n", tick, period); + free(wire); + return 0; + } if (has_tag) { if (tag >= max_sequences) { fprintf(stderr, "sequencer tag %" PRIu32" (with tick %" PRIu32", period %" PRIu32") is greater than or eq max_sequences %" PRIu32"\n", @@ -774,6 +781,18 @@ uint8_t sequencer_sequence_control_with_origin( tag, max_sequences - 1); return 0; } + if (alignment_period > INT32_MAX) { + fprintf(stderr, "cannot control sequence %" PRIu32 + ": alignment %" PRIu32 " exceeds the maximum %" PRIi32 + " ticks\n", tag, alignment_period, INT32_MAX); + return 0; + } + if (action == SEQUENCE_CONTROL_GATE && value > INT32_MAX) { + fprintf(stderr, "cannot gate sequence %" PRIu32 + ": duration %" PRIu32 " exceeds the maximum %" PRIi32 + " ticks\n", tag, value, INT32_MAX); + return 0; + } if (sequence_origin_may_reclaim(origin)) sequencer_reclaim_retired(); uint8_t result = 0; @@ -866,10 +885,17 @@ static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, bool controls) { amy_grab_lock(); stored_sequence_execution_t *execution = &sequence_executions[slot]; - if (!execution->occupied || !AMY_TIME_GEQ(tick, execution->start_tick)) { + if (!execution->occupied) { amy_release_lock(); return false; } + if (!execution->started) { + if (!AMY_TIME_GEQ(tick, execution->start_tick)) { + amy_release_lock(); + return false; + } + execution->started = true; + } uint32_t elapsed = tick - execution->start_tick; stored_sequence_definition_t *definition = execution->definition; if ((execution->stop_pending && AMY_TIME_GEQ(tick, execution->stop_tick)) @@ -914,8 +940,14 @@ static bool stored_sequence_process_slot(uint32_t slot, uint32_t tick, } } + bool finite_complete = !controls && !definition->has_periodic_event + && elapsed == definition->last_one_shot_tick; amy_grab_lock(); stored_sequence_definition_retire_locked(definition); + if (finite_complete && execution->occupied + && execution->definition == definition + && execution->start_tick == tick - elapsed) + stored_sequence_execution_release_deferred(execution); amy_release_lock(); return true; } diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index 93d8b527..bc9eaf6c 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -55,6 +55,9 @@ def main(): ] expect_error("standalone", lambda: amy.message(sequence_reset=2, synth=1)) + expect_error("tick", lambda: amy.message(ticks=(1.5,), osc=1)) + expect_error("period", lambda: amy.message(ticks=(4, 4), osc=1)) + expect_error("tag", lambda: amy.message(ticks=(0, 4, True), osc=1)) expect_error("only be combined", lambda: amy.message( sequence_control=(2, 1), synth=1)) expect_error("only be combined", lambda: amy.message( @@ -69,6 +72,8 @@ def main(): expect_error("alignment", lambda: amy.message(sequence_control=(2, 1, 1.5))) expect_error("uint32", lambda: amy.message( sequence_control=(2, 2, 1 << 32))) + expect_error("2147483647", lambda: amy.message( + sequence_control=(2, 2, 1 << 31))) expect_error("tag", lambda: amy.message(sequence_reset=1.5)) expect_error("tag", lambda: amy.message(sequence=True, action="start")) expect_error("tag", lambda: amy.message(sequence=1.5, action="start")) @@ -76,6 +81,8 @@ def main(): sequence=2, action="gate", duration=1.5)) expect_error("alignment", lambda: amy.message( sequence=2, action="start", alignment_period=1.5)) + expect_error("2147483647", lambda: amy.message( + sequence=2, action="start", alignment_period=1 << 31)) expect_error("needs action", lambda: amy.message(sequence=2)) expect_error("can only be combined", lambda: amy.message( sequence=2, action="start", synth=1)) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 8c8d7dc5..5fbffcc3 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -309,6 +309,23 @@ static void test_finite_gate_preserves_phase(void) { "event resumes on the original phase after gate expiry"); } +static void test_gate_drops_state_restoration_without_replay(void) { + printf("gate suppression is event-agnostic and does not replay events\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,0,5zPstate-onZ"); + amy_add_message("H2,0,5zPstate-offZ"); + amy_add_message("HC5,1,1Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + CHECK(mark_at("state-on", start), "event before gate is dispatched"); + CHECK(sequencer_sequence_control(5, SEQUENCE_CONTROL_GATE, 3, 1), + "gate covers the later state-restoring event"); + clock_to(start + 6); + CHECK(!marks_named("state-off"), + "suppressed state restoration is neither dispatched nor replayed"); +} + static void test_quantized_stop_targets_current_executions(void) { printf("quantized controls capture the current execution set\n"); sequencer_reset(); @@ -438,6 +455,12 @@ static void test_bounds_and_validation(void) { "one execution beyond configured capacity is rejected"); CHECK(!sequencer_sequence_control(3, 99, 0, 0), "unknown control action is rejected"); + CHECK(!sequencer_sequence_control( + 3, SEQUENCE_CONTROL_START, 0, (uint32_t)INT32_MAX + 1U), + "alignment beyond the wrap-safe interval is rejected"); + CHECK(!sequencer_sequence_control( + 3, SEQUENCE_CONTROL_GATE, (uint32_t)INT32_MAX + 1U, 0), + "gate duration beyond the wrap-safe interval is rejected"); } static void test_wire_control_shape_is_strict(void) { @@ -462,11 +485,20 @@ static void test_wire_control_shape_is_strict(void) { amy_add_message("HR3.0Z"); amy_add_message("HR-1Z"); amy_add_message("HA3Z"); + amy_add_message("H4294967296,0,3zPoverflow-tickZ"); + amy_add_message("H0,4294967296,3zPoverflow-periodZ"); + amy_add_message("H0,0,4294967296zPoverflow-tagZ"); + amy_add_message("H0.5,0,3zPfractional-tickZ"); CHECK(sequencer_sequence_control(3, SEQUENCE_CONTROL_START, 0, 0), "malformed and overflowing resets leave the definition intact"); uint32_t start = sequencer_ticks() + 1; clock_to(start); - CHECK(mark_at("defined", start), "the intact definition still starts"); + CHECK(mark_at("defined", start) + && !marks_named("overflow-tick") + && !marks_named("overflow-period") + && !marks_named("overflow-tag") + && !marks_named("fractional-tick"), + "the intact definition starts without malformed additions"); sequencer_reset(); clear_marks(); @@ -583,6 +615,7 @@ int main(void) { test_parent_stop_leaves_started_child_to_finish(); test_controller_sequence_bounds_repetition(); test_finite_gate_preserves_phase(); + test_gate_drops_state_restoration_without_replay(); test_quantized_stop_targets_current_executions(); test_cyclic_controls_are_bounded_and_recoverable(); test_same_tick_control_is_slot_order_independent(); From 10d5976939e32cdc4671a977b09d67d8515c9c80 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:50:46 +0200 Subject: [PATCH 45/52] Clarify sequence checks and terminology --- .github/workflows/c-cpp.yml | 2 +- Makefile | 1 - docs/sequencer-sequences-status.md | 2 +- src/sequencer.c | 2 +- tests/test_sequencer_sequences.c | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index 30b12070..5322b243 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -111,7 +111,7 @@ jobs: python-version: '3.13' - name: Check generated C API bindings are in sync - run: make check-c-api + run: make check-c-api js-api-test godot-build: # Build the Godot GDExtension for Linux. amy_midi.c is excluded from the diff --git a/Makefile b/Makefile index dbac073c..45703259 100644 --- a/Makefile +++ b/Makefile @@ -82,7 +82,6 @@ check-c-api: $(PYTHON) scripts/gen_amy_js_api.py --check $(PYTHON) scripts/gen_patches_js.py --check $(PYTHON) scripts/gen_pcm_presets_js.py --check - node tests/test_js_api.js js-api-test: node tests/test_js_api.js diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md index 89d70425..763ac6fe 100644 --- a/docs/sequencer-sequences-status.md +++ b/docs/sequencer-sequences-status.md @@ -134,7 +134,7 @@ The host test suite covers: The reusable-sequence C tests run as part of `make ctest`. Python API coverage is in `tests/test_sequence_api.py`, and generated API checks are included in -`make check-c-api`. +`make check-c-api` and `make js-api-test`. ## Target-dependent validation still required diff --git a/src/sequencer.c b/src/sequencer.c index 7a2d5d7b..7f7474bb 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -603,7 +603,7 @@ uint8_t sequencer_sequence_add_wire_with_origin( } if (wire[0] == 'H' && wire[1] != 'C') { fprintf(stderr, "cannot append event to sequence %" PRIu32 - ": only H sequence-control payloads may be nested\n", tag); + ": only HC sequence-control payloads may be composed\n", tag); free(wire); return 0; } diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 5fbffcc3..78959b20 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -438,7 +438,7 @@ static void test_bounds_and_validation(void) { CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("")), "empty payload is rejected"); CHECK(!sequencer_sequence_add_wire(3, 0, 0, strdup("H0,0,1zPbadZ")), - "stored sequences cannot edit definitions recursively"); + "stored sequences cannot contain sequence authoring commands"); for (uint32_t i = 0; i < 8; ++i) { char *payload = strdup("zPfullZ"); From 83051296c415fc205de38865d8937b8198915a35 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:54:24 +0200 Subject: [PATCH 46/52] Test sequence lifetime across the uint32 clock --- tests/test_sequencer_sequences.c | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 78959b20..94405c51 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -564,6 +564,41 @@ static void test_gate_and_stop_cross_clock_rollover(void) { "stop suppresses the event on its aligned boundary"); } +static void test_execution_lifetime_beyond_half_clock_range(void) { + printf("started executions remain valid across the uint32 clock\n"); + sequencer_reset(); + clear_marks(); + amy_add_message("H0,1,2zPlong-periodicZ"); + amy_add_message("HC2,1,1Z"); + uint32_t start = sequencer_ticks() + 1; + clock_to(start); + clear_marks(); + + amy_global.sequencer_tick_count = start + (uint32_t)INT32_MAX; + sequencer_midi_clock_tick(); + CHECK(marks_named("long-periodic") == 2, + "a latched periodic execution keeps running past half-range"); + + sequencer_reset(); + clear_marks(); + amy_add_message("H4294967295,0,3zPuint32-tailZ"); + amy_add_message("HC3,1,1Z"); + start = sequencer_ticks() + 1; + clock_to(start); + clear_marks(); + + amy_global.sequencer_tick_count = start - 2; + sequencer_midi_clock_tick(); + CHECK(marks_named("uint32-tail") == 1, + "a finite event at UINT32_MAX fires exactly once"); + int starts = 0; + for (int i = 0; i < 8; ++i) + starts += sequencer_sequence_control( + 3, SEQUENCE_CONTROL_START, 0, 1); + CHECK(starts == 8, + "the UINT32_MAX finite execution retires on its final event"); +} + static void test_disabled_configuration(void) { printf("invalid reusable-sequence capacities disable the feature safely\n"); const uint32_t capacities[][3] = { @@ -623,6 +658,7 @@ int main(void) { test_timebase_reset_keeps_definitions(); test_start_crosses_clock_rollover(); test_gate_and_stop_cross_clock_rollover(); + test_execution_lifetime_beyond_half_clock_range(); test_bounds_and_validation(); test_wire_control_shape_is_strict(); From ec94860c97055ff33869353a0515d35e8788e9f0 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 22:56:50 +0200 Subject: [PATCH 47/52] Preserve legacy omitted tick fields --- amy/__init__.py | 23 +++++++++++++++++------ src/parse.c | 18 +++++++++++++++++- src/sequencer.c | 6 ------ tests/test_sequence_api.py | 5 ++++- tests/test_sequencer_sequences.c | 24 +++++++++++++++++++----- 5 files changed, 57 insertions(+), 19 deletions(-) diff --git a/amy/__init__.py b/amy/__init__.py index aac39dda..7347dba2 100644 --- a/amy/__init__.py +++ b/amy/__init__.py @@ -286,12 +286,23 @@ def _message_ticks(value): if not 1 <= len(values) <= 3: raise ValueError('ticks needs tick, optional period, and optional tag.') names = ('ticks tick', 'ticks period', 'ticks tag') - normalized = [ - _sequence_uint32(item, names[index]) - for index, item in enumerate(values) - ] - if (len(normalized) >= 2 and normalized[1] - and normalized[0] >= normalized[1]): + normalized = [] + numeric = [] + for index, item in enumerate(values): + # Empty list fields have always meant zero on the AMY wire. Preserve + # that spelling as well as the meaning; the tutorial and existing + # callers use ticks=",period,tag" for a tick-zero event. + if item is None or (isinstance(item, str) and not item.strip()): + normalized.append(item) + numeric.append(0) + else: + parsed = _sequence_uint32(item, names[index]) + normalized.append(parsed) + numeric.append(parsed) + # tick < period is a reusable-sequence invariant. Legacy untagged two- + # field scheduling retains its historical wire behavior. + if (len(numeric) == 3 and numeric[1] + and numeric[0] >= numeric[1]): raise ValueError('ticks tick must be below its nonzero period.') return normalized diff --git a/src/parse.c b/src/parse.c index 56587c57..b06cb4ff 100644 --- a/src/parse.c +++ b/src/parse.c @@ -737,11 +737,27 @@ static int sequence_ticks_prefix(const char *cursor, uint32_t values[3], const char **payload) { int count = 0; while (count < 3) { - if (!sequence_uint32(cursor, &cursor, &values[count])) return -1; + const char *field = cursor; + while (*field == ' ') ++field; + if (*field == ',') { + // The generic AMY list syntax uses an empty field for zero. Keep + // accepting H,period,tag and H,,tag legacy spellings. + values[count] = 0; + cursor = field; + } else if (!sequence_uint32(cursor, &cursor, &values[count])) { + return -1; + } count++; if (*cursor != ',') break; if (count == 3) return -1; cursor++; + const char *next = cursor; + while (*next == ' ') ++next; + // A trailing comma did not add another value in the legacy parser. + if (*next == '\0' || isalpha((unsigned char)*next)) { + cursor = next; + break; + } } if (*cursor != '\0' && !isalpha((unsigned char)*cursor)) return -1; *payload = cursor; diff --git a/src/sequencer.c b/src/sequencer.c index 7f7474bb..20d9083f 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -464,12 +464,6 @@ uint8_t sequencer_add_wire_with_origin(uint32_t tick, uint32_t period, free(wire); return 0; } - if (period != 0 && tick >= period) { - fprintf(stderr, "cannot schedule event: tick %" PRIu32 - " must be below period %" PRIu32 "\n", tick, period); - free(wire); - return 0; - } if (has_tag) { if (tag >= max_sequences) { fprintf(stderr, "sequencer tag %" PRIu32" (with tick %" PRIu32", period %" PRIu32") is greater than or eq max_sequences %" PRIu32"\n", diff --git a/tests/test_sequence_api.py b/tests/test_sequence_api.py index bc9eaf6c..2b6f6ae3 100644 --- a/tests/test_sequence_api.py +++ b/tests/test_sequence_api.py @@ -37,6 +37,9 @@ def main(): assert amy.message(sequence_reset=7) == "HR7Z" assert amy.message(ticks=(1, 4, 2), synth=1, note=60, vel=1) \ == "H1,4,2n60l1i1Z" + assert amy.message(ticks=",24,2", osc=1) == "H,24,2v1Z" + assert amy.message(ticks=(None, 24, 2), osc=1) == "H,24,2v1Z" + assert amy.message(ticks=(4, 4), osc=1) == "H4,4v1Z" sent = [] old_override = amy.override_send @@ -56,7 +59,7 @@ def main(): expect_error("standalone", lambda: amy.message(sequence_reset=2, synth=1)) expect_error("tick", lambda: amy.message(ticks=(1.5,), osc=1)) - expect_error("period", lambda: amy.message(ticks=(4, 4), osc=1)) + expect_error("period", lambda: amy.message(ticks=(4, 4, 2), osc=1)) expect_error("tag", lambda: amy.message(ticks=(0, 4, True), osc=1)) expect_error("only be combined", lambda: amy.message( sequence_control=(2, 1), synth=1)) diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 94405c51..732f80f4 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -62,16 +62,30 @@ static void test_untagged_ticks_and_cumulative_tags(void) { clear_marks(); uint32_t first = next_boundary(sequencer_ticks(), 4); - amy_add_message("H0,4zProotZ"); + amy_add_message("H,4zProotZ"); clock_to(first + 4); - CHECK(mark_at("root", first), "periodic root event fires at global modulo"); + CHECK(mark_at("root", first), + "an omitted tick remains a tick-zero legacy list field"); CHECK(mark_at("root", first + 4), "periodic root event keeps looping"); sequencer_reset(); + CHECK(sequencer_add_wire(4, 4, 0, false, strdup("zPlegacy-periodZ")), + "untagged tick equal to period retains legacy acceptance"); + sequencer_reset(); + + clear_marks(); + amy_add_message("H,4,8zPomitted-local-zeroZ"); + uint32_t start = next_boundary(sequencer_ticks(), 4); + amy_add_message("HC8,1,4Z"); + clock_to(start); + CHECK(mark_at("omitted-local-zero", start), + "H,period,tag remains a reusable tick-zero event"); + sequencer_reset(); + clear_marks(); amy_add_message("H0,0,9zPfirstZ"); amy_add_message("H2,0,9zPsecondZ"); - uint32_t start = next_boundary(sequencer_ticks(), 4); + start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC9,1,4Z"); clock_to(start + 2); CHECK(mark_at("first", start) && mark_at("second", start + 2), @@ -112,9 +126,9 @@ static void test_empty_tick_zero_is_reset_but_payload_is_an_event(void) { sequencer_reset(); clear_marks(); amy_add_message("H0,0,10zPstoredZ"); - amy_add_message("H0,0,10Z"); + amy_add_message("H,,10Z"); CHECK(!sequencer_sequence_control(10, SEQUENCE_CONTROL_START, 0, 0), - "an empty H0,0,tag resets that tag"); + "the legacy empty H,,tag spelling resets that tag"); amy_add_message("H0,0,10zPstoredZ"); uint32_t start = next_boundary(sequencer_ticks(), 4); amy_add_message("HC10,1,4Z"); From f8df2b2ceffaac557de4a91c3b6ba94046de9399 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 23:01:43 +0200 Subject: [PATCH 48/52] Align sequence controls correctly across clock wrap --- src/sequencer.c | 13 +++++++++++-- tests/test_sequencer_sequences.c | 5 +++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/sequencer.c b/src/sequencer.c index 20d9083f..8948bee7 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -755,7 +755,13 @@ static uint32_t sequence_control_tick(uint32_t alignment_period, : current_tick; if (alignment_period != 0) { uint32_t remainder = tick % alignment_period; - if (remainder != 0) tick += alignment_period - remainder; + if (remainder != 0) { + uint32_t delta = alignment_period - remainder; + // The visible uint32 clock restarts at zero on rollover, and zero + // is an alignment boundary for every period. Do not carry a + // pre-rollover modulo phase into the wrapped clock. + tick = delta > UINT32_MAX - tick ? 0 : tick + delta; + } } return tick; } @@ -1019,7 +1025,10 @@ static void sequencer_process_tick(void) { active_unlink(tag); } else { size_t len = strlen(sequences[tag].wire); - wire = (char *)malloc_caps(len + 1, amy_global.config.ram_caps_events); + wire = len >= UINT32_MAX ? NULL + : (char *)malloc_caps( + (uint32_t)(len + 1), + amy_global.config.ram_caps_events); if (wire != NULL) memcpy(wire, sequences[tag].wire, len + 1); else amy_oom("sequencer fire"); } diff --git a/tests/test_sequencer_sequences.c b/tests/test_sequencer_sequences.c index 732f80f4..012f1032 100644 --- a/tests/test_sequencer_sequences.c +++ b/tests/test_sequencer_sequences.c @@ -546,9 +546,10 @@ static void test_start_crosses_clock_rollover(void) { amy_add_message("H0,0,2zPwrap-zeroZ"); amy_add_message("H2,0,2zPwrap-twoZ"); amy_global.sequencer_tick_count = UINT32_MAX - 2; - amy_add_message("HC2,1,4Z"); + amy_add_message("HC2,1,48Z"); clock_to(2); - CHECK(mark_at("wrap-zero", 0), "aligned local zero fires after rollover"); + CHECK(mark_at("wrap-zero", 0), + "non-power-of-two alignment treats wrapped tick zero as a boundary"); CHECK(mark_at("wrap-two", 2), "elapsed local time crosses rollover"); } From 83e883d401b44cd6fc27025f6b106e04791d6aae Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 23:04:21 +0200 Subject: [PATCH 49/52] Cover sequence allocation failure boundaries --- docs/sequencer-sequences-status.md | 18 +++++++++++---- src/sequencer.c | 2 +- tests/test_sequencer_oom.c | 37 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md index 763ac6fe..c1a1c3d7 100644 --- a/docs/sequencer-sequences-status.md +++ b/docs/sequencer-sequences-status.md @@ -104,9 +104,17 @@ Limits are explicit. `max_sequencer_tags` bounds identities, `max_sequence_events` bounds one definition, and `max_sequence_executions` bounds active or alignment-pending executions. Exhaustion, invalid tags, malformed actions, publication allocation failure, -and cyclic start graphs fail without publishing a partial definition. Callers -which deliberately choose small limits should treat a rejected operation as a -normal bounded-resource failure. +and cyclic start graphs reject the affected operation without corrupting the +previously published generation. Callers which deliberately choose small +limits should treat a rejected operation as a normal bounded-resource failure. + +A multi-message upload is not a wire-level transaction. `define_sequence()` +validates every Python event before sending its reset, but a target-side +capacity or transport failure during the subsequent messages can leave the +successfully accepted prefix as the new definition. A protocol which needs +acknowledged all-or-nothing remote upload must add that acknowledgement above +AMY's one-way wire command stream; after a detected failure, reset the tag +before retrying. Resetting a definition does not stop an execution which already holds a snapshot. `RESET_TIMEBASE` removes active and pending executions while @@ -126,8 +134,8 @@ The host test suite covers: - current-execution capture for aligned stop and gate; - arbitrary payloads, sequence composition, bounded cycles, and exhausted execution pools; -- allocation failure at candidate-construction stages and recovery without a - partial publication; +- allocation failure during pool initialization, new-definition creation and + candidate cloning, with recovery and no partial single-event publication; - two competing writers, including checked publication and retry; - Python validation and exact wire serialization; - executable JavaScript serialization and generated binding freshness. diff --git a/src/sequencer.c b/src/sequencer.c index 8948bee7..198e6e08 100644 --- a/src/sequencer.c +++ b/src/sequencer.c @@ -308,7 +308,7 @@ static void stored_sequences_init(uint32_t events, uint32_t executions) { if (sequence_executions != NULL) memset(sequence_executions, 0, execution_bytes); if (stored_sequences == NULL || sequence_executions == NULL) { - amy_oom("stored sequences"); + amy_oom("stored sequences: out of memory\n"); stored_sequences_deinit(); return; } diff --git a/tests/test_sequencer_oom.c b/tests/test_sequencer_oom.c index 0fed5c32..ab32a993 100644 --- a/tests/test_sequencer_oom.c +++ b/tests/test_sequencer_oom.c @@ -35,6 +35,41 @@ static void define_base(void) { "base tail is defined"); } +static void test_initialization_allocation_failures(amy_config_t config) { + printf("partial sequence-pool initialization fails closed\n"); + for (int32_t fail_after = 0; fail_after < 2; ++fail_after) { + sequencer_test_fail_allocation_after(fail_after); + amy_start(config); + sequencer_test_fail_allocation_after(-1); + CHECK(!sequencer_sequence_add_wire( + 1, 0, 0, strdup("zPmust-not-publishZ")), + "pool allocation failure %" PRIi32 " disables definitions", + fail_after); + CHECK(!sequencer_sequence_control( + 1, SEQUENCE_CONTROL_START, 0, 0), + "pool allocation failure %" PRIi32 " disables executions", + fail_after); + amy_stop(); + } +} + +static void test_new_definition_allocation_failures(void) { + printf("new-definition allocation failure leaves an empty tag\n"); + for (int32_t fail_after = 0; fail_after < 2; ++fail_after) { + sequencer_reset(); + sequencer_test_fail_allocation_after(fail_after); + CHECK(!sequencer_sequence_add_wire( + 1, 0, 0, strdup("zPmust-not-publishZ")), + "definition allocation failure %" PRIi32 " rejects the append", + fail_after); + sequencer_test_fail_allocation_after(-1); + CHECK(!sequencer_sequence_control( + 1, SEQUENCE_CONTROL_START, 0, 0), + "definition allocation failure %" PRIi32 + " publishes no empty candidate", fail_after); + } +} + static void test_clone_allocation_failures_preserve_source(void) { printf("every clone allocation failure preserves the published definition\n"); // Clone allocation order: definition, event array, then two wire strings. @@ -75,8 +110,10 @@ int main(void) { config.max_sequencer_tags = 4; config.max_sequence_events = 8; config.max_sequence_executions = 8; + test_initialization_allocation_failures(config); amy_start(config); + test_new_definition_allocation_failures(); test_clone_allocation_failures_preserve_source(); amy_stop(); From e69f537caab4c457ec6bdae9a17305c30bdcacc9 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 23:05:31 +0200 Subject: [PATCH 50/52] Stress sequence publication during rendering --- tests/test_sequencer_concurrency.c | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_sequencer_concurrency.c b/tests/test_sequencer_concurrency.c index 6e81a66b..45313155 100644 --- a/tests/test_sequencer_concurrency.c +++ b/tests/test_sequencer_concurrency.c @@ -23,6 +23,7 @@ static int release_writers = 0; static int a_hits = 0; static int b_hits = 0; static int control_failures = 0; +static int edit_failures = 0; static void after_source_pin(void) { pthread_mutex_lock(&rendezvous_lock); @@ -99,12 +100,19 @@ static void *advance_render_ticks(void *opaque) { return NULL; } -static void *change_sequence_gate(void *opaque) { +static void *change_sequence_gate_and_definition(void *opaque) { uint32_t count = *(uint32_t *)opaque; for (uint32_t i = 0; i < count; ++i) { if (!sequencer_sequence_control( 2, SEQUENCE_CONTROL_GATE, i & 1U, 1)) control_failures++; + // Resetting the future definition must not disturb the immutable + // snapshot currently read by the render thread. Rebuild it each time + // so publication and reclamation race with real tick processing. + if (!sequencer_sequence_reset(2) + || !sequencer_sequence_add_wire( + 2, 0, 1, strdup("zPthread-pulseZ"))) + edit_failures++; } return NULL; } @@ -121,10 +129,12 @@ static void test_render_and_control_threads_share_no_sequence_context(void) { pthread_t render_thread; pthread_t control_thread; control_failures = 0; + edit_failures = 0; CHECK(pthread_create(&render_thread, NULL, advance_render_ticks, &iterations) == 0, "render thread starts"); - CHECK(pthread_create(&control_thread, NULL, change_sequence_gate, + CHECK(pthread_create(&control_thread, NULL, + change_sequence_gate_and_definition, &iterations) == 0, "control thread starts"); pthread_join(render_thread, NULL); @@ -132,6 +142,8 @@ static void test_render_and_control_threads_share_no_sequence_context(void) { CHECK(control_failures == 0, "all concurrent controls target the active execution"); + CHECK(edit_failures == 0, + "concurrent future-definition replacement remains available"); CHECK(sequencer_sequence_reset(2), "external reset is not confused with stored-event dispatch"); } From ab2a02ec351ca4328069955abf674bc459f7262e Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Fri, 4 Sep 2026 23:09:38 +0200 Subject: [PATCH 51/52] Clarify sequence compatibility boundaries --- docs/sequencer-sequences-status.md | 2 ++ docs/synth.md | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/sequencer-sequences-status.md b/docs/sequencer-sequences-status.md index c1a1c3d7..39bd5eb1 100644 --- a/docs/sequencer-sequences-status.md +++ b/docs/sequencer-sequences-status.md @@ -41,8 +41,10 @@ boundaries. | --- | --- | --- | | Untagged `ticks=(tick,)` | Compatible | None | | Untagged `ticks=(tick, period)` | Compatible | None | +| Empty zero fields such as `ticks=",period,tag"` | Compatible | None | | Repeated tagged writes used to replace one event | Changed | Reset and rebuild the definition, or omit the tag for direct scheduling | | A tagged event expected to become active immediately | Changed | Start its sequence explicitly | +| C `amy_event.ticks` with `TICKS_TAG` set | Changed like any tagged event | Build the definition, then issue an explicit start | | Empty `H0,0,tagZ` used as cancellation | Compatible reset spelling | It still resets the future definition; stop an active execution separately | | C code using `amy_config_t` | Source compatible after rebuild | Initialize with `amy_default_config()` and override named fields | | Generated JavaScript or Godot bindings | Regeneration required | Rebuild the bindings with this AMY source | diff --git a/docs/synth.md b/docs/synth.md index 06a4aa96..fbf646ab 100644 --- a/docs/synth.md +++ b/docs/synth.md @@ -221,7 +221,10 @@ AMY starts a musical sequencer that works on `ticks` from startup. You can reset Ticks run at 48 PPQ at the set tempo. The tempo defaults to 108 BPM. This means there are 108 quarter notes a minute, and `48 * 108 = 5184` ticks a minute, 86 ticks a second. The tempo can be changed with `amy.send(tempo=120)`. -You can schedule an event with `amy.send(..., ticks="tick,period,tag")`. All three values are optional past `tick`: +You can schedule an event with `amy.send(..., ticks="tick,period,tag")`. +`period` and `tag` are optional. As in other AMY list fields, an empty numeric +field means zero, so `ticks=",24,7"` is the compact spelling for a tick-zero +event with period 24 and tag 7: ```python amy.send(osc=0, wave=amy.SAW_UP, eg0="0,1,500,0,500,0") # Pluck tone amy.send(osc=0, note=50, vel=1, ticks=amy.sequencer_ticks() + 96) # one-off: fires once, ~1s from now From 397488b33ae928877c71de964d903ad615bd1648 Mon Sep 17 00:00:00 2001 From: Jeroen Vriesman Date: Sat, 5 Sep 2026 10:14:41 +0200 Subject: [PATCH 52/52] Define M_PI portably for MSVC --- src/pcm.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pcm.c b/src/pcm.c index 552085b7..c1468904 100644 --- a/src/pcm.c +++ b/src/pcm.c @@ -3,6 +3,10 @@ #include "amy.h" #include "transfer.h" +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + #ifdef __EMSCRIPTEN__ #include "emscripten.h" #endif