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