/* ===========================================================================
 *  OnMic — AI voice detection (silero VAD via ONNX Runtime Web, no Python).
 *  Falls back to a classic energy+zero-crossing voice filter if the ONNX
 *  model / runtime isn't available, so analysis never breaks.
 * ======================================================================== */
(function (root) {
  "use strict";

  var session = null;     // ort InferenceSession when loaded
  var ioNames = null;     // resolved input/output names
  var ready = false;
  var lastError = "";
  var modelInfo = "";     // human-readable input/output names for diagnostics

  function haveOrt(){ return typeof ort !== "undefined" && ort && ort.InferenceSession; }

  // Load ONNX Runtime config + the silero model (from the panel's vendor/models).
  // panelDir = absolute path to the installed panel folder; fsMod = node fs.
  function init(panelDir, fsMod, logFn){
    return new Promise(function(resolve){
      if (ready) { resolve(true); return; }
      if (!haveOrt()) { lastError="onnxruntime-web not loaded"; resolve(false); return; }
      try {
        // CEF 88 (Premiere CEP 11): no WASM SIMD/threads — use the basic build.
        ort.env.wasm.numThreads = 1;
        ort.env.wasm.simd = false;
        ort.env.wasm.wasmPaths = panelDir + "/vendor/";
      } catch(e){}
      var modelPath = panelDir + "/models/silero_vad.onnx";
      var bytes;
      try { bytes = fsMod.readFileSync(modelPath); }
      catch(e){ lastError="model not found: "+modelPath; resolve(false); return; }
      var u8 = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
      ort.InferenceSession.create(u8).then(function(s){
        session = s;
        ioNames = resolveNames(s);
        ready = true;
        modelInfo = "inputs ["+(s.inputNames||[]).join(", ")+"]  outputs ["+(s.outputNames||[]).join(", ")+"]";
        if (logFn) logFn("[vad] model "+modelInfo);
        resolve(true);
      }).catch(function(err){ lastError=String(err); resolve(false); });
    });
  }

  function pick(names, prefs){ for(var i=0;i<prefs.length;i++) if(names.indexOf(prefs[i])>=0) return prefs[i]; return names[0]; }
  function resolveNames(s){
    var ins=s.inputNames||[], outs=s.outputNames||[];
    return {
      input: pick(ins, ["input"]),
      state: pick(ins, ["state"]),
      sr:    pick(ins, ["sr"]),
      out:   pick(outs, ["output"]),
      stateN:pick(outs, ["stateN","state"])
    };
  }

  function resampleTo16k(samples, sr){
    if (sr===16000) return samples;
    var ratio=16000/sr, n=Math.max(0,Math.round(samples.length*ratio)), out=new Float32Array(n);
    for(var i=0;i<n;i++){ var pos=i/ratio, i0=Math.floor(pos), f=pos-i0; var a=samples[i0]||0, b=(i0+1<samples.length)?samples[i0+1]:a; out[i]=a+(b-a)*f; }
    return out;
  }

  var dbgMaxProb = 0;   // diagnostic: highest speech prob seen (should be ~0.9 when working)
  var dbgFirst = "";    // diagnostic: shapes/prob of the very first inference
  var dbgAudio = "";    // diagnostic: level/length of audio fed to silero
  // v1.03: count clips whose mask SILENTLY degraded to the loudness heuristic
  // (or all-zero) even though the silero model was loaded. The panel logs a
  // warning when this is non-zero — previously the run still claimed "silero".
  var fbClips = 0, fbReason = "";
  function noteFallback(reason){ fbClips++; if(!fbReason) fbReason = String(reason||"").slice(0, 200); }

  // v1.03: gain reference for silero. The old code took the 95th percentile of
  // ALL frame RMS; for a speaker who talks rarely (<~5% of the runtime) that
  // percentile lands on mic BLEED / room tone, and the up-to-300x boost raised
  // the OTHER person's bleed to full speech level — silero then credited their
  // words to this track. Now: estimate the noise floor (20th pct), keep only
  // frames at least ~12 dB above it (likely real speech on THIS mic), and take
  // the 95th pct of those. The boost cap also drops 300x -> 40x (+32 dB);
  // beyond that we are amplifying noise, not speech.
  // NOTE (changelog): conservative heuristic — validate on real episode audio.
  function speechRefFromSorted(sortedR){
    if (!sortedR.length) return 0;
    var nf = sortedR[Math.floor(0.20*(sortedR.length-1))];
    var thr = nf * 3.981;                       // +12 dB over the noise floor
    var i0 = sortedR.length;                     // first index at/above thr
    for (var i=0;i<sortedR.length;i++){ if (sortedR[i] >= thr){ i0 = i; break; } }
    var loudN = sortedR.length - i0;
    if (loudN >= 3){
      return sortedR[i0 + Math.floor(0.95*(loudN-1))];
    }
    return sortedR[Math.floor(0.95*(sortedR.length-1))];   // too few loud frames: old behavior
  }
  var GAIN_CAP = 40;   // was 300

  // Async: returns Uint8Array(nHops) speech mask for the given samples.
  // (Whole-signal path — kept as a drop-in. For large clips use maskForClip,
  //  which decodes in windows so peak RAM is tens of MB instead of ~1GB.)
  function maskForSamples(samples, sr, hopS, nHops, onFrac){
    if (!ready || !session) return Promise.resolve(heuristicMask(samples, sr, hopS, nHops));
    var x = resampleTo16k(samples, sr);
    var FL=512, nFrames=Math.floor(x.length/FL);
    // Boost to a normal SPEECH level (silero expects ~-16 dBFS). Use the 95th
    // percentile of per-frame RMS (the level during speech), NOT the peak — quiet
    // mics with a few loud transients would otherwise stay under-leveled.
    var rmsArr=new Float32Array(nFrames);
    for(var fi=0; fi<nFrames; fi++){ var s=0, o=fi*FL; for(var k=0;k<FL;k++){ var v=x[o+k]; s+=v*v; } rmsArr[fi]=Math.sqrt(s/FL); }
    var sortedR=Float32Array.from(rmsArr).sort();
    var ref = speechRefFromSorted(sortedR);
    var gApplied = (ref>1e-6) ? Math.min(GAIN_CAP, 0.15/ref) : 1;   // 0.15 ≈ -16 dBFS
    // Boost speech to target, then soft-limit (tanh) so loud transients round off
    // instead of hard-clipping — clipped audio wrecks silero's confidence.
    if (gApplied>1.05) for(var gi=0;gi<x.length;gi++){ x[gi]=Math.tanh(x[gi]*gApplied); }
    dbgAudio = "len16k="+x.length+" frames="+nFrames+" gain="+gApplied.toFixed(0)+
               "x speechRef="+(20*Math.log10(ref+1e-12)).toFixed(0)+"dB→"+
               (20*Math.log10(ref*gApplied+1e-12)).toFixed(0)+"dB";
    var CTX=64, IN=CTX+FL;     // silero v5: 64-sample context prepended to each 512 chunk
    var ctx=new Float32Array(CTX);   // context carried from previous chunk (zeros to start)
    var speech=new Uint8Array(nFrames);
    var state=new ort.Tensor("float32", new Float32Array(2*1*128), [2,1,128]);
    var srT=new ort.Tensor("int64", BigInt64Array.from([BigInt(16000)]), []);   // scalar sr
    var f=0;
    return new Promise(function(resolve){
      function step(){
        // process a batch of frames per tick to keep the UI alive
        var budget=0;
        (function loop(){
          if (f>=nFrames){
            var mk = framesToHops(speech, nFrames, hopS, nHops, FL);
            // safety: if silero somehow returns near-empty, use the voice filter.
            var ones=0; for(var z=0;z<mk.length;z++) ones+=mk[z];
            if (mk.length && ones/mk.length < 0.02){ noteFallback("silero marked <2% speech"); mk = heuristicMask(samples, sr, hopS, nHops); }
            resolve(mk); return;
          }
          var inp=new Float32Array(IN);
          inp.set(ctx, 0);
          var base=f*FL;
          for (var c2=0;c2<FL;c2++) inp[CTX+c2]=x[base+c2];
          ctx=inp.slice(IN-CTX, IN);   // last 64 samples become next context
          var feeds={}; feeds[ioNames.input]=new ort.Tensor("float32", inp, [1,IN]);
          feeds[ioNames.state]=state; feeds[ioNames.sr]=srT;
          session.run(feeds).then(function(out){
            var p=out[ioNames.out].data[0];
            // COPY the recurrent state forward (ORT may recycle the output buffer,
            // which would corrupt the state and stop confidence from building).
            var sd=out[ioNames.stateN].data;
            state=new ort.Tensor("float32", Float32Array.from(sd), [2,1,128]);
            if (f===0){ try { dbgFirst="out["+(out[ioNames.out].dims||[]).join("x")+"]="+p.toFixed(3)+" stateN["+(out[ioNames.stateN].dims||[]).join("x")+"]"; } catch(e){} }
            if (p>dbgMaxProb) dbgMaxProb=p;
            speech[f]= p>=0.5 ? 1:0; f++;
            if (onFrac && (f&255)===0) onFrac(f/nFrames);
            if (++budget < 64) loop(); else setTimeout(step,0);
          }).catch(function(err){ lastError=String(err); noteFallback(lastError); resolve(heuristicMask(samples, sr, hopS, nHops)); });
        })();
      }
      step();
    });
  }

  // ---- Windowed, memory-bounded mask (bit-identical to maskForSamples) ----
  // Decodes the clip in `winSec` windows so peak RAM is tens of MB rather than
  // ~1GB. The whole-signal math is reproduced EXACTLY:
  //   * 16k timeline — a streaming resampler computes output positions against
  //     the GLOBAL sample index and carries the seam sample, so no sample is
  //     dropped or duplicated at a window boundary.
  //   * global 95th-pct gain ref — PASS 1 streams every window, resamples it,
  //     and stores ONLY the per-512-frame RMS values (~a few hundred KB/hr), so
  //     the sorted-percentile pick is identical to the whole-signal version.
  //   * tanh boost — applied per sample with the same global gApplied.
  //   * silero 64-sample context + recurrent state — carried continuously
  //     across window boundaries (never reset per window).
  //
  // readWin(inSec, durSec) -> { samples:Float32Array, sr } reads ONE channel for
  // that source-time window (typically engine.readChannelWindow bound to a
  // path+channel via a closure). clipDurSec = clip length in seconds (null = to
  // end of file). hopS/nHops/onFrac as in maskForSamples.
  function maskForClip(readWin, clipDurSec, hopS, nHops, onFrac, opts){
    opts = opts || {};
    var winSec = opts.winSec || 60;
    var FL = 512, CTX = 64, IN = CTX + FL;

    return new Promise(function(resolve, reject){
      // ---- settle-once guard --------------------------------------------
      // The async continuation below runs via setTimeout callbacks (the
      // resampler pump, the drain, readWin reads, the seam peek). If ANY of
      // them throws — most realistically a readWin file-read failure mid-
      // stream — the exception escapes the executor and this Promise would
      // never settle, hanging `await maskForClip` forever. settle() makes
      // sure we resolve exactly once; fallback() resolves to the heuristic
      // mask (or an all-zero mask if we can't get samples) on any throw.
      var settled = false;
      function settle(v){ if(settled) return; settled=true; resolve(v); }
      function settleErr(e){ if(settled) return; settled=true; reject(e); }
      // v1.05 (audit #7): per-chunk cancellation. Stop used to only take effect
      // between clips; opts.shouldCancel is checked before every decode window
      // and every silero drain step, and rejects with the same "cancelled"
      // error the pipeline's try/catch already handles.
      var shouldCancel = opts.shouldCancel || null;
      function cancelled(){ try { return !!(shouldCancel && shouldCancel()); } catch(e){ return false; } }
      function fallback(err){
        if(settled) return;
        if (err && err.message === "cancelled"){ settleErr(err); return; }   // user Stop, not a degrade
        lastError = String(err);
        if (ready) noteFallback(lastError);   // silero WAS loaded — this clip silently degraded
        // best-effort: try the classic voice filter, streamed in windows (async —
        // it yields so the panel repaints and Stop stays live); if even that
        // throws, fall back to an all-zero mask so the await still settles.
        heuristicMaskWindowed(readWin, clipDurSec, hopS, nHops, opts)
          .then(function(mk){ settle(mk); })
          .catch(function(e2){
            if (e2 && e2.message === "cancelled"){ settleErr(e2); }
            else { settle(new Uint8Array(nHops)); }
          });
      }

      // probe sample rate (and confirm the file is readable) with a tiny read
      var probe;
      try { probe = readWin(0, 0); } catch(e){ lastError=String(e); if (ready) noteFallback(lastError); settle(new Uint8Array(nHops)); return; }
      var sr = probe.sr;
      var ratio = 16000 / sr;

      if (!ready || !session){
        // No model: classic voice filter, streamed in windows (bit-identical to
        // heuristicMask over the whole clip) so we never hold the whole clip.
        heuristicMaskWindowed(readWin, clipDurSec, hopS, nHops, opts)
          .then(function(mk){ settle(mk); })
          .catch(function(e){ fallback(e); });
        return;
      }

      // ---------- PASS 1: global stats over the 16k timeline ----------------
      // Stream windows, resample on the global timeline, keep only per-frame RMS.
      var rmsList = [];
      var acc = 0, accN = 0;                 // partial-frame sum-of-squares + count
      streamResampled(function(v){           // onSample (16k, pre-gain)
        acc += v*v; accN++;
        if (accN === FL){ rmsList.push(Math.sqrt(acc/FL)); acc = 0; accN = 0; }
      }, function(){                          // onDone
        var nFrames = rmsList.length;        // == floor(len16k/FL), like whole-signal
        var sortedR = Float32Array.from(rmsList).sort();
        var ref = speechRefFromSorted(sortedR);
        var gApplied = (ref>1e-6) ? Math.min(GAIN_CAP, 0.15/ref) : 1;
        rmsList = null; sortedR = null;
        dbgAudio = "len16k≈"+(nFrames*FL)+" frames="+nFrames+" gain="+gApplied.toFixed(0)+
                   "x speechRef="+(20*Math.log10(ref+1e-12)).toFixed(0)+"dB→"+
                   (20*Math.log10(ref*gApplied+1e-12)).toFixed(0)+"dB";
        pass2(nFrames, ref, gApplied);
      });

      // ---------- PASS 2: silero over windows, continuous ctx + state -------
      function pass2(nFrames, ref, gApplied){
        var doGain = gApplied > 1.05;
        var ctx = new Float32Array(CTX);
        var speech = new Uint8Array(nFrames);
        var state = new ort.Tensor("float32", new Float32Array(2*1*128), [2,1,128]);
        var srT = new ort.Tensor("int64", BigInt64Array.from([BigInt(16000)]), []);
        var queue = new Float32Array(FL), qn = 0;   // partial 512-frame buffer,
        var f = 0;                                   // CARRIED across window seams
        // Back-pressured pipeline: the producer resamples ONE decode window at a
        // time into `winBuf` (a Float32Array, post-gain), then pauses. The
        // consumer drains winBuf through silero 512 samples at a time (qn carries
        // a partial frame across the window seam, so 512-frames that straddle a
        // boundary stay intact), then resumes the producer for the next window.
        // Peak extra RAM ≈ one window of 16k samples (~3.8MB for 60s) — not the
        // whole-clip 16k copy.
        var winBuf = null, winPos = 0;       // current window's gained 16k samples
        var producerDone = false;
        var resumeProducer = null;

        // Collect a window's resampled samples into a right-sized Float32Array.
        var collect = [];
        streamResampledPausable(
          function onSample(v){ collect.push(doGain ? Math.tanh(v*gApplied) : v); },
          function onWindowEnd(resume){      // called after each decode window
            winBuf = Float32Array.from(collect); collect = []; winPos = 0;
            resumeProducer = resume;         // pause producer; drain then resume
            drain();
          },
          function onDone(){
            winBuf = Float32Array.from(collect); collect = []; winPos = 0;
            producerDone = true; drain();
          }
        );

        function drain(){
         try {
          if (cancelled()){ settleErr(new Error("cancelled")); return; }
          if (f >= nFrames){ finish(false); return; }
          // fill the 512-sample queue from the current window buffer
          while (qn < FL && winBuf && winPos < winBuf.length){ queue[qn++] = winBuf[winPos++]; }
          if (qn < FL){
            // current window exhausted; need the next one
            winBuf = null;
            if (producerDone){ finish(false); return; }   // trailing partial frame
            var r = resumeProducer; resumeProducer = null;
            if (r) setTimeout(r, 0);         // let the next window decode
            return;
          }
          var inp = new Float32Array(IN);
          inp.set(ctx, 0);
          inp.set(queue, CTX);
          ctx = inp.slice(IN-CTX, IN);
          qn = 0;
          var feeds = {}; feeds[ioNames.input]=new ort.Tensor("float32", inp, [1,IN]);
          feeds[ioNames.state]=state; feeds[ioNames.sr]=srT;
          session.run(feeds).then(function(out){
            var p=out[ioNames.out].data[0];
            var sd=out[ioNames.stateN].data;
            state=new ort.Tensor("float32", Float32Array.from(sd), [2,1,128]);
            if (f===0){ try { dbgFirst="out["+(out[ioNames.out].dims||[]).join("x")+"]="+p.toFixed(3)+" stateN["+(out[ioNames.stateN].dims||[]).join("x")+"]"; } catch(e){} }
            if (p>dbgMaxProb) dbgMaxProb=p;
            speech[f]= p>=0.5 ? 1:0; f++;
            if (onFrac && (f&255)===0) onFrac(f/nFrames);
            if ((f & 63) !== 0) drain(); else setTimeout(drain, 0);
          }).catch(function(err){ lastError=String(err); finish(true); });
         } catch(e){ fallback(e); }   // any throw in the drain body settles once
        }

        function finish(failed){
          if (failed){ fallback("silero run failed"); return; }
          var mk = framesToHops(speech, nFrames, hopS, nHops, FL);
          var ones=0; for(var z=0;z<mk.length;z++) ones+=mk[z];
          if (mk.length && ones/mk.length < 0.02){
            noteFallback("silero marked <2% speech");
            // Safety net: silero marked almost nothing — fall back to the voice
            // filter. Compute the heuristic envelope in WINDOWS so we don't
            // decode the whole clip (~691MB) into one Float32Array. The
            // heuristic is per-hop with one GLOBAL stat (15th-pct noise floor),
            // so we stream it via heuristicMaskWindowed (bit-identical output;
            // async as of v1.05 so the panel stays responsive).
            heuristicMaskWindowed(readWin, clipDurSec, hopS, nHops, opts)
              .then(function(mk2){ settle(mk2); })
              .catch(function(e2){
                if (e2 && e2.message === "cancelled"){ settleErr(e2); }
                else { settle(new Uint8Array(nHops)); }
              });
            return;
          }
          settle(mk);
        }
      }

      // ---- streaming resampler (synchronous producer, no pause) ------------
      // Calls onSample(v) for each global 16k output sample, in order, then
      // onDone(). Equivalent to resampleTo16k(wholeClip) sample-for-sample.
      function streamResampled(onSample, onDone){
        runResampler(onSample, null, onDone);
      }
      // Pausable variant: after emitting each decode window's resampled output it
      // calls onWindowEnd(resume); the caller invokes resume() to continue. This
      // lets PASS 2 drain a window through (async) silero before decoding more.
      function streamResampledPausable(onSample, onWindowEnd, onDone){
        runResampler(onSample, onWindowEnd, onDone);
      }

      function runResampler(onSample, onWindowEnd, onDone){
        var inFrames = Math.floor(winSec * sr);
        if (inFrames < 1) inFrames = 1;
        var inStart = 0;          // global input index at window start
        var outNext = 0;          // next global 16k output index to emit
        var totalIn = null;       // discovered at the last window

        function pump(){
         try {
          if (cancelled()){ settleErr(new Error("cancelled")); return; }
          var rd = readWin(inStart / sr, winSec);
          var buf = rd.samples;
          var len = buf.length;
          // Distinguish a genuine EOF from a transient short/zero-length read
          // caused by a disk hiccup. We can only tell a glitch from a real EOF
          // when the clip's expected duration is KNOWN (clipDurSec != null): a
          // short read whose window-end falls BEFORE clipDurSec is a glitch, not
          // the end. If clipDurSec is null we read to file end, so a short/empty
          // read IS the legitimate EOF (unchanged from before — no false retry).
          var reachedExpected = (clipDurSec != null) &&
                                ((inStart + len)/sr >= clipDurSec - 1e-9);
          var prematureShort = (clipDurSec != null) &&
                               (len < inFrames || len === 0) && !reachedExpected;
          if (prematureShort){
            var retries = 0, rd2;
            while (retries < 2 && (len < inFrames || len === 0)){
              retries++;
              rd2 = readWin(inStart / sr, winSec);
              if (rd2.samples.length > len){ rd = rd2; buf = rd.samples; len = buf.length; }
            }
            reachedExpected = ((inStart + len)/sr >= clipDurSec - 1e-9);
            if ((len < inFrames || len === 0) && !reachedExpected){
              // Still short before expected end after retries: a real disk-read
              // glitch. Surface it (lastError) and fall back to the heuristic so
              // the run reports a problem instead of silently truncating speech.
              fallback(new Error("short read before EOF at " +
                (inStart/sr).toFixed(3) + "s (got " + len + "/" + inFrames +
                " frames, clip " + clipDurSec + "s)"));
              return;
            }
          }
          var inEnd = inStart + len;
          var isLast = (len < inFrames) ||
                       (clipDurSec != null && inEnd/sr >= clipDurSec - 1e-9) ||
                       (len === 0);
          if (isLast) totalIn = inEnd;
          // seam neighbour: the sample AFTER this window's last sample lives in
          // the next window — peek it so the lerp at the boundary matches the
          // whole-signal result (b = samples[i0+1]).
          var carryNext = NaN;
          if (!isLast){
            var pk = readWin(inEnd/sr, 2/sr);
            carryNext = pk.samples.length ? pk.samples[0] : NaN;
          }
          var tIn = (totalIn != null) ? totalIn : Infinity;
          // emit all global out indices fully determined by this window
          var i = outNext;
          for(;;){
            var pos = i/ratio, i0 = Math.floor(pos);
            if (i0 >= inEnd) break;            // need next window
            var fr = pos - i0;
            var a = buf[i0 - inStart];
            var b;
            if (i0+1 < inEnd) b = buf[i0+1 - inStart];
            else {
              if (i0+1 < tIn){
                if (carryNext === carryNext) b = carryNext;   // not NaN
                else break;                                   // wait for next window
              } else {
                b = a;                                        // final sample → b=a
              }
            }
            onSample(a + (b - a) * fr);
            i++;
          }
          outNext = i;
          buf = null; rd = null;
          inStart = inEnd;
          if (isLast){ onDone(); return; }
          if (onWindowEnd){ onWindowEnd(function(){ pump(); }); }   // pausable
          else setTimeout(pump, 0);                                 // free-run, yield UI
         } catch(e){ fallback(e); }   // any readWin/seam-peek throw settles once
        }
        pump();
      }
    });
  }

  function framesToHops(speech, nFrames, hopS, nHops, FL){
    var frameDur=FL/16000;
    var mask=new Uint8Array(nHops);
    for(var h=0;h<nHops;h++){ var fi=Math.floor((h*hopS)/frameDur); mask[h]=(fi<nFrames && speech[fi])?1:0; }
    return mask;
  }

  // Classic fallback: energy above an adaptive noise floor + voiced (low ZCR).
  function heuristicMask(samples, sr, hopS, nHops){
    var hop=Math.max(1,Math.round(sr*hopS));
    var n=Math.min(nHops, Math.ceil(samples.length/hop));
    var eng=new Float32Array(n), zcr=new Float32Array(n);
    for(var i=0;i<n;i++){
      var s=i*hop, e=Math.min(samples.length,s+hop), sum=0, zc=0, prev=0, c=0;
      for(var j=s;j<e;j++){ var v=samples[j]; sum+=v*v; if(j>s && ((v>=0)!==(prev>=0))) zc++; prev=v; c++; }
      eng[i]= c? 10*Math.log10(sum/c+1e-12) : -120;
      zcr[i]= c? zc/c : 0;
    }
    // adaptive noise floor = 15th percentile of energy
    var sorted=Float32Array.from(eng); sorted.sort();
    var floor=sorted[Math.floor(0.15*(sorted.length-1))||0];
    var mask=new Uint8Array(nHops);
    for(var t=0;t<n;t++){ mask[t]= (eng[t] > floor+6 && zcr[t] < 0.30) ? 1:0; }
    // close gaps shorter than ~120ms
    var gap=Math.max(1,Math.round(0.12/hopS));
    for(var t2=1;t2<n-1;t2++){ if(!mask[t2]){ var a=t2; while(t2<n && !mask[t2]) t2++; if(t2-a<=gap && a>0 && t2<n){ for(var k=a;k<t2;k++) mask[k]=1; } } }
    return mask;
  }

  // ---- Windowed, memory-bounded classic fallback --------------------------
  // Bit-identical to heuristicMask(wholeClip,...) but never decodes the whole
  // clip into one Float32Array. readWin(inSec,durSec)->{samples,sr} reads ONE
  // channel for a source-time window (same closure maskForClip uses). The per-
  // hop energy/ZCR are LOCAL (prev resets each hop, j>s guards the first sample
  // so no cross-hop state), so we just need each hop's contiguous samples; we
  // carry a remainder of <hop samples across each window seam so a hop never
  // straddles a boundary. The one GLOBAL stat — the 15th-pct noise floor — is
  // computed after the streaming pass over the small per-hop `eng` array
  // (nHops entries, not samples), exactly like the whole-clip version.
  // v1.05 (audit #7): now ASYNC — the loop yields to the event loop after each
  // decode window so progress can paint and Stop stays clickable during a
  // loudness-fallback pass over a long clip (it used to stream the whole clip
  // synchronously), and it honors opts.shouldCancel by throwing the pipeline's
  // "cancelled" error. The mask output is unchanged.
  async function heuristicMaskWindowed(readWin, clipDurSec, hopS, nHops, opts){
    opts = opts || {};
    var winSec = opts.winSec || 60;
    // probe sample rate
    var probe = readWin(0, 0);
    var sr = probe.sr;
    var hop = Math.max(1, Math.round(sr*hopS));

    var eng = [], zcr = [];        // per-hop, grown as we stream
    var rem = new Float32Array(0); // leftover (<hop) samples carried across seam
    var inStart = 0;               // global input sample index at window start

    // finalize one full hop's worth of samples from a contiguous buffer slice
    function emitHop(buf, off){
      var sum=0, zc=0, prev=0;
      for(var j=0;j<hop;j++){ var v=buf[off+j]; if(j>0 && ((v>=0)!==(prev>=0))) zc++; prev=v; sum+=v*v; }
      eng.push(10*Math.log10(sum/hop+1e-12));
      zcr.push(zc/hop);
    }

    var inFrames = Math.floor(winSec*sr); if(inFrames<1) inFrames=1;
    for(;;){
      if (opts.shouldCancel && opts.shouldCancel()) throw new Error("cancelled");
      var rd = readWin(inStart/sr, winSec);
      var buf = rd.samples, len = buf.length;
      // v1.03: distinguish genuine EOF from a transient short/empty read (disk
      // hiccup) when the clip duration is KNOWN — the same fix the silero path
      // got in June, which this fallback path was missed by. Retry twice; if it
      // persists, record the problem instead of silently truncating the tail.
      // v1.04: FIXED 3s tolerance (was max(3s, 2%) — the 2% term scaled to ~2.4
      // min on a 2-hour clip, so a real mid-clip truncation that far from the
      // end was silently accepted as EOF). 3s still covers the common case of a
      // media file being a hair shorter than its declared duration.
      var _eofTol = 3.0;
      var _reached = (clipDurSec != null) && ((inStart+len)/sr >= clipDurSec - _eofTol);
      if ((clipDurSec != null) && (len < inFrames || len === 0) && !_reached){
        var _retries = 0, _rd2;
        while (_retries < 2 && (len < inFrames || len === 0)){
          _retries++;
          try { _rd2 = readWin(inStart/sr, winSec); } catch(eR){ break; }
          if (_rd2 && _rd2.samples.length > len){ rd = _rd2; buf = rd.samples; len = buf.length; }
        }
        if ((len < inFrames || len === 0) && !((inStart+len)/sr >= clipDurSec - _eofTol)){
          lastError = "heuristicMaskWindowed: short read before EOF at " + (inStart/sr).toFixed(3) +
                      "s (clip " + clipDurSec + "s) — possible disk glitch, speech after this point may be missed";
        }
      }
      var isLast = (len < inFrames) ||
                   (clipDurSec != null && (inStart+len)/sr >= clipDurSec - 1e-9) ||
                   (len === 0);
      // concat remainder + this window into one contiguous buffer
      var work;
      if (rem.length){ work = new Float32Array(rem.length+len); work.set(rem,0); work.set(buf,rem.length); }
      else work = buf;
      var total = work.length, pos = 0;
      // emit every FULL hop in `work`; keep the tail (<hop) as the new remainder
      while (total - pos >= hop){ emitHop(work, pos); pos += hop; }
      rem = (total-pos>0) ? work.slice(pos) : new Float32Array(0);
      inStart += len; buf=null; rd=null; work=null;
      if (isLast) break;
      // yield between decode windows: repaint + Stop responsiveness
      await new Promise(function(r){ setTimeout(r, 0); });
    }
    // trailing partial hop (mirrors whole-clip n=ceil(len/hop): the last short
    // chunk still becomes a hop, averaged over its actual sample count)
    if (rem.length){
      var sum2=0, zc2=0, prev2=0, c2=rem.length;
      for(var j2=0;j2<c2;j2++){ var v2=rem[j2]; if(j2>0 && ((v2>=0)!==(prev2>=0))) zc2++; prev2=v2; sum2+=v2*v2; }
      eng.push(10*Math.log10(sum2/c2+1e-12));
      zcr.push(zc2/c2);
    }

    var nAll = eng.length;
    var n = Math.min(nHops, nAll);
    // adaptive noise floor = 15th percentile of energy. The whole-clip version
    // builds `eng` with length n = min(nHops, ceil(len/hop)) and sorts THAT, so
    // the percentile is over the first n hops only — replicate that here.
    var engArr = Float32Array.from(eng.slice(0, n));
    var sorted = Float32Array.from(engArr); sorted.sort();
    var floor = sorted[Math.floor(0.15*(sorted.length-1))||0];
    var mask = new Uint8Array(nHops);
    for(var t=0;t<n;t++){ mask[t] = (engArr[t] > floor+6 && zcr[t] < 0.30) ? 1:0; }
    // close gaps shorter than ~120ms
    var gap = Math.max(1, Math.round(0.12/hopS));
    for(var t2=1;t2<n-1;t2++){ if(!mask[t2]){ var a=t2; while(t2<n && !mask[t2]) t2++; if(t2-a<=gap && a>0 && t2<n){ for(var k=a;k<t2;k++) mask[k]=1; } } }
    return mask;
  }

  var API = { init:init, maskForSamples:maskForSamples, maskForClip:maskForClip,
              heuristicMask:heuristicMask, heuristicMaskWindowed:heuristicMaskWindowed,
              resampleTo16k:resampleTo16k,
              isReady:function(){return ready;}, error:function(){return lastError;},
              modelInfo:function(){return modelInfo;},
              maxProb:function(){return dbgMaxProb;}, firstFrame:function(){return dbgFirst;},
              audioInfo:function(){return dbgAudio;},
              resetStats:function(){dbgMaxProb=0; dbgFirst=""; dbgAudio=""; fbClips=0; fbReason="";},
              fallbacks:function(){ return { count: fbClips, reason: fbReason }; },
              // test-only: inject a fake silero session so maskForClip exercises
              // the windowed runResampler path without a real ONNX model.
              _setSessionForTest:function(s2, names){ session=s2; ioNames=names; ready=!!s2; } };
  if (typeof module==="object" && module.exports) module.exports=API;
  root.OnMicVAD = API;
})(typeof window!=="undefined"?window:this);
