Skip to content

Commit 86e7c4e

Browse files
bwhitmanclaude
andcommitted
Teach loop(step) in AMY_AGENTS, the generator prompt, and online docs
All sketch guidance now documents the loop(step) convention: step is the global 32nd-note index on the sequencer's bar-locked grid (step % 32 == 0 is always a downbeat, the same grid AMYSequence events fire on), and the first call lands on a downbeat. Every example converts from a hand-rolled call counter (or, in the generator's arpeggio example, wall-clock BPM-to-milliseconds math) to the step grid. The zero-argument loop() is documented as legacy and still works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ca987a6 commit 86e7c4e

3 files changed

Lines changed: 41 additions & 41 deletions

File tree

AMY_AGENTS.md

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,21 @@ kwargs above (see the LFO section). If a name isn't in the right list, it doesn'
4242
4343
---
4444

45-
## Timing & rhythm: `loop()` runs every 32nd note from the always-on sequencer — count calls, don't clock-math beats
46-
47-
AMY's sequencer is always running and calls `loop()` once per **32nd note**. For anything
48-
tempo- or beat-based, schedule events by counting `loop()` calls on that grid — **don't**
49-
convert BPM to milliseconds and measure the gap between beats with a wall clock
50-
(`tulip.amy_ticks_ms()` / `time`). The grid is tempo-locked for you, so hand-rolled beat
51-
math is just extra code that drifts.
45+
## Timing & rhythm: `loop(step)` runs every 32nd note on the sequencer's bar-locked grid — use `step`, don't clock-math beats
46+
47+
AMY's sequencer is always running and calls `loop(step)` once per **32nd note**, starting
48+
on a bar downbeat. `step` is the global 32nd-note index on the sequencer's bar-locked
49+
grid: **`step % 32 == 0` is always a downbeat**, and it is the same grid `AMYSequence`
50+
events fire on, so patterns built from `step` stay in phase with AMY-sequenced patterns.
51+
For anything tempo- or beat-based, schedule events from `step`**don't** keep your own
52+
call counter (it can drift if a callback is dropped), and **don't** convert BPM to
53+
milliseconds and measure the gap between beats with a wall clock (`tulip.amy_ticks_ms()`
54+
/ `time`). The grid is tempo-locked for you, so hand-rolled beat math is just extra code
55+
that drifts. (A legacy zero-argument `def loop():` still works and starts on a downbeat,
56+
but new sketches should take `step`.)
5257

5358
Set the tempo with **`sequencer.tempo(bpm)`** (`import sequencer`). The grid is always in
54-
32nd notes: 1 beat (quarter) = **8** calls, 8th = 4, 16th = 2, a 4/4 bar = 32.
59+
32nd notes: 1 beat (quarter) = **8** steps, 8th = 4, 16th = 2, a 4/4 bar = 32.
5560

5661
### Example — "a four-on-the-floor kick drum, 4/4 at 120 BPM"
5762

@@ -64,16 +69,15 @@ sequencer.tempo(120)
6469
amy.send(synth=10, num_voices=1, synth_flags=3, patch=384, amp=5)
6570

6671
KICK = 36
67-
step = 0
68-
def loop():
69-
global step
72+
def loop(step):
7073
if step % 8 == 0: # every 8 thirty-second notes = one beat -> four-on-the-floor
7174
amy.send(synth=10, note=KICK, vel=1)
72-
step += 1
7375
```
7476

75-
> Source of truth: `tulip/amyboardweb/sketches/house_generator.py`, `acid_generator.py`
76-
> (`sequencer.tempo(...)` + 32-steps-per-bar patterns driven by a `loop()` step counter).
77+
> Source of truth: `_start_sketch_loop` in `tulip/shared/amyboard-py/amyboard.py`
78+
> (downbeat-aligned dispatch and the `loop(step)` signature) plus
79+
> `tulip/amyboardweb/sketches/house_generator.py`, `acid_generator.py`
80+
> (`sequencer.tempo(...)` + 32-steps-per-bar patterns).
7781
7882
> **GM drums need a drum-kit patch (384-390).** To play drums by GM note number (36=kick,
7983
> 38=snare, 42=closed hat, 46=open hat, 49=crash...) load a drum-kit patch: `amy.send(synth=10,
@@ -105,7 +109,9 @@ arps, ostinatos); keep `loop()` for UI, knobs, and evolving the pattern.
105109
`1/divider` of a whole note (so `AMYSequence(32, 32)` = one 4/4 bar of 32nd-note slots).
106110
`seq.add(position, amy.send, synth=…, note=…, vel=…)` schedules a repeating note; it
107111
returns an event with `.update(position, amy.send, …)` to change it and `.remove()` to
108-
delete it.
112+
delete it. `AMYSequence` slots and `loop(step)` share the same bar-locked grid: slot
113+
`p` of a one-bar `AMYSequence(32, 32)` fires exactly when `step % 32 == p`, so a
114+
`loop(step)` part (bass line, chord stabs) stays in phase with an `AMYSequence` groove.
109115

