Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
74bed04
Add reusable sequencer groups
Sep 3, 2026
066bc1f
Expand sequencer group behavior coverage
Sep 3, 2026
83ae7f1
Document sequencer group API
Sep 3, 2026
1098528
Cover sequencer group identity and rollover
Sep 3, 2026
45fc871
Add sequencer group usage guides
Sep 3, 2026
b1e995f
Align sequencer group terminology and links
Sep 4, 2026
c3ebcae
Harden sequencer group validation and diagnostics
Sep 4, 2026
ac8ea86
Expand sequencer group edge-case coverage
Sep 4, 2026
6c9829c
Unify sequencer group tick processing
Sep 4, 2026
2c1c052
Cover sequencer group control boundaries
Sep 4, 2026
2aa4323
Clarify sequencer group contracts and errors
Sep 4, 2026
b791eb2
Cover sequencer group republish and tag edges
Sep 4, 2026
90b62c6
Simplify reusable sequences around sequencer tags
Sep 4, 2026
bc3c85b
Document reusable tagged sequences
Sep 4, 2026
16c7e51
Rewrite sequence guides for the simplified API
Sep 4, 2026
29aa50a
Make sequencer tags cumulative sequences
Sep 4, 2026
06309fa
Document cumulative sequencer tags
Sep 4, 2026
2139516
Align sequence test terminology
Sep 4, 2026
fca1579
Remove retired sequence append from Godot
Sep 4, 2026
2669c3a
Cover concurrent sequence definition generations
Sep 4, 2026
b2a8865
Move sequence version reclamation off render path
Sep 4, 2026
7c98ad2
Reclaim sequence versions at control boundary
Sep 4, 2026
b6f559a
Defer render-fired sequence reset cleanup
Sep 4, 2026
0198b50
Make sequence triggers note-like and strict
Sep 4, 2026
361ac40
Migrate first-party sequencer callers
Sep 4, 2026
3060cc0
Test sequence publication allocation failures
Sep 4, 2026
f22307a
Cover concurrent sequence publication retry
Sep 4, 2026
dcbd284
Define bounded sequence composition semantics
Sep 4, 2026
092941c
Accept note-like sequence control velocity
Sep 4, 2026
33f4c01
Preserve existing config member offsets
Sep 4, 2026
ab5f302
Document and test sequence API migration
Sep 4, 2026
36aa150
Simplify stored sequence slots
Sep 4, 2026
380f20e
Ignore generated sequence test binaries
Sep 4, 2026
4aab0fc
Use explicit run state for sequence control
Sep 4, 2026
cc2407f
Document boolean sequence run control
Sep 4, 2026
841ccd2
Expose named sequence control actions
Sep 4, 2026
f03875f
Document start stop and gate actions
Sep 4, 2026
596047b
Document reusable sequence model and compatibility
Sep 4, 2026
065a229
Separate render and external sequence dispatch
Sep 4, 2026
98873a9
Make same-tick sequence controls slot independent
Sep 4, 2026
1b1e527
Test concurrent sequence render and control
Sep 4, 2026
fc3c9e5
Reject unrepresentable sequence capacities
Sep 4, 2026
1fd5e22
Validate sequence integers without truncation
Sep 4, 2026
04db763
Define strict wrap-safe sequence timing
Sep 4, 2026
10d5976
Clarify sequence checks and terminology
Sep 4, 2026
8305129
Test sequence lifetime across the uint32 clock
Sep 4, 2026
ec94860
Preserve legacy omitted tick fields
Sep 4, 2026
f8df2b2
Align sequence controls correctly across clock wrap
Sep 4, 2026
83e883d
Cover sequence allocation failure boundaries
Sep 4, 2026
e69f537
Stress sequence publication during rendering
Sep 4, 2026
ab2a02e
Clarify sequence compatibility boundaries
Sep 4, 2026
397488b
Define M_PI portably for MSVC
Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/c-cpp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ jobs:
python-version: '3.13'

- name: Check generated C API bindings are in sync
run: make check-c-api
run: make check-c-api js-api-test

godot-build:
# Build the Godot GDExtension for Linux. amy_midi.c is excluded from the
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ tests/tst
tests/test_clock_wrap
tests/test_sequencer_active
tests/test_sequencer_bounds
tests/test_sequencer_sequences
tests/test_sequencer_oom
tests/test_sequencer_concurrency
tests/test_bus_config
tests/test_patch_slots
tests/test_synth_readout
Expand Down
29 changes: 26 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ EMSCRIPTEN_OPTIONS = -s WASM=1 --bind \
-s ASYNCIFY -s ASYNCIFY_STACK_SIZE=128000
PYTHON = python3

