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