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:
parent
9701f49913
commit
da7c94e67f
14 changed files with 686 additions and 115 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
|||
# Build output — assembled from index.html + assets/ by build.sh
|
||||
dist/
|
||||
tools/
|
||||
|
||||
# Python build artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) + "%";
|
||||
|
|
|
|||
|
|
@ -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) + "%";
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
628
tests/fixtures/track-format.json
vendored
628
tests/fixtures/track-format.json
vendored
|
|
@ -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": [
|
||||
{
|
||||
"id": "end-stop",
|
||||
"status": "new",
|
||||
"expectFail": ["js", "py"],
|
||||
"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] } ] }
|
||||
},
|
||||
{
|
||||
"id": "rep-then-next",
|
||||
"status": "new",
|
||||
"expectFail": ["js", "py"],
|
||||
"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] } ] }
|
||||
},
|
||||
{
|
||||
"id": "relative-goto-back-two",
|
||||
"status": "new",
|
||||
"expectFail": ["js", "py"],
|
||||
"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] } ] }
|
||||
"sound": "beep",
|
||||
"groups": [4],
|
||||
"sub": 1,
|
||||
"swing": false,
|
||||
"poly": false,
|
||||
"mute": false,
|
||||
"gainDb": 0,
|
||||
"levels": [2, 1, 1, 1]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "end-stop",
|
||||
"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]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rep-then-next",
|
||||
"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]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "relative-goto-back-two",
|
||||
"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]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Reference in a new issue