.PHONY: default all clean amy-module test ctest web deploy-web godot-api c-api check-c-api
.PHONY: default all clean amy-module test ctest web deploy-web godot-api c-api check-c-api js-api-test

default: $(TARGET)
all: default
Expand All @@ -83,6 +83,9 @@ check-c-api:
$(PYTHON) scripts/gen_patches_js.py --check
$(PYTHON) scripts/gen_pcm_presets_js.py --check

js-api-test:
node tests/test_js_api.js

SOURCES += src/algorithms.c src/amy.c src/envelope.c src/examples.c src/parse.c \
src/filters.c src/oscillators.c src/pcm.c src/interp_partials.c src/custom.c \
src/delay.c src/log2_exp2.c src/patches.c src/transfer.c src/sequencer.c \
Expand Down Expand Up @@ -124,26 +127,46 @@ amy-message: $(OBJECTS) src/amy-message.o
# Plain C tests for things the audio-rendering suite can't reach -- e.g. clock
# rollovers 50 days out, which you can only hit by fast-forwarding the counters.
CTESTS = tests/test_clock_wrap tests/test_sequencer_active tests/test_sequencer_bounds \
tests/test_sequencer_sequences \
tests/test_sequencer_oom \
tests/test_sequencer_concurrency \
tests/test_bus_config tests/test_patch_slots \
tests/test_synth_readout tests/test_log2_lut tests/test_clone_on_grow \
tests/test_timebase_reset tests/test_osc_free_on_release \
tests/test_voice_osc_range tests/test_dist_coefs tests/test_dist_scope

# Static pattern rules, so these win over the generic %.o: %.c above (which
# would compile without -Isrc and fail to find amy.h).
$(addsuffix .o,$(CTESTS)): %.o: %.c $(HEADERS) src/patches.h
SEQUENCE_SPECIAL_TESTS = tests/test_sequencer_oom tests/test_sequencer_concurrency

$(addsuffix .o,$(filter-out $(SEQUENCE_SPECIAL_TESTS),$(CTESTS))): %.o: %.c $(HEADERS) src/patches.h
$(CC) $(CFLAGS) -Isrc -c $< -o $@

$(CTESTS): %: %.o $(OBJECTS)
$(filter-out $(SEQUENCE_SPECIAL_TESTS),$(CTESTS)): %: %.o $(OBJECTS)
$(CC) $(CFLAGS) $(OBJECTS) $< -Wall $(LIBS) -o $@

