Skip to content

Add finite stored sequencer patterns - #1148

Closed
linuxificator wants to merge 8 commits into
shorepine:mainfrom
linuxificator:upstream/nested_sequencer
Closed

Add finite stored sequencer patterns#1148
linuxificator wants to merge 8 commits into
shorepine:mainfrom
linuxificator:upstream/nested_sequencer

Conversation

@linuxificator

@linuxificator linuxificator commented Aug 30, 2026

Copy link
Copy Markdown

Motivation

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 one quantized event. Without a stored child phrase, a host must
either resend the entire phrase for every occurrence or reproduce AMY's clock
and stream it at exactly the right time.

The motivating downstream example is the
LB Omnichord rhythm engine,
which keeps repeating rhythm parts running while selecting short fills from a
preloaded catalogue. I used that application to validate the design, but kept
all knowledge of drums, fills, roles, selection and continuation policy out of
AMY. The implementation stores and schedules ordinary AMY events and is usable
for 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 without adding any arpeggiator-specific
behavior to AMY. The downstream wire and overlap tests are documented in the
Omnichord sequencer contract
.

Design

  • A stored definition uses the root sequencer's existing tick, period and tag
    semantics.
  • The same definition can play as LOOP or ONE_SHOT; there is no separate
    fill abstraction.
  • Trigger, root scheduling and stop can be quantized to AMY's sequencer clock.
  • A finite tagged mute is the generic leaf operation applications can use to
    suppress selected running patterns without resetting their phase.
  • Nesting stops after one child level. A root event may trigger a stored
    pattern, but a stored pattern cannot trigger or schedule another pattern.
    This covers the musical hierarchy I needed while preventing recursive cycles
    and keeping execution, lifetime and memory bounded.
  • All new wire operations are grouped under the existing zQ extended-control
    family. Stored events use
    zQE<pattern>,<tick>[,<period>[,<tag>]]<event>Z; no new top-level command
    family is introduced and H remains the root-scheduling envelope.
  • Matching C and Python APIs are included.

The portable defaults remain deliberately small: 32 definitions, 64 local
event tags per definition and 32 active or pending instances. All limits are
configurable, and max_patterns=0 disables the feature. LB Omnichord configures
1,024 definition slots because its public test catalogue currently preloads 270
fills and its reserved ID space is intended for more than 700. Definition slots
and active players remain separate limits, so 1,024 slots do not create 1,024
players.

Compatibility

Existing sequencer behavior is a hard requirement for this change. The new
wire operations and C/Python calls are opt-in. Existing H wire messages and
amy_add_event() tick scheduling retain their previous parsing and execution
paths.

The native regression test covers legacy H modulo timing, repetition, tag
replacement and clearing, anonymous coexistence, and absolute C-API tick
delivery. It also covers one-shot/loop timing, quantization, immutable commits,
reset and timebase behavior, rollover, nesting rejection and configured bounds.
The existing Shorepine C and Python/audio suites pass unchanged.

Validation

  • make ctest
  • AMY_TEST_THRESHOLD_DB=-70.0 make test — all 134 Python/audio tests pass
  • LB Omnichord full local matrix — all 195 tests pass
  • Independent LB Omnichord CI run — all 192 tests pass against the pinned AMY fork revision
  • The downstream Omnichord application was manually tested after migration to
    the grouped zQE wire operation
  • The arpeggio one-shot integration was manually tested with live rate changes
    and long chord holds; running notes complete their original gate without
    hanging or being cut short

@dpwe

dpwe commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

I get the general idea, that this is a mechanism for transferring entire timed collections of events into AMY which can then be triggered as a unit by single commands. However, I don't entirely understand the abstractions, and I find the text in synth.md to be too much about the implementation (which I guess the reader won't care about) and not enough about the actual function. Perhaps you could explain the facility via a worked-through musically-motivated example?

@linuxificator

Copy link
Copy Markdown
Author

