/* ============================================================
   AudioPanel — live audio streaming, monitoring, and WAV recording
   Expects: { protocolCtrl, connState, addLog }
   ============================================================ */
(function () {
  const { useState, useEffect, useLayoutEffect, useRef, useCallback } = React;

  /* ---- WAV encoder ---- */
  function encodeWav(leftData, rightData, sampleRate) {
    const numChannels = rightData ? 2 : 1;
    const numSamples = leftData.length;
    const dataSize = numSamples * numChannels * 2;
    const buf = new ArrayBuffer(44 + dataSize);
    const v = new DataView(buf);
    const s = (o, str) => { for (let i = 0; i < str.length; i++) v.setUint8(o + i, str.charCodeAt(i)); };
    s(0, "RIFF"); v.setUint32(4, 36 + dataSize, true);
    s(8, "WAVE"); s(12, "fmt ");
    v.setUint32(16, 16, true); v.setUint16(20, 1, true); // PCM
    v.setUint16(22, numChannels, true); v.setUint32(24, sampleRate, true);
    v.setUint32(28, sampleRate * numChannels * 2, true);
    v.setUint16(32, numChannels * 2, true); v.setUint16(34, 16, true);
    s(36, "data"); v.setUint32(40, dataSize, true);
    let off = 44;
    for (let i = 0; i < numSamples; i++) {
      v.setInt16(off, Math.round(Math.max(-1, Math.min(1, leftData[i])) * 32767), true); off += 2;
      if (rightData) {
        v.setInt16(off, Math.round(Math.max(-1, Math.min(1, rightData[i] || 0)) * 32767), true); off += 2;
      }
    }
    return buf;
  }

  /* ---- VU Meter ---- */
  // .vu-track is exactly 100px tall; the bar uses position:absolute/bottom:0,
  // so height in px == height in % — avoids flex percentage-height resolution quirks.
  function VuMeter({ level, active }) {
    const dB = level > 0.0001 ? 20 * Math.log10(level) : -60;
    const pct = Math.max(0, Math.min(100, (dB + 60) / 60 * 100));
    const color = pct > 85 ? "var(--color-spark)" : pct > 65 ? "#EDBE00" : "var(--color-pulse)";
    const barPx = Math.round(pct); // 0‥100 px inside the 100px track
    return (
      <div className="vu-wrap">
        <div className="vu-track">
          <div className="vu-bar" style={{ height: barPx + "px", background: active ? color : "var(--border-strong)" }} />
        </div>
        <div className="vu-label">{active && pct > 0 ? dB.toFixed(0) + " dB" : "—"}</div>
      </div>
    );
  }

  /* ---- Single channel strip ---- */
  function AudioChannel({ stream, vuLevel, streaming, isMonitored, recordRoute, onMonitor, onRecordRoute }) {
    return (
      <div className={"audio-channel" + (streaming ? " audio-channel--active" : "")}>
        <div className="audio-ch-header">
          <div className="audio-ch-name">{stream.label}</div>
          <div className="audio-ch-desc">{stream.desc}</div>
        </div>

        <VuMeter level={vuLevel} active={streaming} />

        {/* Monitor */}
        <div className="audio-section">
          <label
            className="cf-radio audio-monitor-lbl"
            onClick={(e) => { e.preventDefault(); onMonitor(); }}
          >
            <input type="radio" name="monitor"
              checked={isMonitored} onChange={() => {}} readOnly />
            <span className="cf-radio-dot" />
            Monitor
          </label>
        </div>

        {/* Record routing */}
        <div className="audio-section">
          <div className="audio-section-label">Record</div>
          <div className="cf-radio-group">
            {["left", "right"].map(side => (
              <label key={side} className="cf-radio"
                onClick={(e) => { e.preventDefault(); onRecordRoute(side); }}>
                <input type="radio" name={"rec-" + stream.id}
                  checked={recordRoute === side} onChange={() => {}} readOnly />
                <span className="cf-radio-dot" />
                {side.charAt(0).toUpperCase() + side.slice(1)}
              </label>
            ))}
          </div>
        </div>
      </div>
    );
  }

  /* ---- Cooley-Tukey FFT (in-place, power-of-2) ---- */
  function doFFT(re, im) {
    const n = re.length;
    for (let i = 1, j = 0; i < n; i++) {
      let bit = n >> 1;
      for (; j & bit; bit >>= 1) j ^= bit;
      j ^= bit;
      if (i < j) {
        let t = re[i]; re[i] = re[j]; re[j] = t;
        t = im[i]; im[i] = im[j]; im[j] = t;
      }
    }
    for (let len = 2; len <= n; len <<= 1) {
      const ang = -2 * Math.PI / len;
      const wRe = Math.cos(ang), wIm = Math.sin(ang);
      for (let i = 0; i < n; i += len) {
        let curRe = 1, curIm = 0;
        for (let j = 0; j < (len >> 1); j++) {
          const h = len >> 1;
          const uRe = re[i+j], uIm = im[i+j];
          const vRe = re[i+j+h]*curRe - im[i+j+h]*curIm;
          const vIm = re[i+j+h]*curIm + im[i+j+h]*curRe;
          re[i+j] = uRe+vRe; im[i+j] = uIm+vIm;
          re[i+j+h] = uRe-vRe; im[i+j+h] = uIm-vIm;
          const nr = curRe*wRe - curIm*wIm;
          curIm = curRe*wIm + curIm*wRe; curRe = nr;
        }
      }
    }
  }

  /* ---- Colormap: near-black → indigo → flow → spark → white ---- */
  const SPECTRO_CMAP = (() => {
    const stops = [
      [0,    [10,  8,   26 ]],
      [0.25, [80,  26,  198]],
      [0.5,  [48,  209, 255]],
      [0.75, [249, 128, 61 ]],
      [1,    [255, 248, 180]],
    ];
    const map = new Uint8Array(256 * 3);
    for (let i = 0; i < 256; i++) {
      const t = i / 255;
      let si = 1;
      while (si < stops.length - 1 && t > stops[si][0]) si++;
      const lo = stops[si-1], hi = stops[si];
      const f = (t - lo[0]) / (hi[0] - lo[0]);
      map[i*3  ] = Math.round(lo[1][0] + f*(hi[1][0]-lo[1][0]));
      map[i*3+1] = Math.round(lo[1][1] + f*(hi[1][1]-lo[1][1]));
      map[i*3+2] = Math.round(lo[1][2] + f*(hi[1][2]-lo[1][2]));
    }
    return map;
  })();

  /* =================== SpectrogramPanel =================== */
  function SpectrogramPanel({ sampleRate, hopSize, callbackRef, monitorLabel }) {
    const [fftSize, setFftSize]     = useState(512);
    const [histSec, setHistSec]     = useState(5);
    const [dbFloor, setDbFloor]     = useState(-80);
    const [maxFreqHz, setMaxFreqHz] = useState(null); // null = sampleRate/2

    const canvasRef      = useRef(null);
    const imgDataRef     = useRef(null);
    const binMapRef      = useRef(new Int32Array(0));
    const sampleRingRef  = useRef(new Float32Array(8192));
    const ringPosRef     = useRef(0);
    const dirtyRef       = useRef(false);
    const settingsRef    = useRef({});

    const nyquist        = sampleRate / 2;
    const effectiveMax   = Math.min(maxFreqHz || nyquist, nyquist);
    const displayBins    = Math.max(1, Math.round((fftSize / 2) * (effectiveMax / nyquist)));
    const colCount       = Math.max(10, Math.round(histSec * sampleRate / hopSize));

    // Log-frequency axis helpers (used in render for labels and in useLayoutEffect for binMap)
    const fMin    = Math.max(20, sampleRate / fftSize);
    const logMin  = Math.log(fMin);
    const logMax  = Math.log(effectiveMax);

    // Freq axis tick marks — filtered to the visible log range
    const FREQ_MARKS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
    const freqLabels = FREQ_MARKS
      .filter(f => f >= fMin && f <= effectiveMax)
      .map(freq => ({
        freq,
        yPct: (1 - (Math.log(freq) - logMin) / (logMax - logMin)) * 100,
      }));

    // Keep settings ref current so the audio callback always reads fresh values
    settingsRef.current  = { fftSize, dbFloor, displayBins, colCount };

    // Recreate ImageData + log→bin map whenever canvas dimensions change
    useLayoutEffect(() => {
      const imgData = new ImageData(colCount, displayBins);
      for (let i = 3; i < imgData.data.length; i += 4) imgData.data[i] = 255; // alpha
      imgDataRef.current = imgData;
      dirtyRef.current = true;

      // For each canvas row y, precompute the FFT bin on a log frequency scale
      const _fMin   = Math.max(20, sampleRate / fftSize);
      const _logMin = Math.log(_fMin);
      const _logMax = Math.log(effectiveMax);
      const maxBin  = fftSize / 2 - 1;
      const bmap    = new Int32Array(displayBins);
      for (let y = 0; y < displayBins; y++) {
        const t    = (displayBins - 1 - y) / Math.max(1, displayBins - 1); // 0=bottom, 1=top
        const freq = Math.exp(_logMin + t * (_logMax - _logMin));
        bmap[y]    = Math.min(maxBin, Math.max(0, Math.round(freq * fftSize / sampleRate)));
      }
      binMapRef.current = bmap;
    }, [colCount, displayBins]); // eslint-disable-line

    // Register sample callback — allocated once, reads live settings via ref
    useEffect(() => {
      const re  = new Float32Array(4096);
      const im  = new Float32Array(4096);
      const win = new Float32Array(4096);
      let lastFftSize = 0;

      const handleSamples = (f32) => {
        const { fftSize, dbFloor, displayBins, colCount } = settingsRef.current;

        // Rebuild hann window only when fftSize changes
        if (fftSize !== lastFftSize) {
          for (let i = 0; i < fftSize; i++) win[i] = 0.5*(1-Math.cos(2*Math.PI*i/(fftSize-1)));
          lastFftSize = fftSize;
        }

        // Accumulate samples in ring buffer
        const ring = sampleRingRef.current;
        for (let i = 0; i < f32.length; i++) {
          ring[ringPosRef.current & 8191] = f32[i];
          ringPosRef.current++;
        }
        if (ringPosRef.current < fftSize) return;

        // Fill FFT input from latest fftSize samples
        const start = ringPosRef.current - fftSize;
        for (let i = 0; i < fftSize; i++) {
          re[i] = ring[(start + i) & 8191] * win[i];
          im[i] = 0;
        }
        doFFT(re.subarray(0, fftSize), im.subarray(0, fftSize));

        // Scroll ImageData left by one column, write new spectrum at the right edge
        const imgData = imgDataRef.current;
        if (!imgData) return;
        const pixels  = imgData.data;
        const rowBytes = colCount * 4;

        for (let y = 0; y < displayBins; y++) {
          const rowOff = y * rowBytes;
          pixels.copyWithin(rowOff, rowOff + 4, rowOff + rowBytes);
        }

        const bmap = binMapRef.current;
        for (let y = 0; y < displayBins; y++) {
          const bin = bmap[y];
          const mag = Math.sqrt(re[bin]*re[bin] + im[bin]*im[bin]) / (fftSize * 0.5);
          const dB  = mag > 1e-9 ? 20*Math.log10(mag) : -160;
          const t   = Math.max(0, Math.min(1, (dB - dbFloor) / -dbFloor));
          const ci  = Math.round(t * 255) * 3;
          const px  = (y * colCount + colCount - 1) * 4;
          pixels[px]   = SPECTRO_CMAP[ci];
          pixels[px+1] = SPECTRO_CMAP[ci+1];
          pixels[px+2] = SPECTRO_CMAP[ci+2];
        }

        dirtyRef.current = true;
      };

      callbackRef.current = handleSamples;
      return () => { if (callbackRef.current === handleSamples) callbackRef.current = null; };
    }, [callbackRef]); // eslint-disable-line

    // RAF render loop
    useEffect(() => {
      let rafId;
      const tick = () => {
        if (dirtyRef.current && canvasRef.current && imgDataRef.current) {
          dirtyRef.current = false;
          canvasRef.current.getContext('2d').putImageData(imgDataRef.current, 0, 0);
        }
        rafId = requestAnimationFrame(tick);
      };
      rafId = requestAnimationFrame(tick);
      return () => cancelAnimationFrame(rafId);
    }, []); // eslint-disable-line

    const fmtHz = f => f >= 1000
      ? (f % 1000 === 0 ? `${f/1000} kHz` : `${(f/1000).toFixed(1)} kHz`)
      : `${Math.round(f)} Hz`;

    return (
      <div className="spectro-wrap">
        <div className="spectro-header">
          <span className="spectro-title">Spectrogram{monitorLabel ? ` — ${monitorLabel}` : ''}</span>
          <span className="spectro-freq-range">{fmtHz(fMin)} – {fmtHz(effectiveMax)} · log</span>
        </div>

        {!monitorLabel ? (
          <div className="spectro-empty">Select a channel to monitor to see the spectrogram</div>
        ) : (
          <div className="spectro-canvas-wrap">
            <canvas ref={canvasRef} className="spectro-canvas" width={colCount} height={displayBins} />
            <div className="spectro-freq-axis">
              {freqLabels.map(({ freq, yPct }) => (
                <div key={freq} className="spectro-freq-label" style={{ top: `${yPct}%` }}>
                  <span>{fmtHz(freq)}</span>
                </div>
              ))}
            </div>
          </div>
        )}

        <div className="spectro-controls">
          <div className="spectro-ctrl-group">
            <span className="spectro-ctrl-label">FFT size</span>
            <select className="spectro-select" value={fftSize}
              onChange={e => { setFftSize(+e.target.value); ringPosRef.current = 0; }}>
              {[256, 512, 1024, 2048].map(n => <option key={n} value={n}>{n}</option>)}
            </select>
          </div>
          <div className="spectro-ctrl-group">
            <span className="spectro-ctrl-label">History</span>
            <select className="spectro-select" value={histSec}
              onChange={e => setHistSec(+e.target.value)}>
              {[2, 5, 10].map(n => <option key={n} value={n}>{n}s</option>)}
            </select>
          </div>
          <div className="spectro-ctrl-group spectro-ctrl-group--wide">
            <span className="spectro-ctrl-label">Floor: {dbFloor} dB</span>
            <input type="range" min={-120} max={-20} step={5} value={dbFloor}
              className="spectro-slider" onChange={e => setDbFloor(+e.target.value)} />
          </div>
          <div className="spectro-ctrl-group">
            <span className="spectro-ctrl-label">Max freq</span>
            <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
              <input type="number" min={500} max={nyquist} step={500}
                value={maxFreqHz || nyquist} className="spectro-freq-input"
                onChange={e => setMaxFreqHz(Math.min(nyquist, Math.max(500, +e.target.value || nyquist)))} />
              <span className="spectro-ctrl-label" style={{ textTransform: 'none', letterSpacing: 0 }}>Hz</span>
            </div>
          </div>
        </div>
      </div>
    );
  }

  /* =================== InputSourceCard =================== */
  const INPUT_SOURCES = [
    { n: 0, label: "Onboard mics",  desc: "Tympan built-in" },
    { n: 1, label: "Mic jack",      desc: "3.5 mm external" },
    { n: 2, label: "Line-in",       desc: "Analog header"   },
    { n: 3, label: "PDM mic",       desc: "Digital header"  },
  ];

  function InputSourceCard({ protocolCtrl, connState, addLog }) {
    const [activeInput,   setActiveInput]   = useState(null);
    const [activeChannel, setActiveChannel] = useState(null);
    const [pending, setPending]             = useState(false);
    const [error, setError]                 = useState(null);
    const isOpen = connState === "open";

    // Query current source + channel on connect; reset on disconnect
    useEffect(() => {
      if (isOpen) {
        protocolCtrl.inputStatus().then(n => setActiveInput(n)).catch(() => {});
        protocolCtrl.channelStatus().then(ch => setActiveChannel(ch)).catch(() => {});
      } else {
        setActiveInput(null);
        setActiveChannel(null);
        setError(null);
      }
    }, [isOpen]); // eslint-disable-line

    const handleSet = async (n) => {
      if (pending || activeInput === n) return;
      setPending(true);
      setError(null);
      try {
        await protocolCtrl.setInput(n);
        setActiveInput(n);
        addLog("Input switched to: " + INPUT_SOURCES[n].label);
      } catch (e) {
        setError(e.message);
      } finally {
        setPending(false);
      }
    };

    const handleSetChannel = async (ch) => {
      if (pending || activeChannel === ch) return;
      setPending(true);
      setError(null);
      try {
        await protocolCtrl.setChannel(ch);
        setActiveChannel(ch);
        addLog("Channel set to: " + ch);
      } catch (e) {
        setError(e.message);
      } finally {
        setPending(false);
      }
    };

    return (
      <div className="audio-ctrl-card">
        <div className="audio-ctrl-title">Input Source</div>
        {error && (
          <div className="audio-ctrl-error">
            {error}
            <button onClick={() => setError(null)} className="audio-ctrl-dismiss">✕</button>
          </div>
        )}
        <div className="audio-input-grid">
          {INPUT_SOURCES.map(src => (
            <button key={src.n}
              className={"audio-input-btn" + (activeInput === src.n ? " audio-input-btn--active" : "")}
              disabled={!isOpen || pending}
              onClick={() => handleSet(src.n)}
            >
              <span className="audio-input-dot" />
              <div>
                <div className="audio-input-label">{src.label}</div>
                <div className="audio-input-desc">{src.desc}</div>
              </div>
            </button>
          ))}
        </div>
        <div className="audio-input-channel-row">
          <span className="audio-ctrl-hint" style={{ margin: 0 }}>Input channel</span>
          <div className="audio-channel-toggle">
            {["L", "R"].map(ch => (
              <button key={ch}
                className={"audio-channel-btn" + (activeChannel === ch ? " audio-channel-btn--active" : "")}
                disabled={!isOpen || pending}
                onClick={() => handleSetChannel(ch)}
              >
                {ch}
              </button>
            ))}
          </div>
        </div>
        <div className="audio-ctrl-hint">Switches live — no reboot required. Input gain reapplied automatically.</div>
      </div>
    );
  }

  /* =================== WavPlaybackCard =================== */
  function WavPlaybackCard({ protocolCtrl, connState, addLog }) {
    const [wavFiles, setWavFiles]       = useState([]);
    const [loadingFiles, setLoadingFiles] = useState(false);
    const [selectedWav, setSelectedWav] = useState("");
    const [playMode, setPlayMode]       = useState("once");
    const [playState, setPlayState]     = useState("idle"); // idle | playing | stopping
    const [error, setError]             = useState(null);
    const isOpen = connState === "open";

    const fetchWavFiles = useCallback(async () => {
      if (!isOpen) return;
      setLoadingFiles(true);
      setError(null);
      try {
        const files = await protocolCtrl.list();
        const wavs  = files.filter(f => f.name.toLowerCase().endsWith(".wav"));
        setWavFiles(wavs);
        setSelectedWav(prev => {
          if (prev && wavs.some(f => f.name === prev)) return prev;
          return wavs.length > 0 ? wavs[0].name : "";
        });
      } catch (e) {
        setError("Could not load file list: " + e.message);
      } finally {
        setLoadingFiles(false);
      }
    }, [isOpen, protocolCtrl]);

    useEffect(() => {
      if (isOpen) fetchWavFiles();
      else { setWavFiles([]); setSelectedWav(""); setPlayState("idle"); setError(null); }
    }, [isOpen]); // eslint-disable-line

    // Reset to idle when firmware signals playback finished
    useEffect(() => {
      protocolCtrl.onPlayDone = () => {
        setPlayState("idle");
        addLog("WAV playback complete.");
      };
      return () => { protocolCtrl.onPlayDone = null; };
    }, [protocolCtrl, addLog]);

    const handlePlay = async () => {
      if (!selectedWav || playState !== "idle") return;
      setError(null);
      setPlayState("playing");
      try {
        if (playMode === "once") {
          await protocolCtrl.playOnce(selectedWav);
          addLog("Playing (once): " + selectedWav);
        } else {
          await protocolCtrl.playLoop(selectedWav);
          addLog("Playing (loop): " + selectedWav);
        }
      } catch (e) {
        setError(e.message);
        setPlayState("idle");
      }
    };

    const handleStop = async () => {
      if (playState === "idle") return;
      setPlayState("stopping");
      try {
        await protocolCtrl.playStop();
        addLog("WAV playback stopped.");
      } catch (e) {
        setError(e.message);
      } finally {
        setPlayState("idle");
      }
    };

    const isPlaying = playState === "playing";

    return (
      <div className="audio-ctrl-card">
        <div className="audio-ctrl-title-row">
          <span className="audio-ctrl-title">WAV Playback</span>
          <button className="fm-btn fm-btn-ghost fm-btn-sm" onClick={fetchWavFiles}
            disabled={!isOpen || loadingFiles || isPlaying}>
            {loadingFiles ? "Loading…" : "Refresh"}
          </button>
        </div>
        {error && (
          <div className="audio-ctrl-error">
            {error}
            <button onClick={() => setError(null)} className="audio-ctrl-dismiss">✕</button>
          </div>
        )}

        {!loadingFiles && wavFiles.length === 0 ? (
          <div className="audio-ctrl-hint">No .wav files on SD card. Upload one via the File Manager tab.</div>
        ) : (
          <>
            <select className="audio-wav-select" value={selectedWav}
              onChange={e => setSelectedWav(e.target.value)}
              disabled={!isOpen || isPlaying || loadingFiles}>
              {wavFiles.map(f => <option key={f.name} value={f.name}>{f.name}</option>)}
            </select>

            <div className="cf-radio-group">
              {[{ v: "once", label: "Play once" }, { v: "loop", label: "Loop" }].map(opt => (
                <label key={opt.v} className="cf-radio"
                  onClick={e => { e.preventDefault(); if (!isPlaying) setPlayMode(opt.v); }}>
                  <input type="radio" checked={playMode === opt.v} onChange={() => {}} readOnly />
                  <span className="cf-radio-dot" />
                  {opt.label}
                </label>
              ))}
            </div>

            <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
              <button className="fm-btn fm-btn-primary" onClick={handlePlay}
                disabled={!isOpen || !selectedWav || playState !== "idle"}>
                ▶ Play
              </button>
              <button className="fm-btn fm-btn-danger" onClick={handleStop}
                disabled={!isOpen || playState === "idle"}>
                {playState === "stopping" ? "Stopping…" : "■ Stop"}
              </button>
              {isPlaying && (
                <span className="audio-ctrl-playing">
                  <span className="rec-dot" style={{ background: "var(--color-pulse)", marginRight: 0 }} />
                  Playing{playMode === "loop" ? " · looping" : ""}
                </span>
              )}
            </div>
          </>
        )}
        <div className="audio-ctrl-hint">
          WAV must match device sample rate. Playback replaces mic input;
          {playMode === "once" ? " mic restores when file ends." : " send Stop to restore mic."}
        </div>
      </div>
    );
  }

  /* =================== AudioPanel =================== */
  function AudioPanel({ protocolCtrl, connState, addLog }) {
    const [streamInfo, setStreamInfo] = useState(null);
    const [discovering, setDiscovering] = useState(false);
    const [streaming, setStreaming] = useState(false);
    const [starting, setStarting] = useState(false);
    const [stopping, setStopping] = useState(false);
    const [recording, setRecording] = useState(false);
    const [monitorId, setMonitorId] = useState(null);
    const [recordRoutes, setRecordRoutes] = useState({});  // { streamId: 'left'|'right' }
    const [vuLevels, setVuLevels] = useState([0, 0]);
    const [error, setError] = useState(null);

    // Refs for use in audio callbacks (avoid stale closure)
    const audioCtxRef = useRef(null);
    const playTimeRef = useRef(0);
    const vuRawRef = useRef([0, 0]);
    const recordBufsRef = useRef({ left: [], right: [] });
    const sampleRateRef = useRef(16000);
    const monitorIdRef = useRef(null);
    const recordRoutesRef = useRef({});
    const recordingRef = useRef(false);
    const streamingRef = useRef(false);
    const spectroCallbackRef = useRef(null);

    const isOpen = connState === "open";

    // Keep refs in sync with state
    useEffect(() => { monitorIdRef.current = monitorId; }, [monitorId]);
    useEffect(() => { recordRoutesRef.current = recordRoutes; }, [recordRoutes]);
    useEffect(() => { recordingRef.current = recording; }, [recording]);
    useEffect(() => { streamingRef.current = streaming; }, [streaming]);

    // VU decay animation — runs only while streaming
    useEffect(() => {
      if (!streaming) {
        vuRawRef.current = [0, 0];
        setVuLevels([0, 0]);
        return;
      }
      let rafId;
      let lastTick = 0;
      const tick = (now) => {
        if (now - lastTick >= 66) {  // ~15 fps — enough for smooth visual, lighter on React renders
          for (let i = 0; i < vuRawRef.current.length; i++) vuRawRef.current[i] *= 0.88;
          setVuLevels([...vuRawRef.current]);
          lastTick = now;
        }
        rafId = requestAnimationFrame(tick);
      };
      rafId = requestAnimationFrame(tick);
      return () => cancelAnimationFrame(rafId);
    }, [streaming]);

    // Wire up audio packet handler
    useEffect(() => {
      const serial = protocolCtrl?._serial;
      if (!serial) return;

      serial.onAudioPacket = (streamId, int16Array) => {
        // int16 → float32
        const f32 = new Float32Array(int16Array.length);
        for (let i = 0; i < int16Array.length; i++) f32[i] = int16Array[i] / 32767;

        // VU: peak RMS
        let sumSq = 0;
        for (let i = 0; i < f32.length; i++) sumSq += f32[i] * f32[i];
        const rms = Math.sqrt(sumSq / f32.length);
        if (streamId < vuRawRef.current.length && rms > vuRawRef.current[streamId]) {
          vuRawRef.current[streamId] = rms;
        }

        // Monitor: schedule for playback
        if (monitorIdRef.current === streamId && audioCtxRef.current) {
          schedulePacket(f32);
        }

        // Spectrogram: feed monitored channel
        if (spectroCallbackRef.current && streamId === monitorIdRef.current) {
          spectroCallbackRef.current(f32);
        }

        // Record: route to left/right buffer
        if (recordingRef.current) {
          const route = recordRoutesRef.current[streamId];
          if (route === "left")  recordBufsRef.current.left.push(f32.slice());
          else if (route === "right") recordBufsRef.current.right.push(f32.slice());
        }
      };

      return () => { serial.onAudioPacket = null; };
    }, [protocolCtrl]); // eslint-disable-line

    // Stop streaming automatically on disconnect
    useEffect(() => {
      if (connState !== "open" && streamingRef.current) {
        protocolCtrl?._serial?.disableAudioMode();
        streamingRef.current = false;
        setStreaming(false);
        setRecording(false);
        audioCtxRef.current?.close().catch(() => {});
        audioCtxRef.current = null;
      }
    }, [connState]); // eslint-disable-line

    /* Schedule a Float32 packet for Web Audio playback with jitter-free timing. */
    const schedulePacket = useCallback((f32Samples) => {
      const ctx = audioCtxRef.current;
      if (!ctx || ctx.state === "closed") return;
      const sr = sampleRateRef.current;
      const now = ctx.currentTime;
      // Keep a ~50 ms lookahead; reset if we fall behind
      if (playTimeRef.current < now + 0.01) playTimeRef.current = now + 0.05;
      const audioBuf = ctx.createBuffer(1, f32Samples.length, sr);
      audioBuf.getChannelData(0).set(f32Samples);
      const src = ctx.createBufferSource();
      src.buffer = audioBuf;
      src.connect(ctx.destination);
      src.start(playTimeRef.current);
      playTimeRef.current += f32Samples.length / sr;
    }, []);

    /* ---- Actions ---- */

    const discoverStreams = async () => {
      setDiscovering(true);
      setError(null);
      try {
        const info = await protocolCtrl.streamInfo();
        sampleRateRef.current = info.sampleRate;
        vuRawRef.current = new Array(info.streams.length).fill(0);
        setStreamInfo(info);
        addLog("Streams detected: " + info.streams.map(s => s.label).join(", ")
          + " @ " + info.sampleRate.toLocaleString() + " Hz");
      } catch (err) {
        setError(err.message || "Detection failed");
      }
      setDiscovering(false);
    };

    const startStreaming = async () => {
      if (!streamInfo) return;
      setStarting(true);
      setError(null);
      try {
        // Create AudioContext early — must be inside the click handler for autoplay policy
        const ctx = new AudioContext({ sampleRate: sampleRateRef.current });
        if (ctx.state === "suspended") await ctx.resume();
        audioCtxRef.current = ctx;
        playTimeRef.current = 0;

        protocolCtrl._serial.enableAudioMode();
        await protocolCtrl.streamStart(3);

        setStreaming(true);
        addLog("Audio streaming started");
      } catch (err) {
        protocolCtrl._serial.disableAudioMode();
        audioCtxRef.current?.close().catch(() => {});
        audioCtxRef.current = null;
        setError(err.message || "Failed to start streaming");
      }
      setStarting(false);
    };

    const stopStreaming = async () => {
      setStopping(true);
      // Finish any in-progress recording before stopping the stream
      if (recordingRef.current) saveRecording();
      try {
        await protocolCtrl.streamStop();
      } catch (_) { /* ignore — device may have already stopped */ }
      protocolCtrl._serial.disableAudioMode();
      audioCtxRef.current?.close().catch(() => {});
      audioCtxRef.current = null;
      setStreaming(false);
      addLog("Audio streaming stopped");
      setStopping(false);
    };

    const startRecording = () => {
      recordBufsRef.current = { left: [], right: [] };
      setRecording(true);
      addLog("Recording started");
    };

    const saveRecording = () => {
      setRecording(false);
      const bufs = recordBufsRef.current;
      const sr = sampleRateRef.current;

      const merge = (arrays) => {
        if (!arrays.length) return null;
        const total = arrays.reduce((n, a) => n + a.length, 0);
        const out = new Float32Array(total);
        let off = 0;
        for (const a of arrays) { out.set(a, off); off += a.length; }
        return out;
      };

      const leftData  = merge(bufs.left);
      const rightData = merge(bufs.right);

      if (!leftData && !rightData) {
        addLog("Nothing recorded — assign at least one stream to Left or Right before recording");
        return;
      }

      // For stereo WAV both channels must be the same length — pad shorter one
      let L = leftData  || new Float32Array((rightData || new Float32Array(0)).length);
      let R = rightData;
      if (R && L.length !== R.length) {
        const maxLen = Math.max(L.length, R.length);
        const padL = new Float32Array(maxLen); padL.set(L); L = padL;
        const padR = new Float32Array(maxLen); padR.set(R); R = padR;
      }

      const wavBuf = encodeWav(L, R, sr);
      const blob = new Blob([wavBuf], { type: "audio/wav" });
      const url  = URL.createObjectURL(blob);
      const a    = document.createElement("a");
      a.href = url;
      a.download = "recording-" + new Date().toISOString().slice(0, 19).replace(/:/g, "-") + ".wav";
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);

      const durationSec = (L.length / sr).toFixed(1);
      addLog("Recording saved: " + (R ? "stereo" : "mono") + ", " + durationSec + " s");
    };

    const handleMonitor = (streamId) => {
      const next = monitorIdRef.current === streamId ? null : streamId;
      setMonitorId(next);
      monitorIdRef.current = next;
      if (next !== null && audioCtxRef.current?.state === "suspended") {
        audioCtxRef.current.resume();
      }
    };

    const handleRecordRoute = (streamId, side) => {
      setRecordRoutes(prev => {
        const next = { ...prev };
        if (next[streamId] === side) delete next[streamId]; // click same side = deselect
        else next[streamId] = side;
        recordRoutesRef.current = next;
        return next;
      });
    };

    /* ---- Render ---- */

    if (!isOpen) {
      return (
        <div className="fm-empty">
          <Icon name="audio" style={{ width: 44, height: 44, color: "var(--color-indigo)", opacity: 0.4 }} />
          <div className="fm-empty-title">No device connected</div>
          <div className="fm-empty-sub">Connect a device to stream and record audio.</div>
        </div>
      );
    }

    const routeChips = streamInfo
      ? streamInfo.streams.filter(s => recordRoutes[s.id]).map(s => (
          <span key={s.id} className="audio-route-chip">
            {s.label} → {(recordRoutes[s.id] || "").toUpperCase()}
          </span>
        ))
      : [];

    return (
      <div className="audio-wrap">

        {/* Header card */}
        <div className="audio-header-card">
          <div className="audio-header-left">
            <span className="audio-header-title">
              <Icon name="audio" /> Audio Streams
            </span>
            {streamInfo && (
              <span className="audio-meta">
                {streamInfo.sampleRate.toLocaleString()} Hz
                &nbsp;&middot;&nbsp;{streamInfo.hopSize} samples / packet
                &nbsp;&middot;&nbsp;{streamInfo.streams.length} stream{streamInfo.streams.length !== 1 ? "s" : ""}
              </span>
            )}
          </div>
          <div className="audio-header-actions">
            {!streamInfo && (
              <button className="fm-btn fm-btn-primary" disabled={discovering} onClick={discoverStreams}>
                {discovering ? "Detecting…" : "Detect Streams"}
              </button>
            )}
            {streamInfo && !streaming && (
              <>
                <button className="fm-btn fm-btn-primary" disabled={starting} onClick={startStreaming}>
                  {starting ? "Starting…" : "Start Streaming"}
                </button>
                <button className="fm-btn fm-btn-ghost fm-btn-sm" disabled={discovering || starting} onClick={discoverStreams}>
                  Re-detect
                </button>
              </>
            )}
            {streamInfo && streaming && (
              <button className="fm-btn fm-btn-danger" disabled={stopping} onClick={stopStreaming}>
                {stopping ? "Stopping…" : "Stop Streaming"}
              </button>
            )}
          </div>
        </div>

        {error && (
          <div className="fm-error-banner">
            <Icon name="alert" /> {error}
          </div>
        )}

        {/* Input source + WAV playback — always visible when connected */}
        <div className="audio-controls-row">
          <InputSourceCard protocolCtrl={protocolCtrl} connState={connState} addLog={addLog} />
          <WavPlaybackCard protocolCtrl={protocolCtrl} connState={connState} addLog={addLog} />
        </div>

        {!streamInfo && !discovering && !error && (
          <div className="fm-empty">
            <Icon name="audio" style={{ width: 44, height: 44, color: "var(--color-indigo)", opacity: 0.4 }} />
            <div className="fm-empty-title">No streams detected yet</div>
            <div className="fm-empty-sub">Click "Detect Streams" to query the device for available audio streams.</div>
          </div>
        )}

        {streamInfo && (
          <>
            {/* Channel strips */}
            <div className="audio-channels">
              {streamInfo.streams.map(stream => (
                <AudioChannel
                  key={stream.id}
                  stream={stream}
                  vuLevel={vuLevels[stream.id] || 0}
                  streaming={streaming}
                  isMonitored={monitorId === stream.id}
                  recordRoute={recordRoutes[stream.id] || null}
                  onMonitor={() => handleMonitor(stream.id)}
                  onRecordRoute={side => handleRecordRoute(stream.id, side)}
                />
              ))}
            </div>

            {/* Record bar — only shown while streaming */}
            {streaming && (
              <div className="audio-record-bar">
                <div className="audio-record-routing">
                  {routeChips.length > 0
                    ? routeChips
                    : <span className="audio-route-hint">Set Left/Right routing above before recording</span>
                  }
                </div>
                {!recording ? (
                  <button className="fm-btn fm-btn-primary" onClick={startRecording}>
                    <span className="rec-dot" /> Record
                  </button>
                ) : (
                  <button className="fm-btn fm-btn-danger" onClick={saveRecording}>
                    &#9632; Stop &amp; Save
                  </button>
                )}
              </div>
            )}

            {/* Spectrogram — shown while streaming */}
            {streaming && (
              <SpectrogramPanel
                sampleRate={sampleRateRef.current}
                hopSize={streamInfo.hopSize}
                callbackRef={spectroCallbackRef}
                monitorLabel={monitorId !== null
                  ? (streamInfo.streams.find(s => s.id === monitorId)?.label || null)
                  : null}
              />
            )}
          </>
        )}

      </div>
    );
  }

  window.AudioPanel = AudioPanel;
})();
