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