/* ===========================================================================
 *  OnMic engine — pure-JS analyzer (the only analysis engine; no Python)
 *  Runs inside the panel (Node enabled in CEP) OR standalone in Node for tests.
 *  No Python. Optional AI VAD is injected via opts.vad (see vad.js).
 * ======================================================================== */
(function (root) {
  "use strict";
  var fs = (typeof require === "function") ? require("fs") : null;

  // Surfaced read-glitch errors (disk hiccups during windowed streaming).
  // Cleared at the start of each analyze()/speakingMatrix() build; readErrors()
  // lets the run report a problem instead of silently under-detecting speech.
  var _readErrors = [];
  function noteReadError(msg){ msg=String(msg); for(var _i=0;_i<_readErrors.length;_i++){ if(_readErrors[_i]===msg) return; } _readErrors.push(msg); }

  // ---- helpers -----------------------------------------------------------
  function normName(s){ return String(s).replace(/^\s+|\s+$/g,"").toLowerCase(); }
  function clamp(x,a,b){ return x<a?a:(x>b?b:x); }
  function percentile(arr, p){
    if(!arr.length) return -120;
    var a = Float64Array.from(arr); a.sort();
    var idx = clamp(Math.floor((p/100)*(a.length-1)),0,a.length-1);
    return a[idx];
  }

  // ---- WAV decode (PCM 16/24/32-int + 32-float, multichannel) ------------
  // Reads ONE channel for a source-time window [inSec, inSec+durSec].
  function readFullSync(fd, buf, off, len, pos){
    var got = 0;
    while(got < len){
      var n = fs.readSync(fd, buf, off + got, len - got, pos + got);
      if(n <= 0) break;
      got += n;
    }
    return got;
  }
  function readWavMeta(fd){
    var head = Buffer.alloc(12);
    if(readFullSync(fd, head, 0, 12, 0) < 12) throw new Error("WAV header is incomplete");
    var riff = head.toString("ascii",0,4);
    if((riff!=="RIFF" && riff!=="RF64") || head.toString("ascii",8,12)!=="WAVE")
      throw new Error("Not a standard WAV/RF64 file");
    var pos = 12, fmt=null, dataOffset=-1, dataLen=0;
    var ch = Buffer.alloc(8);
    var ds64DataLen = null;
    var safety = 0;
    while(safety++ < 10000){
      var n = readFullSync(fd, ch, 0, 8, pos);
      if(n < 8) break;
      var id = ch.toString("ascii",0,4);
      var size = ch.readUInt32LE(4);
      if(id==="ds64" && size>=24){
        var db = Buffer.alloc(size);
        if(readFullSync(fd, db, 0, size, pos+8) < size) throw new Error("WAV ds64 chunk is incomplete");
        if (typeof db.readBigUInt64LE === "function") {
          ds64DataLen = Number(db.readBigUInt64LE(8));
        }
      } else if(id==="fmt "){
        var fb = Buffer.alloc(size);
        if(readFullSync(fd, fb, 0, size, pos+8) < size) throw new Error("WAV fmt chunk is incomplete");
        var audioFormat = fb.readUInt16LE(0);
        var channels = fb.readUInt16LE(2);
        var sampleRate = fb.readUInt32LE(4);
        var bits = fb.readUInt16LE(14);
        if(audioFormat===0xFFFE && size>=40){ audioFormat = fb.readUInt16LE(24); } // EXTENSIBLE subformat
        fmt = { audioFormat:audioFormat, channels:channels, sampleRate:sampleRate, bits:bits };
      } else if(id==="data"){
        dataOffset = pos+8; dataLen = size;
        if (riff==="RF64" && size===0xFFFFFFFF) {
          if (ds64DataLen != null && ds64DataLen > 0) dataLen = ds64DataLen;
          else {
            try { dataLen = Math.max(0, fs.fstatSync(fd).size - dataOffset); }
            catch(eFs){ throw new Error("RF64 WAV has no readable data size"); }
          }
        }
        break; // data is what we need; stop scanning
      }
      pos += 8 + size + (size & 1); // chunks are word-aligned
    }
    if(!fmt) throw new Error("WAV has no fmt chunk");
    if(dataOffset<0) throw new Error("WAV has no data chunk");
    return { fmt:fmt, dataOffset:dataOffset, dataLen:dataLen };
  }

  function decodeSampleAt(buf, off, fmt){
    var b = fmt.bits;
    if(fmt.audioFormat===3){ // IEEE float
      if(b===32) return buf.readFloatLE(off);
      if(b===64) return buf.readDoubleLE(off);
    }
    if(b===16) return buf.readInt16LE(off)/32768;
    if(b===24){
      var v = buf[off] | (buf[off+1]<<8) | (buf[off+2]<<16);
      if(v & 0x800000) v |= ~0xFFFFFF; // sign extend
      return v/8388608;
    }
    if(b===32) return buf.readInt32LE(off)/2147483648;
    if(b===8) return (buf[off]-128)/128;
    return 0;
  }

  // Returns { samples:Float32Array, sr }
  function readChannelWindow(path, channel, inSec, durSec, opts){
    opts = opts || {};
    var fd = fs.openSync(path,"r");
    try{
      var meta = readWavMeta(fd);
      var fmt = meta.fmt;
      var bytesPer = fmt.bits/8;
      var frame = bytesPer*fmt.channels;
      var totalFrames = Math.floor(meta.dataLen/frame);
      var startFrame = Math.max(0, Math.floor((inSec||0)*fmt.sampleRate));
      var nFrames = (durSec==null) ? (totalFrames-startFrame)
                                   : Math.floor(durSec*fmt.sampleRate);
      nFrames = Math.max(0, Math.min(nFrames, totalFrames-startFrame));
      var out = new Float32Array(nFrames);
      if(nFrames===0) return { samples:out, sr:fmt.sampleRate };
      if(channel>=fmt.channels) throw new Error("channel "+channel+" >= "+fmt.channels+" in "+path);
      // read in blocks to limit memory
      var BLK = 1<<16; // frames per block
      var blkBuf = Buffer.alloc(BLK*frame);
      var done = 0;
      while(done < nFrames){
        var want = Math.min(BLK, nFrames-done);
        var byteOff = meta.dataOffset + (startFrame+done)*frame;
        var gotBytes = readFullSync(fd, blkBuf, 0, want*frame, byteOff);
        var gotFrames = Math.floor(gotBytes/frame);
        if(gotFrames < want){
          if (opts.allowShort) {
            if (gotFrames > 0) {
              for(var ps=0;ps<gotFrames;ps++){
                out[done+ps] = decodeSampleAt(blkBuf, ps*frame + channel*bytesPer, fmt);
              }
            }
            return { samples:out.slice(0, done + Math.max(0, gotFrames)), sr:fmt.sampleRate, shortRead:true };
          }
          throw new Error("Short audio read before the end of " + path + " (got " + gotFrames + "/" + want + " frames)");
        }
        for(var i=0;i<gotFrames;i++){
          out[done+i] = decodeSampleAt(blkBuf, i*frame + channel*bytesPer, fmt);
        }
        done += gotFrames;
      }
      return { samples:out, sr:fmt.sampleRate };
    } finally { fs.closeSync(fd); }
  }

  function wavChannelCount(path){
    var fd = fs.openSync(path,"r");
    try{ return readWavMeta(fd).fmt.channels; } finally{ fs.closeSync(fd); }
  }

  // ---- RMS envelope (dBFS per hop) ---------------------------------------
  function rmsDbEnvelope(samples, sr, hopS){
    var hop = Math.max(1, Math.round(sr*hopS));
    var n = samples.length ? Math.ceil(samples.length/hop) : 0;
    var out = new Float32Array(n);
    for(var i=0;i<n;i++){
      var s=i*hop, e=Math.min(samples.length,s+hop), sum=0, c=0;
      for(var j=s;j<e;j++){ var v=samples[j]; sum+=v*v; c++; }
      var rms = c? Math.sqrt(sum/c) : 0;
      out[i] = rms>1e-9 ? 20*Math.log10(rms) : -120;
    }
    return out;
  }
  function applyGainDb(env, gainDb){
    gainDb = +gainDb || 0;
    if(!gainDb || !env) return env;
    for(var i=0;i<env.length;i++){
      if(env[i] > -119) env[i] += gainDb;
    }
    return env;
  }

  // Streaming, memory-bounded equivalent of
  //   rmsDbEnvelope(readChannelWindow(path,ch,srcIn,durS).samples, sr, hopS)
  // but it never decodes the whole clip (~691MB) into one Float32Array. Instead
  // it reads the clip in `winSec` windows and computes the per-hop RMS dBFS
  // incrementally, freeing each window before the next. The RMS-dB envelope is
  // PER-HOP with NO cross-window recurrent state, so the only care needed is to
  // keep a hop from straddling a window seam: we carry a remainder of <hop
  // samples across each boundary so every emitted hop sees the exact same
  // contiguous samples it would in the whole-clip version → bit-identical output.
  function computeRmsDbEnvelopeWindowed(path, channel, srcIn, durS, hopS, winSec){
    winSec = winSec || 60;
    // probe sample rate with a zero-length read
    var sr = readChannelWindow(path, channel, srcIn||0, 0).sr;
    var hop = Math.max(1, Math.round(sr*hopS));
    var out = [];
    function emitFull(buf, off){
      var sum=0; for(var j=0;j<hop;j++){ var v=buf[off+j]; sum+=v*v; }
      var rms=Math.sqrt(sum/hop);
      out.push(rms>1e-9 ? 20*Math.log10(rms) : -120);
    }
    var rem = new Float32Array(0);   // leftover (<hop) samples across the seam
    var read = 0;                    // frames consumed from the clip so far
    var totalDur = (durS==null) ? null : durS;
    var inFrames = Math.floor(winSec*sr); if(inFrames<1) inFrames=1;
    for(;;){
      // remaining duration to request (so we never over-read past durS)
      var reqDur = winSec;
      if (totalDur!=null){ var left = totalDur - read/sr; if(left<=0){ reqDur=0; } else if(left<winSec) reqDur=left; }
      var rd = readChannelWindow(path, channel, (srcIn||0) + read/sr, reqDur, { allowShort: true });
      var buf = rd.samples, len = buf.length;
      // Distinguish a genuine EOF from a transient short/zero read (disk hiccup).
      // We can only tell them apart when the clip duration is KNOWN (totalDur !=
      // null) and we asked for real data (reqDur > 0): a short read whose window
      // ends BEFORE totalDur is a glitch, not the end. Retry a couple of times;
      // if it persists, record an error (so the run reports a problem) instead
      // of silently truncating a real speaker's tail. When totalDur is null we
      // read to file end, so a short/empty read is the legitimate EOF as before.
      // Treat a read that ends within ~2% (or 3s) of the clip's declared end as a
      // normal EOF, not a glitch: a media file is commonly a hair shorter than its
      // declared duration (rounding / trims), which was false-flagging healthy
      // clips. Only a short read well BEFORE the end is a real disk hiccup.
      // v1.05: FIXED 3s tolerance (was max(3s, 2%) — the 2% term reached ~2.4 min
      // on a 2-hour clip, so a real mid-clip truncation that far from the end was
      // treated as normal EOF and never surfaced as a read error). Only gates the
      // error report; the emitted envelope and the isLast break are unaffected.
      var _eofTol = 3.0;
      var reachedExpected = (totalDur!=null) && ((read+len)/sr >= totalDur - _eofTol);
      var prematureShort = (totalDur!=null) && (reqDur>0) &&
                           (len < inFrames || len === 0) && !reachedExpected;
      if (prematureShort){
        var retries=0, rd2;
        while (retries < 2 && (len < inFrames || len === 0)){
          retries++;
          rd2 = readChannelWindow(path, channel, (srcIn||0) + read/sr, reqDur, { allowShort: true });
          if (rd2.samples.length > len){ rd = rd2; buf = rd.samples; len = buf.length; }
        }
        if ((len < inFrames || len === 0) && !((read+len)/sr >= totalDur - _eofTol)){
          noteReadError("rmsDbEnvelopeWindowed: short read before EOF at " +
            ((srcIn||0)+read/sr).toFixed(3) + "s (got " + len + "/" + inFrames +
            " frames, clip " + totalDur + "s) — possible disk glitch, tail may be under-detected");
          // Terminate (we still break below) but only AFTER surfacing the error.
        }
      }
      var isLast = (len < inFrames) ||
                   (totalDur!=null && (read+len)/sr >= totalDur - 1e-9) ||
                   (len === 0) || (reqDur===0);
      var work;
      if (rem.length){ work=new Float32Array(rem.length+len); work.set(rem,0); work.set(buf,rem.length); }
      else work = buf;
      var tot=work.length, pos=0;
      while (tot-pos >= hop){ emitFull(work, pos); pos += hop; }
      rem = (tot-pos>0) ? work.slice(pos) : new Float32Array(0);
      read += len; buf=null; rd=null; work=null;
      if (isLast) break;
    }
    // trailing partial hop (whole-clip n=ceil(len/hop): the last short chunk is
    // still a hop, averaged over its actual sample count)
    if (rem.length){
      var sum2=0, c2=rem.length; for(var j2=0;j2<c2;j2++){ var v2=rem[j2]; sum2+=v2*v2; }
      var rms2=Math.sqrt(sum2/c2);
      out.push(rms2>1e-9 ? 20*Math.log10(rms2) : -120);
    }
    return Float32Array.from(out);
  }

  // v1.05 (audit #7): in-memory memo around the streamed envelope. One run
  // streams each mic clip up to three times (VAD gain pass, VAD inference, this
  // envelope); the VAD side already caches its masks, so the envelope was the
  // remaining repeat IO on every re-analysis — a pace/setting tweak, camera
  // detection's speakingMatrix, or a fresh Preview all re-streamed every clip.
  // Keyed on file identity (path+size+mtime) plus the exact read/hop params, so
  // any media change invalidates naturally. Cached arrays are handed out as
  // COPIES because callers apply gain in place (applyGainDb). An envelope whose
  // computation logged a read glitch is NOT cached, so a transient disk hiccup
  // is never replayed. Output is bit-identical to the uncached path.
  var _envCache = {}, _envOrder = [];
  var ENV_CACHE_MAX = 16;   // ≤ ~1.5MB per 2h clip at 20ms hops → ~24MB worst case
  function rmsDbEnvelopeWindowed(path, channel, srcIn, durS, hopS, winSec){
    var key = "";
    try {
      var st = fs.statSync(path);
      key = [path, st.size, st.mtime ? st.mtime.getTime() : 0, channel||0, srcIn||0,
             (durS==null ? "" : durS), hopS, winSec||60].join("|");
    } catch(e){ key = ""; }
    if (key && _envCache[key]) return Float32Array.from(_envCache[key]);
    var errsBefore = _readErrors.length;
    var env = computeRmsDbEnvelopeWindowed(path, channel, srcIn, durS, hopS, winSec);
    if (key && _readErrors.length === errsBefore){
      _envCache[key] = Float32Array.from(env);
      _envOrder.push(key);
      if (_envOrder.length > ENV_CACHE_MAX) delete _envCache[_envOrder.shift()];
    }
    return env;
  }

  // ---- channel/mix detection (correlation + levels) ----------------------
  function detectChannels(path){
    var fd = fs.openSync(path,"r");
    var meta, fmt;
    try{ meta = readWavMeta(fd); fmt = meta.fmt; } finally{ fs.closeSync(fd); }
    var nch = fmt.channels;
    var total = Math.floor(meta.dataLen/(fmt.bits/8*nch));
    var dur = total/fmt.sampleRate;
    var start = Math.max(0, dur*0.2);
    var win = Math.min(60, dur-start); if(win<=0){ start=0; win=Math.min(60,dur); }
    // load each channel's window
    var cols=[], levels=[];
    for(var c=0;c<nch;c++){
      var w = readChannelWindow(path, c, start, win).samples;
      cols.push(w);
      var env = rmsDbEnvelope(w, fmt.sampleRate, 0.05);
      levels.push({ ch:c, peak:Math.round(percentile(env,95)), median:Math.round(percentile(env,50)) });
    }
    // near-identical pairs => mix
    var mix={};
    for(var i=0;i<nch;i++) for(var j=i+1;j<nch;j++){
      var a=cols[i], b=cols[j], dot=0, na=0, nb=0, L=Math.min(a.length,b.length);
      for(var k=0;k<L;k++){ dot+=a[k]*b[k]; na+=a[k]*a[k]; nb+=b[k]*b[k]; }
      var denom=Math.sqrt(na)*Math.sqrt(nb);
      var corr = denom>0 ? dot/denom : 0;
      if(corr>0.995){ mix[i]=true; mix[j]=true; }
    }
    var mixArr=Object.keys(mix).map(Number).sort(function(x,y){return x-y;});
    var iso=[]; for(var m=0;m<nch;m++) if(!mix[m]) iso.push(m);
    // Drop channels that are essentially digital silence: a dead channel is not a
    // usable mic, and leaving it in suggested_iso makes the automatic speaker->channel
    // map grab an EMPTY channel. (Real case: mics on ch2 + ch4 with a silent ch3 in
    // between — Speaker 2 was pairing to ch3 and read as never talking, disabling all
    // of that camera.) Keep the silent list only if EVERY channel is silent.
    var peakOf={}; for(var Li=0;Li<levels.length;Li++) peakOf[levels[Li].ch]=levels[Li].peak;
    var isoLive = iso.filter(function(ch){ return peakOf[ch]==null || peakOf[ch] > -80; });
    if(isoLive.length) iso = isoLive;
    if(!iso.length) iso = Array.from({length:nch},function(_,x){return x;});
    return { ok:true, channels:nch, levels:levels, mix:mixArr, suggested_iso:iso };
  }

  // ---- camera selection --------------------------------------------------
  function buildCamIndex(cameras){
    return cameras.map(function(cam){
      var keys={}; (cam.speakers||[]).forEach(function(s){ keys[normName(s)]=true; });
      var arr=Object.keys(keys);
      return { track:cam.track|0, name:cam.name||(""+cam.track), keys:arr, set:keys, size:arr.length };
    });
  }
  function setEq(a,b){ if(a.length!==b.length) return false; for(var i=0;i<a.length;i++) if(b.indexOf(a[i])<0) return false; return true; }
  function isSubset(s, set){ for(var i=0;i<s.length;i++) if(!set[s[i]]) return false; return true; }
  function soloCam(key, idx){
    var exact=idx.filter(function(c){ return c.size===1 && c.set[key]; });
    if(exact.length) return exact[0];
    var cont=idx.filter(function(c){ return c.set[key]; });
    if(cont.length) return cont.reduce(function(a,b){ return b.size<a.size?b:a; });
    return null;
  }
  function camForSet(keys, idx){
    var exact=idx.filter(function(c){ return setEq(c.keys,keys); });
    if(exact.length) return exact[0];
    var sup=idx.filter(function(c){ return isSubset(keys,c.set); });
    if(sup.length) return sup.reduce(function(a,b){ return b.size<a.size?b:a; });
    return idx.reduce(function(best,c){
      var inter=keys.filter(function(k){return c.set[k];}).length;
      var bi=keys.filter(function(k){return best.set[k];}).length;
      if(inter>bi || (inter===bi && c.size<best.size)) return c; return best;
    }, idx[0]);
  }
  function widestCam(idx){ return idx.reduce(function(a,b){ return b.size>a.size?b:a; }); }
  function effectiveWideFrequency(wf){
    wf = Number(wf);
    if(!(wf>=0)) return 0.25;
    // v1.02: Low is Low again. The old remap (0.1 -> 0.20, "Low was too shy")
    // pushed Low most of the way to Medium, so users who chose Low still saw
    // frequent wides during single-speaker stretches. The menu value is now
    // honored exactly.
    return wf;
  }

  // ---- the analysis ------------------------------------------------------
  function analyze(cfg, opts){
    opts = opts||{};
    _readErrors = [];
    var log = opts.log || function(){};
    var prog = opts.progress || function(){};
    var P = cfg.params||{};
    var hopS = (P.analysis_hop_ms||20)/1000;
    var speakers = cfg.speakers;
    var idx = buildCamIndex(cfg.cameras);
    var nSp = speakers.length;
    var keys = speakers.map(function(s){ return normName(s.name); });
    var totalSteps = nSp+1;

    // duration from clips (edited timeline length)
    var maxEnd = cfg.duration_s||0;
    speakers.forEach(function(s){ (s.clips||[]).forEach(function(c){ maxEnd=Math.max(maxEnd, c.seq_start_s + c.dur_s); }); });
    var nHops = Math.ceil(maxEnd/hopS);
    if(nHops<=0) throw new Error("No clips found on the mic tracks.");

    // build aligned envelopes (+ optional VAD mask) per speaker.
    // VAD masks may be PRECOMPUTED per clip (clip._vadMask, e.g. from ONNX silero)
    // or computed on the fly via a sync opts.vad fallback.
    var useVad = !!(opts.vad || opts.useVad);
    var env=[], masks=[], maskCov=[], vadUsed=false;
    var durationS = nHops*hopS;
    var thr = P.speaking_threshold_db!=null? P.speaking_threshold_db : -40;
    var margin = P.dominance_margin_db!=null? P.dominance_margin_db : 6;
    // per-channel normalization (boost quiet mics to a common target)
    var target = P.normalize_target_db!=null? P.normalize_target_db : -18;
    var silFloor = P.silence_floor_db!=null? P.silence_floor_db : -60;
    var gains=new Float64Array(nSp);

    if (opts.precomputed) {
      // OFFLINE TUNING PATH: env / masks / maskCov / gains supplied from a prior
      // analyze_dump (audio-derived, param-independent). Skip WAV reading + VAD so
      // the cut-decision logic below can be replayed and tuned without the audio.
      var pc = opts.precomputed;
      env = pc.env; masks = pc.masks || []; maskCov = pc.maskCov || [];
      for (var pg=0; pg<nSp; pg++) gains[pg] = (pc.gains && pc.gains[pg]!=null) ? pc.gains[pg] : 0;
      vadUsed = !!(masks.length && masks[0]);
      useVad = vadUsed;
    } else {
      for(var si=0; si<nSp; si++){
        prog(si, totalSteps); log("[progress] "+si+"/"+totalSteps+" analysing "+speakers[si].name);
        var aligned=new Float32Array(nHops).fill(-120);
        var amask = useVad ? new Uint8Array(nHops) : null;
        // Which hops are actually COVERED by a VAD mask. The mask can end
        // shorter than the RMS envelope (e.g. a clip's tail past the mask's
        // length); uncovered tail hops must fall back to energy gating instead
        // of being treated as masked-silent (which would drop loud tail speech).
        var amaskCov = useVad ? new Uint8Array(nHops) : null;
        var spVad = false;
        var clips = speakers[si].clips || [{ wav:speakers[si].wav, channel:speakers[si].channel||0, seq_start_s:0, dur_s:maxEnd, src_in_s:speakers[si].offset_s? -speakers[si].offset_s:0 }];
        for(var ci=0; ci<clips.length; ci++){
          var c=clips[ci];
          // We only need the raw whole-clip samples for the LEGACY sync VAD
          // fallback (opts.vad without a precomputed c._vadMask). In every other
          // case compute the RMS-dB envelope by STREAMING 60s windows so we never
          // hold ~691MB; the windowed envelope is bit-identical (per-hop, no
          // cross-window state — see rmsDbEnvelopeWindowed).
          var needSamples = !c._vadMask && !!opts.vad;
          var e, m;
          if (needSamples){
            var rd=readChannelWindow(c.wav, c.channel||0, c.src_in_s||0, c.dur_s);
            if(!rd.samples.length) continue;
            e=rmsDbEnvelope(rd.samples, rd.sr, hopS);
            m = opts.vad(rd.samples, rd.sr, hopS, e.length);
          } else {
            e=rmsDbEnvelopeWindowed(c.wav, c.channel||0, c.src_in_s||0, c.dur_s, hopS);
            if(!e.length) continue;
            m = c._vadMask ? c._vadMask : null;
          }
          applyGainDb(e, c.gain_db);
          var h0=Math.round(c.seq_start_s/hopS);
          for(var h=0; h<e.length && h0+h<nHops; h++) aligned[h0+h]=e[h];
          if(m){ spVad=true; for(var k=0;k<m.length && h0+k<nHops;k++){ amaskCov[h0+k]=1; if(m[k]) amask[h0+k]=1; } }
        }
        if(spVad) vadUsed=true;
        env.push(aligned); masks.push((useVad && spVad) ? amask : null); maskCov.push((useVad && spVad) ? amaskCov : null);
      }
      if(_readErrors.length){ for(var re=0; re<_readErrors.length; re++) log("[warn] "+_readErrors[re]); }
      if(P.normalize_levels!==false){
        for(var i=0;i<nSp;i++){
          var fin=[]; for(var t=0;t<nHops;t++){ var v=env[i][t]; if(v>-119) fin.push(v); }
          var ref = fin.length? percentile(fin,99) : target;
          // More than ~40x gain turns another speaker's room bleed into a
          // convincing "voice" signal on an otherwise quiet listener mic.
          gains[i]=clamp(target-ref,0,(P.normalize_max_gain_db!=null?P.normalize_max_gain_db:32));
        }
      }
    }
    prog(nSp, totalSteps); log("[progress] "+nSp+"/"+totalSteps+" smoothing + building cuts");

    // DEV DIAGNOSTIC dump: env + VAD masks + gains, so cut-cadence tuning can be
    // reproduced and refined offline (no re-VAD). Gated by opts.dumpPath, which the
    // panel only sets when the DEV "project folder" is configured — distributed end
    // users never set that, so they never write it. Overwrites one small file/run.
    if (opts.dumpPath && fs) {
      try {
        var _dump = { hopS:hopS, nHops:nHops, keys:keys, gains:Array.prototype.slice.call(gains), env:[], masks:[], maskCov:[] };
        for (var _d=0; _d<nSp; _d++){
          _dump.env.push(Buffer.from(env[_d].buffer, env[_d].byteOffset, env[_d].byteLength).toString("base64"));
          _dump.masks.push(masks[_d] ? Buffer.from(masks[_d].buffer, masks[_d].byteOffset, masks[_d].byteLength).toString("base64") : null);
          _dump.maskCov.push(maskCov[_d] ? Buffer.from(maskCov[_d].buffer, maskCov[_d].byteOffset, maskCov[_d].byteLength).toString("base64") : null);
        }
        fs.writeFileSync(opts.dumpPath, JSON.stringify(_dump));
        log("[debug] wrote analyze dump: "+opts.dumpPath);
      } catch(eD){ log("[debug] dump failed: "+eD); }
    }

    // gated matrix
    function gat(i,t){
      var g=env[i][t]+gains[i];
      if(masks[i] && maskCov[i] && maskCov[i][t]){ if(!masks[i][t]) return -Infinity; }
      // mask present but this hop is past the mask's coverage (e.g. clip tail):
      // fall back to energy gating instead of treating it as masked-silent.
      else if(g<thr) return -Infinity;
      if(env[i][t]<silFloor) return -Infinity;
      return g;
    }

    // per-hop state
    var states=new Array(nHops);
    for(var t=0;t<nHops;t++){
      var active=[]; for(var i2=0;i2<nSp;i2++){ var gg=gat(i2,t); if(isFinite(gg)) active.push([i2,gg]); }
      if(active.length>1){
        // A VAD correctly recognizes speech but cannot know whose mic it came
        // from. If a heavily normalized quiet channel contains a much weaker
        // copy of speech that is strong on another raw mic, it is bleed, not a
        // second speaker. Reject it before it can steal a cut or create a fake
        // reaction/overlap shot.
        var rawBest=-Infinity;
        for(var ai=0;ai<active.length;ai++) rawBest=Math.max(rawBest,env[active[ai][0]][t]);
        var bleedGain=(P.bleed_gain_guard_db!=null)?P.bleed_gain_guard_db:18;
        var bleedGap=(P.bleed_rejection_db!=null)?P.bleed_rejection_db:12;
        var softBleedGain=(P.soft_bleed_gain_guard_db!=null)?P.soft_bleed_gain_guard_db:5;
        var softBleedGap=(P.soft_bleed_rejection_db!=null)?P.soft_bleed_rejection_db:18;
        var clean=[];
        for(var af=0;af<active.length;af++){
          var asi=active[af][0];
          var rawGap=rawBest-env[asi][t];
          var hardBleed=(gains[asi]>=bleedGain && rawGap>=bleedGap);
          var boostedQuietBleed=(gains[asi]>=softBleedGain && rawGap>=softBleedGap);
          if(!(hardBleed || boostedQuietBleed)) clean.push(active[af]);
        }
        if(clean.length) active=clean;
      }
      if(!active.length){ states[t]=["silence",null]; continue; }
      if(active.length===1){ states[t]=["single",keys[active[0][0]]]; continue; }
      active.sort(function(a,b){return b[1]-a[1];});
      if(active[0][1]-active[1][1] >= margin) states[t]=["single",keys[active[0][0]]];
      else states[t]=["overlap", active.map(function(a){return keys[a[0]];}).sort()];
    }
    var _ovBase = P.overlap_min_ms!=null?P.overlap_min_ms:600;
    // WIDE-SHOT FREQUENCY: cut to the wide (group) camera only for genuinely
    // SUSTAINED cross-talk. The lower the "Wide-shot frequency", the longer an
    // overlap must last before it shows the wide — shorter cross-talk resolves to
    // the loudest single speaker instead. Off (0) => never go wide on overlap.
    // v1.09 (producer report): the threshold used to be clamped UP by the pace's
    // overlap_min_ms — the standard pace's 1600ms swallowed the formula's whole
    // 600–1410ms range, so every dropdown setting demanded the same rarely-met
    // 1.6s of continuous cross-talk and "Overlap only" produced no wides at all.
    // The dropdown formula now stands on its own (High 600ms … Overlap-only
    // 1410ms), and the first flicker-collapse pass runs at the SMALLER of the
    // two thresholds so it can't pre-delete overlaps the wide pass would keep.
    var _wf = effectiveWideFrequency((P.wide_frequency!=null) ? P.wide_frequency : 0.25);
    var _wideThrMs = (_wf<=0) ? Infinity
                   : Math.round(600 + (0.5 - Math.min(_wf,0.5)) * 1800);
    var _pass1Ms = (P.overlap_to_wide!==false && _wf>0) ? Math.min(_ovBase, _wideThrMs) : _ovBase;
    if (P.overlap_to_wide!==false && _wf>0) {
      var _bridgeMs = (P.overlap_bridge_ms!=null) ? P.overlap_bridge_ms : 350;
      bridgeOverlapGaps(states, Math.round((_bridgeMs/1000)/hopS));
    }
    collapseShortOverlaps(states, env, gains, keys, nSp, _pass1Ms, hopS, masks, silFloor, maskCov);
    if (_wideThrMs > _pass1Ms) {
      collapseShortOverlaps(states, env, gains, keys, nSp, _wideThrMs, hopS, masks, silFloor, maskCov);
    }

    // raw camera per hop
    var rawCam=new Array(nHops), rawReason=new Array(nHops);
    for(var t2=0;t2<nHops;t2++){
      var st=states[t2];
      if(st[0]==="silence"){ rawCam[t2]=null; rawReason[t2]="silence"; }
      else if(st[0]==="single"){ var cam=soloCam(st[1],idx); rawCam[t2]=cam?cam.track:null; rawReason[t2]=origName(st[1],speakers); }
      else { var cam2 = (P.overlap_to_wide!==false)? camForSet(st[1],idx) : soloCam(st[1][0],idx);
             rawCam[t2]=cam2?cam2.track:null; rawReason[t2]="overlap:"+st[1].map(function(k){return origName(k,speakers);}).join(","); }
    }

    var switchHops=Math.max(1, Math.round((P.switch_delay_ms!=null?P.switch_delay_ms:250)/1000/hopS));
    removeShortBlips(rawCam, rawReason, switchHops);
    // Hysteresis: hold the current speaker through brief backchannel interjections.
    // If camera X appears for less than min_interject_s while sandwiched between two
    // runs of the SAME other camera Y (Y…X…Y), keep Y on screen instead of whipping
    // to X and straight back. Kills A→B→A flicker (e.g. a half-second "yeah") which
    // is the biggest source of excess cuts on lively multi-person episodes. Silence
    // breaks a sandwich (a switch after a real pause is natural and is kept).
    var interjectHops=Math.max(0, Math.round((P.min_interject_s!=null?P.min_interject_s:1.0)/hopS));
    // Content-aware interjection collapse: suppress talkover (previous speaker kept
    // the floor) but keep genuine short hand-offs. yield_fraction = how much of the
    // interjection the previous speaker must still be talking to count as talkover.
    var yieldFrac = (P.interject_yield_frac!=null) ? P.interject_yield_frac : 0.5;
    collapseInterjectionsSmart(rawCam, rawReason, env, gains, keys, idx, thr, interjectHops, yieldFrac);
    fillHolds(rawCam, rawReason, idx);
    var runs=smoothRuns(rawCam, rawReason, P, hopS, idx);

    // per-hop silence flag (for snap-to-pause + reaction shots)
    var sil=new Array(nHops); for(var ts=0;ts<nHops;ts++) sil[ts]=(states[ts][0]==="silence")?1:0;
    runs=snapRunsToPauses(runs, sil, P, hopS);
    runs=insertReactions(runs, sil, idx, P, hopS);
    runs=insertEstablishingWides(runs, sil, idx, P, hopS);
    runs=limitLowWides(runs, idx, env, gains, keys, silFloor, P);
    runs=mergeAdjacent(runs);
    var segments=runsToSegments(runs, hopS, durationS, P);

    // silence regions (for dead-air trimming) — contiguous no-one-talking runs >= 0.25s
    var silences=[]; (function(){
      var i=0; while(i<nHops){ if(!sil[i]){ i++; continue; } var j=i; while(j<nHops && sil[j]) j++;
        if((j-i)*hopS>=0.25) silences.push({ start_s:Math.round(i*hopS*1000)/1000, end_s:Math.round(j*hopS*1000)/1000 });
        i=j; }
    })();

    // diag
    var diag=computeDiag(states, env, gains, speakers, keys, nSp, durationS, nHops);

    return {
      fps: cfg.fps||23.976,
      duration_s: Math.round(durationS*1000)/1000,
      cameras: idx.map(function(c){ return { track:c.track, name:c.name,
        speakers: c.keys.map(function(k){ return origName(k,speakers); }) }; }),
      segments: segments,
      silences: silences,
      diag: diag,
      generated_with: { vad_used:vadUsed, params:P }
    };
  }

  function origName(key, speakers){ for(var i=0;i<speakers.length;i++) if(normName(speakers[i].name)===key) return speakers[i].name; return key; }

  // v1.12 (producer report, round 2): real speech is amplitude-modulated — during
  // genuine cross-talk one voice's syllable gaps (100–300ms) constantly break the
  // per-hop overlap state, so a "continuous" 1.4s overlap almost never exists at
  // 20ms-hop granularity (the v1.11 fix held on steady test tones, not people).
  // Bridge short non-overlap gaps between overlap bursts of the SAME speaker set
  // so a burst of cross-talk counts as one sustained overlap. Isolated blips
  // bridged this way still fall short of the wide threshold and collapse to a
  // single speaker, so this cannot invent wides where there was no real exchange.
  function bridgeOverlapGaps(states, maxGapHops){
    if(maxGapHops<=0) return;
    var n=states.length, i=0;
    function key(st){ return st[1].join("|"); }
    while(i<n){
      if(states[i][0]!=="overlap"){ i++; continue; }
      var j=i; while(j<n && states[j][0]==="overlap" && key(states[j])===key(states[i])) j++;
      var k=j; while(k<n && (k-j)<=maxGapHops && states[k][0]!=="overlap") k++;
      if(k<n && (k-j)<=maxGapHops && states[k][0]==="overlap" && key(states[k])===key(states[i])){
        for(var t=j;t<k;t++) states[t]=["overlap", states[i][1].slice()];
        continue;   // run extended — rescan from i so chains of bursts merge
      }
      i=j;
    }
  }
  function collapseShortOverlaps(states, env, gains, keys, nSp, overlapMinMs, hopS, masks, silFloor, maskCov){
    var minHops=Math.max(1, Math.round((overlapMinMs/1000)/hopS));
    var floor = (silFloor!=null) ? silFloor : -60;
    var i=0, n=states.length;
    while(i<n){
      if(states[i][0]!=="overlap"){ i++; continue; }
      var j=i; while(j<n && states[j][0]==="overlap") j++;
      if(j-i<minHops){
        // Whoever carries the most SPEECH over the run wins: sum energy ABOVE
        // the silence floor (a positive number per talking hop), honoring the
        // VAD mask where it covers the hop. The old scoring summed raw dB
        // (negative per talking hop, 0 for silent hops), so the LEAST-active
        // speaker scored highest and short cross-talk resolved to whoever was
        // NOT talking — cutting the camera to the silent person.
        var best=0, bestE=-Infinity;
        for(var s=0;s<nSp;s++){
          var sum=0;
          for(var t=i;t<j;t++){
            if(masks && masks[s] && maskCov && maskCov[s] && maskCov[s][t] && !masks[s][t]) continue; // VAD says not voice
            var v=env[s][t]+gains[s];
            if(v>-119 && v>floor) sum += (v - floor);
          }
          if(sum>bestE){ bestE=sum; best=s; }
        }
        for(var t2=i;t2<j;t2++) states[t2]=["single",keys[best]];
      }
      i=j;
    }
  }
  function removeShortBlips(rawCam, rawReason, switchHops){
    var n=rawCam.length, i=0;
    while(i<n){
      if(rawCam[i]==null){ i++; continue; }
      var j=i; while(j<n && rawCam[j]===rawCam[i]) j++;
      var leftSil=(i===0)||(rawCam[i-1]==null);
      var rightSil=(j===n)||(rawCam[j]==null);
      if((j-i)<switchHops && (leftSil||rightSil)){ for(var t=i;t<j;t++){ rawCam[t]=null; rawReason[t]="silence"; } }
      i=j;
    }
  }
  function collapseInterjections(rawCam, rawReason, interjectHops){
    if(interjectHops<=0) return;
    var n=rawCam.length, i=0;
    while(i<n){
      if(rawCam[i]==null){ i++; continue; }            // silence: skip (breaks sandwiches)
      var j=i; while(j<n && rawCam[j]===rawCam[i]) j++; // run [i,j) of camera rawCam[i]
      var prev=(i>0)?rawCam[i-1]:null;
      var next=(j<n)?rawCam[j]:null;
      // X sandwiched by the same other camera Y on both sides, and short enough:
      if((j-i)<interjectHops && prev!=null && prev===next && prev!==rawCam[i]){
        var fr=rawReason[i-1];
        for(var t=i;t<j;t++){ rawCam[t]=prev; rawReason[t]=fr; }
      }
      i=j;
    }
  }
  // Map a camera track back to the speaker indices it represents (so we can look
  // at those speakers' energy). Returns [] for a track with no tagged speaker
  // (e.g. an untagged wide).
  function trackSpeakerIdxs(track, idx, keys){
    var out=[];
    for(var c=0;c<idx.length;c++){
      if(idx[c].track===track){
        var ks=idx[c].keys||[];
        for(var k=0;k<ks.length;k++){ var si=keys.indexOf(ks[k]); if(si>=0) out.push(si); }
        break;
      }
    }
    return out;
  }
  // CONTENT-AWARE interjection collapse (the key to professional-grade bimodal cutting).
  // Old behavior reverted X->Y purely on duration (any short X sandwiched by Y…Y
  // was killed), which threw away genuine quick turns along with backchannel. Here
  // we revert X->Y only when Y KEPT TALKING through X — i.e. Y did NOT yield the
  // floor (a laugh/breath/"mhm" over the current speaker). If Y went quiet during
  // X (a real hand-off), we KEEP X even when it's short. Because false switches are
  // now caught by *content* rather than by clamping length, the min-shot floor can
  // drop back down and real 1-2s turns survive — matching a professional edit's mix of long
  // holds + quick banter. `thr` is the same speaking threshold used for gating.
  function collapseInterjectionsSmart(rawCam, rawReason, env, gains, keys, idx, thr, maxInterjectHops, yieldFrac){
    if(maxInterjectHops<=0) return;
    var n=rawCam.length, i=0;
    while(i<n){
      if(rawCam[i]==null){ i++; continue; }
      var j=i; while(j<n && rawCam[j]===rawCam[i]) j++;
      var prev=(i>0)?rawCam[i-1]:null;
      var next=(j<n)?rawCam[j]:null;
      // v1.09: a wide EARNED by sustained cross-talk (reason "overlap:…") is not
      // a backchannel interjection — it already survived the wide-threshold
      // collapse. Suppressing it here (A → wide → A, floor-holder still talking,
      // which in an overlap is true by definition) was the second half of why
      // "Overlap only" never showed the wide.
      if(String(rawReason[i]||"").indexOf("overlap:")===0){ i=j; continue; }
      if((j-i)<maxInterjectHops && prev!=null && prev===next && prev!==rawCam[i]){
        var ysi=trackSpeakerIdxs(prev, idx, keys);
        var talk=0, tot=j-i;
        for(var t=i;t<j;t++){
          var ytalk=false;
          for(var y=0;y<ysi.length;y++){ if(env[ysi[y]][t]+gains[ysi[y]] > thr){ ytalk=true; break; } }
          if(ytalk) talk++;
        }
        var frac = tot>0 ? talk/tot : 1;
        // Y kept talking (or we can't tell because Y has no tagged speaker) => it's
        // talkover => suppress X. Y yielded => keep X (real quick turn).
        if(ysi.length===0 || frac >= yieldFrac){
          var fr=rawReason[i-1];
          for(var t2=i;t2<j;t2++){ rawCam[t2]=prev; rawReason[t2]=fr; }
        }
      }
      i=j;
    }
  }
  function fillHolds(rawCam, rawReason, idx){
    var n=rawCam.length, last=null;
    for(var t=0;t<n;t++){ if(rawCam[t]==null){ if(last!=null) rawCam[t]=last; } else last=rawCam[t]; }
    var firstReal=null; for(var t2=0;t2<n;t2++) if(rawCam[t2]!=null){ firstReal=rawCam[t2]; break; }
    if(firstReal==null) firstReal=widestCam(idx).track;
    for(var t3=0;t3<n;t3++){ if(rawCam[t3]==null){ rawCam[t3]=firstReal; rawReason[t3]="intro"; } else break; }
  }
  function smoothRuns(rawCam, rawReason, P, hopS, idx){
    var n=rawCam.length;
    var switchHops=Math.max(0, Math.round((P.switch_delay_ms!=null?P.switch_delay_ms:250)/1000/hopS));
    var minHops=Math.max(1, Math.round((P.min_shot_s!=null?P.min_shot_s:2)/hopS));
    var maxHops=(P.max_shot_s)? Math.round(P.max_shot_s/hopS):0;
    var runs=[], curCam=rawCam[0], curStart=0, curReason=rawReason[0], cand=null, candSince=null;
    function push(end){ runs.push([curStart,end,curCam,curReason]); }
    for(var t=1;t<n;t++){
      var want=rawCam[t];
      if(want===curCam){ cand=null; candSince=null; continue; }
      if(cand!==want){ cand=want; candSince=t; }
      var held=t-candSince+1, curDur=t-curStart;
      if(held>=switchHops && curDur>=minHops){
        var cut=candSince; if(cut-curStart<minHops) cut=curStart+minHops;
        push(cut); curCam=want; curStart=cut; curReason=rawReason[t]; cand=null; candSince=null;
      }
    }
    push(n);
    // Variety wide: break up very long single-camera holds with an occasional
    // wide, scaled by the Wide-shot frequency. Lower setting => only much longer
    // holds get a wide; Off (0) => never insert a variety wide.
    var _wf2 = effectiveWideFrequency((P.wide_frequency!=null) ? P.wide_frequency : 0.25);
    // Variety (timer-based) wide is OFF for "Off" (0) and "Overlap only" (<0.1):
    // those modes only go wide on genuine cross-shot cross-talk. Low and above
    // add a light-to-stronger occasional variety wide on long single-cam holds.
    var varietyMax = (_wf2 < 0.1) ? 0 : Math.round(maxHops * (1 + (0.5 - Math.min(_wf2,0.5)) * 2.5));
    if(varietyMax && varietyMax>minHops){
      var wide=widestCam(idx).track, exp=[];
      runs.forEach(function(r){
        var s=r[0],e=r[1],cam=r[2],reason=r[3];
        if(e-s<=varietyMax || cam===wide){ exp.push([s,e,cam,reason]); return; }
        var ss=s; while(e-ss>varietyMax){ var cut=ss+varietyMax; exp.push([ss,cut,cam,reason]); var we=Math.min(e,cut+minHops); exp.push([cut,we,wide,"variety_wide"]); ss=we; } if(ss<e) exp.push([ss,e,cam,reason]);
      });
      runs=mergeAdjacent(exp);
    }
    return runs;
  }
  function mergeAdjacent(runs){ if(!runs.length) return runs; var out=[runs[0].slice()]; for(var i=1;i<runs.length;i++){ if(runs[i][2]===out[out.length-1][2]) out[out.length-1][1]=runs[i][1]; else out.push(runs[i].slice()); } return out; }

  // Snap cut points to the nearest pause so switches land in natural silence,
  // not mid-word. Opt-in (P.snap_to_pause). Each internal boundary is nudged to
  // the closest silent hop within +/- snap_window_ms, keeping boundaries ordered.
  function snapRunsToPauses(runs, sil, P, hopS){
    if(!P.snap_to_pause || runs.length<2) return runs;
    var win=Math.max(1, Math.round((P.snap_window_ms!=null?P.snap_window_ms:400)/1000/hopS));
    for(var i=1;i<runs.length;i++){
      var b=runs[i][0], best=null;
      for(var d=0; d<=win; d++){
        if(sil[b-d]){ best=b-d; break; }
        if(sil[b+d]){ best=b+d; break; }
      }
      if(best!=null && best>runs[i-1][0]+1 && best<runs[i][1]-1){ runs[i-1][1]=best; runs[i][0]=best; }
    }
    return runs;
  }

  // Reaction shots: during longer pauses (silence regions), cut to a listener
  // (a solo cam that isn't the held speaker) for a beat, then return. Works on
  // the actual silence regions — which are normally the tail of a speaker's held
  // run — not on whole runs. Deterministic: fires on every Nth qualifying pause
  // (N from reaction_frequency: 1=high, 2=med, 4=low).
  function insertReactions(runs, sil, idx, P, hopS){
    var freq = P.reaction_frequency!=null ? P.reaction_frequency : 0;   // 0..3
    if(!freq || !runs.length) return runs;
    var everyN = freq>=3 ? 1 : (freq>=2 ? 2 : 4);
    var minPauseHops = Math.max(1, Math.round((P.reaction_min_pause_ms!=null?P.reaction_min_pause_ms:1400)/1000/hopS));
    var reactLen     = Math.max(1, Math.round((P.reaction_len_ms!=null?P.reaction_len_ms:1200)/1000/hopS));
    var solos = idx.filter(function(c){ return c.size===1; });
    if(solos.length<2) return runs;  // need >=2 people to cut to a listener

    var n=runs[runs.length-1][1];
    var camAt=new Array(n), reasonAt=new Array(n);
    runs.forEach(function(r){ for(var t=r[0];t<r[1];t++){ camAt[t]=r[2]; reasonAt[t]=r[3]; } });

    // find qualifying silence regions
    var counter=0, rot=0, i=0;
    while(i<n){
      if(!sil[i]){ i++; continue; }
      var j=i; while(j<n && sil[j]) j++;
      if(j-i>=minPauseHops){
        counter++;
        if(counter % everyN === 0){
          var mid=(i+j)>>1, cam=camAt[mid], pick=null;
          for(var k=0;k<solos.length;k++){ var c=solos[(rot+k)%solos.length]; if(c.track!==cam){ pick=c; rot=(rot+k+1)%solos.length; break; } }
          if(pick){
            var len=j-i, rl=Math.min(reactLen,len), rs=i+((len-rl)>>1), reEnd=rs+rl;
            for(var t2=rs;t2<reEnd;t2++){ camAt[t2]=pick.track; reasonAt[t2]="reaction"; }
          }
        }
      }
      i=j;
    }
    // rebuild runs from the per-hop camera track
    var out=[], cs=0;
    for(var t=1;t<=n;t++){ if(t===n || camAt[t]!==camAt[cs]){ out.push([cs,t,camAt[cs],reasonAt[cs]]); cs=t; } }
    return out;
  }
  // Establishing wides: professional editors add the room/wide shot (~5% of screen
  // time) at natural breaks. This cuts to the widest camera at the start of every
  // Nth qualifying pause for establish_len, then returns to the planned camera.
  // establish_wide_frequency 0 = off; higher = more often (everyN = round(1/freq)).
  function insertEstablishingWides(runs, sil, idx, P, hopS){
    var freq = P.establish_wide_frequency!=null ? P.establish_wide_frequency : 0;
    // v1.02: establishing wides now OBEY the Wide-shot frequency setting. They
    // used to fire at the pace preset's fixed rate regardless of the dropdown,
    // so "Low" (and even "Overlap only") still cut to the wide at every Nth
    // breath-pause during single-speaker stretches. Scale the rate by the user
    // setting (Medium 0.25 = the preset's tuned rate), and turn them fully OFF
    // for Off/Overlap-only, where the wide should only appear on real cross-talk.
    var _wfE = effectiveWideFrequency((P.wide_frequency!=null) ? P.wide_frequency : 0.25);
    if(_wfE < 0.1) freq = 0;
    else freq = freq * (_wfE/0.25);
    if(!(freq>0) || !runs.length) return runs;
    var wide = widestCam(idx).track;
    var everyN = Math.max(1, Math.round(1/freq));
    var basePauseMs = (P.establish_min_pause_ms!=null?P.establish_min_pause_ms:700);
    // Below Medium, also require a REAL pause (a natural beat, not a breath) so
    // the rarer wides land at genuine section breaks.
    if(_wfE < 0.25) basePauseMs = Math.max(basePauseMs, 1000);
    var minPauseHops = Math.max(1, Math.round(basePauseMs/1000/hopS));
    var estLen = Math.max(1, Math.round((P.establish_len_ms!=null?P.establish_len_ms:1800)/1000/hopS));
    var n=runs[runs.length-1][1];
    var camAt=new Array(n), reasonAt=new Array(n);
    runs.forEach(function(r){ for(var t=r[0];t<r[1];t++){ camAt[t]=r[2]; reasonAt[t]=r[3]; } });
    var counter=0, i=0;
    while(i<n){
      if(!sil[i]){ i++; continue; }
      var j=i; while(j<n && sil[j]) j++;
      if(j-i>=minPauseHops){
        counter++;
        if(counter % everyN === 0){
          var s=i, e=Math.min(n, i+estLen);
          if(camAt[s]!==wide){
            for(var t2=s;t2<e;t2++){ camAt[t2]=wide; reasonAt[t2]="establish_wide"; }
          }
        }
      }
      i=j;
    }
    var out=[], cs=0;
    for(var t=1;t<=n;t++){ if(t===n || camAt[t]!==camAt[cs]){ out.push([cs,t,camAt[cs],reasonAt[cs]]); cs=t; } }
    return out;
  }

  // Low is a screen-time target, not "use every overlap that passes a slightly
  // longer threshold". v1.12 made real cross-talk detectable by bridging brief
  // syllable gaps; that was correct for Overlap only, but it exposed how close
  // Low's old behavior was to that mode and could make lively episodes ~50% wide.
  // Keep complete wide shots, sample them evenly through the edit, and cap all
  // wide sources together at about 5% of the run.
  function limitLowWides(runs, idx, env, gains, keys, silFloor, P){
    var wf=effectiveWideFrequency((P.wide_frequency!=null) ? P.wide_frequency : 0.25);
    if(Math.abs(wf-0.1)>0.0001 || !runs.length) return runs;
    var target=(P.low_wide_screen_fraction!=null) ? Number(P.low_wide_screen_fraction) : 0.05;
    target=clamp(isFinite(target)?target:0.05, 0, 0.25);
    var wide=widestCam(idx).track, n=runs[runs.length-1][1];
    var hopS=(P.analysis_hop_ms||20)/1000;
    var leadHops=Math.max(0,Math.ceil(((P.lead_offset_ms!=null?P.lead_offset_ms:120)/1000)/hopS));
    var budget=Math.floor(n*target), candidates=[], total=0;
    for(var i=0;i<runs.length;i++){
      if(runs[i][2]===wide){
        var d=runs[i][1]-runs[i][0];
        // runsToSegments shifts adjacent internal boundaries by the same lead,
        // so an internal wide keeps its duration. Only a final wide gains lead.
        var cost=d+((i===runs.length-1 && i>0)?leadHops:0);
        candidates.push({ index:i, duration:d, cost:cost }); total+=cost;
      }
    }
    if(total<=budget) return runs;

    var keep={}, used=0, avg=total/candidates.length;
    var keepCount=Math.max(1,Math.min(candidates.length,Math.floor(budget/avg)));
    var c=0, cumulative=0;
    for(var pick=0;pick<keepCount;pick++){
      var targetPoint=(pick+0.5)*total/keepCount;
      while(c<candidates.length-1 && cumulative+candidates[c].cost<targetPoint){
        cumulative+=candidates[c].cost; c++;
      }
      var cand=candidates[c];
      if(!keep[cand.index] && used+cand.cost<=budget){
        keep[cand.index]=true; used+=cand.cost;
      }
    }

    function replacementTrack(runIndex, r){
      var best=-1, bestScore=0;
      for(var s=0;s<keys.length;s++){
        var score=0;
        for(var t=r[0];t<r[1] && t<env[s].length;t++){
          var v=env[s][t]+gains[s];
          if(v>silFloor && v>-119) score += v-silFloor;
        }
        if(score>bestScore){ bestScore=score; best=s; }
      }
      if(best>=0){ var solo=soloCam(keys[best],idx); if(solo && solo.track!==wide) return solo.track; }
      for(var p=runIndex-1;p>=0;p--) if(runs[p][2]!==wide) return runs[p][2];
      for(var q=runIndex+1;q<runs.length;q++) if(runs[q][2]!==wide) return runs[q][2];
      for(var x=0;x<idx.length;x++) if(idx[x].track!==wide) return idx[x].track;
      return wide;
    }

    var out=[];
    for(var rIdx=0;rIdx<runs.length;rIdx++){
      var r=runs[rIdx].slice();
      if(r[2]===wide && !keep[rIdx]){
        r[2]=replacementTrack(rIdx,r);
        r[3]="low_wide_cap:"+String(r[3]||"");
      }
      out.push(r);
    }
    return mergeAdjacent(out);
  }
  function runsToSegments(runs, hopS, durationS, P){
    var lead=(P.lead_offset_ms!=null?P.lead_offset_ms:120)/1000, segs=[];
    runs.forEach(function(r,i){
      var s=r[0]*hopS, e=r[1]*hopS;
      if(i>0 && lead) s=Math.max(segs[segs.length-1].start_s+0.001, s-lead);
      segs.push({ start_s:Math.round(s*1000)/1000, end_s:Math.round(e*1000)/1000, camera_track:r[2]|0, reason:r[3] });
    });
    if(segs.length){
      segs[0].start_s=0;
      for(var i2=1;i2<segs.length;i2++) segs[i2-1].end_s=segs[i2].start_s;
      segs[segs.length-1].end_s=Math.round(durationS*1000)/1000;
      var cleaned=segs.filter(function(s){ return s.end_s-s.start_s>1e-6; });
      for(var i3=1;i3<cleaned.length;i3++) cleaned[i3-1].end_s=cleaned[i3].start_s;
      if(cleaned.length){ cleaned[0].start_s=0; cleaned[cleaned.length-1].end_s=Math.round(durationS*1000)/1000; }
      segs=cleaned;
    }
    return segs;
  }
  function computeDiag(states, env, gains, speakers, keys, nSp, durationS, nHops){
    var dom={}, act={}, sil=0;
    states.forEach(function(st){
      if(st[0]==="silence") sil++;
      else if(st[0]==="single"){ dom[st[1]]=(dom[st[1]]||0)+1; act[st[1]]=(act[st[1]]||0)+1; }
      else st[1].forEach(function(k){ act[k]=(act[k]||0)+1; });
    });
    var tot=Math.max(1,nHops);
    var rows=speakers.map(function(sp,i){
      var k=keys[i], col=env[i], peak=percentile(Array.from(col),95);
      return { name:sp.name, onscreen:+(100*(dom[k]||0)/tot).toFixed(1), talks:+(100*(act[k]||0)/tot).toFixed(1),
               gain:Math.round(gains[i]), peak:Math.round(peak) };
    });
    return { duration_s:durationS, silence:+(100*sil/tot).toFixed(1), speakers:rows };
  }

  // ---- camera auto-detect: speaking timeline + mouth-motion correlation ---
  // Per-speaker "is talking" timeline, downsampled to stepS, mirroring analyze()'s
  // envelope build + level normalization + gating (energy-based; honors precomputed
  // VAD masks on clips). Correlated against per-camera motion to guess who each
  // camera shows. Returns { stepS, nSteps, names, speaking:[Float32Array], anyone }.
  function speakingMatrix(cfg, opts, stepS){
    opts = opts||{};
    _readErrors = [];
    var P = cfg.params||{};
    var hopS = (P.analysis_hop_ms||20)/1000;
    var speakers = cfg.speakers, nSp = speakers.length;
    var maxEnd = cfg.duration_s||0;
    speakers.forEach(function(s){ (s.clips||[]).forEach(function(c){ maxEnd=Math.max(maxEnd, c.seq_start_s + c.dur_s); }); });
    var nHops = Math.ceil(maxEnd/hopS);
    if(nHops<=0) throw new Error("No clips found on the mic tracks.");

    var env=[], masks=[], maskCov=[];
    for(var si=0; si<nSp; si++){
      var aligned=new Float32Array(nHops).fill(-120);
      var amask=new Uint8Array(nHops), amaskCov=new Uint8Array(nHops), hasMask=false;
      var clips = speakers[si].clips || [{ wav:speakers[si].wav, channel:speakers[si].channel||0, seq_start_s:0, dur_s:maxEnd, src_in_s:speakers[si].offset_s? -speakers[si].offset_s:0 }];
      for(var ci=0; ci<clips.length; ci++){
        var c=clips[ci];
        // Stream the RMS-dB envelope in 60s windows (bit-identical, per-hop, no
        // cross-window state) instead of decoding the whole clip (~691MB) at
        // once. speakingMatrix never needs the raw samples (masks come from
        // precomputed c._vadMask), so this is always safe here.
        var e=rmsDbEnvelopeWindowed(c.wav, c.channel||0, c.src_in_s||0, c.dur_s, hopS);
        if(!e.length) continue;
        applyGainDb(e, c.gain_db);
        var h0=Math.round(c.seq_start_s/hopS);
        for(var h=0; h<e.length && h0+h<nHops; h++) aligned[h0+h]=e[h];
        if(c._vadMask){ hasMask=true; for(var k=0;k<c._vadMask.length && h0+k<nHops;k++){ amaskCov[h0+k]=1; if(c._vadMask[k]) amask[h0+k]=1; } }
      }
      env.push(aligned); masks.push(hasMask?amask:null); maskCov.push(hasMask?amaskCov:null);
    }

    var thr = P.speaking_threshold_db!=null? P.speaking_threshold_db : -40;
    var target = P.normalize_target_db!=null? P.normalize_target_db : -18;
    var silFloor = P.silence_floor_db!=null? P.silence_floor_db : -60;
    var gains=new Float64Array(nSp);
    if(P.normalize_levels!==false){
      for(var gi=0; gi<nSp; gi++){
        var fin=[]; for(var t=0;t<nHops;t++){ var v=env[gi][t]; if(v>-119) fin.push(v); }
        var ref = fin.length? percentile(fin,99) : target;
        gains[gi]=clamp(target-ref,0,(P.normalize_max_gain_db!=null?P.normalize_max_gain_db:32));
      }
    }
    function baseSpeakingHop(i,t){
      if(env[i][t]<silFloor) return 0;
      // mask present and this hop is covered by it -> trust the mask; a hop past
      // the mask's coverage (clip tail) falls through to energy gating below so
      // loud tail speech isn't masked to silence.
      if(masks[i] && maskCov[i] && maskCov[i][t]) return masks[i][t]?1:0;
      return (env[i][t]+gains[i])>=thr ? 1 : 0;
    }
    function speakingHop(i,t){
      if(!baseSpeakingHop(i,t)) return 0;
      var bleedGain=(P.bleed_gain_guard_db!=null)?P.bleed_gain_guard_db:18;
      var rawBest=env[i][t];
      for(var j=0;j<nSp;j++) if(j!==i && baseSpeakingHop(j,t)) rawBest=Math.max(rawBest,env[j][t]);
      var bleedGap=(P.bleed_rejection_db!=null)?P.bleed_rejection_db:12;
      var softBleedGain=(P.soft_bleed_gain_guard_db!=null)?P.soft_bleed_gain_guard_db:5;
      var softBleedGap=(P.soft_bleed_rejection_db!=null)?P.soft_bleed_rejection_db:18;
      var rawGap=rawBest-env[i][t];
      if(gains[i]>=bleedGain && rawGap>=bleedGap) return 0;
      if(gains[i]>=softBleedGain && rawGap>=softBleedGap) return 0;
      return 1;
    }

    var hopsPerStep=Math.max(1, Math.round(stepS/hopS));
    var nSteps=Math.ceil(nHops/hopsPerStep);
    var speaking=[];
    for(var s2=0;s2<nSp;s2++){
      var arr=new Float32Array(nSteps);
      for(var st=0; st<nSteps; st++){
        var a=st*hopsPerStep, b=Math.min(nHops, a+hopsPerStep), on=0, cnt=0;
        for(var tt=a;tt<b;tt++){ on+=speakingHop(s2,tt); cnt++; }
        arr[st]=cnt? on/cnt : 0;
      }
      speaking.push(arr);
    }
    var anyone=new Float32Array(nSteps);
    for(var sa=0; sa<nSteps; sa++){ var any=0; for(var s3=0;s3<nSp;s3++) if(speaking[s3][sa]>=0.5){ any=1; break; } anyone[sa]=any; }

    return { stepS:stepS, nSteps:nSteps, hopsPerStep:hopsPerStep,
             names:speakers.map(function(s){return s.name;}), speaking:speaking, anyone:anyone };
  }

  // NaN-aware Pearson correlation over paired samples (skips NaN in either array).
  function pearsonNaN(a,b){
    var n=Math.min(a.length,b.length), i, ma=0,mb=0,c=0;
    for(i=0;i<n;i++){ var x=a[i],y=b[i]; if(x!==x||y!==y) continue; ma+=x; mb+=y; c++; }
    if(c<3) return 0; ma/=c; mb/=c;
    var num=0,da=0,db=0;
    for(i=0;i<n;i++){ var x2=a[i],y2=b[i]; if(x2!==x2||y2!==y2) continue; var dx=x2-ma, dy=y2-mb; num+=dx*dy; da+=dx*dx; db+=dy*dy; }
    var d=Math.sqrt(da*db); return d>1e-9? num/d : 0;
  }

  // Given per-camera motion arrays (length nSteps; null = couldn't decode that
  // camera) and a speakingMatrix, assign each camera to the speaker it best
  // "moves with", or flag it a wide shot. A suggestion the user confirms.
  function correlateCameras(motionByCam, sm, opts){
    opts = opts||{};
    var wideMargin = opts.wideMargin!=null? opts.wideMargin : 0.06;
    var minCorr = opts.minCorr!=null? opts.minCorr : 0.08;
    var nSp = sm.names.length, out=[];
    for(var c=0;c<motionByCam.length;c++){
      var m=motionByCam[c];
      if(!m){ out.push({ camIndex:c, bestSpeaker:-1, wide:false, undetected:true, bestCorr:0, corrs:[] }); continue; }
      var corrs=[], best=-1, bestV=-2, second=-2;
      for(var s=0;s<nSp;s++){
        var cv=pearsonNaN(m, sm.speaking[s]); corrs.push(cv);
        if(cv>bestV){ second=bestV; bestV=cv; best=s; } else if(cv>second){ second=cv; }
      }
      var anyV=pearsonNaN(m, sm.anyone);
      var undetected = bestV<minCorr && anyV<minCorr;
      var wide = !undetected && ((anyV-bestV>wideMargin) || (bestV>minCorr && (bestV-second)<0.03 && anyV>=bestV-0.02));
      out.push({ camIndex:c, corrs:corrs, anyCorr:anyV, bestSpeaker:(wide||undetected)?-1:best,
                 bestCorr:bestV, secondCorr:second, wide:wide, undetected:undetected });
    }
    return out;
  }

  // Given per-FACE mouth-motion arrays for ONE camera + a speakingMatrix, assign
  // each visible face to a DISTINCT best-matching speaker (greedy by correlation),
  // and return the set of speakers that camera shows. This is what lets a 2-shot
  // resolve to the two specific people in it (not "everyone").
  function assignFacesToSpeakers(faceMotions, sm, opts){
    opts = opts||{};
    var minCorr = opts.minCorr!=null? opts.minCorr : 0.04;
    var nF = faceMotions.length, nS = sm.names.length;
    var pairs=[];
    for(var f=0; f<nF; f++){
      if(!faceMotions[f]) continue;
      for(var s=0; s<nS; s++){ pairs.push([ pearsonNaN(faceMotions[f], sm.speaking[s]), f, s ]); }
    }
    pairs.sort(function(a,b){ return b[0]-a[0]; });
    var faceUsed={}, spUsed={}, assign=[], corr=[];
    for(var i=0;i<nF;i++){ assign[i]=-1; corr[i]=0; }
    for(var p=0; p<pairs.length; p++){
      var c=pairs[p][0], ff=pairs[p][1], ss=pairs[p][2];
      if(faceUsed[ff] || spUsed[ss]) continue;
      faceUsed[ff]=1; spUsed[ss]=1; assign[ff]=ss; corr[ff]=c;
    }
    var speakers=[];
    for(var f2=0; f2<nF; f2++){
      if(assign[f2]>=0 && corr[f2]>=minCorr && speakers.indexOf(assign[f2])<0) speakers.push(assign[f2]);
    }
    return { speakers:speakers, assign:assign, corr:corr };
  }

  var API = { readErrors:function(){return _readErrors.slice();},
              clearReadErrors:function(){ _readErrors=[]; },
              analyze:analyze, detectChannels:detectChannels, readChannelWindow:readChannelWindow,
              wavChannelCount:wavChannelCount, rmsDbEnvelope:rmsDbEnvelope,
              rmsDbEnvelopeWindowed:rmsDbEnvelopeWindowed, normName:normName,
              speakingMatrix:speakingMatrix, correlateCameras:correlateCameras, pearsonNaN:pearsonNaN,
              assignFacesToSpeakers:assignFacesToSpeakers };
  if(typeof module==="object" && module.exports) module.exports=API;
  root.OnMicEngine = API;   // also expose globally for the panel's <script> include
})(typeof window!=="undefined"?window:this);
