diff --git a/docs/playback-flow-test.md b/docs/playback-flow-test.md new file mode 100644 index 0000000..4db9d0c --- /dev/null +++ b/docs/playback-flow-test.md @@ -0,0 +1,46 @@ +# Per-track playback flow — on-device test checklist + +The parsing/serialization and the `_end_plan`/`_goto_target` decision logic are covered by +`tests/run.mjs` and unit tests. What still needs a real device (PM_K-1 / PM_X-1) is the +**runtime**: that the gapless seam fires stop / advance / relative-goto at the right bar. + +## Setup + +1. Flash the new firmware: copy `dist/app.mpy` (PM_K) or `dist/explorer-app.mpy` (PM_X) onto the + device as `/app.mpy` (or use the editor's one-click push — it serves this build). +2. Author the test tracks in the web editor: paste each program string into the program-string + field, **Save** it as a set-list item, then **Save to device**. (Multi-item cases: create the + items in order in one set-list.) +3. On the device, select the set-list and press play (Button A). + +`b` sets the cycle length in bars; a cycle = `b`, else one master bar. The action fires +after `rep × cycle` bars (rep defaults to 1). + +## Cases + +| # | Track(s) | Expect | ✓ | +|---|----------|--------|---| +| 1 | `t120;kick:4` | **Loops forever.** Joystick-right advances manually. | ☐ | +| 2 | `t120;b2;kick:4;end=stop` | Plays **2 bars, then stops** by itself (LED → green, transport stops). | ☐ | +| 3 | `t120;b2;kick:4;rep=3;end=stop` | Plays **6 bars (3×2), then stops.** | ☐ | +| 4 | A=`t120;b2;kick:4;end=next` B=`t100;b2;snare:4` | A plays 2 bars then **gaplessly advances to B** (no gap, no count-in, tempo jumps to 100). | ☐ | +| 5 | A=`t120;b2;kick:4;rep=2;end=next` B=`t100;b2;snare:4` | A plays **4 bars** (2×2) then advances to B. | ☐ | +| 6 | 1=`t120;b2;kick:4` 2=`t120;b2;snare:4;end=next` 3=`t120;b2;clap:4;end=-2` | 3 jumps **back 2 → track 1** after its cycle (a looping verse→chorus section). | ☐ | +| 7 | 1=`t120;b2;kick:4;end=-2` (first item) | `end=-2` before the start **clamps to track 1** → it just loops itself. | ☐ | +| 8 | last item `…;end=next` | Advancing past the last item **wraps to the first** (set-list loops). | ☐ | +| 9 | any of the above, mid-flow | **Manual joystick-right / footswitch always advances immediately,** regardless of `end=`. | ☐ | +| 10 | global **Continue ON** + an item `t120;b2;kick:4;end=stop` | The item **still stops** — explicit `end=` overrides the global Continue default. | ☐ | +| 11 | global **Continue ON** + `t120;b2;kick:4` (no `end=`) | **Advances** after 2 bars — legacy behavior preserved (Continue = default `end=next`). | ☐ | + +## What to watch for + +- **Seam quality** (cases 4–6): the swap should be click-on-the-beat with no audible gap, dropout, + or double-trigger at the boundary. This is the highest-risk part of the change. +- **Stop cleanliness** (cases 2–3): no extra click after the stop; speaker goes quiet; the play + session is logged. +- **Ramp/trainer interaction**: a track with `rmp…`/`tr…` *and* an `end=` should ramp/gap normally + and still fire the end-action at `rep × cycle`. +- **Memory**: relative-goto and advance both go through `_prepare_next` (which `gc.collect()`s + before parsing). Watch for any MemoryError fallback (it leaves the track looping instead). + +Report any case that misbehaves with its number and what happened. diff --git a/docs/track-format.md b/docs/track-format.md index 0ce4302..b1da9ca 100644 --- a/docs/track-format.md +++ b/docs/track-format.md @@ -209,11 +209,11 @@ Surfaced by the runner. **Resolved** (now identical on web + firmware, verified volume knob and no count-in, so it parses past them and does not carry them. (Contrast `@db` gain, which the device *does* round-trip as a per-lane field even though it doesn't apply it.) -**Still open — web side only** (`engine.js`, flagged `expectFail: ["js"]`): +**Resolved on the web side** (`engine.js` now matches the firmware): -- **Tempo clamp** — firmware clamps `t` to `[5,300]`; `engine.js` does not yet. -- **Empty patch** — firmware injects a default `beep:4`; `engine.js` returns zero lanes (the - editor guards emptiness separately, so this may stay as-is). +- **Tempo clamp** — `patchToSetup` clamps `t` to `[5,300]`. +- **Empty patch** — `patchToSetup` defaults to a `beep:4` lane when no lanes are given. (The + editor still shows its "no lanes" hint by checking the *raw* input for a `:` token.) The capability/version handshake (the device already replies with its firmware version over SysEx) should gate features so the editor can warn when a track uses something the connected diff --git a/editor-beta.html b/editor-beta.html index 19b77bc..a6eb869 100644 --- a/editor-beta.html +++ b/editor-beta.html @@ -317,6 +317,18 @@ 0 = manual +
+ + + +
@@ -436,7 +448,7 @@ const APP_VERSION = "0.0.1-dev"; /* ========================================================================= STATE ========================================================================= */ -const state = { bpm: 120, volume: 0.7, running: false }; +const state = { bpm: 120, volume: 0.7, running: false, rep: null, end: null }; const trainer = { on: false, playBars: 2, muteBars: 2 }; const ramp = { on: false, startBpm: 80, amount: 5, everyBars: 4 }; @@ -784,8 +796,30 @@ function syncPracticeUI() { $("rampOn").checked = ramp.on; $("rampStart").value = ramp.startBpm; $("rampAmt").value = ramp.amount; $("rampEvery").value = ramp.everyBars; $("countTime").value = timers.totalMs > 0 ? fmtClock(timers.totalMs) : ""; $("segBarsIn").value = segBars || 0; + syncEndActionUI(); refreshFeatureBoxes(); renderTimers(); } +// Per-track playback flow control: state.end (null | 'stop' | int offset) + state.rep (cycles). +function syncEndActionUI() { + const end = state.end; + const action = end == null ? "loop" : end === "stop" ? "stop" : end === 1 ? "next" : "goto"; + $("endAction").value = action; + $("gotoWrap").hidden = action !== "goto"; + $("repWrap").hidden = action === "loop"; + if (action === "goto") $("endGoto").value = typeof end === "number" ? end : -1; + $("endRep").value = state.rep && state.rep > 1 ? state.rep : 1; +} +function readEndActionUI() { + const action = $("endAction").value; + $("gotoWrap").hidden = action !== "goto"; + $("repWrap").hidden = action === "loop"; + const rep = Math.max(1, parseInt($("endRep").value, 10) || 1); + if (action === "loop") { state.end = null; state.rep = null; } + else if (action === "next") { state.end = 1; state.rep = rep; } + else if (action === "stop") { state.end = "stop"; state.rep = rep; } + else { state.end = parseInt($("endGoto").value, 10) || 0; state.rep = rep; } + updateCtx(); +} function refreshFeatureBoxes() { $("trainerBox").classList.toggle("on", trainer.on); $("rampBox").classList.toggle("on", ramp.on); @@ -1477,7 +1511,7 @@ function commitPatchField() { setPatchMsg("✓ decoded set list “" + sl.title + "” (" + sl.items.length + " item" + (sl.items.length > 1 ? "s" : "") + ") — loaded item 1", true); } else { const s = patchToSetup(payload); // plain-text patch - if (!s.lanes.length) throw new Error("no lanes — try e.g. kick:4"); + if (!payload.includes(":")) throw new Error("no lanes — try e.g. kick:4"); // raw-input check (patchToSetup itself defaults to beep:4) applyPatch(payload); setPatchMsg("✓ " + s.lanes.length + " lane" + (s.lanes.length > 1 ? "s" : "") + " · " + s.bpm + " BPM", true); } @@ -1532,6 +1566,9 @@ $("countTime").addEventListener("input", (e) => { timers.totalMs = parseTime(e.t $("elapsedReset").addEventListener("click", () => { timers.elapsedMs = 0; renderTimers(); }); $("countReset").addEventListener("click", () => { timers.remainingMs = timers.totalMs; renderTimers(); }); $("segBarsIn").addEventListener("input", (e) => { segBars = Math.max(0, parseInt(e.target.value, 10) || 0); renderTimers(); syncPatchSoon(); }); +$("endAction").addEventListener("change", readEndActionUI); +$("endGoto").addEventListener("input", readEndActionUI); +$("endRep").addEventListener("input", readEndActionUI); $("continueMode").addEventListener("change", (e) => { continueMode = e.target.checked; lsSet(LS.continue, continueMode); }); $("timersOn").addEventListener("change", (e) => { timersOn = e.target.checked; lsSet(LS.timers, timersOn); refreshFeatureBoxes(); renderTimers(); }); $("trayMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); $("trayMenu").hidden = !$("trayMenu").hidden; }); diff --git a/editor.html b/editor.html index d00f438..c86a2fd 100644 --- a/editor.html +++ b/editor.html @@ -317,6 +317,18 @@ 0 = manual +
+ + + +
@@ -436,7 +448,7 @@ const APP_VERSION = "0.0.1-dev"; /* ========================================================================= STATE ========================================================================= */ -const state = { bpm: 120, volume: 0.7, running: false }; +const state = { bpm: 120, volume: 0.7, running: false, rep: null, end: null }; const trainer = { on: false, playBars: 2, muteBars: 2 }; const ramp = { on: false, startBpm: 80, amount: 5, everyBars: 4 }; @@ -782,8 +794,30 @@ function syncPracticeUI() { $("rampOn").checked = ramp.on; $("rampStart").value = ramp.startBpm; $("rampAmt").value = ramp.amount; $("rampEvery").value = ramp.everyBars; $("countTime").value = timers.totalMs > 0 ? fmtClock(timers.totalMs) : ""; $("segBarsIn").value = segBars || 0; + syncEndActionUI(); refreshFeatureBoxes(); renderTimers(); } +// Per-track playback flow control: state.end (null | 'stop' | int offset) + state.rep (cycles). +function syncEndActionUI() { + const end = state.end; + const action = end == null ? "loop" : end === "stop" ? "stop" : end === 1 ? "next" : "goto"; + $("endAction").value = action; + $("gotoWrap").hidden = action !== "goto"; + $("repWrap").hidden = action === "loop"; + if (action === "goto") $("endGoto").value = typeof end === "number" ? end : -1; + $("endRep").value = state.rep && state.rep > 1 ? state.rep : 1; +} +function readEndActionUI() { + const action = $("endAction").value; + $("gotoWrap").hidden = action !== "goto"; + $("repWrap").hidden = action === "loop"; + const rep = Math.max(1, parseInt($("endRep").value, 10) || 1); + if (action === "loop") { state.end = null; state.rep = null; } + else if (action === "next") { state.end = 1; state.rep = rep; } + else if (action === "stop") { state.end = "stop"; state.rep = rep; } + else { state.end = parseInt($("endGoto").value, 10) || 0; state.rep = rep; } + updateCtx(); +} function refreshFeatureBoxes() { $("trainerBox").classList.toggle("on", trainer.on); $("rampBox").classList.toggle("on", ramp.on); @@ -1466,7 +1500,7 @@ function commitPatchField() { setPatchMsg("✓ decoded set list “" + sl.title + "” (" + sl.items.length + " item" + (sl.items.length > 1 ? "s" : "") + ") — loaded item 1", true); } else { const s = patchToSetup(payload); // plain-text patch - if (!s.lanes.length) throw new Error("no lanes — try e.g. kick:4"); + if (!payload.includes(":")) throw new Error("no lanes — try e.g. kick:4"); // raw-input check (patchToSetup itself defaults to beep:4) applyPatch(payload); setPatchMsg("✓ " + s.lanes.length + " lane" + (s.lanes.length > 1 ? "s" : "") + " · " + s.bpm + " BPM", true); } @@ -1519,6 +1553,9 @@ $("countTime").addEventListener("input", (e) => { timers.totalMs = parseTime(e.t $("elapsedReset").addEventListener("click", () => { timers.elapsedMs = 0; renderTimers(); }); $("countReset").addEventListener("click", () => { timers.remainingMs = timers.totalMs; renderTimers(); }); $("segBarsIn").addEventListener("input", (e) => { segBars = Math.max(0, parseInt(e.target.value, 10) || 0); renderTimers(); }); +$("endAction").addEventListener("change", readEndActionUI); +$("endGoto").addEventListener("input", readEndActionUI); +$("endRep").addEventListener("input", readEndActionUI); $("continueMode").addEventListener("change", (e) => { continueMode = e.target.checked; lsSet(LS.continue, continueMode); }); $("timersOn").addEventListener("change", (e) => { timersOn = e.target.checked; lsSet(LS.timers, timersOn); refreshFeatureBoxes(); renderTimers(); }); $("trayMenuBtn").addEventListener("click", (e) => { e.stopPropagation(); $("trayMenu").hidden = !$("trayMenu").hidden; }); diff --git a/src/engine.js b/src/engine.js index 81b9a39..741c650 100644 --- a/src/engine.js +++ b/src/engine.js @@ -244,8 +244,9 @@ function patchToSetup(str) { 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; + else if (tok.startsWith("t")) s.bpm = Math.max(5, Math.min(300, parseInt(tok.slice(1), 10) || 120)); // clamp like the firmware } + if (!s.lanes.length) s.lanes.push(laneStrToCfg("beep:4")); // a patch always has >=1 lane (match the firmware default) return s; } diff --git a/tests/fixtures/track-format.json b/tests/fixtures/track-format.json index 1151411..3d5b1a8 100644 --- a/tests/fixtures/track-format.json +++ b/tests/fixtures/track-format.json @@ -511,11 +511,8 @@ }, { "id": "tempo-clamp-high", - "status": "divergence", - "expectFail": [ - "js" - ], - "note": "Firmware clamps t to [5,300]; engine.js does not. Spec = clamp everywhere.", + "status": "stable", + "note": "Firmware clamps t to [5,300]; engine.js now clamps too. Spec = clamp everywhere.", "in": "t999;kick:4=Xxxx", "norm": { "bpm": 300, @@ -542,10 +539,7 @@ }, { "id": "empty-defaults-to-beep", - "status": "divergence", - "expectFail": [ - "js" - ], + "status": "stable", "note": "Firmware injects a default beep:4; engine.js returns no lanes (editor guards emptiness). Spec = beep:4.", "in": "", "norm": {