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>
51 lines
2.1 KiB
JavaScript
51 lines
2.1 KiB
JavaScript
// JS adapter: loads the REAL grammar out of src/engine.js (no copy) and emits the
|
|
// neutral normalized structure defined in docs/track-format.md §5.
|
|
//
|
|
// engine.js has no top-level side effects (only const/function declarations) and references
|
|
// `window` only inside ensureAudio(), which we never call — so it loads cleanly in Node.
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join } from "node:path";
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const src = readFileSync(join(here, "..", "..", "src", "engine.js"), "utf8");
|
|
|
|
// Expose the pure codec functions from engine.js's top-level scope.
|
|
globalThis.window = globalThis.window || {};
|
|
const api = new Function(src + "\n;return { patchToSetup, setupToPatch };")();
|
|
|
|
const groupsArr = (s) => String(s).split(/[^0-9]+/).filter(Boolean).map(Number);
|
|
|
|
export function normalize(patch) {
|
|
const s = api.patchToSetup(patch);
|
|
return {
|
|
bpm: s.bpm,
|
|
bars: s.bars || 0,
|
|
volume: s.volume == null ? null : s.volume,
|
|
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,
|
|
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),
|
|
sub: c.stepsPerBeat || 1,
|
|
swing: !!c.swing,
|
|
poly: !!c.poly,
|
|
mute: c.enabled === false,
|
|
gainDb: c.gainDb || 0,
|
|
levels: (c.beatsOn || []).map((v) => v | 0),
|
|
})),
|
|
};
|
|
}
|
|
|
|
// serialize(parse(x)) — used for the idempotency check.
|
|
export function canonical(patch) {
|
|
return api.setupToPatch(api.patchToSetup(patch));
|
|
}
|
|
|
|
// CLI: `node js_adapter.mjs '<patch>'` prints normalized JSON.
|
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
process.stdout.write(JSON.stringify(normalize(process.argv[2] ?? "")));
|
|
}
|