Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
72 changes: 72 additions & 0 deletions amy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,78 @@ def send(**kwargs):
send_raw(m)


def pattern_begin(pattern, length_ticks):
"""Begin a new staging definition for a finite-length sequencer pattern."""
send_raw("zQB%d,%dZ" % (pattern, length_ticks))


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('zQE' + ','.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_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)


# 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
AMY_PATTERN_ONE_SHOT=0
AMY_PATTERN_LOOP=1
AMY_PATTERN_UNTAGGED=4294967295
RESET_SEQUENCER=4096
RESET_ALL_OSCS=8192
RESET_TIMEBASE=16384
Expand Down
47 changes: 46 additions & 1 deletion amy/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2163,6 +2163,52 @@ 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)
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_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 = [
'zQB3,96Z',
'zQE3,0,96,7n36l1i10Z',
'zQE3,24,,8v2l0Z',
'zQC3Z',
'zQT3,1,96,12Z',
'zQA3,0,24,384,192,9Z',
'zQM12,48Z',
'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.

Expand Down Expand Up @@ -2341,4 +2387,3 @@ def main(argv):

if __name__ == "__main__":
main(sys.argv)

47 changes: 47 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,42 @@ Notes:

<!-- END GENERATED C API DOCS -->

### 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);
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_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);
```

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. `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,
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:
Expand Down Expand Up @@ -204,6 +240,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 |
Expand Down Expand Up @@ -507,6 +546,14 @@ 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 | 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 |
| `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 |
| `V` | `volume`| `volume` | float | Volume knob for the addressed bus (`bus`/`y`, default 0) in the final mixdown, default 1.0 |
Expand Down
141 changes: 141 additions & 0 deletions docs/nested-pattern-abstractions.md
Original file line number Diff line number Diff line change
@@ -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,<tag>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<pattern>,<tick>[,<period>[,<tag>]]<event>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.
Loading