Hi,

I have 3 use cases where is functionality is needed:

a) dynamic fills
In the drum part of my omnichord the user can choose certain fills, 5 choices per rhythm, and a fill density: per how many bars the user wants a fill. And then the sequencer has to play the chosen rhythm and loop through the fills, but also mute parts of the percussion during the fill.
Implementing all that becomes very difficult with only a flat pattern. Especially handling the user interaction; live updating the fills and mutes without cutting fills halfway becomes complex code.
However, with this nested structure it's easy. I can preload hundreds of fill patterns (5x times the number of available rhythms), and just call them form the main sequencer loop, the same with the mutes.
Making live changes to the rhythm by selecting more fills or changing the fill density is trivial and I don't have to "code around the cases where interaction would create musical nonsense".
So, creating a certain static sequencer pattern is fine in a flat sequencer, making live interaction possible with large sets of possibilities becomes very complex, I was unable to work around the edge cases where muting the other percussion didn't had the right timing and patterns got cut off at the wrong time, and the sequencer patterns became very long.
The possibility of preloading all the riffs also reduced the amount of amy commands needed during live interaction.

b) arpeggio's
Actually the same as in a, although it creating arpeggio's would be possible with the flat sequencer and updating the was less complex, it became much easier once I used one-shots, again especially the live interaction and prevention of musical nonsense (like long arpeggio's being cut halfway a note during live interaction) was easy with the arpeggio's in on-shot sequencer patterns.
With this one-shots the controlling program doesn't have to know where the sequencer is to prevent the musical nonsense, it can just update the appropriate mutes and one-shots from the main sequencer and everything is fine by itself.

c) African rhythms (not implemented yet in the omnichord)
The western rhythms are simple repeating patterns, the fills are not that special it's basically just repetition. Quite boring actually.
So I dived a bit into the amazing rich percussion culture of some African countries. So far I've cataloged only 40 basic cycles with 12 anchoring patterns and 187 smaller patterns. I'm now setting up the basic interactions between players, depending on style, region and instrument settings. The result should be a set of behavior rules, and then I have to find a proper parametrization.
Since this is not just creating a flat pattern, but live interaction and interaction from the behavior of the virtual players it's completely impossible to implement this without preloading the 187 smaller patterns in one-shots. I don't know if I have to extend the idea to N-shots for this, so in stead of a loop/on-shot maybe even have loop/N-shots where N can be 1, but also any other number.

@linuxificator

Copy link
Copy Markdown
Author

@dpwe

dpwe commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the added documentation, I understand things better now.

My concern is that this adds a lot of additional concepts to the AMY interface and may not be completely "in tune" with the existing AMY abstractions. I recognize, however, the functionality of adding whole sequences as objects in AMY both necessitates new concepts and is useful.

As I understand it, the key functionality here is to have a new kind of AMY object, a "sequencer pattern", consisting of multiple sequenced notes, which can be scheduled and played as a unit. The patterns can be defined before they are actually played, and can be started and stopped on phrase boundaries rather than mid-stream/asynchronously.

