Clean, dependency-light front page. Only three things ship here: - index.html — two-button landing: Mobile -> mobile.html, Desktop -> pm_e-2.html - mobile.html — touch-first PWA (+ mobile-sessions.html practice journal) - pm_e-2.html — engraved-notation editor build.sh/deploy.sh trimmed to just these; deploy mirrors dist/ to the web root with rsync --delete. README/CLAUDE.md rewritten for the slim scope. The full project (PM_E-1 editor, embeddable widget, all hardware form-factor pages, Pico firmware editions, the Rust port, and the KiCad/SPICE hardware design) is preserved on the `concepts` branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
294 lines
20 KiB
JavaScript
294 lines
20 KiB
JavaScript
/* =========================================================================
|
||
SHARED ENGINE — inlined into every page by build.sh.
|
||
Audio voices (all SYNTHESIZED — no samples), the Web Audio look-ahead
|
||
scheduler primitives (PORTS TO FIRMWARE), and the share-language codec.
|
||
Each host supplies its own state globals (state, meters, ramp, trainer,
|
||
segBars, masterBeat…), its own setBpm, advanceMaster and scheduler().
|
||
Conventions (kept close to what people already know): GM drum names +
|
||
note-number aliases, drum-tab step patterns (X accent / x normal / g ghost /
|
||
. - _ rest), subdivisions /2 /3 /4, Euclidean (k,n) shorthand, per-lane gain
|
||
in dB (@<db>), and ~ = polymeter.
|
||
========================================================================= */
|
||
const LOOKAHEAD_MS = 25, SCHEDULE_AHEAD = 0.12;
|
||
const SWING_RATIO = 2 / 3; // triplet swing: the off-beat lands on the last triplet
|
||
let audioCtx = null, masterGain = null, noiseBuf = null, schedulerTimer = null;
|
||
|
||
|
||
// --- grouping: "2+2+3" → groups / beatsPerBar / group-start indices ---
|
||
function parseGroups(str) {
|
||
const parts = String(str).split(/[^0-9]+/).map((s) => parseInt(s, 10)).filter((n) => n >= 1 && n <= 12);
|
||
let total = 0; const groups = [];
|
||
for (const p of parts) { if (total + p > 12) break; groups.push(p); total += p; }
|
||
if (!groups.length) groups.push(4);
|
||
const beatsPerBar = groups.reduce((a, b) => a + b, 0);
|
||
const groupStarts = new Set(); let acc = 0;
|
||
for (const g of groups) { groupStarts.add(acc); acc += g; }
|
||
return { groups, beatsPerBar, groupStarts };
|
||
}
|
||
|
||
// --- audio context ---
|
||
function ensureAudio() {
|
||
if (audioCtx) return;
|
||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
masterGain = audioCtx.createGain();
|
||
masterGain.gain.value = state.volume;
|
||
masterGain.connect(audioCtx.destination);
|
||
}
|
||
|
||
// --- shared noise buffer ---
|
||
function getNoise() {
|
||
if (!noiseBuf) {
|
||
const n = Math.floor(audioCtx.sampleRate * 1.0);
|
||
noiseBuf = audioCtx.createBuffer(1, n, audioCtx.sampleRate);
|
||
const d = noiseBuf.getChannelData(0);
|
||
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
|
||
}
|
||
return noiseBuf;
|
||
}
|
||
|
||
// --- synthesized GM-style voices (level = velocity) ---
|
||
function ampEnv(time, peak, dur, attack) {
|
||
const g = audioCtx.createGain();
|
||
peak = Math.max(0.0003, peak);
|
||
g.gain.setValueAtTime(0.0001, time);
|
||
g.gain.exponentialRampToValueAtTime(peak, time + (attack || 0.001));
|
||
g.gain.exponentialRampToValueAtTime(0.0001, time + dur);
|
||
return g;
|
||
}
|
||
function tone(time, type, f0, f1, dur) {
|
||
const o = audioCtx.createOscillator(); o.type = type;
|
||
o.frequency.setValueAtTime(f0, time);
|
||
if (f1 && f1 !== f0) o.frequency.exponentialRampToValueAtTime(Math.max(1, f1), time + Math.min(dur, 0.09));
|
||
o.start(time); o.stop(time + dur + 0.02); return o;
|
||
}
|
||
function noiseSrc(time, dur) { const s = audioCtx.createBufferSource(); s.buffer = getNoise(); s.start(time); s.stop(time + dur + 0.02); return s; }
|
||
function filt(type, freq, q) { const f = audioCtx.createBiquadFilter(); f.type = type; f.frequency.value = freq; if (q) f.Q.value = q; return f; }
|
||
function v_tone(time, level, type, f0, f1, dur, peak) { const o = tone(time, type, f0, f1, dur), g = ampEnv(time, peak * level, dur, 0.002); o.connect(g); g.connect(masterGain); }
|
||
function v_noise(time, level, fType, freq, q, dur, peak, attack) { const n = noiseSrc(time, dur), f = filt(fType, freq, q), g = ampEnv(time, peak * level, dur, attack); n.connect(f); f.connect(g); g.connect(masterGain); }
|
||
// 6 detuned square oscillators → bandpass + highpass = the classic 808/909 metallic hi-hat/cymbal timbre.
|
||
function metalHat(time, level, dur, hpFreq, peak) {
|
||
const fund = 40, ratios = [2, 3, 4.16, 5.43, 6.79, 8.21];
|
||
const bp = filt("bandpass", 10000, 0.8), hp = filt("highpass", hpFreq, 0), g = ampEnv(time, peak * level, dur, 0.001);
|
||
ratios.forEach((r) => { const o = audioCtx.createOscillator(); o.type = "square"; o.frequency.value = fund * r; o.start(time); o.stop(time + dur + 0.02); o.connect(bp); });
|
||
bp.connect(hp); hp.connect(g); g.connect(masterGain);
|
||
}
|
||
|
||
// --- voice table + sample aliases ---
|
||
const DRUMS = {
|
||
beep: (t, l) => v_tone(t, l, "square", l >= 1 ? 1600 : 1100, 0, 0.04, 0.5),
|
||
kick: (t, l) => v_tone(t, l, "sine", 150, 50, 0.18, 1.0),
|
||
snare: (t, l) => { v_tone(t, l, "triangle", 190, 140, 0.12, 0.45); v_noise(t, l, "highpass", 1500, 0, 0.2, 0.8); },
|
||
rim: (t, l) => { const o = tone(t, "square", 1700, 0, 0.04), bp = filt("bandpass", 1700, 4), g = ampEnv(t, 0.6 * l, 0.04); o.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||
clap: (t, l) => { const bp = filt("bandpass", 1200, 1.4); bp.connect(masterGain); [0, 0.012, 0.024].forEach((d, i) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, (i < 2 ? 0.5 : 0.85) * l, 0.06); n.connect(e); e.connect(bp); }); },
|
||
hatClosed: (t, l) => v_noise(t, l, "highpass", 7000, 0, 0.045, 0.5),
|
||
hatOpen: (t, l) => v_noise(t, l, "highpass", 7000, 0, 0.32, 0.45, 0.002),
|
||
ride: (t, l) => { v_noise(t, l, "bandpass", 6000, 0.8, 0.4, 0.32, 0.002); v_tone(t, l, "square", 5200, 0, 0.1, 0.13); },
|
||
crash: (t, l) => v_noise(t, l, "highpass", 4000, 0, 0.8, 0.5, 0.002),
|
||
tomLow: (t, l) => v_tone(t, l, "sine", 150, 100, 0.25, 0.9),
|
||
tomMid: (t, l) => v_tone(t, l, "sine", 220, 150, 0.23, 0.9),
|
||
tomHigh: (t, l) => v_tone(t, l, "sine", 300, 210, 0.20, 0.9),
|
||
tambourine:(t, l) => v_noise(t, l, "highpass", 8000, 0, 0.12, 0.5),
|
||
cowbell: (t, l) => { const sum = audioCtx.createGain(), bp = filt("bandpass", 2640, 1.2), g = ampEnv(t, 0.8 * l, 0.3); [540, 800].forEach((f) => tone(t, "square", f, 0, 0.3).connect(sum)); sum.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||
woodblock: (t, l) => v_tone(t, l, "triangle", 1800, 1500, 0.06, 0.8),
|
||
claves: (t, l) => v_tone(t, l, "sine", 2500, 0, 0.045, 0.85),
|
||
jamblock: (t, l) => { const o = tone(t, "square", 2600, 2000, 0.045), bp = filt("bandpass", 2000, 6), g = ampEnv(t, 0.8 * l, 0.045); o.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||
// --- electronic drum-machine voices (synthesized — these machines ARE synths in reality) ---
|
||
kick808: (t, l) => { v_tone(t, l, "sine", 120, 45, 0.7, 1.0); v_noise(t, l * 0.5, "highpass", 2000, 0, 0.008, 0.4, 0.001); }, // long boom + click
|
||
snare808: (t, l) => { v_tone(t, l, "triangle", 178, 168, 0.16, 0.4); v_tone(t, l, "triangle", 331, 320, 0.12, 0.18); v_noise(t, l, "highpass", 1000, 0, 0.16, 0.7); },
|
||
clap808: (t, l) => { const bp = filt("bandpass", 1100, 1.3); bp.connect(masterGain); [0, 0.01, 0.02, 0.032].forEach((d, i) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, (i < 3 ? 0.5 : 0.85) * l, 0.05); n.connect(e); e.connect(bp); }); },
|
||
hat808: (t, l) => metalHat(t, l, 0.045, 7000, 0.4),
|
||
openHat808: (t, l) => metalHat(t, l, 0.34, 7000, 0.38),
|
||
cowbell808: (t, l) => { const sum = audioCtx.createGain(), bp = filt("bandpass", 2640, 1.2), g = ampEnv(t, 0.8 * l, 0.3); [540, 800].forEach((f) => tone(t, "square", f, 0, 0.3).connect(sum)); sum.connect(bp); bp.connect(g); g.connect(masterGain); },
|
||
tom808: (t, l) => v_tone(t, l, "sine", 120, 78, 0.34, 0.9),
|
||
kick909: (t, l) => { v_tone(t, l, "sine", 110, 46, 0.26, 1.0); v_tone(t, l, "triangle", 280, 60, 0.035, 0.5); v_noise(t, l * 0.6, "highpass", 3000, 0, 0.01, 0.5, 0.001); }, // punchy + click
|
||
snare909: (t, l) => { v_tone(t, l, "triangle", 190, 162, 0.09, 0.28); v_noise(t, l, "highpass", 1200, 0, 0.2, 0.85); },
|
||
clap909: (t, l) => { const bp = filt("bandpass", 1000, 1.0); bp.connect(masterGain); [0, 0.009, 0.018].forEach((d) => { const n = noiseSrc(t + d, 0.05), e = ampEnv(t + d, 0.6 * l, 0.05); n.connect(e); e.connect(bp); }); const tail = noiseSrc(t + 0.018, 0.2), te = ampEnv(t + 0.018, 0.35 * l, 0.2, 0.001); tail.connect(te); te.connect(bp); },
|
||
hat909: (t, l) => metalHat(t, l, 0.05, 9000, 0.4),
|
||
ride909: (t, l) => { metalHat(t, l, 0.5, 6000, 0.3); v_noise(t, l, "bandpass", 7000, 0.7, 0.18, 0.18, 0.002); },
|
||
crash909: (t, l) => { metalHat(t, l, 0.9, 5000, 0.34); v_noise(t, l, "highpass", 4000, 0, 0.9, 0.4, 0.002); },
|
||
};
|
||
const VOICES = [
|
||
["beep", "beep"], ["kick", "kick"], ["snare", "snare"], ["rim", "rim/stick"], ["clap", "clap"],
|
||
["hatClosed", "hat closed"], ["hatOpen", "hat open"], ["ride", "ride"], ["crash", "crash"],
|
||
["tomLow", "tom low"], ["tomMid", "tom mid"], ["tomHigh", "tom high"], ["tambourine", "tambourine"],
|
||
["cowbell", "cowbell"], ["woodblock", "wood block"], ["claves", "claves"], ["jamblock", "jam block"],
|
||
["kick808", "808 kick"], ["snare808", "808 snare"], ["clap808", "808 clap"], ["hat808", "808 hat"], ["openHat808", "808 open hat"], ["cowbell808", "808 cowbell"], ["tom808", "808 tom"],
|
||
["kick909", "909 kick"], ["snare909", "909 snare"], ["clap909", "909 clap"], ["hat909", "909 hat"], ["ride909", "909 ride"], ["crash909", "909 crash"],
|
||
];
|
||
// Default kit points the friendly GM names at the punchier 808/909 renders
|
||
// (samples removed — those synth voices sound better). Pick "kick808"/"kick909"
|
||
// etc. explicitly for a specific machine flavour.
|
||
const KIT_ALIAS = {
|
||
kick: "kick909", snare: "snare909", clap: "clap909",
|
||
hatClosed: "hat909", hatOpen: "openHat808", ride: "ride909", crash: "crash909",
|
||
cowbell: "cowbell808",
|
||
};
|
||
// General-MIDI percussion note numbers → our voice names (so MIDI/DAW users can type numbers)
|
||
const GM_NUM = {
|
||
35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare",
|
||
41: "tomLow", 42: "hatClosed", 43: "tomLow", 44: "hatClosed", 45: "tomMid",
|
||
46: "hatOpen", 47: "tomMid", 48: "tomHigh", 49: "crash", 50: "tomHigh",
|
||
51: "ride", 53: "ride", 54: "tambourine", 56: "cowbell", 75: "claves",
|
||
76: "woodblock", 77: "woodblock",
|
||
};
|
||
// Euclidean / Bjorklund-class even distribution: k hits over n steps, rotated by rot
|
||
function euclid(k, n, rot) {
|
||
n = Math.max(1, n | 0); k = Math.max(0, Math.min(n, k | 0)); rot = (((rot | 0) % n) + n) % n;
|
||
const r = []; for (let i = 0; i < n; i++) { const j = (i + rot) % n; r.push(((j * k) % n) < k ? 1 : 0); }
|
||
return r;
|
||
}
|
||
function playInstrument(type, time, level) {
|
||
(DRUMS[KIT_ALIAS[type] || type] || DRUMS[type] || DRUMS.beep)(time, level);
|
||
}
|
||
|
||
// --- scheduler primitives (PORTS TO FIRMWARE) ---
|
||
function masterBeatsPerBar() { return meters.length ? meters[0].beatsPerBar : 4; }
|
||
|
||
function isMutedAt(t) { return muteWindows.some((w) => t >= w.start && t < w.end); }
|
||
|
||
// --- per-step scheduling + dynamics ---
|
||
function scheduleMeterTick(m, time) {
|
||
const spb = m.stepsPerBeat;
|
||
const barLen = m.beatsPerBar * spb;
|
||
const tickInBar = ((m.tick % barLen) + barLen) % barLen;
|
||
m.vq.push({ time, step: tickInBar, bar: Math.floor(m.tick / barLen) }); // playhead (per step) + measure (advance even when muted)
|
||
if (!m.enabled || isMutedAt(time)) return;
|
||
const lvl = m.beatsOn[tickInBar] | 0; // dynamics: 0 mute · 1 normal · 2 accent · 3 ghost
|
||
if (!lvl) return;
|
||
const lin = m.gainDb ? Math.pow(10, m.gainDb / 20) : 1; // per-lane dB gain → linear, applied at schedule time (no stutter)
|
||
playInstrument(m.sound, time, (lvl === 2 ? 1.0 : lvl === 3 ? 0.25 : 0.6) * lin);
|
||
// opt-in per-hit hook (a page may define onMeterHit to e.g. emit MIDI out to external gear);
|
||
// (sound name, audio-context time of the hit, dynamic level 1/2/3). No-op on pages that don't set it.
|
||
if (typeof onMeterHit === "function") onMeterHit(m.sound, time, lvl);
|
||
}
|
||
|
||
function refBarDur() { return (meters.length ? meters[0].beatsPerBar : 4) * (60 / state.bpm); }
|
||
|
||
// --- step duration (poly / swing / straight) ---
|
||
function laneStepDur(m, tick) {
|
||
if (m.poly) return refBarDur() / (m.beatsPerBar * m.stepsPerBeat); // true ratio polyrhythm (no swing)
|
||
const beat = 60 / state.bpm;
|
||
if (m.swing && m.stepsPerBeat % 2 === 0) { // swing even subdivisions (8ths, 16ths): long–short pairs
|
||
const pairDur = beat / (m.stepsPerBeat / 2);
|
||
return ((tick % m.stepsPerBeat) % 2) === 0 ? SWING_RATIO * pairDur : (1 - SWING_RATIO) * pairDur;
|
||
}
|
||
return beat / m.stepsPerBeat; // straight: shared even grid
|
||
}
|
||
|
||
// --- pattern cell codec: char ⇄ (level, ornament) ---
|
||
// level: 0 rest / 1 normal / 2 accent / 3 ghost. ornament: 0 none / 1 flam / 2 drag / 3 roll.
|
||
// Ornaments use new letters, UPPER-case = accented hit, lower-case = normal hit (case carries the
|
||
// dynamic so it stays orthogonal): f/F flam · d/D drag · z/Z roll. Ghosted ornaments aren't expressible.
|
||
function patCell(ch) {
|
||
switch (ch) {
|
||
case "X": return [2, 0];
|
||
case "x": case "1": return [1, 0];
|
||
case "g": return [3, 0];
|
||
case "f": return [1, 1]; case "F": return [2, 1];
|
||
case "d": return [1, 2]; case "D": return [2, 2];
|
||
case "z": return [1, 3]; case "Z": return [2, 3];
|
||
default: return [0, 0]; // . - _ / anything else = rest
|
||
}
|
||
}
|
||
function cellCh(lvl, orn) {
|
||
if (orn === 1) return lvl >= 2 ? "F" : "f";
|
||
if (orn === 2) return lvl >= 2 ? "D" : "d";
|
||
if (orn === 3) return lvl >= 2 ? "Z" : "z";
|
||
return lvl === 3 ? "g" : lvl >= 2 ? "X" : lvl >= 1 ? "x" : ".";
|
||
}
|
||
|
||
// --- share-language codec: config ⇄ lane token ---
|
||
function laneCfgToStr(c) {
|
||
let s = c.sound + ":" + c.groupsStr;
|
||
const spb = c.stepsPerBeat || 1;
|
||
if (spb !== 1 || c.swing) s += "/" + spb + (c.swing ? "s" : ""); // "/2s" = swung eighths
|
||
const on = c.beatsOn || []; // per-step dynamics: one char per pad (X accent / x normal / g ghost / . mute)
|
||
const orn = c.orns || []; // per-step ornament (flam/drag/roll), parallel to beatsOn
|
||
const gs = parseGroups(c.groupsStr).groupStarts; // default = accent group starts only; everything else sounds at normal
|
||
const anyOrn = orn.some((v) => (v | 0) !== 0); // any ornament → not the implicit default; must write the pattern
|
||
const isDefault = !anyOrn && on.length && on.every((v, i) => (v | 0) === (((i % spb) === 0 && gs.has(i / spb)) ? 2 : 1));
|
||
if (on.length && !isDefault) s += "=" + on.map((v, i) => cellCh(v | 0, orn[i] | 0)).join("");
|
||
if (c.gainDb) s += "@" + c.gainDb; // per-lane gain in dB (e.g. @-3, @2)
|
||
if (c.poly) s += "~";
|
||
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
|
||
return s;
|
||
}
|
||
function laneStrToCfg(tok) {
|
||
let poly = false, disabled = false, gainDb = 0;
|
||
while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) disabled = true; else poly = true; tok = tok.slice(0, -1); }
|
||
const at = tok.indexOf("@"); if (at >= 0) { gainDb = parseFloat(tok.slice(at + 1)) || 0; tok = tok.slice(0, at); } // @<db> gain
|
||
const ci = tok.indexOf(":"); if (ci < 0) return null;
|
||
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = null;
|
||
if (/^\d+$/.test(sound) && GM_NUM[sound]) sound = GM_NUM[sound]; // GM note number → name
|
||
// Euclidean shorthand (k,n[,rot]) — replaces an explicit =pattern
|
||
let eucK = null, eucN = null, eucRot = 0;
|
||
const em = rest.match(/\((\d+)(?:,(\d+))?(?:,(\d+))?\)/);
|
||
if (em) { eucK = +em[1]; eucN = em[2] != null ? +em[2] : null; eucRot = em[3] != null ? +em[3] : 0; rest = rest.slice(0, em.index) + rest.slice(em.index + em[0].length); }
|
||
const eq = rest.indexOf("="); if (eq >= 0) { pattern = rest.slice(eq + 1); rest = rest.slice(0, eq); }
|
||
let groupsStr = rest, sub = 1, swing = false; const sl = rest.indexOf("/");
|
||
if (sl >= 0) { groupsStr = rest.slice(0, sl); const sp = rest.slice(sl + 1); swing = /s$/i.test(sp); sub = parseInt(sp, 10) || 1; }
|
||
let { beatsPerBar: bpb, groupStarts } = parseGroups(groupsStr);
|
||
let beatsOn, orns;
|
||
if (eucK != null) { // k hits spread evenly; first hit accented
|
||
let n = eucN || (bpb * sub);
|
||
if (eucN) { if (n % bpb === 0) sub = n / bpb; else { bpb = n; sub = 1; groupsStr = String(n); } }
|
||
let first = true;
|
||
beatsOn = euclid(eucK, n, eucRot).map((h) => h ? (first ? (first = false, 2) : 1) : 0);
|
||
orns = beatsOn.map(() => 0); // euclid hits carry no ornament
|
||
} else if (pattern != null) {
|
||
// pattern cells: per-step (level, ornament) — X accent, x/1 normal, g ghost, f/F flam, d/D drag,
|
||
// z/Z roll, . - _ / anything else = rest. See patCell().
|
||
const cells = pattern.split("").map(patCell);
|
||
beatsOn = cells.map((c) => c[0]);
|
||
orns = cells.map((c) => c[1]);
|
||
} else {
|
||
// no pattern → every subdivision sounds at normal, accent on group starts (the grouping IS the accent map)
|
||
beatsOn = Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 && groupStarts.has(i / sub)) ? 2 : 1);
|
||
orns = beatsOn.map(() => 0);
|
||
}
|
||
if (!DRUMS[sound]) sound = "beep";
|
||
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, orns, poly, swing, enabled: !disabled, gainDb };
|
||
}
|
||
|
||
// --- share-language codec: patch ⇄ setup ---
|
||
function setupToPatch(s) {
|
||
const parts = ["v1", "t" + s.bpm];
|
||
if (s.volume != null) parts.push("vol" + Math.round(s.volume * 100));
|
||
if (s.countMs > 0) parts.push("cd" + Math.round(s.countMs / 1000));
|
||
if (s.bars > 0) parts.push("b" + s.bars);
|
||
(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: [], 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("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 = 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;
|
||
}
|
||
|
||
// --- base64url(JSON) set-list codec ---
|
||
function b64u(str) { return btoa(unescape(encodeURIComponent(str))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }
|
||
function unb64u(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); return decodeURIComponent(escape(atob(s))); }
|
||
|
||
|
||
function codeToSetlist(code) {
|
||
const o = JSON.parse(unb64u(code));
|
||
return { title: o.t || "Shared set list", description: o.d || "", items: (o.i || []).map((x) => ({ name: x.n || "Item", ...patchToSetup(x.p) })) };
|
||
}
|