metronome/src/engine.js
Me Here 5ab2096fc4 PolyMeter — slim main: landing chooser + mobile app + notation editor
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>
2026-06-08 11:44:45 -05:00

294 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =========================================================================
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): longshort 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) })) };
}