Implement per-track playback flow (rep / end / relative goto)

Adds the per-track end-action model designed in docs/track-format.md §3, end to
end across both engines, both firmwares, and the editors.

Grammar (parsed + serialized by engine.js and both app.py):
  rep=<n>     cycles before the end-action fires (default 1)
  end=stop    stop after rep cycles
  end=next    advance one track (sugar for end=+1)
  end=<±N>    relative goto after rep cycles (e.g. end=-2 = D.S.)
  (absent)    loop forever — the metronome default

Firmware runtime (pico-cp + pico-explorer): _on_new_bar now consults a per-track
_end_plan() and fires stop / gapless-advance / relative-goto at the right bar.
A cycle = b<bars>, else one master bar; fire bar = rep * cycle. Explicit end=
governs; with no end, the global Continue toggle stays a default (=end=next, still
needs b<bars>) so existing set-lists and the CONT UI are unchanged. _prepare_next
takes a target index; the seam machinery, _do_advance and live-sync all carry rep/end.

Editors (editor.html + editor-beta.html): state.rep/state.end thread through
applySetup / currentSetup / currentPatch so load -> edit -> save preserves the
flow; authoring is via the program-string field (no graphical control yet).

Tests: the 3 playback-flow vectors now pass on both engines (39 pass / 3 known).
Runtime decision logic (_end_plan / _goto_target) unit-tested for stop, rep,
relative goto clamp/wrap, and legacy-Continue precedence. Codec round-trip
verified idempotent. Both firmwares compile + mpy-cross clean.

