Both index.html (editor) and player.html (hardware-player mockup) now pull
their common code from src/ via a new build-time include marker
(/*@BUILD:include:src/…@*/), resolved by build.sh:
src/engine.js — audio voices (DRUMS×30), Web Audio scheduler primitives,
and the share-language codec (patch/set-list encode+decode)
src/setlists.js — SEED_SETLISTS, so the player ships the SAME default set
lists as the editor (player BUILTIN = SEED_SETLISTS)
src/base.css — reset + VARASYS brand palette + type stack
The editor inlines the CC0 acoustic samples; the player passes an empty
SAMPLES object and the shared playInstrument falls back to its synth voices,
so the device stays faithfully synth-only. Each app keeps its own state
globals, setBpm, advanceMaster/scheduler, and UI. ~400 lines of duplicated
engine code removed; the player's favicon is now the shared @BUILD:favicon@.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
15 KiB
JavaScript
221 lines
15 KiB
JavaScript
/* =========================================================================
|
||
SHARED ENGINE — inlined into BOTH index.html and player.html by build.sh.
|
||
Audio voices, 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(), and its own SAMPLES object (the
|
||
editor inlines CC0 one-shots; the player passes {} for pure synth).
|
||
========================================================================= */
|
||
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;
|
||
const sampleBuffers = {}; // decoded CC0 acoustic one-shots (VCSL, CC0); acoustic voices play these, electronic stay synth
|
||
|
||
|
||
// --- 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 + embedded-sample decode ---
|
||
function ensureAudio() {
|
||
if (audioCtx) return;
|
||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
masterGain = audioCtx.createGain();
|
||
masterGain.gain.value = state.volume;
|
||
masterGain.connect(audioCtx.destination);
|
||
for (const k in SAMPLES) { // decode embedded one-shots once
|
||
const bin = atob(SAMPLES[k]); const u8 = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
||
audioCtx.decodeAudioData(u8.buffer, (b) => { sampleBuffers[k] = b; }, () => {});
|
||
}
|
||
}
|
||
|
||
// --- 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"],
|
||
];
|
||
const SAMPLE_ALIAS = { tomMid: "tomLow" }; // VCSL has only 2 toms — mid = low tom pitched up
|
||
const SAMPLE_RATE = { tomMid: 1.19 }; // ~3 semitones
|
||
function playInstrument(type, time, level) {
|
||
const buf = sampleBuffers[type] || sampleBuffers[SAMPLE_ALIAS[type]];
|
||
if (buf) { const s = audioCtx.createBufferSource(); s.buffer = buf; s.playbackRate.value = SAMPLE_RATE[type] || 1; const g = audioCtx.createGain(); g.gain.value = level; s.connect(g); g.connect(masterGain); s.start(time); return; }
|
||
(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;
|
||
playInstrument(m.sound, time, lvl === 2 ? 1.0 : lvl === 3 ? 0.25 : 0.6);
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// --- 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 / . 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.poly) s += "~";
|
||
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
|
||
return s;
|
||
}
|
||
function laneStrToCfg(tok) {
|
||
let poly = false, disabled = false;
|
||
while (/[~!]$/.test(tok)) { if (tok.endsWith("!")) disabled = true; else poly = true; tok = tok.slice(0, -1); }
|
||
const ci = tok.indexOf(":"); if (ci < 0) return null;
|
||
let sound = tok.slice(0, ci), rest = tok.slice(ci + 1), pattern = null;
|
||
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; }
|
||
const bpb = parseGroups(groupsStr).beatsPerBar;
|
||
// pattern levels: X=accent(2), x/1=normal(1), . / anything else = mute(0); no pattern → default (first of each beat accented)
|
||
const 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 };
|
||
}
|
||
|
||
// --- 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) })) };
|
||
}
|