110116
### Example — four-on-the-floor kick + 8th hats that keep time no matter what loop() does
111117

@@ -121,7 +127,7 @@ for pos in (0, 8, 16, 24):
121127
for pos in range(0, 32, 4):
122128
seq.add(pos, amy.send, synth=10, note=42, vel=0.3) # 8th-note closed hats
123129

124-
def loop():
130+
def loop(step):
125131
pass # free for UI/display/knobs -- the pattern plays itself
126132
```
127133

@@ -156,11 +162,8 @@ import amy, amyboard, sequencer
156162
sequencer.tempo(120)
157163
amy.send(synth=10, patch=384, num_voices=6, synth_flags=3)
158164
d = amyboard.display
159-
step = -1
160165

161-
def loop():
162-
global step
163-
step += 1
166+
def loop(step):
164167
if step % 8 == 0:
165168
amy.send(synth=10, note=36, vel=1) # (better: AMYSequence, see above)
166169
if step % 32 == 0: # once per bar, content changed
@@ -215,7 +218,7 @@ amy.send(synth=1, patch=0, num_voices=4) # Juno patch (has a filter/res
215218
# Map CC 42 -> resonance on synth 1, linear, range 0..8:
216219
amy.send(synth=1, midi_cc="42,0,0,8,0,i%iR%v")
217220

218-
def loop():
221+
def loop(step):
219222
pass
220223
```
221224

@@ -254,7 +257,7 @@ import amy
254257
amy.send(synth=1, patch=0, num_voices=1) # num_voices=1 -> mono; voice stealing is automatic
255258
amy.send(synth=1, portamento=80) # optional: glide between notes for a mono lead
256259

257-
def loop():
260+
def loop(step):
258261
pass
259262
```
260263

@@ -296,7 +299,7 @@ amy.send(synth=1, osc=0, wave=amy.PULSE,
296299
# CC 1 -> LFO rate, 0.1..5 Hz. CMD i%iv1f%v == amy.send(synth=%i, osc=1, freq=%v)
297300
amy.send(synth=1, midi_cc="1,0,0.1,5,0,i%iv1f%v")
298301

299-
def loop():
302+
def loop(step):
300303
pass
301304
```
302305