Also: untrack stale __pycache__/*.pyc build artifacts and gitignore them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-31 00:37:06 -05:00
parent 9701f49913
commit da7c94e67f
14 changed files with 686 additions and 115 deletions

4
.gitignore vendored
View file

@ -1,3 +1,7 @@
# Build output — assembled from index.html + assets/ by build.sh
dist/
tools/
# Python build artifacts
__pycache__/
*.pyc

View file

@ -72,7 +72,7 @@ bars = "b" int ; (* cycle length in bars; drives C
trainer = "tr" int "/" int ; (* playBars "/" muteBars (gap trainer) *)
ramp = "rmp" int "/" signed "/" int ; (* startBpm "/" amount "/" everyBars *)
rep = "rep" int ; (* NEW: cycles before end fires; default 1 *)
rep = "rep=" int ; (* cycles before end fires; default 1 *)
end = "end" "=" ( "stop" | "next" | signed ) ; (* NEW: see §3 *)
lane = sound ":" groups [ "/" sub [ "s" ] ] [ euclid ] [ "=" pattern ]
@ -115,9 +115,13 @@ pattern = *( "X" | "x" | "g" | "." | "-" | "_" ) ; (* per-step dynamics *)
---
## 3. Playback flow (NEW)
## 3. Playback flow
Replaces the old **global** `Continue` toggle with **per-track** behavior.
Per-track playback behavior (parsed + serialized by both engines; firmware runtime implemented).
**Implementation note:** the device keeps the global `Continue` toggle as a *default* — a track
with an explicit `end=` governs itself; a track without one falls back to `end=next` while
Continue is on (and still needs `b<bars>`), else it loops. So per-track `end=` overrides the
global toggle rather than replacing the UI.
**Default (no `end=` token) — loop forever**, exactly like a metronome. Manual advance
(joystick / footswitch) always moves to the next track. "Vamp until cue" is therefore the

View file

@ -769,13 +769,14 @@ let historyName = null; // item whose past-session history is shown
let continueMode = lsGet(LS.continue, false); // auto-advance to next item when countdown ends
let timersOn = lsGet(LS.timers, true); // master switch for the elapsed/countdown timers
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs, bars: segBars }; }
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs, bars: segBars, rep: state.rep, end: state.end }; }
function applySetup(s) {
setBpm(s.bpm); applyLanes(s.lanes);
if (s.trainer) Object.assign(trainer, s.trainer);
if (s.ramp) Object.assign(ramp, s.ramp);
timers.totalMs = s.countMs || 0; timers.remainingMs = timers.totalMs; // per-item time countdown
segBars = s.bars || 0; segBarCount = 0; // per-item bar-length + counter
state.rep = s.rep != null ? s.rep : null; state.end = s.end != null ? s.end : null; // per-track playback flow (preserved on round-trip)
syncPracticeUI(); updateCtx();
}
function syncPracticeUI() {
@ -1053,7 +1054,7 @@ function importAll(file) {
Patch: v1;t<bpm>;vol<pct>;<lane>;…[;tr<play>/<mute>][;rmp<start>/<step>/<every>]
Lane: <sound>:<grouping>[/<sub>][=<pattern x/.>][~ poly][! disabled]
========================================================================= */
function currentPatch() { return setupToPatch({ bpm: state.bpm, volume: state.volume, lanes: snapshotLanes(), trainer, ramp }); }
function currentPatch() { return setupToPatch({ bpm: state.bpm, volume: state.volume, lanes: snapshotLanes(), trainer, ramp, rep: state.rep, end: state.end }); }
function setVolume(pct) {
state.volume = Math.max(0, Math.min(1, pct / 100));
$("vol").value = Math.round(state.volume * 100); volVal.textContent = Math.round(state.volume * 100) + "%";

View file

@ -767,13 +767,14 @@ let historyName = null; // item whose past-session history is shown
let continueMode = lsGet(LS.continue, false); // auto-advance to next item when countdown ends
let timersOn = lsGet(LS.timers, true); // master switch for the elapsed/countdown timers
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs, bars: segBars }; }
function currentSetup() { return { bpm: state.bpm, lanes: snapshotLanes(), trainer: { ...trainer }, ramp: { ...ramp }, countMs: timers.totalMs, bars: segBars, rep: state.rep, end: state.end }; }
function applySetup(s) {
setBpm(s.bpm); applyLanes(s.lanes);
if (s.trainer) Object.assign(trainer, s.trainer);
if (s.ramp) Object.assign(ramp, s.ramp);
timers.totalMs = s.countMs || 0; timers.remainingMs = timers.totalMs; // per-item time countdown
segBars = s.bars || 0; segBarCount = 0; // per-item bar-length + counter
state.rep = s.rep != null ? s.rep : null; state.end = s.end != null ? s.end : null; // per-track playback flow (preserved on round-trip)
syncPracticeUI(); updateCtx();
}
function syncPracticeUI() {
@ -1049,7 +1050,7 @@ function importAll(file) {
Patch: v1;t<bpm>;vol<pct>;<lane>;…[;tr<play>/<mute>][;rmp<start>/<step>/<every>]
Lane: <sound>:<grouping>[/<sub>][=<pattern x/.>][~ poly][! disabled]
========================================================================= */
function currentPatch() { return setupToPatch({ bpm: state.bpm, volume: state.volume, lanes: snapshotLanes(), trainer, ramp }); }
function currentPatch() { return setupToPatch({ bpm: state.bpm, volume: state.volume, lanes: snapshotLanes(), trainer, ramp, rep: state.rep, end: state.end }); }
function setVolume(pct) {
state.volume = Math.max(0, Math.min(1, pct / 100));
$("vol").value = Math.round(state.volume * 100); volVal.textContent = Math.round(state.volume * 100) + "%";

View file

@ -273,7 +273,7 @@ def _euclid(k, n, rot): # even distribution: k hi
return [1 if ((((i + rot) % n) * k) % n) < k else 0 for i in range(n)]
def parse_program(s):
bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None
bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None; rep = None; end = None
for tok in s.strip().split(';'):
tok = tok.strip()
if not tok: continue
@ -293,11 +293,23 @@ def parse_program(s):
try: trainer = {'play': max(0, int(p[0])), 'mute': max(0, int(p[1]))}
except ValueError: pass
continue
if tok.startswith('rep='): # rep=<n> cycles before the end-action fires (playback flow)
try: rep = max(1, int(tok[4:]))
except ValueError: pass
continue
if tok.startswith('end='): # end=stop | end=next(+1) | end=<+/-N> relative goto; absent = loop forever
v = tok[4:]
if v == 'stop': end = 'stop'
elif v == 'next': end = 1
else:
try: end = int(v)
except ValueError: pass
continue
if ':' not in tok: continue
lane = _parse_lane(tok)
if lane: lanes.append(lane)
if not lanes: lanes = [_parse_lane("beep:4")]
return max(5, min(300, bpm)), lanes, bars, ramp, trainer
return max(5, min(300, bpm)), lanes, bars, ramp, trainer, rep, end
def _parse_lane(tok):
poly = '~' in tok; mute = '!' in tok
@ -478,6 +490,7 @@ class App:
self._touchDown = False; self._touchSeen = 0
self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.bars = 0; self.rgb = (0, 0, 0)
self.ramp = None; self.trainer = None; self._lastbar = -1; self._muted = False; self._ramp_base = 120
self.rep = None; self.end = None # per-track playback flow: rep=cycles, end=stop|next|+/-N goto
self._dirty = False; self._overlay = None; self._ovbtns = [] # on-device editing: unsaved edits + modal
self.continue_on = False; self._advance = False; self._grid = {} # auto-advance + pad hit-test geometry
self._next_pending = None; self._seam_t = 0; self._need_redraw = False # gapless seam between tracks
@ -593,7 +606,7 @@ class App:
items = self.setlists[self.sl]['items']
self.idx = i % len(items)
self.name, prog = items[self.idx]
self.bpm, self.lanes, self.bars, self.ramp, self.trainer = parse_program(prog)
self.bpm, self.lanes, self.bars, self.ramp, self.trainer, self.rep, self.end = parse_program(prog)
self._beat_ns = 60_000_000_000 // max(1, self.bpm); self._rebuild_dur_all() # step grids ready for this lane set
self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False
self._dirty = False; self._overlay = None # fresh load -> no unsaved edits
@ -608,6 +621,9 @@ class App:
if self.ramp: parts.append('rmp%d/%d/%d' % (self.ramp.get('start', self.bpm), self.ramp['amt'], self.ramp['every']))
if self.trainer: parts.append('tr%d/%d' % (self.trainer['play'], self.trainer['mute']))
for L in self.lanes: parts.append(lane_to_str(L))
if self.end is not None: # per-track playback flow (default = loop forever -> omitted)
if self.rep and self.rep > 1: parts.append('rep=' + str(self.rep))
parts.append('end=' + ('stop' if self.end == 'stop' else 'next' if self.end == 1 else ('+%d' % self.end if self.end > 0 else str(self.end))))
return ';'.join(parts)
# ---------- on-device editing: tap a beat to cycle it; tap the title to save/revert ----------
@ -1055,8 +1071,8 @@ class App:
try: cur = self._prog_str()
except Exception: cur = None
if patch and patch != cur:
bpm, lanes, bars, ramp, trainer = parse_program(patch)
self.bpm = bpm; self.lanes = lanes; self.bars = bars; self.ramp = ramp; self.trainer = trainer
bpm, lanes, bars, ramp, trainer, rep, end = parse_program(patch)
self.bpm = bpm; self.lanes = lanes; self.bars = bars; self.ramp = ramp; self.trainer = trainer; self.rep = rep; self.end = end
self._beat_ns = 60_000_000_000 // max(1, bpm); self._rebuild_dur_all()
self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False
self._dirty = False; self._overlay = None
@ -1240,28 +1256,50 @@ class App:
try: self.midi.write(clk)
except Exception: pass
self._clock_next += tick_ns
def _end_plan(self):
# Per-track playback flow. Returns None to loop forever, else (fire_bars, action) where action is
# 'stop' or a signed int goto offset. Explicit end= governs; otherwise the global Continue toggle
# acts as a default end=next (legacy behaviour, still needs b<bars> to define the segment).
end = self.end
if end is None:
if self.continue_on and self.bars: end = 1
else: return None
cyc = self.bars if self.bars else 1 # a cycle = b<bars>, else one master bar
reps = self.rep if self.rep else 1
return (cyc * reps, end)
def _goto_target(self, offset):
items = self.setlists[self.sl]['items']; n = len(items)
t = self.idx + offset
return 0 if t < 0 else (t % n if t >= n else t) # before first -> clamp; past last -> wrap (loop)
def _end_stop(self):
self.running = False; self.spk.duty_cycle = 0; self.reset_playheads(); self._log_play()
self.led_rest(); self.draw_meters(); self._sync_broadcast("stop")
def _on_new_bar(self, bar):
# Pre-parse the next track during the LAST bar of this segment, so the swap at the seam is allocation-free
if self.bars and self.continue_on and self._next_pending is None and bar == self.bars - 1:
self._prepare_next()
if self.bars and bar > 0 and bar % self.bars == 0: # segment boundary
self._seg_start = time.monotonic() # timer resets with the bar counter
if self.continue_on:
if self._next_pending is None: self._prepare_next() # late-toggled Continue: prep on the spot
plan = self._end_plan() # None = loop forever; else (fire_bars, action)
if plan is not None and plan[1] != 'stop' and self._next_pending is None and bar == plan[0] - 1:
self._prepare_next(self._goto_target(plan[1])) # pre-parse the target during the bar before the seam
if self.bars and bar > 0 and bar % self.bars == 0: # segment boundary -> reset the on-screen timer
self._seg_start = time.monotonic()
if plan is not None and bar > 0 and bar == plan[0]: # fire the end-action
action = plan[1]
if not (self.bars and bar % self.bars == 0): self._seg_start = time.monotonic() # no-bars: still reset the timer
if action == 'stop':
self._end_stop()
else:
if self._next_pending is None: self._prepare_next(self._goto_target(action)) # late prep
if self._next_pending is not None:
self._seam_t = self.lanes[0]['next'] # the wall-clock time of THIS boundary step
self._seam_t = self.lanes[0]['next'] # wall-clock time of THIS boundary step
self._advance = True # tick() will swap to the prepared track
# Note: per-master-step continuous ramp handles the bpm reset implicitly (seg_bar wraps to 0)
t = self.trainer # gap trainer: silence during the rest bars
self._muted = bool(t and (t['play'] + t['mute']) and (bar % (t['play'] + t['mute'])) >= t['play'])
def _prepare_next(self): # parse the next playlist item into a side holder
def _prepare_next(self, target=None): # parse a playlist item into a side holder for the gapless seam
items = self.setlists[self.sl]['items']
nxt = (self.idx + 1) % len(items)
if nxt == self.idx: return # 1-item playlist -> just loop, no swap
nxt = (self.idx + 1) % len(items) if target is None else target
if nxt == self.idx: return # same track (1-item list or self-goto) -> just loop, no swap
name, prog = items[nxt]
gc.collect() # defragment before parse_program allocates new lanes
try:
bpm, lanes, bars, ramp, trainer = parse_program(prog)
bpm, lanes, bars, ramp, trainer, rep, end = parse_program(prog)
except MemoryError:
gc.collect(); return # leave _next_pending None -> the segment just loops
beat = 60_000_000_000 // max(1, bpm) # pre-compute B's durs against B's bpm so the seam swap is allocation-free
@ -1276,13 +1314,14 @@ class App:
else:
d = beat // sub; L['durs'] = tuple(d for _ in range(steps))
self._next_pending = {'lanes': lanes, 'bpm': bpm, 'bars': bars, 'ramp': ramp,
'trainer': trainer, 'name': name, 'idx': nxt}
'trainer': trainer, 'name': name, 'idx': nxt, 'rep': rep, 'end': end}
def _do_advance(self): # gapless seam: swap the prepared track in at seam_t
n = self._next_pending
if n is None: return
self._next_pending = None
self.lanes = n['lanes']; self.bpm = n['bpm']; self.bars = n['bars']
self.ramp = n['ramp']; self.trainer = n['trainer']; self.name = n['name']; self.idx = n['idx']
self.rep = n['rep']; self.end = n['end'] # the swapped-in track's own playback flow governs from here
self._beat_ns = 60_000_000_000 // max(1, self.bpm); self._rebuild_dur_all() # B's step grids built at the seam
self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False; self._m_steps = 0
self._dirty = False; self._overlay = None

View file

@ -242,7 +242,7 @@ def _euclid(k, n, rot): # even distribution: k hi
return [1 if ((((i + rot) % n) * k) % n) < k else 0 for i in range(n)]
def parse_program(s):
bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None
bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None; rep = None; end = None
for tok in s.strip().split(';'):
tok = tok.strip()
if not tok: continue
@ -262,11 +262,23 @@ def parse_program(s):
try: trainer = {'play': max(0, int(p[0])), 'mute': max(0, int(p[1]))}
except ValueError: pass
continue
if tok.startswith('rep='): # rep=<n> cycles before the end-action fires (playback flow)
try: rep = max(1, int(tok[4:]))
except ValueError: pass
continue
if tok.startswith('end='): # end=stop | end=next(+1) | end=<+/-N> relative goto; absent = loop forever
v = tok[4:]
if v == 'stop': end = 'stop'
elif v == 'next': end = 1
else:
try: end = int(v)
except ValueError: pass
continue
if ':' not in tok: continue
lane = _parse_lane(tok)
if lane: lanes.append(lane)
if not lanes: lanes = [_parse_lane("beep:4")]
return max(5, min(300, bpm)), lanes, bars, ramp, trainer
return max(5, min(300, bpm)), lanes, bars, ramp, trainer, rep, end
def _parse_lane(tok):
poly = '~' in tok; mute = '!' in tok
@ -382,6 +394,7 @@ class App:
self._chord_xz = 0 # 0 = not in chord; else monotonic_ns of the chord start
self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.bars = 0
self.ramp = None; self.trainer = None; self._lastbar = -1; self._muted = False; self._ramp_base = 120
self.rep = None; self.end = None # per-track playback flow: rep=cycles, end=stop|next|+/-N goto
self._overlay = None # menu stack: None / 'menu' / 'settings' / 'help' / 'about' / 'log' / 'msg'
self._modal_cursor = 0 # focused row in the current modal
self._modal_rows = [] # tuples (label, value_str_or_None, action) for current modal
@ -494,7 +507,7 @@ class App:
items = self.setlists[self.sl]['items']
self.idx = i % len(items)
self.name, prog = items[self.idx]
self.bpm, self.lanes, self.bars, self.ramp, self.trainer = parse_program(prog)
self.bpm, self.lanes, self.bars, self.ramp, self.trainer, self.rep, self.end = parse_program(prog)
self._beat_ns = 60_000_000_000 // max(1, self.bpm); self._rebuild_dur_all()
self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False
self._overlay = None
@ -509,6 +522,9 @@ class App:
if self.ramp: parts.append('rmp%d/%d/%d' % (self.ramp.get('start', self.bpm), self.ramp['amt'], self.ramp['every']))
if self.trainer: parts.append('tr%d/%d' % (self.trainer['play'], self.trainer['mute']))
for L in self.lanes: parts.append(lane_to_str(L))
if self.end is not None: # per-track playback flow (default = loop forever -> omitted)
if self.rep and self.rep > 1: parts.append('rep=' + str(self.rep))
parts.append('end=' + ('stop' if self.end == 'stop' else 'next' if self.end == 1 else ('+%d' % self.end if self.end > 0 else str(self.end))))
return ';'.join(parts)
def toggle_continue(self):
self.continue_on = not self.continue_on; self.draw_status()
@ -784,8 +800,8 @@ class App:
try: cur = self._prog_str()
except Exception: cur = None
if patch and patch != cur:
bpm, lanes, bars, ramp, trainer = parse_program(patch)
self.bpm = bpm; self.lanes = lanes; self.bars = bars; self.ramp = ramp; self.trainer = trainer
bpm, lanes, bars, ramp, trainer, rep, end = parse_program(patch)
self.bpm = bpm; self.lanes = lanes; self.bars = bars; self.ramp = ramp; self.trainer = trainer; self.rep = rep; self.end = end
self._beat_ns = 60_000_000_000 // max(1, bpm); self._rebuild_dur_all()
self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False
self._overlay = None
@ -969,26 +985,49 @@ class App:
try: self.midi.write(clk)
except Exception: pass
self._clock_next += tick_ns
def _end_plan(self):
# Per-track playback flow. None = loop forever; else (fire_bars, action) where action is 'stop' or a
# signed int goto offset. Explicit end= governs; otherwise the global Continue toggle = default end=next.
end = self.end
if end is None:
if self.continue_on and self.bars: end = 1
else: return None
cyc = self.bars if self.bars else 1 # a cycle = b<bars>, else one master bar
reps = self.rep if self.rep else 1
return (cyc * reps, end)
def _goto_target(self, offset):
items = self.setlists[self.sl]['items']; n = len(items)
t = self.idx + offset
return 0 if t < 0 else (t % n if t >= n else t) # before first -> clamp; past last -> wrap (loop)
def _end_stop(self):
self.running = False; self.spk.duty_cycle = 0; self.reset_playheads(); self._log_play()
self._set_run_dot(); self.draw_meters(); self._sync_broadcast("stop")
def _on_new_bar(self, bar):
if self.bars and self.continue_on and self._next_pending is None and bar == self.bars - 1:
self._prepare_next()
if self.bars and bar > 0 and bar % self.bars == 0:
plan = self._end_plan() # None = loop forever; else (fire_bars, action)
if plan is not None and plan[1] != 'stop' and self._next_pending is None and bar == plan[0] - 1:
self._prepare_next(self._goto_target(plan[1])) # pre-parse the target during the bar before the seam
if self.bars and bar > 0 and bar % self.bars == 0: # segment boundary -> reset the on-screen timer
self._seg_start = time.monotonic()
if self.continue_on:
if self._next_pending is None: self._prepare_next()
if plan is not None and bar > 0 and bar == plan[0]: # fire the end-action
action = plan[1]
if not (self.bars and bar % self.bars == 0): self._seg_start = time.monotonic() # no-bars: still reset the timer
if action == 'stop':
self._end_stop()
else:
if self._next_pending is None: self._prepare_next(self._goto_target(action)) # late prep
if self._next_pending is not None:
self._seam_t = self.lanes[0]['next']
self._advance = True
self._seam_t = self.lanes[0]['next'] # wall-clock time of THIS boundary step
self._advance = True # tick() will swap to the prepared track
t = self.trainer
self._muted = bool(t and (t['play'] + t['mute']) and (bar % (t['play'] + t['mute'])) >= t['play'])
def _prepare_next(self):
def _prepare_next(self, target=None):
items = self.setlists[self.sl]['items']
nxt = (self.idx + 1) % len(items)
nxt = (self.idx + 1) % len(items) if target is None else target
if nxt == self.idx: return
name, prog = items[nxt]
gc.collect()
try:
bpm, lanes, bars, ramp, trainer = parse_program(prog)
bpm, lanes, bars, ramp, trainer, rep, end = parse_program(prog)
except MemoryError:
gc.collect(); return
beat = 60_000_000_000 // max(1, bpm)
@ -1003,13 +1042,14 @@ class App:
else:
d = beat // sub; L['durs'] = tuple(d for _ in range(steps))
self._next_pending = {'lanes': lanes, 'bpm': bpm, 'bars': bars, 'ramp': ramp,
'trainer': trainer, 'name': name, 'idx': nxt}
'trainer': trainer, 'name': name, 'idx': nxt, 'rep': rep, 'end': end}
def _do_advance(self):
n = self._next_pending
if n is None: return
self._next_pending = None
self.lanes = n['lanes']; self.bpm = n['bpm']; self.bars = n['bars']
self.ramp = n['ramp']; self.trainer = n['trainer']; self.name = n['name']; self.idx = n['idx']
self.rep = n['rep']; self.end = n['end'] # the swapped-in track's own playback flow governs from here
self._beat_ns = 60_000_000_000 // max(1, self.bpm); self._rebuild_dur_all()
self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False; self._m_steps = 0
self._overlay = None

View file

@ -226,18 +226,24 @@ function setupToPatch(s) {
(s.lanes || []).forEach((c) => parts.push(laneCfgToStr(c)));
if (s.trainer && s.trainer.on) parts.push("tr" + s.trainer.playBars + "/" + s.trainer.muteBars);
if (s.ramp && s.ramp.on) parts.push("rmp" + s.ramp.startBpm + "/" + s.ramp.amount + "/" + s.ramp.everyBars);
if (s.end != null) { // per-track playback flow (default = loop forever)
if (s.rep != null && s.rep > 1) parts.push("rep=" + s.rep); // cycles before end fires (1 = default, omitted)
parts.push("end=" + (s.end === "stop" ? "stop" : s.end === 1 ? "next" : s.end > 0 ? "+" + s.end : String(s.end)));
}
return parts.join(";");
}
function patchToSetup(str) {
const s = { bpm: 120, volume: null, countMs: 0, bars: 0, lanes: [], trainer: { on: false, playBars: 2, muteBars: 2 }, ramp: { on: false, startBpm: 80, amount: 5, everyBars: 4 } };
const s = { bpm: 120, volume: null, countMs: 0, bars: 0, lanes: [], rep: null, end: null, trainer: { on: false, playBars: 2, muteBars: 2 }, ramp: { on: false, startBpm: 80, amount: 5, everyBars: 4 } };
for (let tok of String(str).split(";")) {
tok = tok.trim(); if (!tok || tok === "v1") continue;
if (tok.includes(":")) { const c = laneStrToCfg(tok); if (c) s.lanes.push(c); } // lanes contain ":" → matched first
else if (tok.startsWith("vol")) s.volume = (parseInt(tok.slice(3), 10) || 0) / 100;
else if (tok.startsWith("cd")) s.countMs = (parseInt(tok.slice(2), 10) || 0) * 1000;
else if (tok.startsWith("b")) s.bars = parseInt(tok.slice(1), 10) || 0; // segment bar-length
else if (tok.startsWith("rep=")) s.rep = parseInt(tok.slice(4), 10) || 1; // playback flow: cycles before end fires
else if (tok.startsWith("end=")) { const v = tok.slice(4); s.end = v === "stop" ? "stop" : v === "next" ? 1 : (parseInt(v, 10) || 0); } // stop | next(+1) | relative goto ±N
else if (tok.startsWith("tr")) { const [p, m] = tok.slice(2).split("/"); s.trainer = { on: true, playBars: +p || 1, muteBars: +m || 0 }; }
else if (tok.startsWith("rmp")) { const [a, b, c] = tok.slice(3).split("/"); s.ramp = { on: true, startBpm: +a || 80, amount: +b || 0, everyBars: +c || 1 }; }
else if (tok.startsWith("b")) s.bars = parseInt(tok.slice(1), 10) || 0; // segment bar-length
else if (tok.startsWith("t")) s.bpm = parseInt(tok.slice(1), 10) || 120;
}
return s;

View file

@ -25,8 +25,8 @@ export function normalize(patch) {
countMs: s.countMs || 0,
ramp: s.ramp && s.ramp.on ? { start: s.ramp.startBpm, amt: s.ramp.amount, every: s.ramp.everyBars } : null,
trainer: s.trainer && s.trainer.on ? { play: s.trainer.playBars, mute: s.trainer.muteBars } : null,
rep: s.rep == null ? null : s.rep, // not parsed yet → undefined → null
end: s.end == null ? null : s.end, // not parsed yet → undefined → null
end: s.end == null ? null : s.end,
rep: s.end == null ? null : (s.rep == null ? 1 : s.rep), // rep only meaningful with end; defaults to 1
lanes: (s.lanes || []).map((c) => ({
sound: c.sound,
groups: groupsArr(c.groupsStr),

View file

@ -31,8 +31,8 @@ NS = {}
exec("\n".join(_segs), NS)
def _prog_str(bpm, lanes, bars, ramp, trainer):
# Mirrors app.py App._prog_str (app.py:577) using the real lane_to_str.
def _prog_str(bpm, lanes, bars, ramp, trainer, rep=None, end=None):
# Mirrors app.py App._prog_str using the real lane_to_str.
parts = ["t" + str(bpm)]
if bars:
parts.append("b" + str(bars))
@ -42,6 +42,10 @@ def _prog_str(bpm, lanes, bars, ramp, trainer):
parts.append("tr%d/%d" % (trainer["play"], trainer["mute"]))
for L in lanes:
parts.append(NS["lane_to_str"](L))
if end is not None:
if rep and rep > 1:
parts.append("rep=" + str(rep))
parts.append("end=" + ("stop" if end == "stop" else "next" if end == 1 else ("+%d" % end if end > 0 else str(end))))
return ";".join(parts)
@ -55,7 +59,7 @@ def _gain_db(g):
def normalize(patch):
bpm, lanes, bars, ramp, trainer = NS["parse_program"](patch)
bpm, lanes, bars, ramp, trainer, rep, end = NS["parse_program"](patch)
return {
"bpm": bpm,
"bars": bars,
@ -63,8 +67,8 @@ def normalize(patch):
"countMs": 0, # device has no count-in
"ramp": {"start": ramp.get("start", bpm), "amt": ramp["amt"], "every": ramp["every"]} if ramp else None,
"trainer": {"play": trainer["play"], "mute": trainer["mute"]} if trainer else None,
"rep": None, # not parsed yet
"end": None, # not parsed yet
"rep": None if end is None else (rep if rep else 1), # rep only meaningful with end; defaults to 1
"end": end,
"lanes": [
{
"sound": L["sound"],

View file

@ -5,183 +5,655 @@
"id": "minimal",
"status": "stable",
"in": "t120;kick:4=Xxxx",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"lanes": [ { "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }
"norm": {
"bpm": 120,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
}
]
}
},
{
"id": "backbeat",
"status": "stable",
"in": "t120;kick:4=Xxxx;snare:4=.x.x;hatClosed:4/2=X.x.x.x.",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"norm": {
"bpm": 120,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{ "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] },
{ "sound": "snare", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [0,1,0,1] },
{ "sound": "hatClosed", "groups": [4], "sub": 2, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,0,1,0,1,0,1,0] }
] }
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
},
{
"sound": "snare",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [0, 1, 0, 1]
},
{
"sound": "hatClosed",
"groups": [4],
"sub": 2,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 0, 1, 0, 1, 0, 1, 0]
}
]
}
},
{
"id": "odd-meter-2+2+3",
"status": "stable",
"in": "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2=X.x.X.x.X.x.x.",
"norm": { "bpm": 130, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"norm": {
"bpm": 130,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{ "sound": "kick", "groups": [2,2,3], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [1,0,0,1,0,0,1] },
{ "sound": "hatClosed", "groups": [2,2,3], "sub": 2, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,0,1,0,2,0,1,0,2,0,1,0,1,0] }
] }
{
"sound": "kick",
"groups": [2, 2, 3],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [1, 0, 0, 1, 0, 0, 1]
},
{
"sound": "hatClosed",
"groups": [2, 2, 3],
"sub": 2,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 0, 1, 0, 2, 0, 1, 0, 2, 0, 1, 0, 1, 0]
}
]
}
},
{
"id": "swing",
"status": "stable",
"in": "t150;ride:4/2s=X.x.x.x.;kick:4=X..x",
"norm": { "bpm": 150, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"norm": {
"bpm": 150,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{ "sound": "ride", "groups": [4], "sub": 2, "swing": true, "poly": false, "mute": false, "gainDb": 0, "levels": [2,0,1,0,1,0,1,0] },
{ "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,0,0,1] }
] }
{
"sound": "ride",
"groups": [4],
"sub": 2,
"swing": true,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 0, 1, 0, 1, 0, 1, 0]
},
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 0, 0, 1]
}
]
}
},
{
"id": "ghost-notes",
"status": "stable",
"in": "t92;snare:4/3=..gg.gX.gg.g",
"norm": { "bpm": 92, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"lanes": [ { "sound": "snare", "groups": [4], "sub": 3, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [0,0,3,3,0,3,2,0,3,3,0,3] } ] }
"norm": {
"bpm": 92,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "snare",
"groups": [4],
"sub": 3,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [0, 0, 3, 3, 0, 3, 2, 0, 3, 3, 0, 3]
}
]
}
},
{
"id": "polymeter",
"status": "stable",
"in": "t100;kick:4=Xxxx;claves:5=Xxxxx~",
"norm": { "bpm": 100, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"norm": {
"bpm": 100,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{ "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] },
{ "sound": "claves", "groups": [5], "sub": 1, "swing": false, "poly": true, "mute": false, "gainDb": 0, "levels": [2,1,1,1,1] }
] }
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
},
{
"sound": "claves",
"groups": [5],
"sub": 1,
"swing": false,
"poly": true,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1, 1]
}
]
}
},
{
"id": "disabled-lane",
"status": "stable",
"in": "t120;kick:4=Xxxx;hatClosed:4=Xxxx!",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"norm": {
"bpm": 120,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{ "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] },
{ "sound": "hatClosed", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": true, "gainDb": 0, "levels": [2,1,1,1] }
] }
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
},
{
"sound": "hatClosed",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": true,
"gainDb": 0,
"levels": [2, 1, 1, 1]
}
]
}
},
{
"id": "ramp",
"status": "stable",
"in": "t80;woodblock:4=Xxxx;rmp80/4/4",
"norm": { "bpm": 80, "bars": 0, "volume": null, "countMs": 0, "ramp": { "start": 80, "amt": 4, "every": 4 }, "trainer": null, "rep": null, "end": null,
"lanes": [ { "sound": "woodblock", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }
"norm": {
"bpm": 80,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": {
"start": 80,
"amt": 4,
"every": 4
},
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "woodblock",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
}
]
}
},
{
"id": "gap-trainer",
"status": "stable",
"in": "t100;kick:4=Xxxx;tr2/2",
"norm": { "bpm": 100, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": { "play": 2, "mute": 2 }, "rep": null, "end": null,
"lanes": [ { "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }
"norm": {
"bpm": 100,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": {
"play": 2,
"mute": 2
},
"rep": null,
"end": null,
"lanes": [
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
}
]
}
},
{
"id": "segment-bars",
"status": "stable",
"in": "t88;b8;kick:4=X.x.",
"norm": { "bpm": 88, "bars": 8, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"lanes": [ { "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,0,1,0] } ] }
"norm": {
"bpm": 88,
"bars": 8,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 0, 1, 0]
}
]
}
},
{
"id": "gm-note-number-alias",
"status": "stable",
"in": "t120;36:4=Xxxx",
"note": "GM note 36 = kick. Both engines resolve the number to the voice name.",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"lanes": [ { "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }
"norm": {
"bpm": 120,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
}
]
}
},
{
"id": "default-pattern",
"status": "stable",
"note": "No =pattern: every subdivision sounds at normal; accent only on group starts (the grouping is the accent map). One group of 4 ⇒ accent on beat 1 only, off-8ths sound at normal.",
"in": "t120;hatClosed:4/2",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"lanes": [ { "sound": "hatClosed", "groups": [4], "sub": 2, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1,1,1,1,1] } ] }
"norm": {
"bpm": 120,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "hatClosed",
"groups": [4],
"sub": 2,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1, 1, 1, 1, 1]
}
]
}
},
{
"id": "default-pattern-odd-meter",
"status": "stable",
"note": "No =pattern over a 2+2+3 grouping ⇒ accents land on group starts (beats 1,3,5); all 8ths sound.",
"in": "t130;hatClosed:2+2+3/2",
"norm": { "bpm": 130, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"lanes": [ { "sound": "hatClosed", "groups": [2,2,3], "sub": 2, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1,2,1,1,1,2,1,1,1,1,1] } ] }
"norm": {
"bpm": 130,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "hatClosed",
"groups": [2, 2, 3],
"sub": 2,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 1, 1, 1]
}
]
}
},
{
"id": "euclid",
"status": "stable",
"note": "Euclid (k,n) shorthand: 3 hits spread over 8 steps, first accented. Parsed identically on both engines.",
"in": "t120;kick:4(3,8)",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"lanes": [ { "sound": "kick", "groups": [4], "sub": 2, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,0,0,1,0,0,1,0] } ] }
"norm": {
"bpm": 120,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "kick",
"groups": [4],
"sub": 2,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 0, 0, 1, 0, 0, 1, 0]
}
]
}
},
{
"id": "vol-and-countin",
"status": "divergence",
"expectFail": ["py"],
"expectFail": [
"py"
],
"note": "INTENTIONAL host difference (permanent, not a bug): vol (master volume) and cd (count-in) are web-authoring fields. The device has a hardware volume knob and no count-in, so it omits them. Kept as a vector to document the boundary.",
"in": "t120;vol80;cd2;kick:4=Xxxx",
"norm": { "bpm": 120, "bars": 0, "volume": 0.8, "countMs": 2000, "ramp": null, "trainer": null, "rep": null, "end": null,
"lanes": [ { "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }
"norm": {
"bpm": 120,
"bars": 0,
"volume": 0.8,
"countMs": 2000,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
}
]
}
},
{
"id": "unknown-sound",
"status": "stable",
"note": "Unknown sound name falls back to beep on both engines.",
"in": "t120;blorp:4=Xxxx",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"lanes": [ { "sound": "beep", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }
"norm": {
"bpm": 120,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "beep",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
}
]
}
},
{
"id": "tempo-clamp-high",
"status": "divergence",
"expectFail": ["js"],
"expectFail": [
"js"
],
"note": "Firmware clamps t to [5,300]; engine.js does not. Spec = clamp everywhere.",
"in": "t999;kick:4=Xxxx",
"norm": { "bpm": 300, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"lanes": [ { "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }
"norm": {
"bpm": 300,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
}
]
}
},
{
"id": "empty-defaults-to-beep",
"status": "divergence",
"expectFail": ["js"],
"expectFail": [
"js"
],
"note": "Firmware injects a default beep:4; engine.js returns no lanes (editor guards emptiness). Spec = beep:4.",
"in": "",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": null, "end": null,
"lanes": [ { "sound": "beep", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }
"norm": {
"bpm": 120,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "beep",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
}
]
}
},
{
"id": "end-stop",
"status": "new",
"expectFail": ["js", "py"],
"status": "stable",
"note": "Per-track playback flow — not yet implemented anywhere.",
"in": "t120;kick:4=Xxxx;end=stop",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": 1, "end": "stop",
"lanes": [ { "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }
"norm": {
"bpm": 120,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": 1,
"end": "stop",
"lanes": [
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
}
]
}
},
{
"id": "rep-then-next",
"status": "new",
"expectFail": ["js", "py"],
"status": "stable",
"note": "Play 4 cycles then auto-advance. next ⇒ +1.",
"in": "t120;kick:4=Xxxx;rep=4;end=next",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": 4, "end": 1,
"lanes": [ { "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }
"norm": {
"bpm": 120,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": 4,
"end": 1,
"lanes": [
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
}
]
}
},
{
"id": "relative-goto-back-two",
"status": "new",
"expectFail": ["js", "py"],
"status": "stable",
"note": "Relative goto (D.S.): after 1 cycle, jump back two tracks.",
"in": "t120;kick:4=Xxxx;end=-2",
"norm": { "bpm": 120, "bars": 0, "volume": null, "countMs": 0, "ramp": null, "trainer": null, "rep": 1, "end": -2,
"lanes": [ { "sound": "kick", "groups": [4], "sub": 1, "swing": false, "poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] } ] }
"norm": {
"bpm": 120,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": 1,
"end": -2,
"lanes": [
{
"sound": "kick",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 1, 1]
}
]
}
}
]
}