diff --git a/.gitignore b/.gitignore index af067a3..0ca36a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ # Build output — assembled from index.html + assets/ by build.sh dist/ tools/ + +# Python build artifacts +__pycache__/ +*.pyc diff --git a/docs/track-format.md b/docs/track-format.md index 6ff3dc0..0ce4302 100644 --- a/docs/track-format.md +++ b/docs/track-format.md @@ -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`), 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 diff --git a/editor-beta.html b/editor-beta.html index b9b0c3e..19b77bc 100644 --- a/editor-beta.html +++ b/editor-beta.html @@ -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;vol;;…[;tr/][;rmp//] Lane: :[/][=][~ 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) + "%"; diff --git a/editor.html b/editor.html index 1915633..d00f438 100644 --- a/editor.html +++ b/editor.html @@ -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;vol;;…[;tr/][;rmp//] Lane: :[/][=][~ 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) + "%"; diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc deleted file mode 100644 index e64e643..0000000 Binary files a/pico-cp/__pycache__/app.cpython-312.pyc and /dev/null differ diff --git a/pico-cp/__pycache__/code.cpython-312.pyc b/pico-cp/__pycache__/code.cpython-312.pyc deleted file mode 100644 index 29e08de..0000000 Binary files a/pico-cp/__pycache__/code.cpython-312.pyc and /dev/null differ diff --git a/pico-cp/app.py b/pico-cp/app.py index cdf6c04..c073336 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -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= 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 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, 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 diff --git a/pico-explorer/app.py b/pico-explorer/app.py index 8abc586..1b7039d 100644 --- a/pico-explorer/app.py +++ b/pico-explorer/app.py @@ -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= 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, 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 diff --git a/pico/__pycache__/main.cpython-312.pyc b/pico/__pycache__/main.cpython-312.pyc deleted file mode 100644 index 2c1d997..0000000 Binary files a/pico/__pycache__/main.cpython-312.pyc and /dev/null differ diff --git a/src/engine.js b/src/engine.js index 7f9b637..81b9a39 100644 --- a/src/engine.js +++ b/src/engine.js @@ -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; diff --git a/tests/adapters/js_adapter.mjs b/tests/adapters/js_adapter.mjs index 2c70e67..31e3be3 100644 --- a/tests/adapters/js_adapter.mjs +++ b/tests/adapters/js_adapter.mjs @@ -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), diff --git a/tests/adapters/py_adapter.py b/tests/adapters/py_adapter.py index 8917fc1..56d892d 100644 --- a/tests/adapters/py_adapter.py +++ b/tests/adapters/py_adapter.py @@ -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"], diff --git a/tests/fixtures/track-format.json b/tests/fixtures/track-format.json index 6cc0069..1151411 100644 --- a/tests/fixtures/track-format.json +++ b/tests/fixtures/track-format.json @@ -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] + } + ] + } } ] } diff --git a/wokwi/__pycache__/ssd1306.cpython-312.pyc b/wokwi/__pycache__/ssd1306.cpython-312.pyc deleted file mode 100644 index ca23178..0000000 Binary files a/wokwi/__pycache__/ssd1306.cpython-312.pyc and /dev/null differ