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