# Build only the sequencer and its OOM test with the test-only allocation hook;
# every other test and every production target uses the ordinary object.
tests/sequencer_testing_impl.o: src/sequencer.c $(HEADERS) src/patches.h
$(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -c $< -o $@

tests/test_sequencer_oom.o: tests/test_sequencer_oom.c $(HEADERS) src/patches.h
$(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -Isrc -c $< -o $@

tests/test_sequencer_concurrency.o: tests/test_sequencer_concurrency.c $(HEADERS) src/patches.h
$(CC) $(CFLAGS) -DAMY_SEQUENCE_TESTING -Isrc -c $< -o $@

$(SEQUENCE_SPECIAL_TESTS): %: %.o tests/sequencer_testing_impl.o $(filter-out src/sequencer.o,$(OBJECTS))
$(CC) $(CFLAGS) $(filter-out src/sequencer.o,$(OBJECTS)) tests/sequencer_testing_impl.o $< -Wall $(LIBS) -o $@

ctest: $(CTESTS)
@for t in $(CTESTS); do echo "== $$t"; ./$$t || exit 1; done

amy-module: amy-example
${EXTRA_PIP_ENV} ${PYTHON} -m pip install -r requirements.txt; touch src/amy.c; ${EXTRA_PIP_ENV} ${PYTHON} -m pip install . --force-reinstall --no-deps; cd ..

test: amy-module
${PYTHON} tests/test_sequence_api.py
${PYTHON} -m amy.test

qtest: amy-module
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ AMY was built by [DAn Ellis](https://research.google/people/DanEllis/) and [Bria
* [**Interactive AMY tutorial**](https://shorepine.github.io/amy/tutorial.html)
* [**AMY API**](docs/api.md)
* [**AMY Synthesizer Details**](docs/synth.md)
* [**AMY Reusable Sequences**](docs/sequencer-sequences.md)
* [**Distortion in AMY**](docs/distortions.md)
* [**AMY's MIDI specification**](docs/midi.md)
* [**AMY in Arduino Getting Started**](docs/arduino.md)
Expand Down Expand Up @@ -171,6 +172,7 @@ It's good to understand what wire messages are but you don't need to construct t
* [**Interactive AMY tutorial**](https://shorepine.github.io/amy/tutorial.html)
* [**AMY API**](docs/api.md)
* [**AMY Synthesizer Details**](docs/synth.md)
* [**AMY Reusable Sequences**](docs/sequencer-sequences.md)
* [**Distortion in AMY**](docs/distortions.md)
* [**AMY's MIDI specification**](docs/midi.md)
* [**AMY in Arduino Getting Started**](docs/arduino.md)
Expand Down
222 changes: 221 additions & 1 deletion amy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,164 @@ def str_of_int(arg):
return str(int(arg))


def _list_values(value):
"""Return a wire-list argument as individual values for validation."""
if isinstance(value, str):
return value.split(',')
if isinstance(value, (list, tuple)):
return list(value)
return [value]


_SEQUENCE_UINT32_MAX = (1 << 32) - 1
_SEQUENCE_MAX_INTERVAL = (1 << 31) - 1


def _sequence_uint32(value, name, allow_template=False):
"""Return one exact sequence integer without lossy numeric coercion."""
if allow_template and isinstance(value, str) and value.startswith('%'):
return value
if isinstance(value, bool):
raise ValueError('%s must be a non-negative integer.' % name)
if isinstance(value, int):
result = value
elif isinstance(value, str) and value.strip().isdigit():
result = int(value.strip())
else:
raise ValueError('%s must be a non-negative integer.' % name)
if result < 0:
raise ValueError('%s must be non-negative.' % name)
if result > _SEQUENCE_UINT32_MAX:
raise ValueError('%s must be in uint32 range.' % name)
return result


def _sequence_interval(value, name, allow_template=False):
result = _sequence_uint32(value, name, allow_template=allow_template)
if isinstance(result, str):
return result
if result > _SEQUENCE_MAX_INTERVAL:
raise ValueError('%s must not exceed 2147483647 ticks.' % name)
return result


def _message_ticks(value):
values = _list_values(value)
if not 1 <= len(values) <= 3:
raise ValueError('ticks needs tick, optional period, and optional tag.')
names = ('ticks tick', 'ticks period', 'ticks tag')
normalized = []
numeric = []
for index, item in enumerate(values):
# Empty list fields have always meant zero on the AMY wire. Preserve
# that spelling as well as the meaning; the tutorial and existing
# callers use ticks=",period,tag" for a tick-zero event.
if item is None or (isinstance(item, str) and not item.strip()):
normalized.append(item)
numeric.append(0)
else:
parsed = _sequence_uint32(item, names[index])
normalized.append(parsed)
numeric.append(parsed)
# tick < period is a reusable-sequence invariant. Legacy untagged two-
# field scheduling retains its historical wire behavior.
if (len(numeric) == 3 and numeric[1]
and numeric[0] >= numeric[1]):
raise ValueError('ticks tick must be below its nonzero period.')
return normalized


def _sequence_control_values(value):
"""Validate the low-level ``HC`` payload without blocking templates."""
values = _list_values(value)
if len(values) < 2:
raise ValueError('sequence_control needs at least tag and action.')
values[0] = _sequence_uint32(
values[0], 'sequence_control tag', allow_template=True)
raw_action = values[1]
if isinstance(raw_action, str) and raw_action.startswith('%'):
# Command templates substitute the token before AMY parses HC. The
# resulting wire value must still be the integer 0, 1, or 2.
if not 2 <= len(values) <= 4:
raise ValueError('A templated sequence_control needs tag, action, and up to duration and alignment_period.')
for index in range(2, len(values)):
values[index] = _sequence_interval(
values[index], 'templated sequence_control field',
allow_template=True)
return values
if isinstance(raw_action, int) and not isinstance(raw_action, bool):
action = raw_action
elif isinstance(raw_action, str) and raw_action.isdigit():
action = int(raw_action)
else:
raise ValueError('sequence_control action must be an integer: stop=0, start=1, or gate=2.')
if action in (SEQUENCE_CONTROL_STOP, SEQUENCE_CONTROL_START):
if len(values) not in (2, 3):
raise ValueError('A start/stop sequence_control needs tag, action, and optional alignment_period.')
elif action == SEQUENCE_CONTROL_GATE:
if len(values) not in (3, 4):
raise ValueError('A gate sequence_control needs tag, gate, duration, and optional alignment_period.')
else:
raise ValueError('sequence_control action must be stop=0, start=1, or gate=2.')
values[1] = action
field_names = ('sequence_control duration', 'sequence_control alignment_period') \
if action == SEQUENCE_CONTROL_GATE else ('sequence_control alignment_period',)
for index, name in enumerate(field_names, start=2):
if index < len(values):
values[index] = _sequence_interval(
values[index], name, allow_template=True)
return values


def _normalize_sequence_action(kwargs):
"""Translate a named sequence action into the existing HC primitive."""
if 'sequence' not in kwargs:
for key in ('action', 'duration', 'alignment_period'):
if key in kwargs:
raise ValueError('%s is only valid with sequence.' % key)
return kwargs
if 'sequence_control' in kwargs or 'sequence_reset' in kwargs:
raise ValueError('sequence cannot be combined with sequence_control or sequence_reset.')
extra = set(kwargs) - {
'sequence', 'action', 'duration', 'alignment_period', 'ticks'
}
if extra:
raise ValueError('sequence can only be combined with action, duration, alignment_period, and ticks.')
if 'action' not in kwargs:
raise ValueError("sequence needs action='start', 'stop', or 'gate'.")
tag = _sequence_uint32(kwargs['sequence'], 'Sequence tag')
alignment = _sequence_interval(
kwargs.get('alignment_period', 0), 'Sequence alignment_period')
action_name = kwargs['action']
actions = {
'stop': SEQUENCE_CONTROL_STOP,
'start': SEQUENCE_CONTROL_START,
'gate': SEQUENCE_CONTROL_GATE,
}
if not isinstance(action_name, str) or action_name not in actions:
raise ValueError("Sequence action must be 'start', 'stop', or 'gate'.")
action = actions[action_name]
if action == SEQUENCE_CONTROL_GATE:
if 'duration' not in kwargs:
raise ValueError("Sequence action='gate' needs a duration in ticks.")
duration = _sequence_interval(
kwargs['duration'], 'Sequence gate duration')
control = (tag, action, duration, alignment)
else:
if 'duration' in kwargs:
raise ValueError('Sequence duration is only valid with action=\'gate\'.')
control = (tag, action, alignment)
normalized = {}
if 'ticks' in kwargs:
normalized['ticks'] = kwargs['ticks']
normalized['sequence_control'] = control
return normalized


_KW_MAP_LIST = [ # Order matters because patch_string must come last.
# 'ticks' must come first: 'H' is recognized only as first char in wire message.
# Sequence/ticks headers must come first: 'H' is only recognized as the
# first wire character. sequence_control follows a ticks
# header when it is used as that scheduled event's payload.
('ticks', 'HL'),
('osc', 'vI'), ('wave', 'wI'), ('note', 'nF'), ('vel', 'lF'), ('amp', 'aC'), ('freq', 'fC'), ('duty', 'dC'),
('feedback', 'bF'), ('reset', 'SI'), ('phase', 'PF'), ('sample_offset', 'poI'), ('fit', 'pFF'), ('fit_search', 'pSI'), ('pan', 'QC'), ('client', 'gI'),
Expand All @@ -253,6 +409,8 @@ def str_of_int(arg):
('dist_clip', 'GCI'), ('dist_fold', 'GFI'), ('dist_crush', 'GHL'), ('dist_drive', 'GDC'), ('dist_mix', 'GMC'),
('algo_source', 'OL'), ('load_sample', 'zL'), ('transfer_file', 'zTL'), ('disk_sample', 'zFL'),
('algorithm', 'oI'), ('chorus', 'kL'), ('reverb', 'hL'), ('echo', 'ML'), ('patch', 'KI'),
('sequence_reset', 'HRI'),
('sequence_control', 'HCL'),
('external_channel', 'WI'), ('portamento', 'mI'), ('tempo', 'jF'), ('sequencer_run', 'zYI'),
('external_midi_sync', 'zCI'),
('synth', 'iI'), ('pedal', 'ipI'), ('synth_flags', 'ifI'), ('num_voices', 'ivI'), ('oscs_per_voice', 'inI'),
Expand All @@ -277,6 +435,9 @@ def message(**kwargs):
# Each keyword maps to two or three chars, first one or two are the wire protocol prefix, last is an arg type code
# I=int, F=float, S=str, L=list, C=ctrl_coefs
global show_warnings, _KW_MAP, _KW_PRIORITY, _ARG_HANDLERS
kwargs = _normalize_sequence_action(kwargs)
if kwargs.get('ticks') is not None:
kwargs['ticks'] = _message_ticks(kwargs['ticks'])
if show_warnings:
# Check for possible user confusions.
if 'voices' in kwargs and 'preset' in kwargs and 'osc' not in kwargs:
Expand All @@ -296,6 +457,20 @@ def message(**kwargs):
if 'wave' not in kwargs or kwargs['wave'] != BYO_PARTIALS:
raise ValueError('\'num_partials\' must be used with \'wave\'=BYO_PARTIALS.')

outer_sequence_keys = {'ticks', 'sequence_reset'} & kwargs.keys()
if len(outer_sequence_keys) > 1:
raise ValueError('Use only one of sequence_reset or ticks in a message.')
if 'sequence_reset' in kwargs and len(kwargs) != 1:
raise ValueError('sequence_reset must be sent as a standalone message.')
if 'sequence_reset' in kwargs:
kwargs['sequence_reset'] = _sequence_uint32(
kwargs['sequence_reset'], 'sequence_reset tag')
if 'sequence_control' in kwargs:
if set(kwargs) - {'sequence_control', 'ticks'}:
raise ValueError('sequence_control can only be combined with ticks.')
kwargs['sequence_control'] = _sequence_control_values(
kwargs['sequence_control'])

# Validity check all the passed args.
prioritized_keys = []
for key, arg in kwargs.items():
Expand Down Expand Up @@ -373,6 +548,51 @@ def send(**kwargs):
send_raw(m)


def _sequence_ticks(value):
"""Normalize a stored-sequence event's local (tick, period) tuple."""
if isinstance(value, str):
values = value.split(',')
elif isinstance(value, (list, tuple)):
values = list(value)
else:
values = [value]
if not 1 <= len(values) <= 2:
raise ValueError('A stored sequence event needs ticks=(tick,) or ticks=(tick, period).')
tick = _sequence_uint32(values[0], 'Stored sequence tick')
period = _sequence_uint32(values[1], 'Stored sequence period') \
if len(values) == 2 else 0
if period and tick >= period:
raise ValueError('A stored sequence tick must be below its nonzero period.')
return tick, period


def define_sequence(tag, events):
"""Replace one reusable tagged sequence with ordinary AMY events.

Each event is a mapping accepted by :func:`message` and must contain a
local ``ticks`` value with one or two fields. All event messages are
validated before the reset is sent, then the definition is written as a
per-tag reset followed by explicit cumulative event appends. Executions
which already started keep their previous immutable definition.
"""
sequence_tag = _sequence_uint32(tag, 'Sequence tag')
event_messages = []
for event in events:
values = dict(event)
if 'ticks' not in values:
raise ValueError('Every stored sequence event needs a ticks value.')
if 'sequence_reset' in values:
raise ValueError('Stored sequence events cannot contain sequence authoring commands.')
tick, period = _sequence_ticks(values.pop('ticks'))
if not values:
raise ValueError('Every stored sequence event needs an AMY payload.')
event_messages.append(message(ticks=(tick, period, sequence_tag), **values))

send_raw(message(sequence_reset=sequence_tag))
for event_message in event_messages:
send_raw(event_message)


# Plots a time domain and spectra of audio
def show(data):
import matplotlib.pyplot as plt
Expand Down
3 changes: 3 additions & 0 deletions amy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@
TICKS_TICK=0
TICKS_PERIOD=1
TICKS_TAG=2
SEQUENCE_CONTROL_STOP=0
SEQUENCE_CONTROL_START=1
SEQUENCE_CONTROL_GATE=2
RESET_SEQUENCER=4096
RESET_ALL_OSCS=8192
RESET_TIMEBASE=16384
Expand Down
20 changes: 8 additions & 12 deletions amy/examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,18 +257,14 @@ def example_sequencer_drums():
# Update high cowbell
amy.send(osc=4, note=70)

# Add patterns
# Hi hat every 1/8th note
amy.send(ticks=[0, 24, 0], osc=2, vel=2.0)

# Bass drum every quarter note
amy.send(ticks=[0, 96, 1], osc=0, vel=1.0)

# Snare every quarter note, counterphase to BD
amy.send(ticks=[24, 96, 2], osc=1, vel=1.0)

# Cow once every other cycle
amy.send(ticks=[0, 192, 3], osc=3, vel=1.0)
# Store all parts as one reusable pattern, then start it explicitly.
amy.define_sequence(0, [
dict(ticks=(0, 24), osc=2, vel=2.0), # hi-hat every eighth note
dict(ticks=(0, 96), osc=0, vel=1.0), # bass drum every quarter
dict(ticks=(24, 96), osc=1, vel=1.0), # counterphase snare
dict(ticks=(0, 192), osc=3, vel=1.0), # cowbell every other cycle
])
amy.send(sequence=0, action='start', alignment_period=1)

def example_fm():
amy.reset()
Expand Down
Loading
Loading