Skip to content
Merged
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
33 changes: 31 additions & 2 deletions docs/amyboard/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,37 @@ import amy
amy.send(osc=0, wave=amy.AMY_MIDI) # osc 0 now sends MIDI instead of audio

# Play a MIDI note on channel 1 every quarter note (48 ticks), held for an eighth note.
amy.send(osc=0, note=60, vel=1, ticks="0,48,1") # note on at tick 0 of each period
amy.send(osc=0, note=60, vel=0, ticks="24,48,2") # note off at tick 24 of each period
amy.send(osc=0, note=60, vel=1, ticks="0,48,7") # note on at tick 0 of each period
amy.send(osc=0, note=60, vel=0, ticks="24,48,7") # note off at tick 24 of each period
```

`ticks="tick,period,tag"` schedules an event on AMY's sequencer, and events sent
to the same `tag` **accumulate** -- the second send above adds to tag 7 rather
than replacing the first -- so one tag can hold a whole pattern. Both events
above are on tag 7, and clearing tag 7 takes the pattern down as a unit:

```python
amy.send(ticks="0,0,7") # neither tick nor period: drop everything on tag 7
```

There's no way to remove one event from a tag while leaving the others, so
changing a pattern means clearing the tag and re-sending the events that
survive. `ticks=` claims the rest of its message, so the clear has to be its own
`amy.send()`. Events that land on the same tick play in ascending tag order, and
within one tag in the order you added them. See ["AMY's sequencer and
ticks"](https://github.com/shorepine/amy/blob/main/docs/synth.md#amys-sequencer-and-ticks)
for the full picture, including the untagged one-off form (`ticks=<tick>`) used
elsewhere in these docs.

If you'd rather not manage tags by hand, `sequencer.AMYSequence` wraps all of
this -- it takes a tag of its own and handles the clear-and-rebuild for you:

```python
import sequencer
seq = sequencer.AMYSequence(length=16, divider=8) # 16 steps of an eighth note
seq.add(0, amy.send, osc=0, note=60, vel=1) # step 0
seq.add(4, amy.send, osc=0, note=67, vel=1) # step 4
# seq.clear() # ...and take the whole pattern down
```

`amy.AMY_MIDI` always sends on MIDI channel 1, and notes that arrived over MIDI in are not echoed back out. See the [AMY MIDI docs](https://github.com/shorepine/amy/blob/main/docs/midi.md#sending-midi-out) for the full details.
Expand Down
2 changes: 1 addition & 1 deletion tulip/server/refdocs/amy/_VENDORED_FROM.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
Auto-generated by tulip/server/sync_amy_docs.py — do not edit by hand.
Source: amy@0fb0a00b5a9f9443d7e1f85261cc7e70a0adb76b
Source: amy@5c97024f01bd112fc24170299f0d5a0da1f489ed
Files: api.md, arduino.md, billie_jean.md, distortions.md, godot.md, juno_patches.md, midi.md, synth.md, upgrading.md
4 changes: 2 additions & 2 deletions tulip/server/refdocs/amy/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ amy_start(amy_config);
| `max_sequencer_tags` | Int | 256 | How many sequencer items to handle |
| `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 |
| `max_memory_patches` | Int | 32 | How many in memory patches to support |
| `i2s_lrc`, `i2s_dout`, `i2s_din`, `i2s_bclk`, `i2s_mclk` | Int | -1 | Pin numbers for the I2S interface |
| `midi_out`, `midi_in` | Int | -1 | Pin number for the MIDI UART pins |
| `midi_uart` | 0,1,[2] | -1 | UART device index for MCU. Default 1 (`UART1`) on Pi Pico and ESP. Teensy is always `8` |
Expand Down Expand Up @@ -503,7 +503,7 @@ At bus scope only the constant term of `GD`/`GM` is used; a bus sum has no per-n

