Skip to content

Commit 9227ccf

Browse files
bwhitmanclaude
andcommitted
AMYboard: phase-lock sketch loop() to the sequencer downbeat
Sketch loop() callbacks fire on the AMY sequencer's absolute tick grid (every 6 ticks = a 32nd note), the same clock AMYSequence events use -- but a sketch's own step counter starts at 0 whenever the sketch loads, which is some arbitrary 32nd note inside the bar. Any pattern built on that counter plays at a constant phase offset from AMY-sequenced patterns (e.g. dx7_groove_128_bpm's bass/piano/arp vs. its drums). Two changes in _start_sketch_loop: - The first loop() call is held until the next bar boundary (tick % 192 == 0 at 48 PPQ, 4/4), so existing sketches' counters start on the downbeat, in phase with AMYSequence bars. - If the sketch declares loop(step), it gets the global 32nd-note index on the bar-locked grid (step % 32 == 0 is always a downbeat). Deriving position from step instead of a local counter also can't drift if a callback is ever dropped (mp_sched_schedule queue full). Plain loop() still works via a one-time TypeError fallback. The default sketch template (amyboard.py and both spss.js copies) now uses loop(step) and documents the grid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 60d7b67 commit 9227ccf

2 files changed

Lines changed: 46 additions & 12 deletions

File tree

tulip/amyboardweb/static/spss.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,11 @@ const CURRENT_ENV_DIR = CURRENT_BASE_DIR;
9090
// there is no MicroPython. Keep it in sync with amyboard.py's DEFAULT_SKETCH_SOURCE.
9191
var AMYBOARD_DEFAULT_SKETCH =
9292
"# AMYboard Sketch\n" +
93-
"# Code put here runs first, then loop() is called every 32nd note.\n" +
93+
"# Code put here runs first, then loop(step) is called every 32nd note,\n" +
94+
"# starting on a bar downbeat. step counts 32nd notes on the sequencer's\n" +
95+
"# bar-locked grid, so step % 32 == 0 is always a downbeat.\n" +
9496
"import amyboard, amy\n\n" +
95-
"def loop():\n pass\n\n" +
97+
"def loop(step):\n pass\n\n" +
9698
"# Do not edit. Set automatically by the knobs on AMYboard Online.\n" +
9799
"_auto_generated_knobs = \"\"\"\n\"\"\"\n";
98100
function _get_default_sketch() {
@@ -5635,7 +5637,7 @@ async function _reset_amyboard_send_and_cleanup() {
56355637
console.log('reset: zP factory_reset sent');
56365638
await sleep_ms(2000);
56375639
// Set JS state to defaults.
5638-
var defaultSketch = "# AMYboard Sketch\n# Code put here runs first, then loop() is called every 32nd note.\nimport amyboard, amy\n\ndef loop():\n pass\n\n# Do not edit. Set automatically by the knobs on AMYboard Online.\n_auto_generated_knobs = \"\"\"\n\"\"\"\n";
5640+
var defaultSketch = "# AMYboard Sketch\n# Code put here runs first, then loop(step) is called every 32nd note,\n# starting on a bar downbeat. step counts 32nd notes on the sequencer's\n# bar-locked grid, so step % 32 == 0 is always a downbeat.\nimport amyboard, amy\n\ndef loop(step):\n pass\n\n# Do not edit. Set automatically by the knobs on AMYboard Online.\n_auto_generated_knobs = \"\"\"\n\"\"\"\n";
56395641
// SYNC 2: clear the knob log so a later Write doesn't re-emit the old
56405642
// session's knobs (the default sketch has an empty knobs block).
56415643
if (window.knob_log && typeof window.knob_log.clear === 'function') window.knob_log.clear();

tulip/shared/amyboard-py/amyboard.py

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,12 @@ def show(self, full=False):
197197

198198
DEFAULT_SKETCH_SOURCE = """\
199199
# AMYboard Sketch
200-
# Code put here runs first, then loop() is called every 32nd note.
200+
# Code put here runs first, then loop(step) is called every 32nd note,
201+
# starting on a bar downbeat. step counts 32nd notes on the sequencer's
202+
# bar-locked grid, so step % 32 == 0 is always a downbeat.
201203
import amyboard, amy
202204
203-
def loop():
205+
def loop(step):
204206
pass
205207
206208
# Do not edit. Set automatically by the knobs on AMYboard Online.
@@ -726,23 +728,53 @@ def run_sketch():
726728

727729

728730
def _start_sketch_loop(loop_fn):
729-
"""Schedule loop_fn via TulipSequence (every 32nd note, ~60ms)."""
731+
"""Schedule loop_fn via TulipSequence (every 32nd note).
732+
733+
Sketch loops ride the AMY sequencer's absolute tick count -- the same
734+
clock AMYSequence events fire on. The first call is held until a bar
735+
boundary (4 beats), so a sketch that keeps its own step counter starts
736+
on the downbeat, in phase with any AMY-sequenced patterns. A sketch can
737+
instead declare loop(step): step is the global 32nd-note index on that
738+
bar-locked grid (step % 32 == 0 is always a downbeat), which stays in
739+
phase even if a callback is ever dropped.
740+
"""
730741
import sequencer
731742
global _sketch_seq
732-
_loop_running = False
743+
ticks_per_step = int((4.0 / 32.0) * sequencer.PPQ) # one 32nd note
744+
ticks_per_bar = 4 * sequencer.PPQ
745+
_busy = False
746+
_started = False
747+
_takes_step = None
733748

734749
def _guarded_loop(tick):
735-
nonlocal _loop_running
736-
if _loop_running:
750+
nonlocal _busy, _started, _takes_step
751+
if _busy:
737752
return
738-
_loop_running = True
753+
_busy = True
739754
try:
740-
loop_fn()
755+
if not _started:
756+
if tick % ticks_per_bar:
757+
return # hold loop() until the next downbeat
758+
_started = True
759+
step = tick // ticks_per_step
760+
if _takes_step is None:
761+
# First call decides the signature: prefer loop(step), fall
762+
# back to loop() for sketches that don't take an argument.
763+
try:
764+
loop_fn(step)
765+
_takes_step = True
766+
except TypeError:
767+
_takes_step = False
768+
loop_fn()
769+
elif _takes_step:
770+
loop_fn(step)
771+
else:
772+
loop_fn()
741773
except Exception as e:
742774
print("sketch.loop() error:")
743775
sys.print_exception(e)
744776
finally:
745-
_loop_running = False
777+
_busy = False
746778

747779
_sketch_seq = sequencer.TulipSequence(32, _guarded_loop)
748780

0 commit comments

Comments
 (0)