@sfgartland/better-pi-voice 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,9 +21,10 @@ import { basename, join } from "node:path";
21
21
  import { pathToFileURL } from "node:url";
22
22
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
23
23
  import { type Clock, systemClock, type TimerHandle } from "../clock.ts";
24
- import { float32ToS16le, levelFromS16le, s16leToFloat32 } from "../formats.ts";
25
- import { modelsDir } from "../model-download.ts";
24
+ import { float32ToS16le, s16leToFloat32 } from "../formats.ts";
25
+ import { MODEL_CATALOG, modelsDir } from "../model-download.ts";
26
26
  import type { FinalizeOutcome, StartOptions, VoiceBackend, VoiceEvent, VoiceStream } from "../types.ts";
27
+ import { createLevelPauseDetector, createVadPauseDetector, type PauseDetector } from "./pause-detector.ts";
27
28
 
28
29
  type StreamText = { full: string; committed: string; tentative: string };
29
30
  type StreamUpdate = {
@@ -47,11 +48,16 @@ type CppStream = {
47
48
  readonly snapshot: CppTranscript;
48
49
  };
49
50
  type CppSession = { stream(options: Record<string, unknown>): Promise<CppStream>; dispose(): void };
51
+ type CppVadSession = { streamFeed(pcm: Float32Array): Promise<{ probs: number[] }>; dispose(): void };
50
52
  type CppModel = {
51
53
  readonly capabilities: { supportsStreaming: boolean; languages: string[]; nativeSampleRate: number };
52
54
  createSession(): CppSession;
53
55
  transcribe(pcm: Float32Array, options?: Record<string, unknown>): Promise<{ text: string }>;
54
56
  dispose(): void;
57
+ /** Present only on a VAD-role model. */
58
+ vadInfo?: { frameSamples: number };
59
+ /** Present only on a VAD-role model. */
60
+ createVadSession?(options?: { nThreads?: number }): CppVadSession;
55
61
  };
56
62
 
57
63
  /**
@@ -243,16 +249,29 @@ export async function availableModels(): Promise<AvailableModel[]> {
243
249
  return [...byLabel.values()].sort((a, b) => a.label.localeCompare(b.label));
244
250
  }
245
251
 
252
+ /**
253
+ * Find the Silero VAD GGUF if it is already on disk, reusing the same scan as
254
+ * `availableModels`: the Hugging Face Hub cache and the setup wizard's flat
255
+ * cache. It is usually inherited from pi-voice's bundled model, not downloaded
256
+ * by this extension.
257
+ */
258
+ export async function resolveVadModelPath(): Promise<string | undefined> {
259
+ const entry = MODEL_CATALOG.find((model) => model.role === "vad");
260
+ if (!entry) return undefined;
261
+ const models = await availableModels();
262
+ return models.find((model) => basename(model.path) === entry.filename)?.path;
263
+ }
264
+
246
265
  /**
247
266
  * Loaded models are held for this long after the last session ends.
248
267
  *
249
- * Zero by default: a warm reload measures about 0.4s for the model this was
250
- * measured, which is cheaper than holding several hundred megabytes between
251
- * recordings. Raise it for larger models, where a reload costs enough to be
252
- * worth the memory. `DEFAULT_LOAD_SHED_RATIO` only has anything to do while
253
- * this is non-zero, because at zero the memory is already back.
268
+ * A minute, not zero: a warm reload measures about 0.4 s for the model this was
269
+ * measured on, but with the recording queue several recordings can land in one
270
+ * burst, and reloading a 135 MB–700 MB model between each of them is felt.
271
+ * Residency is released as soon as the queue empties and the window passes, and
272
+ * the load-shed check below hands the memory back early on a busy machine.
254
273
  */
255
- export const DEFAULT_IDLE_UNLOAD_MS = 0;
274
+ export const DEFAULT_IDLE_UNLOAD_MS = 60_000;
256
275
  /** How often to check whether the machine has become busy while idle. */
257
276
  export const DEFAULT_LOAD_CHECK_MS = 60_000;
258
277
  /** One-minute load average per CPU at which the model is handed back early. */
@@ -295,6 +314,11 @@ export type ParakeetBackendOptions = {
295
314
  systemLoad?: (() => number) | undefined;
296
315
  /** Injectable for tests, so residency can be exercised without a GGUF. */
297
316
  loadModel?: ((path: string) => Promise<CppModel>) | undefined;
317
+ /**
318
+ * The Silero VAD GGUF to use for pause detection. `undefined` auto-resolves
319
+ * from the model caches; `null` disables VAD and uses the level heuristic.
320
+ */
321
+ vadModelPath?: string | null | undefined;
298
322
  };
299
323
 
300
324
  async function defaultLoadModel(path: string): Promise<CppModel> {
@@ -427,7 +451,13 @@ class ParakeetStream implements VoiceStream {
427
451
  }
428
452
  }
429
453
 
430
- /** Chunk length for incremental batch transcription. */
454
+ /**
455
+ * Chunk length for incremental batch transcription.
456
+ *
457
+ * A 15 s chunk costs about 4.4 s to decode at the measured 3.38 RTF, which halves
458
+ * text latency against the old 30 s window. It is a *target*: audio is cut at the
459
+ * first real pause at or after 70% of it, so unbroken speech still grows past it.
460
+ */
431
461
  export const DEFAULT_CHUNK_SECONDS = 15;
432
462
 
433
463
  /**
@@ -446,8 +476,8 @@ export const DEFAULT_CHUNK_SECONDS = 15;
446
476
  export type BatchStreamOptions = {
447
477
  /** 0 disables incremental chunks and transcribes everything at finalize. */
448
478
  chunkSeconds: number;
449
- /** Level at or below which a boundary counts as a real pause. */
450
- silenceLevel: number | "auto";
479
+ /** Decides where a pause is; the backend picks VAD or the level heuristic. */
480
+ detector: PauseDetector;
451
481
  transcribe(pcm: Float32Array): Promise<string>;
452
482
  emit(event: VoiceEvent): void;
453
483
  /** Called once when the session ends, so residency can be scheduled. */
@@ -466,30 +496,28 @@ const MIN_CHUNK_FRACTION = 0.7;
466
496
  * no pause therefore stays in one piece and is transcribed when recording ends.
467
497
  */
468
498
  const SILENCE_LEVEL = 0.35;
469
- /** Below this much variation there is no pause to find, so nothing is cut. */
470
- const MIN_DYNAMIC_RANGE = 0.1;
471
- /** Where in the noise-to-speech range a boundary stops counting as speech. */
472
- const PAUSE_RANGE_FRACTION = 0.3;
473
499
 
474
500
  class ParakeetBatchStream implements VoiceStream {
475
501
  private chunks: Buffer[] = [];
476
- private levels: number[] = [];
477
502
  private bytes = 0;
478
503
  private queue: Promise<void> = Promise.resolve();
479
504
  private closed = false;
480
505
  private finalizeStarted = false;
481
506
  private failure: string | undefined;
482
507
  private released = false;
508
+ private pendingTasks = 0;
483
509
  private readonly chunkBytes: number;
510
+ private readonly detector: PauseDetector;
484
511
 
485
512
  constructor(private readonly options: BatchStreamOptions) {
486
513
  this.chunkBytes = Math.round(options.chunkSeconds * 1000 * BYTES_PER_MS);
514
+ this.detector = options.detector;
487
515
  }
488
516
 
489
517
  feed(pcm: Buffer): void {
490
518
  if (this.closed || this.finalizeStarted) return;
491
519
  this.chunks.push(Buffer.from(pcm));
492
- this.levels.push(levelFromS16le(pcm));
520
+ this.detector.push(pcm);
493
521
  this.bytes += pcm.length;
494
522
  if (this.chunkBytes > 0) this.cutIfReady();
495
523
  }
@@ -511,16 +539,37 @@ class ParakeetBatchStream implements VoiceStream {
511
539
  close(): void {
512
540
  this.closed = true;
513
541
  // The queue may still be transcribing; releasing the model now could dispose
514
- // it under an in-flight transcribe. Hand it back once the queue settles.
542
+ // it under an in-flight transcribe. Hand it back once the queue settles,
543
+ // or immediately when there is nothing in flight.
544
+ if (this.pendingTasks === 0) {
545
+ this.notifyReleased();
546
+ return;
547
+ }
515
548
  void this.queue.then(
516
549
  () => this.notifyReleased(),
517
550
  () => this.notifyReleased(),
518
551
  );
519
552
  }
520
553
 
554
+ /**
555
+ * Transcribe the buffered tail right now, without waiting for the chunk
556
+ * target. A new recording calls this so a queued batch tail starts decoding
557
+ * immediately instead of after `chunkSeconds` more audio.
558
+ */
559
+ cut(): void {
560
+ if (this.finalizeStarted) return;
561
+ const all = this.takeAll();
562
+ if (all) this.enqueue(all);
563
+ }
564
+
521
565
  private notifyReleased(): void {
522
566
  if (this.released) return;
523
567
  this.released = true;
568
+ try {
569
+ this.detector.dispose?.();
570
+ } catch {
571
+ // Releasing a VAD session that is already gone is not worth reporting.
572
+ }
524
573
  this.options.onReleased?.();
525
574
  }
526
575
 
@@ -529,77 +578,40 @@ class ParakeetBatchStream implements VoiceStream {
529
578
  }
530
579
 
531
580
  /**
532
- * Where a pause starts, derived from this recording rather than from a fixed
533
- * number.
534
- *
535
- * A fixed threshold cannot work across rooms: in our test recordings, one recording
536
- * never dropped below 0.05 (music playing) while another paused around 0.3.
537
- * So the floor and the speech level are taken from the buffer's own level
538
- * distribution and a pause is anything in the lowest part of that range.
539
- *
540
- * When the level barely varies there is no pause structure to find, and this
541
- * returns undefined so nothing is cut. That is the safe answer for unbroken
542
- * speech or steady background noise.
543
- */
544
- private pauseThreshold(): number | undefined {
545
- const configured = this.options.silenceLevel;
546
- if (typeof configured === "number") return configured;
547
-
548
- const levels = this.levels;
549
- if (levels.length < 8) return undefined;
550
- const sorted = [...levels].sort((a, b) => a - b);
551
- // The second-smallest level, not a low percentile: pauses are brief, so a
552
- // 10th percentile often IS speech. Two silent chunks in forty still yields a
553
- // zero floor, whereas the percentile reported 1.00 and found no pause
554
- // at all. Taking the second rather than the smallest ignores one stray click.
555
- const noise = sorted[1]!;
556
- const speech = sorted[Math.floor(0.75 * (sorted.length - 1))]!;
557
- const range = speech - noise;
558
- if (range < MIN_DYNAMIC_RANGE) return undefined;
559
- return noise + PAUSE_RANGE_FRACTION * range;
560
- }
561
-
562
- /**
563
- * Cut at the first genuine pause at or after the minimum length.
564
- *
565
- * The search starts at the minimum and runs to the end of what has been
566
- * captured, so the window slides forward. An earlier version searched only the
567
- * span between the minimum and the target length, anchored at the start of the
568
- * buffer: if the first pause fell outside that span, nothing could ever be cut
569
- * again and the whole utterance arrived at the end. Chunk length is therefore
570
- * a target, not a limit, and grows when speech is unbroken.
581
+ * Cut at the first genuine pause at or after the minimum length. The
582
+ * detector owns the pause logic; this only applies its first answer.
571
583
  */
572
584
  private cutIfReady(): void {
573
- const minKeep = this.chunkBytes * MIN_CHUNK_FRACTION;
574
- if (this.bytes < minKeep) return;
575
- const threshold = this.pauseThreshold();
576
- if (threshold === undefined) return;
577
-
578
- let cumulative = 0;
579
- for (let i = 0; i < this.chunks.length; i++) {
580
- cumulative += this.chunks[i]!.length;
581
- if (cumulative < minKeep) continue;
582
- if (this.levels[i]! <= threshold) {
583
- this.cutAfter(i);
584
- return;
585
+ const cuts = this.detector.cuts(this.bytes);
586
+ if (cuts.length === 0) return;
587
+ this.cutAt(cuts[0]!);
588
+ }
589
+
590
+ /** Split the buffer at a byte offset and transcribe the head. */
591
+ private cutAt(offset: number): void {
592
+ const head: Buffer[] = [];
593
+ let taken = 0;
594
+ while (this.chunks.length > 0 && taken < offset) {
595
+ const chunk = this.chunks[0]!;
596
+ if (taken + chunk.length <= offset) {
597
+ this.chunks.shift();
598
+ head.push(chunk);
599
+ taken += chunk.length;
600
+ continue;
585
601
  }
602
+ const split = offset - taken;
603
+ head.push(chunk.subarray(0, split));
604
+ this.chunks[0] = chunk.subarray(split);
605
+ taken = offset;
586
606
  }
587
- }
588
-
589
- /** Split the buffer after chunk `index` and transcribe the head. */
590
- private cutAfter(index: number): void {
591
- const taken = this.chunks.slice(0, index + 1);
592
- this.chunks = this.chunks.slice(index + 1);
593
- this.levels = this.levels.slice(index + 1);
594
- for (const chunk of taken) this.bytes -= chunk.length;
595
- this.enqueue(taken.length === 1 ? taken[0]! : Buffer.concat(taken));
607
+ this.bytes -= taken;
608
+ if (head.length > 0) this.enqueue(head.length === 1 ? head[0]! : Buffer.concat(head));
596
609
  }
597
610
 
598
611
  private takeAll(): Buffer | undefined {
599
612
  if (this.bytes === 0) return undefined;
600
613
  const all = this.chunks.length === 1 ? this.chunks[0]! : Buffer.concat(this.chunks);
601
614
  this.chunks = [];
602
- this.levels = [];
603
615
  this.bytes = 0;
604
616
  return all;
605
617
  }
@@ -607,6 +619,7 @@ class ParakeetBatchStream implements VoiceStream {
607
619
  /** Chunks are transcribed one at a time, so their text stays in order. */
608
620
  private enqueue(pcm: Buffer): void {
609
621
  const samples = s16leToFloat32(pcm);
622
+ this.pendingTasks++;
610
623
  this.queue = this.queue
611
624
  .then(async () => {
612
625
  if (this.closed || this.failure) return;
@@ -621,6 +634,9 @@ class ParakeetBatchStream implements VoiceStream {
621
634
  message: `Local transcription failed: ${this.failure}`,
622
635
  code: "local_failed",
623
636
  });
637
+ })
638
+ .finally(() => {
639
+ this.pendingTasks--;
624
640
  });
625
641
  }
626
642
  }
@@ -642,9 +658,17 @@ export class ParakeetBackend implements VoiceBackend {
642
658
  readonly model = "parakeet";
643
659
 
644
660
  private resolution: Promise<string | undefined> | undefined;
661
+ private vadResolution: Promise<string | undefined> | undefined;
645
662
  private model_: CppModel | undefined;
646
663
  private idleTimer: TimerHandle | undefined;
647
664
  private loadTimer: TimerHandle | undefined;
665
+ /** Open streams; the model cannot be freed while this is non-zero. */
666
+ private activeSessions = 0;
667
+ /** A release asked to unload while sessions were still alive. */
668
+ private pendingUnload = false;
669
+ private disposed = false;
670
+ /** Serialises `model.transcribe` across concurrent batch streams. */
671
+ private transcribeChain: Promise<unknown> = Promise.resolve();
648
672
  private readonly clock: Clock;
649
673
  private readonly idleUnloadMs: number;
650
674
  private readonly loadShedRatio: number;
@@ -666,9 +690,14 @@ export class ParakeetBackend implements VoiceBackend {
666
690
 
667
691
  /**
668
692
  * Drop the model and free its memory. Called on the idle timer, when the
669
- * machine is busy, and on shutdown.
693
+ * machine is busy, and on shutdown. While any stream is still open the model
694
+ * is not touched: freeing it under a running transcription would crash it.
670
695
  */
671
696
  unload(): void {
697
+ if (this.activeSessions > 0) {
698
+ this.pendingUnload = true;
699
+ return;
700
+ }
672
701
  this.cancelIdleTimers();
673
702
  const model = this.model_;
674
703
  this.model_ = undefined;
@@ -680,9 +709,27 @@ export class ParakeetBackend implements VoiceBackend {
680
709
  }
681
710
 
682
711
  async dispose(): Promise<void> {
712
+ this.disposed = true;
713
+ // Returns immediately; the last session release performs the unload.
683
714
  this.unload();
684
715
  }
685
716
 
717
+ /** A stream has ended: free the model once the last one is gone. */
718
+ private onSessionReleased(): void {
719
+ if (this.activeSessions > 0) this.activeSessions--;
720
+ if (this.activeSessions > 0) return;
721
+ this.scheduleIdleUnload();
722
+ }
723
+
724
+ /** All `transcribe` calls go through one chain, so the shared model is never
725
+ * asked to run two batches at once. */
726
+ private serializeTranscribe<T>(task: () => Promise<T>): Promise<T> {
727
+ const run = this.transcribeChain.then(task, task);
728
+ // Keep the chain alive after a rejection so the next call still runs.
729
+ this.transcribeChain = run.catch(() => undefined);
730
+ return run;
731
+ }
732
+
686
733
  private cancelIdleTimers(): void {
687
734
  if (this.idleTimer) {
688
735
  this.clock.clearTimeout(this.idleTimer);
@@ -697,6 +744,12 @@ export class ParakeetBackend implements VoiceBackend {
697
744
  /** A session has ended: hold the model briefly, then let it go. */
698
745
  private scheduleIdleUnload(): void {
699
746
  this.cancelIdleTimers();
747
+ if (this.pendingUnload || this.disposed) {
748
+ // Shutdown was requested while a session was alive; honour it now.
749
+ this.pendingUnload = false;
750
+ this.unload();
751
+ return;
752
+ }
700
753
  if (!this.model_) return;
701
754
  if (this.idleUnloadMs <= 0) {
702
755
  this.unload();
@@ -734,6 +787,54 @@ export class ParakeetBackend implements VoiceBackend {
734
787
  return this.resolution;
735
788
  }
736
789
 
790
+ private resolveVadPath(): Promise<string | undefined> {
791
+ if (this.options.vadModelPath !== undefined) return Promise.resolve(this.options.vadModelPath ?? undefined);
792
+ this.vadResolution ??= resolveVadModelPath();
793
+ return this.vadResolution;
794
+ }
795
+
796
+ /**
797
+ * The batch cutter: Silero VAD when a VAD model can be loaded, the RMS level
798
+ * heuristic otherwise. The fallback keeps the local backend working on a
799
+ * machine that has only the ASR GGUF.
800
+ */
801
+ private async createBatchDetector(chunkSeconds: number, silenceLevel: number | "auto"): Promise<PauseDetector> {
802
+ const levelOptions = {
803
+ chunkSeconds,
804
+ minChunkFraction: MIN_CHUNK_FRACTION,
805
+ silenceLevel,
806
+ bytesPerSecond: BYTES_PER_MS * 1000,
807
+ };
808
+ const path = await this.resolveVadPath();
809
+ if (!path) return createLevelPauseDetector(levelOptions);
810
+ try {
811
+ const vadModel = await this.loadModel(path);
812
+ const frameSamples = vadModel.vadInfo?.frameSamples;
813
+ if (!frameSamples || !vadModel.createVadSession) throw new Error("model does not serve the vad role");
814
+ const vadSession = vadModel.createVadSession({ nThreads: 1 });
815
+ const detector = createVadPauseDetector(vadSession, {
816
+ frameSamples,
817
+ chunkSeconds,
818
+ minChunkFraction: MIN_CHUNK_FRACTION,
819
+ bytesPerSecond: BYTES_PER_MS * 1000,
820
+ });
821
+ return {
822
+ ...detector,
823
+ dispose: () => {
824
+ detector.dispose?.();
825
+ try {
826
+ vadModel.dispose();
827
+ } catch {
828
+ // Disposing a model that is already gone is not worth reporting.
829
+ }
830
+ },
831
+ };
832
+ } catch {
833
+ // No VAD model, or it cannot score: keep working with the heuristic.
834
+ return createLevelPauseDetector(levelOptions);
835
+ }
836
+ }
837
+
737
838
  async checkAvailability(): Promise<{ available: boolean; reason?: string }> {
738
839
  const path = await this.resolvePath();
739
840
  if (!path) {
@@ -756,6 +857,7 @@ export class ParakeetBackend implements VoiceBackend {
756
857
  }
757
858
 
758
859
  async start(options: StartOptions, emit: (event: VoiceEvent) => void): Promise<VoiceStream | undefined> {
860
+ this.disposed = false;
759
861
  const path = await this.resolvePath();
760
862
  if (!path) return undefined;
761
863
 
@@ -782,12 +884,16 @@ export class ParakeetBackend implements VoiceBackend {
782
884
  if (this.mode === "batch") {
783
885
  // Chunked, so text arrives while the user is still speaking; only the
784
886
  // tail is left to transcribe when the recording stops.
887
+ const chunkSeconds = this.options.chunkSeconds ?? DEFAULT_CHUNK_SECONDS;
888
+ const detector = await this.createBatchDetector(chunkSeconds, this.options.silenceLevel ?? SILENCE_LEVEL);
889
+ this.activeSessions++;
785
890
  return new ParakeetBatchStream({
786
- chunkSeconds: this.options.chunkSeconds ?? DEFAULT_CHUNK_SECONDS,
787
- silenceLevel: this.options.silenceLevel ?? SILENCE_LEVEL,
891
+ chunkSeconds,
892
+ detector,
788
893
  emit,
789
- onReleased: () => this.scheduleIdleUnload(),
790
- transcribe: async (pcm) => (await model.transcribe(pcm, { language, timestamps: "none" })).text,
894
+ onReleased: () => this.onSessionReleased(),
895
+ transcribe: (pcm) =>
896
+ this.serializeTranscribe(async () => (await model.transcribe(pcm, { language, timestamps: "none" })).text),
791
897
  });
792
898
  }
793
899
 
@@ -814,7 +920,8 @@ export class ParakeetBackend implements VoiceBackend {
814
920
  commitPolicy: "stable_prefix" as const,
815
921
  stablePrefixAgreementN: STABLE_PREFIX_AGREEMENT,
816
922
  });
817
- return new ParakeetStream(stream, session, emit, () => this.scheduleIdleUnload());
923
+ this.activeSessions++;
924
+ return new ParakeetStream(stream, session, emit, () => this.onSessionReleased());
818
925
  } catch (error) {
819
926
  // A session that was created but whose stream failed must still be freed.
820
927
  session?.dispose();
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Where a batch recording should be cut, behind one interface.
3
+ *
4
+ * The default detector is the RMS heuristic the batch stream has always used:
5
+ * it infers the noise floor and speech level from the recording's own chunk
6
+ * levels and treats anything in the lowest part of that range as a pause. It
7
+ * needs no extra model, but it is fragile in exactly the cases VAD exists for —
8
+ * steady background noise, music, or a room where speech and noise overlap.
9
+ * `createVadPauseDetector` (Silero) replaces it when a VAD model is available.
10
+ */
11
+
12
+ import { levelFromS16le, s16leToFloat32 } from "../formats.ts";
13
+ import { cutCandidates, planCuts, speechSegments } from "./vad.ts";
14
+
15
+ export interface PauseDetector {
16
+ /** Feed the same PCM the batch stream receives. */
17
+ push(pcm: Buffer): void;
18
+ /** Cut offsets in bytes available so far, best first. */
19
+ cuts(capturedBytes: number): number[];
20
+ /** Release anything held (a VAD session), when the stream ends. */
21
+ dispose?(): void;
22
+ }
23
+
24
+ export type PauseDetectorOptions = {
25
+ chunkSeconds: number;
26
+ /** Do not cut before this fraction of the target length. */
27
+ minChunkFraction: number;
28
+ /** A pinned level, or "auto" to derive one per recording. */
29
+ silenceLevel: number | "auto";
30
+ bytesPerSecond: number;
31
+ };
32
+
33
+ /** Below this much variation there is no pause to find, so nothing is cut. */
34
+ const MIN_DYNAMIC_RANGE = 0.1;
35
+ /** Where in the noise-to-speech range a boundary stops counting as speech. */
36
+ const PAUSE_RANGE_FRACTION = 0.3;
37
+
38
+ /**
39
+ * The RMS heuristic, moved out of `ParakeetBatchStream` unchanged.
40
+ *
41
+ * `cuts(capturedBytes)` looks at only the most recent `capturedBytes` worth of
42
+ * pushed audio, because callers cut a prefix off their buffer between calls and
43
+ * the detector must score the same window the caller now holds.
44
+ */
45
+ export function createLevelPauseDetector(options: PauseDetectorOptions): PauseDetector {
46
+ const levels: number[] = [];
47
+ const lengths: number[] = [];
48
+
49
+ /** Slice a window of `capturedBytes` off the end of the history. */
50
+ const window = (capturedBytes: number): { levels: number[]; lengths: number[] } => {
51
+ let total = 0;
52
+ for (const length of lengths) total += length;
53
+ const start = Math.max(0, total - capturedBytes);
54
+ let accumulated = 0;
55
+ let index = 0;
56
+ while (index < lengths.length && accumulated < start) {
57
+ accumulated += lengths[index]!;
58
+ index++;
59
+ }
60
+ return { levels: levels.slice(index), lengths: lengths.slice(index) };
61
+ };
62
+
63
+ const thresholdFor = (windowLevels: readonly number[]): number | undefined => {
64
+ if (typeof options.silenceLevel === "number") return options.silenceLevel;
65
+ if (windowLevels.length < 8) return undefined;
66
+ const sorted = [...windowLevels].sort((a, b) => a - b);
67
+ // The second-smallest level, not a low percentile: pauses are brief, so a
68
+ // 10th percentile often IS speech. Two silent chunks in forty still yields a
69
+ // zero floor, whereas the percentile reported 1.00 and found no pause at
70
+ // all. Taking the second rather than the smallest ignores one stray click.
71
+ const noise = sorted[1]!;
72
+ const speech = sorted[Math.floor(0.75 * (sorted.length - 1))]!;
73
+ const range = speech - noise;
74
+ if (range < MIN_DYNAMIC_RANGE) return undefined;
75
+ return noise + PAUSE_RANGE_FRACTION * range;
76
+ };
77
+
78
+ return {
79
+ push(pcm: Buffer): void {
80
+ levels.push(levelFromS16le(pcm));
81
+ lengths.push(pcm.length);
82
+ },
83
+
84
+ cuts(capturedBytes: number): number[] {
85
+ const minKeep = options.chunkSeconds * options.bytesPerSecond * options.minChunkFraction;
86
+ if (capturedBytes < minKeep) return [];
87
+ const windowed = window(capturedBytes);
88
+ const threshold = thresholdFor(windowed.levels);
89
+ if (threshold === undefined) return [];
90
+
91
+ let cumulative = 0;
92
+ for (let i = 0; i < windowed.lengths.length; i++) {
93
+ cumulative += windowed.lengths[i]!;
94
+ if (cumulative < minKeep) continue;
95
+ if (windowed.levels[i]! <= threshold) return [cumulative];
96
+ }
97
+ return [];
98
+ },
99
+ };
100
+ }
101
+
102
+ /** The slice of transcribe-cpp's `VadSession` this needs. */
103
+ export type VadSessionLike = {
104
+ streamFeed(pcm: Float32Array): Promise<{ probs: number[] }>;
105
+ dispose?(): void;
106
+ };
107
+
108
+ export type VadPauseDetectorOptions = {
109
+ frameSamples: number;
110
+ chunkSeconds: number;
111
+ minChunkFraction: number;
112
+ bytesPerSecond: number;
113
+ sampleRate?: number;
114
+ bytesPerSample?: number;
115
+ };
116
+
117
+ /**
118
+ * Silero-VAD cut planning.
119
+ *
120
+ * Frames are scored asynchronously, so probabilities arrive a frame or two
121
+ * behind the audio. `cuts()` therefore only considers what has actually been
122
+ * scored: it maps the caller's current window onto the scored frames, runs the
123
+ * segmentation/planning port over them, and returns the first planned cut. A
124
+ * frame that fails to score counts as silence, so a broken VAD degrades to
125
+ * "cut at the quiet part" rather than stalling.
126
+ */
127
+ export function createVadPauseDetector(session: VadSessionLike, options: VadPauseDetectorOptions): PauseDetector {
128
+ const { frameSamples, chunkSeconds, minChunkFraction, bytesPerSecond } = options;
129
+ const sampleRate = options.sampleRate ?? 16_000;
130
+ const bytesPerSample = options.bytesPerSample ?? 2;
131
+ const frameBytes = frameSamples * bytesPerSample;
132
+
133
+ const probs: number[] = [];
134
+ let pending = Buffer.alloc(0);
135
+ let totalBytes = 0;
136
+ let chain: Promise<void> = Promise.resolve();
137
+
138
+ return {
139
+ push(pcm: Buffer): void {
140
+ if (pcm.length === 0) return;
141
+ totalBytes += pcm.length;
142
+ pending = Buffer.concat([pending, pcm]);
143
+ while (pending.length >= frameBytes) {
144
+ const frame = pending.subarray(0, frameBytes);
145
+ pending = pending.subarray(frameBytes);
146
+ const samples = s16leToFloat32(Buffer.from(frame));
147
+ chain = chain
148
+ .then(async () => {
149
+ const result = await session.streamFeed(samples);
150
+ for (const prob of result.probs) probs.push(prob);
151
+ })
152
+ .catch(() => {
153
+ // A scoring failure must not stall cutting.
154
+ probs.push(0);
155
+ });
156
+ }
157
+ },
158
+
159
+ cuts(capturedBytes: number): number[] {
160
+ const bufferStartBytes = Math.max(0, totalBytes - capturedBytes);
161
+ const frameStart = Math.floor(bufferStartBytes / frameBytes);
162
+ if (probs.length <= frameStart) return [];
163
+
164
+ const windowProbs = probs.slice(frameStart);
165
+ const scoredRelativeBytes = probs.length * frameBytes - bufferStartBytes;
166
+ if (scoredRelativeBytes <= 0) return [];
167
+
168
+ const segments = speechSegments(windowProbs, frameSamples, { sampleRate });
169
+ // No speech in the window means there is nothing to cut around; a steady
170
+ // quiet buffer must not produce a "cut" just because it is quiet.
171
+ if (segments.length === 0) return [];
172
+
173
+ const shift = frameStart * frameBytes - bufferStartBytes;
174
+ const candidates = cutCandidates(segments, frameSamples, bytesPerSample, windowProbs.length * frameSamples).map(
175
+ (offset) => offset + shift,
176
+ );
177
+ return planCuts({
178
+ candidates,
179
+ startBytes: 0,
180
+ capturedBytes: scoredRelativeBytes,
181
+ targetSeconds: chunkSeconds,
182
+ minSeconds: chunkSeconds * minChunkFraction,
183
+ maxSeconds: chunkSeconds * 2,
184
+ bytesPerSecond,
185
+ });
186
+ },
187
+
188
+ dispose(): void {
189
+ session.dispose?.();
190
+ },
191
+ };
192
+ }