// ============ WEB AUDIO ENGINE ============
// Real audio pipeline per deck:
//   source → highShelf → midPeaking → lowShelf → filter(LP/HP) → channelGain → masterGain → destination
// Plus analyser node for VU meters.

const AudioEngine = (() => {
  let ctx = null;
  let masterGain = null;
  let masterAnalyser = null;
  const decks = {}; // side -> deck nodes

  function ensureCtx() {
    if (ctx) return ctx;
    ctx = new (window.AudioContext || window.webkitAudioContext)();
    masterGain = ctx.createGain();
    masterGain.gain.value = 0.8;
    masterAnalyser = ctx.createAnalyser();
    masterAnalyser.fftSize = 256;
    masterGain.connect(masterAnalyser);
    masterAnalyser.connect(ctx.destination);
    return ctx;
  }

  function resumeCtx() {
    ensureCtx();
    if (ctx.state === 'suspended') ctx.resume();
  }

  function initDeck(side) {
    ensureCtx();
    if (decks[side]) return decks[side];

    // EQ: Low shelf, Mid peaking, High shelf
    const low = ctx.createBiquadFilter();
    low.type = 'lowshelf';
    low.frequency.value = 200;
    low.gain.value = 0; // -26 to +26 dB

    const mid = ctx.createBiquadFilter();
    mid.type = 'peaking';
    mid.frequency.value = 1000;
    mid.Q.value = 0.7;
    mid.gain.value = 0;

    const high = ctx.createBiquadFilter();
    high.type = 'highshelf';
    high.frequency.value = 4000;
    high.gain.value = 0;

    // Filter (LP when < 0.5, HP when > 0.5)
    const filter = ctx.createBiquadFilter();
    filter.type = 'allpass';
    filter.frequency.value = 20000;

    // FX chain (send/dry mix via per-fx gain)
    const dry = ctx.createGain();
    dry.gain.value = 1;

    // Reverb (convolver with synthetic IR)
    const reverb = ctx.createConvolver();
    reverb.buffer = makeImpulseResponse(ctx, 2, 2.5);
    const reverbGain = ctx.createGain();
    reverbGain.gain.value = 0;

    // Delay
    const delay = ctx.createDelay(2);
    delay.delayTime.value = 0.375;
    const delayFeedback = ctx.createGain();
    delayFeedback.gain.value = 0.4;
    const delayGain = ctx.createGain();
    delayGain.gain.value = 0;
    delay.connect(delayFeedback);
    delayFeedback.connect(delay);

    // Echo (shorter tap)
    const echo = ctx.createDelay(1);
    echo.delayTime.value = 0.18;
    const echoGain = ctx.createGain();
    echoGain.gain.value = 0;

    // Flanger
    const flangerDelay = ctx.createDelay();
    flangerDelay.delayTime.value = 0.005;
    const flangerLFO = ctx.createOscillator();
    flangerLFO.frequency.value = 0.3;
    const flangerLFOGain = ctx.createGain();
    flangerLFOGain.gain.value = 0.002;
    flangerLFO.connect(flangerLFOGain);
    flangerLFOGain.connect(flangerDelay.delayTime);
    try { flangerLFO.start(); } catch {}
    const flangerGain = ctx.createGain();
    flangerGain.gain.value = 0;

    // Channel gain (volume)
    const channel = ctx.createGain();
    channel.gain.value = 0.75;

    // Crossfader gain
    const xfader = ctx.createGain();
    xfader.gain.value = 1;

    // Analyser for VU
    const analyser = ctx.createAnalyser();
    analyser.fftSize = 256;

    // Wire up the graph
    // EQ chain: low -> mid -> high -> filter -> (dry + fx sends) -> channel -> xfader -> master
    low.connect(mid);
    mid.connect(high);
    high.connect(filter);

    filter.connect(dry);
    filter.connect(reverb);
    reverb.connect(reverbGain);
    filter.connect(delay);
    delay.connect(delayGain);
    filter.connect(echo);
    echo.connect(echoGain);
    filter.connect(flangerDelay);
    flangerDelay.connect(flangerGain);

    dry.connect(channel);
    reverbGain.connect(channel);
    delayGain.connect(channel);
    echoGain.connect(channel);
    flangerGain.connect(channel);

    channel.connect(xfader);
    xfader.connect(analyser);
    analyser.connect(masterGain);

    const deck = {
      low, mid, high, filter,
      dry, reverb, reverbGain, delay, delayGain, delayFeedback,
      echo, echoGain, flangerDelay, flangerGain,
      channel, xfader, analyser,
      buffer: null,
      sourceNode: null,
      startTime: 0,
      pauseOffset: 0,
      playing: false,
      playbackRate: 1,
      input: low, // entry point
    };
    decks[side] = deck;
    return deck;
  }

  function makeImpulseResponse(ctx, duration, decay) {
    const rate = ctx.sampleRate;
    const len = rate * duration;
    const impulse = ctx.createBuffer(2, len, rate);
    for (let ch = 0; ch < 2; ch++) {
      const data = impulse.getChannelData(ch);
      for (let i = 0; i < len; i++) {
        data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
      }
    }
    return impulse;
  }

  async function loadBuffer(url) {
    ensureCtx();
    const resp = await fetch(url);
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    const arr = await resp.arrayBuffer();
    return await ctx.decodeAudioData(arr);
  }

  async function loadFile(file) {
    ensureCtx();
    const arr = await file.arrayBuffer();
    return await ctx.decodeAudioData(arr);
  }

  function setTrack(side, buffer) {
    const d = initDeck(side);
    // Stop any existing source
    if (d.sourceNode) {
      try { d.sourceNode.stop(); } catch {}
      try { d.sourceNode.disconnect(); } catch {}
      d.sourceNode = null;
    }
    d.buffer = buffer;
    d.pauseOffset = 0;
    d.playing = false;
  }

  function play(side) {
    resumeCtx();
    const d = decks[side];
    if (!d || !d.buffer) return false;
    if (d.playing) return true;
    const src = ctx.createBufferSource();
    src.buffer = d.buffer;
    src.playbackRate.value = d.playbackRate;
    if (src.detune && d.keylock) {
      src.detune.value = -1200 * Math.log2(d.playbackRate);
    }
    src.connect(d.input);
    src.onended = () => {
      if (d.sourceNode === src) {
        // natural end
        d.playing = false;
        d.pauseOffset = d.buffer?.duration || 0;
      }
    };
    src.start(0, Math.min(d.pauseOffset, d.buffer.duration - 0.01));
    d.sourceNode = src;
    // Track audio-time origin: audioPos at wall-time ctxStartWall, advancing at rate
    d.ctxStartWall = ctx.currentTime;
    d.audioStartPos = d.pauseOffset;
    d.playing = true;
    return true;
  }

  function pause(side) {
    const d = decks[side];
    if (!d || !d.playing || !d.sourceNode) return;
    const elapsed = (ctx.currentTime - d.ctxStartWall) * (d.playbackRate || 1);
    d.pauseOffset = Math.max(0, d.audioStartPos + elapsed);
    try { d.sourceNode.stop(); } catch {}
    try { d.sourceNode.disconnect(); } catch {}
    d.sourceNode = null;
    d.playing = false;
  }

  function seek(side, seconds) {
    const d = decks[side];
    if (!d || !d.buffer) return;
    const wasPlaying = d.playing;
    if (wasPlaying) {
      try { d.sourceNode?.stop(); } catch {}
      try { d.sourceNode?.disconnect(); } catch {}
      d.sourceNode = null;
      d.playing = false;
    }
    d.pauseOffset = Math.max(0, Math.min(seconds, d.buffer.duration));
    if (wasPlaying) play(side);
  }

  function getCurrentTime(side) {
    const d = decks[side];
    if (!d || !d.buffer) return 0;
    if (d.playing) {
      const elapsed = (ctx.currentTime - d.ctxStartWall) * (d.playbackRate || 1);
      return Math.max(0, d.audioStartPos + elapsed);
    }
    return d.pauseOffset;
  }

  function getDuration(side) {
    const d = decks[side];
    return d?.buffer?.duration || 0;
  }

  function isPlaying(side) {
    return !!decks[side]?.playing;
  }

  // EQ in [0..1]:
  //   0.5 = unity (0dB)
  //   0.0 = full kill (≈ -40dB, effectively muted band)
  //   1.0 = +6dB max boost
  // Real DJ behavior: asymmetric — cuts go much deeper than boosts.
  function setEQ(side, { low, mid, high }) {
    const d = initDeck(side);
    const now = ctx.currentTime;
    const smooth = 0.015; // 15ms smoothing — prevents zipper noise
    const toDb = (v) => {
      // Asymmetric: below 0.5 kills hard, above 0.5 boosts gently
      if (v < 0.5) {
        // 0.5 -> 0dB, 0 -> -40dB (virtual kill)
        return -40 * (1 - v / 0.5);
      }
      // 0.5 -> 0dB, 1 -> +6dB
      return 12 * (v - 0.5);
    };
    if (low != null) {
      d.low.gain.setTargetAtTime(toDb(low), now, smooth);
    }
    if (mid != null) {
      d.mid.gain.setTargetAtTime(toDb(mid), now, smooth);
    }
    if (high != null) {
      d.high.gain.setTargetAtTime(toDb(high), now, smooth);
    }
  }

  // Filter in [0..1]: 0 = full LP (cut highs), 0.5 = bypass, 1 = full HP (cut lows)
  // Implementation note: to avoid audible clicks from switching filter.type mid-stream,
  // we keep a static LP and HP filter chain and just move the frequencies. A single
  // biquad filter is used — when near center we push the cutoff out of audible range.
  function setFilter(side, v) {
    const d = initDeck(side);
    const now = ctx.currentTime;
    const smooth = 0.02; // 20ms smoothing
    const targetType = v < 0.49 ? 'lowpass' : v > 0.51 ? 'highpass' : 'allpass';
    // Only swap type if actually changing — avoids clicks
    if (d.filter.type !== targetType) {
      // Before swapping, set a safe frequency first
      d.filter.type = targetType;
    }
    let targetFreq;
    if (targetType === 'allpass') {
      targetFreq = 20000;
    } else if (targetType === 'lowpass') {
      const t = v / 0.5; // 0..1 (1 = bypass)
      targetFreq = 100 * Math.pow(200, t); // 100Hz..20kHz log
    } else {
      const t = (v - 0.5) / 0.5; // 0..1
      targetFreq = 20 * Math.pow(1000, t); // 20Hz..20kHz log
    }
    d.filter.frequency.setTargetAtTime(targetFreq, now, smooth);
  }

  function setChannelVolume(side, v) {
    const d = initDeck(side);
    const now = ctx.currentTime;
    d.channel.gain.setTargetAtTime(Math.max(0, Math.min(1, v)), now, 0.01);
  }

  function setCrossfader(v) {
    ensureCtx();
    const now = ctx.currentTime;
    // Equal-power crossfade
    const a = Math.cos((v * Math.PI) / 2);
    const b = Math.sin((v * Math.PI) / 2);
    if (decks.a) decks.a.xfader.gain.setTargetAtTime(a, now, 0.008);
    if (decks.b) decks.b.xfader.gain.setTargetAtTime(b, now, 0.008);
  }

  function setMasterVolume(v) {
    ensureCtx();
    const now = ctx.currentTime;
    if (masterGain) masterGain.gain.setTargetAtTime(Math.max(0, Math.min(1, v)), now, 0.01);
  }

  function setPlaybackRate(side, rate, keylock = false) {
    const d = decks[side];
    if (!d) return;
    rate = Math.max(0.5, Math.min(2, rate || 1));
    // Rebase audio-position so getCurrentTime stays continuous across rate changes
    if (d.playing && d.ctxStartWall != null) {
      const elapsed = (ctx.currentTime - d.ctxStartWall) * (d.playbackRate || 1);
      d.audioStartPos = d.audioStartPos + elapsed;
      d.ctxStartWall = ctx.currentTime;
    }
    d.playbackRate = rate;
    d.keylock = keylock;
    if (d.sourceNode) {
      try {
        const now = ctx.currentTime;
        d.sourceNode.playbackRate.cancelScheduledValues(now);
        d.sourceNode.playbackRate.setValueAtTime(rate, now);
        // Keylock: counter the pitch shift induced by rate change.
        if (d.sourceNode.detune) {
          d.sourceNode.detune.cancelScheduledValues(now);
          d.sourceNode.detune.setValueAtTime(keylock ? (-1200 * Math.log2(rate)) : 0, now);
        }
      } catch {}
    }
  }

  function setFX(side, fxName, { on, amount }) {
    const d = initDeck(side);
    const wet = on ? Math.max(0, Math.min(1, amount)) : 0;
    if (fxName === 'reverb') d.reverbGain.gain.value = wet * 0.8;
    else if (fxName === 'delay') {
      d.delayGain.gain.value = wet * 0.7;
      d.delayFeedback.gain.value = on ? (0.35 + amount * 0.35) : 0.4;
    }
    else if (fxName === 'echo') d.echoGain.gain.value = wet * 0.6;
    else if (fxName === 'flanger') d.flangerGain.gain.value = wet * 0.6;
    else if (fxName === 'crush') {
      // Fake bit-crush via HP filter resonance — simple
      d.flangerGain.gain.value = wet * 0.3;
    }
  }

  function getLevel(side) {
    const d = decks[side];
    if (!d || !d.analyser) return 0;
    const buf = new Uint8Array(d.analyser.fftSize);
    d.analyser.getByteTimeDomainData(buf);
    let sum = 0;
    for (let i = 0; i < buf.length; i++) {
      const v = (buf[i] - 128) / 128;
      sum += v * v;
    }
    return Math.sqrt(sum / buf.length); // RMS 0..1
  }

  // --- Beat / BPM detection (simple energy-based) ---
  // ============================================================================
  // BEAT DETECTION (techno/house-tuned, 4-on-the-floor friendly)
  // ============================================================================
  //
  // Pipeline:
  //   1. Downmix → low-bandpass (40-200Hz) for kick energy → 100Hz envelope
  //   2. Spectral flux (positive frame-to-frame energy increase) → onset signal
  //   3. Adaptive threshold + peak-pick → discrete onset times
  //   4. Coarse autocorrelation (60-200 BPM, 1 BPM steps) → candidate tempos
  //   5. Half/double-time disambiguation: prefer 110-140 BPM range
  //   6. Refine: dense search ±2 BPM at 0.05 BPM resolution
  //   7. Phase comb filter: slide Dirac comb across full envelope, pick
  //      offset that maximizes total onset hits → globally-consistent firstBeat
  //
  // Designed for: 4-on-the-floor techno/house at 115-135 BPM. Robust against
  // bass-heavy drops (uses spectral flux not RMS), intro silence (comb filter
  // looks at full track), and half/double-time confusion (BPM range bias).

  async function detectBPM(buffer) {
    const sr = buffer.sampleRate;
    const ch0 = buffer.getChannelData(0);
    const ch1 = buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : ch0;

    // ----- 1. Bandpass-filtered kick envelope (40-200Hz) -----
    // Use OfflineAudioContext to do real biquad filtering — much cleaner than
    // hand-rolling a filter loop. Output: 100Hz mono envelope of bass energy.
    const ENV_RATE = 100; // 10ms per frame
    const oac = new OfflineAudioContext(1, buffer.length, sr);
    const src = oac.createBufferSource();
    src.buffer = buffer;
    const lp = oac.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.value = 200;
    lp.Q.value = 0.7;
    const hp = oac.createBiquadFilter();
    hp.type = 'highpass';
    hp.frequency.value = 40;
    hp.Q.value = 0.7;
    src.connect(hp).connect(lp).connect(oac.destination);
    src.start();
    const filtered = await oac.startRendering();
    const fch = filtered.getChannelData(0);

    const stride = Math.floor(sr / ENV_RATE);
    const N = Math.floor(fch.length / stride);
    const env = new Float32Array(N);
    for (let i = 0; i < N; i++) {
      const start = i * stride;
      const end = Math.min(fch.length, start + stride);
      let s = 0;
      for (let j = start; j < end; j++) s += fch[j] * fch[j];
      env[i] = Math.sqrt(s / Math.max(1, end - start));
    }

    // ----- 2. Spectral flux (positive frame-to-frame increase) -----
    // For low-band envelope, this is essentially: max(0, env[i]-env[i-1]).
    // Plus a small smoothing pass to suppress jitter.
    const flux = new Float32Array(N);
    for (let i = 1; i < N; i++) {
      flux[i] = Math.max(0, env[i] - env[i - 1]);
    }
    // 30ms smoothing
    const smooth = new Float32Array(N);
    const winSm = 3;
    for (let i = 0; i < N; i++) {
      let s = 0, c = 0;
      for (let k = -winSm; k <= winSm; k++) {
        if (i + k >= 0 && i + k < N) { s += flux[i + k]; c++; }
      }
      smooth[i] = s / c;
    }

    // Normalize: divide by adaptive baseline (1.5s moving avg). This makes
    // the autocorrelation invariant to overall track loudness changes.
    const adaptWin = 150; // 1.5s
    const onset = new Float32Array(N);
    for (let i = 0; i < N; i++) {
      let s = 0, c = 0;
      for (let k = -adaptWin; k <= adaptWin; k += 4) {
        if (i + k >= 0 && i + k < N) { s += smooth[i + k]; c++; }
      }
      const avg = s / Math.max(1, c) + 1e-6;
      onset[i] = Math.max(0, smooth[i] / avg - 1);
    }

    // ----- 3. Coarse tempo lookup (60-200 BPM, 1 BPM steps) -----
    function autocorr(bpm) {
      const lag = Math.round((60 / bpm) * ENV_RATE);
      let score = 0;
      const maxI = Math.min(onset.length - lag, ENV_RATE * 60); // first 60s
      for (let i = 0; i < maxI; i++) score += onset[i] * onset[i + lag];
      return score;
    }
    const candidates = [];
    for (let bpm = 60; bpm <= 200; bpm += 1) {
      candidates.push({ bpm, score: autocorr(bpm) });
    }
    candidates.sort((a, b) => b.score - a.score);

    // ----- 4. Half/double-time disambiguation -----
    // The top candidate is often half or double the perceived tempo.
    // Strategy: from top 5 candidates, pick the one in the "preferred" range
    // (110-140 BPM for techno/house) whose score is at least 70% of the top.
    // If none match, fall back to top candidate. Also test 2x and 0.5x of each.
    const TOP = candidates.slice(0, 8);
    const expanded = [];
    TOP.forEach(c => {
      expanded.push({ bpm: c.bpm, score: c.score, src: '1x' });
      if (c.bpm * 2 <= 200) expanded.push({ bpm: c.bpm * 2, score: autocorr(c.bpm * 2), src: '2x' });
      if (c.bpm / 2 >= 60) expanded.push({ bpm: c.bpm / 2, score: autocorr(c.bpm / 2), src: '0.5x' });
    });
    const preferred = expanded
      .filter(c => c.bpm >= 110 && c.bpm <= 140)
      .sort((a, b) => b.score - a.score);
    let chosenBpm;
    if (preferred.length && preferred[0].score >= TOP[0].score * 0.6) {
      chosenBpm = preferred[0].bpm;
    } else {
      chosenBpm = TOP[0].bpm;
    }

    // ----- 5. Refine: dense search ±2 BPM at 0.05 resolution -----
    let bestRefined = chosenBpm, bestScore = autocorr(chosenBpm);
    for (let dB = -2; dB <= 2; dB += 0.05) {
      const b = chosenBpm + dB;
      if (b < 60 || b > 200) continue;
      const s = autocorr(b);
      if (s > bestScore) { bestScore = s; bestRefined = b; }
    }
    const finalBpm = Math.round(bestRefined * 100) / 100;

    // ----- 6. Phase comb filter: find global beat-grid origin -----
    // Slide a Dirac comb (impulse train at finalBpm) across [0, beatSec) at
    // 5ms resolution. The offset that produces the highest sum of onset
    // values at impulse positions is our true firstBeat.
    const beatSec = 60 / finalBpm;
    const beatFrames = beatSec * ENV_RATE;
    const phaseSteps = Math.round(beatFrames / 0.5); // 5ms resolution
    let bestPhase = 0, bestPhaseScore = -1;
    const totalBeats = Math.floor(onset.length / beatFrames);
    for (let p = 0; p < phaseSteps; p++) {
      const offset = (p / phaseSteps) * beatFrames;
      let s = 0;
      for (let k = 0; k < totalBeats; k++) {
        const idxF = offset + k * beatFrames;
        const i0 = Math.floor(idxF);
        const f = idxF - i0;
        // bilinear sample (sub-frame interpolation)
        if (i0 >= 0 && i0 + 1 < onset.length) {
          s += onset[i0] * (1 - f) + onset[i0 + 1] * f;
        }
      }
      if (s > bestPhaseScore) { bestPhaseScore = s; bestPhase = offset; }
    }
    const firstBeatSec = bestPhase / ENV_RATE;
    // Modulo into [0, beatSec) — that's all phaseAlign needs.
    const firstBeatOffset = ((firstBeatSec % beatSec) + beatSec) % beatSec;

    return { bpm: finalBpm, firstBeat: firstBeatOffset };
  }

  return {
    ensureCtx, resumeCtx, initDeck,
    loadBuffer, loadFile, setTrack,
    play, pause, seek,
    getCurrentTime, getDuration, isPlaying,
    setEQ, setFilter, setChannelVolume, setCrossfader, setMasterVolume,
    setPlaybackRate, setFX,
    getLevel, detectBPM,
    // ---- Beat-phase helpers (used by SYNC for phase-alignment) ----
    /**
     * Returns the slave's current position within the bar, expressed as
     * a fractional beat index. e.g. 0.0 = exactly on a beat, 0.5 = halfway
     * to the next beat. Used to compute the seek delta needed to align
     * two decks' downbeats.
     */
    getBeatPhase(side, firstBeat, bpm) {
      const d = decks[side];
      if (!d || !d.buffer || !bpm) return 0;
      const beatSec = 60 / bpm;
      const ct = this.getCurrentTime(side);
      const sinceFirst = ct - (firstBeat || 0);
      // mod into [0, beatSec); take fractional part
      const within = ((sinceFirst % beatSec) + beatSec) % beatSec;
      return within / beatSec; // 0..1
    },
    /**
     * Returns the deck's current position WITHIN A BAR as a fractional beat
     * index in [0, 4). e.g. 0.0 = downbeat, 1.5 = halfway between beats 2 and 3.
     * Used both for the 4-LED beat clock above each fader AND for bar-level
     * sync alignment so the downbeats themselves line up (not just any beat).
     */
    getBarPhase(side, firstBeat, bpm) {
      const d = decks[side];
      if (!d || !d.buffer || !bpm) return 0;
      const beatSec = 60 / bpm;
      const barSec = 4 * beatSec;
      const ct = this.getCurrentTime(side);
      const sinceFirst = ct - (firstBeat || 0);
      const within = ((sinceFirst % barSec) + barSec) % barSec;
      return within / beatSec; // 0..4
    },
    /**
     * Seek the slave so its NEXT downbeat lands at the same wall-clock
     * moment as the master's next downbeat — i.e. the kicks line up.
     * Caller passes the master's current beat phase (0..1) and both
     * decks' beatgrid metadata.
     *   masterPhase: master.getBeatPhase()
     *   slaveSide:   'a' | 'b'
     *   slaveFirstBeat, slaveBpm: from track metadata
     */
    phaseAlign(slaveSide, masterPhase, slaveFirstBeat, slaveBpm, baseRate, masterBarPhase, slaveBarPhase) {
      const d = decks[slaveSide];
      if (!d || !d.buffer || !slaveBpm) return;
      const beatSec = 60 / slaveBpm;
      // ---- Bar-level alignment when caller provides bar phases ----
      // Aligning by intra-beat phase only (0..1) lines up "some beat" of the
      // slave with "some beat" of the master. The kicks audibly thud together
      // but the downbeats may be off by 1, 2, or 3 beats — making the
      // 4-LED clock LEDs blink out of phase, and the bar structure of the mix
      // feel disjointed. With both decks' bar phases (0..4) we can snap the
      // *downbeat* directly, the way DJ software does.
      if (typeof masterBarPhase === 'number' && typeof slaveBarPhase === 'number') {
        let delta = masterBarPhase - slaveBarPhase; // beats
        if (delta > 2) delta -= 4;
        if (delta < -2) delta += 4;
        const deltaSec = delta * beatSec;
        const ct = this.getCurrentTime(slaveSide);
        this.seek(slaveSide, Math.max(0, ct + deltaSec));
      } else {
        const slavePhase = this.getBeatPhase(slaveSide, slaveFirstBeat, slaveBpm);
        let delta = masterPhase - slavePhase;
        if (delta > 0.5) delta -= 1;
        if (delta < -0.5) delta += 1;
        const deltaSec = delta * beatSec;
        const ct = this.getCurrentTime(slaveSide);
        this.seek(slaveSide, Math.max(0, ct + deltaSec));
      }
      // Reset rate cleanly to baseRate so nudgePhase has a known start point.
      const rate = baseRate || d.playbackRate || 1;
      if (d.sourceNode) {
        try {
          const now = ctx.currentTime;
          const safeRate = Math.max(0.5, Math.min(2, rate || 1));
          d.sourceNode.playbackRate.cancelScheduledValues(now);
          d.sourceNode.playbackRate.setValueAtTime(safeRate, now);
          if (d.sourceNode.detune) {
            d.sourceNode.detune.cancelScheduledValues(now);
            d.sourceNode.detune.setValueAtTime(d.keylock ? (-1200 * Math.log2(safeRate)) : 0, now);
          }
        } catch {}
      }
      d.playbackRate = Math.max(0.5, Math.min(2, rate || 1));
      d.alignUntil = 0;
    },
    /**
     * Returns the signed phase error in milliseconds between master and slave.
     * Positive: slave is BEHIND master (needs to speed up briefly).
     * Negative: slave is AHEAD of master.
     * Used for the visual ms drift readout.
     */
    getPhaseErrorMs(slaveSide, masterPhase, slaveFirstBeat, slaveBpm) {
      if (!slaveBpm) return 0;
      const slavePhase = this.getBeatPhase(slaveSide, slaveFirstBeat, slaveBpm);
      let delta = masterPhase - slavePhase;
      if (delta > 0.5) delta -= 1;
      if (delta < -0.5) delta += 1;
      const beatSec = 60 / slaveBpm;
      return delta * beatSec * 1000;
    },
    /**
     * Beat-quantized seek: jump forward/back N beats from the current playhead,
     * landing on the nearest grid line in the destination region.
     * Phase-preserving: the relative beat offset within a bar is maintained.
     */
    beatJump(side, beats, firstBeat, bpm) {
      const d = decks[side];
      if (!d || !d.buffer || !bpm) return;
      const beatSec = 60 / bpm;
      const ct = this.getCurrentTime(side);
      // Snap current pos to nearest beat in source frame, THEN add jump
      const sinceFirst = ct - (firstBeat || 0);
      const beatIdx = Math.round(sinceFirst / beatSec);
      const targetSec = (firstBeat || 0) + (beatIdx + beats) * beatSec;
      this.seek(side, Math.max(0, Math.min(targetSec, d.buffer.duration - 0.01)));
    },
    /**
     * Set delay time on a deck so it equals N beats of the master tempo.
     * `division`: 1/4 = quarter note, 1/8 = eighth, 1/16, 1/2, 1 = whole bar.
     * Updates feedback to a tasteful default and turns the delay on.
     */
    setDelayBeatDivision(side, division, bpm) {
      const d = decks[side];
      if (!d || !bpm) return;
      const beatSec = 60 / bpm;
      // division=0.25 → quarter note = 1 beat. division=1 → 4 beats (whole bar).
      const targetSec = beatSec * (division * 4);
      const now = ctx.currentTime;
      d.delay.delayTime.cancelScheduledValues(now);
      d.delay.delayTime.setTargetAtTime(Math.max(0.01, Math.min(2, targetSec)), now, 0.02);
    },
    /**
     * Tiny continuous correction (≤0.05% rate trim) for one beat to fix
     * accumulated drift without a hard seek. Used by the 1Hz drift watcher.
     */
    nudgePhase(slaveSide, masterPhase, slaveFirstBeat, slaveBpm, baseRate) {
      const d = decks[slaveSide];
      if (!d || !d.buffer || !slaveBpm || !d.playing || !d.sourceNode) return;
      const slavePhase = this.getBeatPhase(slaveSide, slaveFirstBeat, slaveBpm);
      let delta = masterPhase - slavePhase;
      if (delta > 0.5) delta -= 1;
      if (delta < -0.5) delta += 1;
      const beatSec = 60 / slaveBpm;
      const deltaSec = delta * beatSec;
      const now = ctx.currentTime;
      const rate = Math.max(0.5, Math.min(2, baseRate || d.playbackRate || 1));
      const rebaseRate = (nextRate) => {
        if (d.ctxStartWall != null) {
          const elapsed = (now - d.ctxStartWall) * (d.playbackRate || 1);
          d.audioStartPos = d.audioStartPos + elapsed;
          d.ctxStartWall = now;
        }
        d.playbackRate = nextRate;
      };
      const setCorrectionRate = (nextRate) => {
        const safeRate = Math.max(0.5, Math.min(2, nextRate || 1));
        rebaseRate(safeRate);
        d.sourceNode.playbackRate.cancelScheduledValues(now);
        d.sourceNode.playbackRate.setValueAtTime(safeRate, now);
        if (d.sourceNode.detune) {
          d.sourceNode.detune.cancelScheduledValues(now);
          d.sourceNode.detune.setValueAtTime(d.keylock ? (-1200 * Math.log2(safeRate)) : 0, now);
        }
      };
      // If the grid says we are wildly off, do NOT keep hard-seeking the deck.
      // That was the main source of chaotic "clashing" on bad analysis. Hold
      // the simple BPM tempo lock and let the UI invite a manual beat-grid fix.
      if (Math.abs(deltaSec) > 0.08) {
        try { setCorrectionRate(rate); } catch {}
        return;
      }
      // Dead-band: < 3ms is below the threshold of human beat-mismatch
      // perception. Coast back to baseRate smoothly (no abrupt reset).
      if (Math.abs(deltaSec) < 0.003) {
        try { setCorrectionRate(rate); } catch {}
        return;
      }
      // Proportional rate trim with smooth ramp. Target: absorb modest error
      // across several beats. Capped at ±0.5% so tempo lock stays musical.
      const trim = Math.max(-0.005, Math.min(0.005, deltaSec / (beatSec * 3)));
      const targetRate = Math.max(0.5, Math.min(2, rate * (1 + trim)));
      try { setCorrectionRate(targetRate); } catch {}
    },
    get ctx() { return ctx; },
  };
})();

window.AudioEngine = AudioEngine;