| Wire code | C `amy_event` | Python / JS | Type-range | Notes |
| ------ | -------- | ---------- | ---------- | ------------------------------------- |
| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | Tick, period, tag for sequencing (see "AMY's sequencer" in synth.md). `tag` omitted: stored but not individually cancelable. `period` also omitted: a one-off event at that tick. **If used in a wire string message**, the `H` **must** be the first character of the message. |
| `H` | `ticks[3]` | `ticks` | int[,int[,tag]] | Tick, period, tag for sequencing (see "AMY's sequencer" in synth.md). Events on a `tag` ACCUMULATE -- a second send to the same tag adds another scheduled event rather than replacing the first -- so a tag can name a whole pattern, and `ticks="0,0,<tag>"` clears the whole of it. `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` | `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. |
Expand Down
24 changes: 22 additions & 2 deletions tulip/server/refdocs/amy/synth.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@ You can schedule an event with `amy.send(..., ticks="tick,period,tag")`. All thr
amy.send(osc=0, wave=amy.SAW_UP, eg0="0,1,500,0,500,0") # Pluck tone
amy.send(osc=0, note=50, vel=1, ticks=amy.sequencer_ticks() + 96) # one-off: fires once, ~1s from now
amy.send(osc=0, note=38, vel=1, ticks="0,24,7") # repeating, cancelable via tag 7
amy.send(osc=0, ticks="0,0,7") # cancel tag 7
amy.send(osc=0, note=45, vel=1, ticks="12,24,7") # ...adds a second event to tag 7
amy.send(ticks="0,0,7") # clear everything on tag 7
amy.send(osc=0, note=72, vel=1, ticks="0,24") # repeating, not individually cancelable
amy.reset() # Stop everything
```
Expand All @@ -237,7 +238,26 @@ You can schedule repeating events (like a step sequencer or drum machine) with `

For pattern sequencers like drum machines, you will also want to use `tick` alongside `period`. If both are given and `period` is nonzero, `tick` is assumed to be an offset on the `period`. For example, for a 16-step drum machine pattern running on eighth notes (PPQ/2), you would use a `period` of `16 * 24 = 384`. The first slot of the drum machine would have a `tick` of 0, the 2nd would have a `tick` offset of 24, and so on.

`tag` is optional. If you give one, you can cancel that event later by sending `ticks="0,0,tag"` with the same `tag`. If you omitted `tag` when setting up the sequence (a 1- or 2-value `ticks=`), the event is still scheduled and still fires, but it isn't addressable by any tag -- there's no way to cancel or replace it individually (only by something like `amy.reset()`, discarding all sequenced events), so only omit `tag` for events you don't need to manage later.
`tag` is optional. If you give one, events sent to that tag **accumulate**: each `ticks="tick,period,tag"` send adds another scheduled event under that tag, with its own `tick` and `period`, rather than replacing what was already there. So a tag names a *pattern*, not a single event -- a whole drum part, with a different `tick` offset per hit, can live on one tag:

```python
for step, note in ((0, 36), (12, 42), (24, 38), (36, 42)):
amy.send(osc=0, note=note, vel=1, ticks="%d,48,3" % step) # four events, one tag
```

You clear a tag by sending it with neither `tick` nor `period` -- `amy.send(ticks="0,0,tag")`, the same cancel spelling as before -- which drops **every** event stored under that tag. There is no way to remove one event from a tag while leaving its siblings, so an edit means clearing the tag and re-sending the events that survive. `ticks=` claims the rest of its message, so the clear has to be its own send:

```python
amy.send(ticks="0,0,3") # clear the pattern
for step, note in ((0, 36), (12, 42), (24, 40), (36, 42)): # ...and rebuild it
amy.send(osc=0, note=note, vel=1, ticks="%d,48,3" % step)
```

Tags are also what ordering is defined on: two events that land on the same tick fire in ascending tag order, and within one tag in the order you added them.

If you omitted `tag` when setting up the sequence (a 1- or 2-value `ticks=`), the event is still scheduled and still fires, but it isn't addressable by any tag -- there's no way to cancel or replace it individually (only by something like `amy.reset()`, discarding all sequenced events), so only omit `tag` for events you don't need to manage later.

The number of tags AMY accepts is `max_sequencer_tags` (default 256), and because events accumulate that same number now caps the total count of tagged events, not just the count of distinct tags. Overflowing it prints a warning and drops the new event; everything already scheduled keeps playing.

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.

Expand Down
9 changes: 7 additions & 2 deletions tulip/shared/py/drums.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,13 @@ def update_synth(self, name=None):

def update_switches(self):
"""For each on switch in the row, we have to update the sequencer with the new midi base_note."""
for switch in self.objs:
switch.update_sequencer()
# The whole pattern lives under one AMY sequencer tag, and AMY can only
# erase a tag whole -- so each switch's update() re-sends every step.
# Batching collapses a row's worth of those into one rebuild at the end
# rather than one per switch.
with app.drum_seq.batch():
for switch in self.objs:
switch.update_sequencer()

def vel_cb(self, e):
self.vel = e.get_target_obj().get_value() / 100.0
Expand Down
115 changes: 100 additions & 15 deletions tulip/shared/py/sequencer.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,28 +28,67 @@ def stop():
amy.send(sequencer_run=0)

class AMYSequenceEvent:
SEQUENCE_TAG = 0
"""One scheduled step of an AMYSequence.

An event does not own an AMY sequencer tag; its Sequence does, and every
event in that Sequence is scheduled under it. AMY accumulates events on a
tag -- each ticks= adds another entry rather than replacing what was there
-- which is what lets a whole pattern live on one tag. The cost is that
there is no way to replace or drop ONE entry: editing or removing a step
means clearing the tag (ticks="0,0,<tag>", which now takes the whole tag)
and re-sending the events that remain. That is what Sequence.rebuild()
does, and why an edit costs a message per surviving step. Use
`with seq.batch():` to coalesce a run of edits into one rebuild.
"""
def __init__(self, sequence):
self.sequence = sequence
self.tag = None

def amy_sequence_string(self):
return "%d,%d,%d" % (self.tick, self.sequence.period, self.tag)
@property
def tag(self):
# Events used to carry their own tag, one AMY tag per step, which ran
# the 256-tag space out after a few minutes of editing. Kept readable
# here for anything that still looks at it.
return self.sequence.tag

def remove(self):
amy.send(ticks=",,%d" % (self.tag))
self.sequence.events.remove(self)
def amy_sequence_string(self):
return "%d,%d,%d" % (self.tick, self.sequence.period, self.sequence.tag)

def update(self, position, func, args=[], amy_sequenceable=False, **kwargs):
def store(self, position, func, args=[], amy_sequenceable=False, **kwargs):
"""Record what this step plays and when, without telling AMY."""
self.tick = self.sequence.event_length_ticks * position
self.func = func
self.g_args = args
self.g_kwargs = kwargs
if self.tag is None:
self.tag = AMYSequenceEvent.SEQUENCE_TAG
AMYSequenceEvent.SEQUENCE_TAG = AMYSequenceEvent.SEQUENCE_TAG + 1
sequence = self.amy_sequence_string()
self.func(*self.g_args, **self.g_kwargs, ticks=sequence)

def schedule(self):
"""Add this step to AMY under the Sequence's tag."""
self.func(*self.g_args, **self.g_kwargs, ticks=self.amy_sequence_string())

def remove(self):
self.sequence.events.remove(self)
self.sequence.rebuild() # the tag is shared: put back what's left

def update(self, position, func, args=[], amy_sequenceable=False, **kwargs):
self.store(position, func, args=args, amy_sequenceable=amy_sequenceable, **kwargs)
self.sequence.rebuild() # ditto -- one entry can't be edited in place


class _Batch:
"""Context manager returned by AMYSequence.batch(); see there."""
def __init__(self, sequence):
self.sequence = sequence

def __enter__(self):
self.sequence.deferred = self.sequence.deferred + 1
return self.sequence

def __exit__(self, *exc):
seq = self.sequence
seq.deferred = seq.deferred - 1
if seq.deferred == 0 and seq.dirty:
seq.dirty = False
seq.rebuild()
return False


class Sequence:
Expand All @@ -75,13 +114,59 @@ def clear(self):
tulip.seq_remove_callback(self.tag)

class AMYSequence(Sequence):
# One AMY sequencer tag per Sequence, taken at construction and held for
# the Sequence's life. AMY's tag space is max_sequencer_tags (256 by
# default) and this counter doesn't recycle, so a session that builds
# hundreds of Sequences will eventually run out -- but a Sequence is an
# app-sized object, where the old one-tag-per-step scheme burned a tag on
# every step edit and ran out during ordinary use.
SEQUENCE_TAG = 0

def __init__(self, length=1, divider=8):
super().__init__(length, divider)

self.tag = AMYSequence.SEQUENCE_TAG
AMYSequence.SEQUENCE_TAG = AMYSequence.SEQUENCE_TAG + 1
self.deferred = 0 # depth of open batch() blocks
self.dirty = False # a rebuild was asked for while batching

def add(self, position, func, args=[], amy_sequenceable=False, **kwargs):
e = AMYSequenceEvent(self)
e.update(position, func=func, args=args, amy_sequenceable=amy_sequenceable, **kwargs)
e.store(position, func, args=args, amy_sequenceable=amy_sequenceable, **kwargs)
self.events = self.events + [e]
if self.deferred:
self.dirty = True
else:
e.schedule() # adding accumulates, so this needs no rebuild
return e

def rebuild(self):
"""Re-send every event, replacing what AMY holds under our tag.

Editing or removing a step needs this because AMY can only erase a
whole tag, never one entry in it. Inside a batch() it just marks the
Sequence dirty and the rebuild happens once, on the way out.
"""
if self.deferred:
self.dirty = True
return
amy.send(ticks=",,%d" % (self.tag)) # neither tick nor period: clear the tag
for e in self.events:
e.schedule()

def batch(self):
"""Coalesce the rebuilds from a run of edits into a single one:

with seq.batch():
for switch in row:
switch.sequencer_event.update(...)

Without it each edit rebuilds the whole tag, so N edits to a sequence
of N steps cost N**2 messages. Nests, and rebuilds only if something
inside actually changed.
"""
return _Batch(self)

def clear(self):
# One message, whatever the sequence holds -- the tag is ours alone.
amy.send(ticks=",,%d" % (self.tag))
self.events = []
Loading
Loading