My first instinct is: Maybe we can simply add a 4th argument to the ticks tag to indicate that a command belongs to a sequencer pattern group. Your design includes special commands to indicate that a group is starting to be defined, then done being defined, but this is a bit unusual in the AMY syntax and I don't think it's necessary (compare the definition of synth configurations, where you just send commands with the synth tag and they cumulate, no need to say when you're done). So we could have collections of sequencer events that were associated with a second "tag", provided as a second argument after the existing tag (3rd argument to ticks).

Right now, when ticks sequences are introduced, they begin immediately, synchronized to the absolute sequencer clock modulo their defined period. To match your functionality, we'd need to add a mute/unmute function, and have that optionally also sync to length-sized boundaries in the sequencer clock. I like this idea and think it could be useful for all kinds of sequencer entities.

Maybe that covers it? We add a 4th argument, group_tag, to the ticks command that puts sequence events in a specific group (0 by default, I guess), then we add a new command sequence_control, that starts and stops sequencer groups on repeat boundaries (maybe with "immediate" as an option). So your arpeggio example becomes:

amy.send(sequence_control='1,0')  # Mute sequencer group 1
amy.send(ticks='0,48,0,1', synth=1, note=60, vel=1)  # first event in sequencer group 1 
amy.send(ticks='10,48,1,1', synth=1, note=60, vel=0) 
amy.send(ticks='12,48,2,1', synth=1, note=64, vel=1)
amy.send(ticks='22,48,3,1', synth=1, note=64, vel=0) 
amy.send(ticks='24,48,4,1', synth=1, note=67, vel=1)
amy.send(ticks='34,48,5,1', synth=1, note=67, vel=0) 
amy.send(ticks='36,48,6,1', synth=1, note=71, vel=1)
amy.send(ticks='46,48,7,1', synth=1, note=71, vel=0)

amy.send(sequence_control='1,1')  # Enable group 1 on next phrase boundary
...
amy.send(sequence_control='1,0')  # Disable group 1 on next phrase boundary
...
amy.send(group_control='1,1,1')  # Enable group 1 immediately

I think you have a mechanism for directly substituting one playing group for another by reusing a tag, but I think it's OK to replace that with two commands, one to stop one group, and another to start another.

I prefer this design because it adds the minimum to the existing AMY API and seems to reuse most of the syntax. Do you think it covers your use-cases?

@linuxificator

Copy link
Copy Markdown
Author

Thanks, I think this is a good direction, and considering how I actually use the current implementation from LB_Omnichord, I think there is a useful middle ground here.
But I think I will have to implement it first in a separate branch to be completely sure.

Your main concern: my current PR introduces more new AMY concepts. From the application side, I do not particularly care whether AMY exposes a "stored pattern" object as a separate abstraction. What I really need is a collection of sequencer events that can be prepared ahead of time and then activated as a unit.

So I like the idea of extending the existing ticks abstraction with a group_tag, and having a general sequence_control operation instead of the current collection of pattern begin/add/commit/trigger/stop commands.

I think that could make the public AMY interface significantly simpler.

There are, however, a few semantics from the current implementation that I think I still need. If these can be expressed as properties of sequencer groups rather than as a separate nested-pattern abstraction, I would be very happy to rework the PR that way.

The first one is trigger-relative phase.

For the drum fills, I preload hundreds of phrases, but when a fill is triggered it has to start from its own local tick 0. Its phase must not depend on where the absolute AMY sequencer clock happens to be.

The same is true for the small arpeggio phrases.

So I think there are really two useful operations on a group:

temporarily mute/unmute a running group while preserving its phase;
start a finite or repeating execution of a group from local tick 0, optionally quantized to a phrase boundary.

The first is useful for normal accompaniment layers. The second is what I currently use ONE_SHOT for.

The second requirement is finite execution.

For example, I need to be able to say, conceptually:

play group 37 once
play group 37 three times
play group 37 continuously

I do not mind whether this is represented as one-shot/N-shot/loop modes or simply as a repeat count in sequence_control. In fact, a repeat count may fit the existing AMY style better.

This would also solve the possible future African-rhythm use case more cleanly than a special one-shot abstraction.

The third requirement, and probably the most important one for live interaction, is lifetime ownership of an execution that has already started.

For example, an arpeggio child may contain:

tick 0 note on
tick 17 note off

If the user changes the arpeggio rate at tick 5, I want to replace the future triggers immediately, but the note which already started must still receive its original note-off at tick 17.

Likewise, if a fill has already started and the user deselects that fill, I want to remove future launches without cutting off the fill which is currently playing.

So I think an active finite execution needs to retain the group definition/revision which existed when it started. Editing or replacing the stored group should affect future executions, but not mutate an execution which is already in progress.

That does not necessarily have to be visible as a new API concept. It could just be the internal semantics of sequence_control.

The fourth requirement is finite onset muting while preserving phase.

During a fill, some of the normal percussion roles should temporarily stop producing new hits, but:

currently ringing audio should not be killed;
the base rhythm's phase should continue advancing;
after N ticks it should automatically resume in the correct place.

The current PR implements that as a finite tagged mute. I think this could also naturally become one of the sequence_control operations on a group rather than a pattern-specific command.

There is one other implementation detail which I think matters if we use group_tag.

At the moment the sequencer tag is effectively the index in the root sequences[] table. For LB I want hundreds of stored groups, and each group naturally wants its own small local tag namespace.

For example:

group 100: event tags 0..20
group 101: event tags 0..15
group 102: event tags 0..30
...

I would not want to flatten those into tens of thousands of global sequencer tags.

Similarly, inactive preloaded groups should not all be visited on every sequencer tick. The current LB catalogue already has 270 fills and is designed for substantially more, and the African material will add many more small phrases.

So internally I suspect there still needs to be a distinction between stored group definitions and currently active executions, even if that distinction disappears completely from the public API.

I also need stored group definitions to survive RESET_SEQUENCER. In LB, Start deliberately resets the transport/timebase and current scheduled events, but it should not resend hundreds of preloaded phrase definitions every time transport is restarted.

So the model I have in mind is roughly:

ticks + group_tag
|
v
persistent sequencer-group definition

sequence_control
|
v
active execution of that group

with an active execution having:

a local phase starting at zero;
a finite repeat count or infinite looping;
an immutable reference to the group definition it started with;
optional quantized start/stop;
optional onset mute which does not reset phase.

The public API could still be almost exactly what you proposed.

For example, conceptually:

amy.send(ticks='0,48,0,37', ...)
amy.send(ticks='10,48,1,37', ...)
...

and then some form of:

sequence_control(group=37, start=True, repeats=1)
sequence_control(group=37, start=True, repeats=0) # continuous
sequence_control(group=37, mute_onsets=72)

I am not attached to that exact syntax; I think the important part is the semantics.

I also agree that direct substitution of one group for another does not need its own primitive. Two control operations scheduled for the same boundary are fine.

If this interpretation fits what you had in mind, I think I would prefer it over the current PR design. It would let me remove most of the new public "pattern" vocabulary while retaining the properties that actually made the LB code simple.

Internally AMY may still need a small stored-definition table and a bounded active-execution table, but those could become implementation details of the existing sequencer instead of a second user-visible sequencer abstraction.

Does that sound closer to the kind of extension you would be comfortable adding to AMY?

@linuxificator

Copy link
Copy Markdown
Author

One additional thought after considering the abstraction more carefully: I think this can remain completely generic and should not need any LB Omnichord-specific semantics.

I would define a sequence group simply as a persistent named collection of ordinary AMY sequencer events with a local time coordinate. A group can have independent executions, each starting from local tick 0, with a finite repeat count or continuous repetition. An execution retains the definition revision with which it was started, so later edits only affect future executions.

I would also avoid defining anything like an "onset mute". More generally, AMY could support event gating while the execution clock continues. AMY would not interpret the payload or distinguish note-ons, note-offs, drums, fills, etc.; it would simply suppress dispatch of events from the gated group/execution while preserving phase.

That gives two deliberately separate generic operations:

launch: create a new execution from local tick 0;
gate/control: affect an existing execution without resetting its phase.

LB Omnichord fills, percussion suppression and arpeggios then become just applications of those primitives, while AMY itself makes no assumptions about their musical purpose.

@linuxificator

Copy link
Copy Markdown
Author

Superseded by #1151, which reworks the proposal around the sequencer-group abstraction suggested in this discussion. The replacement extends the existing ticks syntax with a group tag and uses one sequence_control family, without retaining the previous nested-pattern API.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants