metronome/src/engine.js
Me Here 4babd1f4ef Phase A — engine v2: drop samples, add conventions + per-lane dB gain
- Remove the VCSL sample kit entirely (editor 351K → 141K). All voices are
  synthesized; the friendly GM names now alias to the punchier 808/909 renders
  (KIT_ALIAS). build.sh drops the @BUILD:samples inlining; assets/samples.json gone.
- Conventions (backward-compatible): GM note-number aliases (36=kick…), '-'/'_'
  rest aliases in step patterns, Euclidean (k,n[,rot]) shorthand.
- Per-lane gain in dB (@<db> in the grammar) applied as a velocity multiplier at
  schedule time — no stutter; threaded through every host's buildMeters + the
  editor's lanes (knob UI comes in Phase B).
- 15/15 engine round-trip tests pass; pages console-clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:33:56 -05:00

252 lines
17 KiB
JavaScript
Raw 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);
}
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
}
// --- 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 isDefault = on.length && on.every((v, i) => (v | 0) === ((i % spb) === 0 ? 2 : 1));
if (on.length && !isDefault) s += "=" + on.map((v) => (v === 3 ? "g" : v >= 2 ? "X" : v >= 1 ? "x" : ".")).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 bpb = parseGroups(groupsStr).beatsPerBar;
let beatsOn;
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);
} else {
// pattern levels: X=accent(2), g=ghost(3), x/1=normal(1), . - _ / anything else = mute(0); no pattern → first of each beat accented
beatsOn = pattern ? pattern.split("").map((ch) => ch === "X" ? 2 : ch === "g" ? 3 : (ch === "x" || ch === "1") ? 1 : 0)
: Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 ? 2 : 1));
}
if (!DRUMS[sound]) sound = "beep";
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, 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);
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 } };
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("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("t")) s.bpm = parseInt(tok.slice(1), 10) || 120;
}
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) })) };
}