@@ -333,7 +336,7 @@ amy.send(synth=1, osc=1, wave=amy.SAW_DOWN, pan=0.8,
333336
# osc 2: slow sine LFO. a mod_source osc is silent, so it needs NO note-on.
334337
amy.send(synth=1, osc=2, wave=amy.SINE, freq=0.15, amp=1)
335338

336-
def loop():
339+
def loop(step):
337340
pass
338341
```
339342

@@ -368,7 +371,7 @@ amy.send(synth=18, osc=1, wave=amy.AUDIO_IN1, pan=1, amp=10) # right
368371
amy.send(synth=18, vel=1, note=60) # note-on sent to both active oscs. note number required but ignored
369372
amy.send(reverb="0.8,0.85,0.5") # global: level, liveness, damping[, xover_hz]
370373

371-
def loop():
374+
def loop(step):
372375
pass
373376
```
374377

@@ -449,7 +452,7 @@ amy.send(synth=1, patch=0, num_voices=4)
449452
enc = amyboard.encoder() # autodetect; works on any device or the simulator
450453
_last = [enc.read(i) for i in range(enc.encoders)]
451454

452-
def loop():
455+
def loop(step):
453456
for i in range(enc.encoders):
454457
pos = enc.read(i)
455458
delta = pos - _last[i]

docs/amyboard/online.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ Click any knob **label** (the text above or below the knob, e.g. "freq") to open
206206

207207
## Write and run Python sketches
208208

209-
When AMYboard starts up, either on web or hardware, it sets up whatever patches are set to each channel (using data stored in a sketch) and then runs a "sketch" set up in `sketch.py`. This is a Python program that runs setup code (in the top level of the code file) and then calls `loop()` periodically -- every 32nd note of the sequencer.
209+
When AMYboard starts up, either on web or hardware, it sets up whatever patches are set to each channel (using data stored in a sketch) and then runs a "sketch" set up in `sketch.py`. This is a Python program that runs setup code (in the top level of the code file) and then calls `loop(step)` periodically -- every 32nd note of the sequencer, starting on a bar downbeat. `step` is the 32nd-note index on the sequencer's bar-locked grid (`step % 32 == 0` is a downbeat), so patterns built from it stay in sync with `sequencer.AMYSequence` patterns. (A zero-argument `loop()` also works.)
210210

211211
<img src="img/code.png">
212212

tulip/server/amyboardworld_db_api.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,7 +1264,7 @@ def retag_hardware(
12641264
- Respond with ONLY the contents of sketch.py as plain MicroPython source.
12651265
- No Markdown, no code fences, no explanation before or after the code.
12661266
- Begin with comment lines, including one line of the form: # DESCRIPTION: <short summary>
1267-
- You MUST define a top-level loop() function. It may just `pass`. loop() is called repeatedly (about every 32nd note) while the sketch runs; use it for sequencing, timing, and reading inputs.
1267+
- You MUST define a top-level loop(step) function. It may just `pass`. loop(step) is called once per 32nd note while the sketch runs, starting on a bar downbeat; use it for sequencing, timing, and reading inputs. step is the global 32nd-note index on the sequencer's bar-locked grid: step % 32 == 0 is always a downbeat, 8 steps = one beat, and it is the same grid AMYSequence events fire on, so derive all rhythm from step (never keep your own call counter and never do BPM-to-milliseconds math).
12681268
- Top-level code runs once at boot (set up synths, effects, callbacks there).
12691269
- Only use the APIs documented below. Do NOT invent functions, modules, or parameters.
12701270
- Keep sketches self-contained and runnable in the web simulator: no network, no filesystem, no SD card, no long blocking loops, and never call time.sleep() inside loop().
@@ -1307,9 +1307,9 @@ def retag_hardware(
13071307
13081308
OTHER MODULES
13091309
- import midi: midi.add_callback(fn) registers fn(msg) for incoming MIDI. msg is a 3-byte sequence [status, data1, data2]; note-on is (status & 0xF0)==0x90 with vel>0, note-off is 0x80 (or 0x90 with vel==0).
1310-
- import tulip: tulip.amy_ticks_ms() returns a millisecond clock. Use it in loop() to schedule events instead of sleeping.
1310+
- import tulip: tulip.amy_ticks_ms() returns a millisecond clock. Only for non-musical timing (UI debounce etc.) -- musical timing should always come from loop(step).
13111311
- from music import Chord, Key: Chord("C:maj").annotations is a list of semitone offsets; Key("A:min") for scales. Root note names are 'C','C#','D','D#','E','F','F#','G','G#','A','A#','B'.
1312-
- import sequencer: sequencer.tempo(bpm) sets the sketch tempo.
1312+
- import sequencer: sequencer.tempo(bpm) sets the sketch tempo (the loop(step) grid follows it).
13131313
- Standard library available: random, math.
13141314
13151315
CONVENTIONS
@@ -1323,7 +1323,7 @@ def retag_hardware(
13231323
import amy
13241324
amy.send(synth=1, patch=256, num_voices=6)
13251325
1326-
def loop():
1326+
def loop(step):
13271327
pass
13281328
13291329
# AMYboard Sketch
@@ -1358,19 +1358,18 @@ def midi_cb(m):
13581358
13591359
midi.add_callback(midi_cb)
13601360
1361-
def loop():
1361+
def loop(step):
13621362
pass
13631363
13641364
# AMYboard Sketch
13651365
# DESCRIPTION: Hold MIDI keys; plays them in order as 8th-note arpeggios.
1366-
import amy, midi, tulip
1366+
import amy, midi, sequencer
13671367
13681368
amy.send(synth=1, grab_midi_notes=0)
1369-
STEP_MS = 250 # 8th note at 120 BPM
1369+
sequencer.tempo(120)
13701370
held = set()
13711371
arp_idx = 0
13721372
last_played = None
1373-
last_step_ms = 0
13741373
13751374
def midi_cb(m):
13761375
if not m or len(m) < 3:
@@ -1385,12 +1384,10 @@ def midi_cb(m):
13851384
13861385
midi.add_callback(midi_cb)
13871386
1388-
def loop():
1389-
global arp_idx, last_played, last_step_ms
1390-
now = tulip.amy_ticks_ms()
1391-
if now - last_step_ms < STEP_MS:
1387+
def loop(step):
1388+
global arp_idx, last_played
1389+
if step % 4: # an 8th note is 4 steps on the 32nd-note grid
13921390
return
1393-
last_step_ms = now
13941391
if last_played is not None:
13951392
amy.send(synth=1, note=last_played, vel=0)
13961393
last_played = None
@@ -1410,7 +1407,7 @@ def loop():
14101407
14111408
amy.send(synth=1, filter_freq={'const': 300, 'ext0': 0.25}, filter_type=amy.FILTER_LPF24)
14121409
1413-
def loop():
1410+
def loop(step):
14141411
r = (amyboard.cv_in(1) + 10.0) / 5.0 # map CV2 to roughly 0-4
14151412
amy.send(synth=1, resonance=r)
14161413
"""

0 commit comments

Comments
 (0)