@sfgartland/better-pi-voice 0.1.1 → 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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,47 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-10-18
10
+
11
+ ### Added
12
+
13
+ - **Non-destructive recording queue.** Starting a recording while earlier ones are still
14
+ transcribing no longer drops the new utterance: each recording is queued, transcribed
15
+ exactly once, and its text is inserted in **recording-start order**, even when calls
16
+ finish out of order. When the queue is full (4 jobs, or 8 MiB of buffered audio) a new
17
+ capture is refused with a notice instead of silently discarding audio, and the status
18
+ line reports the queue depth.
19
+ - **Silero VAD** for batch cuts, behind a new `PauseDetector` interface, with the existing
20
+ RMS level heuristic as the fallback when the VAD model is unavailable.
21
+ - **Eager batch cuts:** beginning a new recording asks the previous batch tail to transcribe
22
+ immediately instead of waiting for the chunk target.
23
+ - A `pi-package` contract guard (`src/packaging.test.ts`) plus install/discoverability notes,
24
+ so the manifest cannot silently drop out of the pi package gallery.
25
+
26
+ ### Changed
27
+
28
+ - **Sequence-aware transcription plumbing.** `EditorSink` rebuilds `prefix + finals in
29
+ order + live interim`, suppresses interims while jobs are pending, and submits once per
30
+ drained queue; `submitOnStop` no longer fires once per final.
31
+ - **Resident local model window** raised to 60 s (`idleUnloadMs` 0 -> 60000), so
32
+ back-to-back recordings stop reloading the model.
33
+ - **Batch chunk length** settled at 15 s (`chunkSeconds` 30 -> 15), matching
34
+ `DEFAULT_CHUNK_SECONDS`.
35
+ - **Keyterm budget is per backend:** Deepgram 100 (its cap), local correction 60.
36
+ - `dispose()` is now async and drains stored text on shutdown.
37
+
38
+ ### Fixed
39
+
40
+ - A recording started during transcription was **silently dropped** (the microphone was
41
+ never opened and the utterance was lost with no notification). This was the bug the
42
+ queue replaces.
43
+ - Parakeet sessions are refcounted and `model.transcribe` is serialized, so an
44
+ unload/dispose can never free the model under a queued job.
45
+ - Failed, empty or hung jobs always free their queue slot via a reap timeout, so one bad
46
+ job cannot deadlock the queue.
47
+ - Stale Parakeet header comments and the README "not implemented yet" line are corrected.
48
+ - The fresh-install test now polls for the setup file instead of racing a fixed delay.
49
+
9
50
  ## [0.1.1] - 2026-10-16
10
51
 
11
52
  ### Added
package/README.md CHANGED
@@ -87,6 +87,10 @@ pi install /path/to/better-pi-voice # from a local checkout
87
87
  pi install npm:@sfgartland/better-pi-voice # once published
88
88
  ```
89
89
 
90
+ The npm package is gallery-discoverable: it carries the `pi-package` keyword and
91
+ declares its extension, so `pi install npm:@sfgartland/better-pi-voice` also works from
92
+ the pi package gallery.
93
+
90
94
  Then add a Deepgram key. The easiest way is `/voice key`:
91
95
 
92
96
  - `/voice key <path-to-dotenv>` — use a key from an existing dotenv file (it checks one is
@@ -252,8 +256,8 @@ There is **no streaming model lighter than parakeet-0.6b**. The genuinely small
252
256
  `medasr` (128 MB), `parakeet-tdt_ctc-110m` (135 MB), `canary-180m-flash` (218 MB),
253
257
  `SenseVoiceSmall` (253 MB), `whisper-small` (270 MB) — are all batch-only, so using one
254
258
  means giving up live text and transcribing after you release the key. With batch running
255
- at 3.5× real-time that is a reasonable trade, and the pipeline already retains the whole
256
- utterance, but it is not implemented yet.
259
+ at 3.5× real-time that is a reasonable trade, and the pipeline retains the whole
260
+ utterance, chunk-transcribes it while you speak, and finishes the tail when you release.
257
261
 
258
262
  For non-English dictation, `nemotron-3.5-asr-streaming-0.6b` is the only streaming option
259
263
  that is not parakeet, at the same size.
@@ -267,7 +271,7 @@ option for the small models, none of which can stream.
267
271
  In batch mode the audio is transcribed in pieces while you are still speaking, so only
268
272
  the tail is left when you release the key:
269
273
 
270
- - `parakeet.chunkSeconds` (default 30) is a **target, not a limit**, and only its lower
274
+ - `parakeet.chunkSeconds` (default 15) is a **target, not a limit**, and only its lower
271
275
  70% matters: the audio is cut at the **first real pause** at or after that point and
272
276
  transcribed in the background. A pause arriving later than the target still triggers a
273
277
  cut, and unbroken speech simply grows a longer chunk
@@ -295,21 +299,36 @@ returns an **empty transcript** for any clip that begins mid-sentence, so a chun
295
299
  merely the *quietest* moment silently discarded everything in it. Requiring real silence
296
300
  costs tail latency on unbroken speech instead of costing speech.
297
301
 
302
+ ### Recording queue
303
+
304
+ Recordings never block each other. Press the shortcut again while an earlier recording
305
+ is still being transcribed and a new one starts immediately; results are always emitted
306
+ in the order the recordings *started*, even when a later one finishes first.
307
+
308
+ - one capture at a time; each recording carries a sequence number and its own audio
309
+ - at most **4** recordings may be waiting, and at most **8 MiB** of buffered audio. When
310
+ either is reached the new press is **refused with a notification** rather than
311
+ silently dropped
312
+ - a job that never settles is abandoned after `jobReapMs` (default 30 s) so the queue
313
+ always advances; a recording that produced nothing stores `""` and is skipped, which
314
+ keeps the order intact
315
+ - the status line shows the depth (`1 recording`, `3 recordings`), so a backlog is
316
+ visible without opening anything
317
+ - with `submitOnStop`, a drained queue is submitted **once**, not once per recording
318
+
298
319
  ### Keeping the model loaded
299
320
 
300
- The model is **released after every recording by default**, because a warm reload is only
301
- about 0.4 s and holding ~700 MB between recordings is not a good trade on a laptop. Set
302
- `parakeet.idleUnloadMs` to keep it resident instead — useful for larger models, where a
303
- reload costs real time. When residency is on, the model is released by whichever of these
304
- happens first:
321
+ The model is **held for a minute after the last recording by default**
322
+ (`parakeet.idleUnloadMs` is `60000`). Queued recordings arrive in bursts, and a warm
323
+ reload of a 135 MB–700 MB GGUF is felt between each one, so keeping it resident across a
324
+ burst is the better trade. Residency is released by whichever of these happens first:
305
325
 
306
- - the idle window expires (`parakeet.idleUnloadMs`)
326
+ - the idle window expires (`parakeet.idleUnloadMs`; set it to `0` to release as soon as
327
+ the queue empties)
307
328
  - the machine gets busy — one-minute load average per CPU reaching **0.8**
308
329
  (`parakeet.loadShedRatio`), checked once a minute
309
330
 
310
331
  A new recording cancels a pending release, and session shutdown releases immediately.
311
- Note that the load-shedding half only matters while residency is on: at the default of 0
312
- the memory is already back after each recording, so there is nothing to shed.
313
332
 
314
333
  ## Settings
315
334
 
@@ -334,9 +353,9 @@ malformed falls back to a default and warns rather than breaking the microphone.
334
353
  "parakeet": {
335
354
  "modelPath": null, // null = read pi-voice's config if present
336
355
  "mode": "batch", // "batch" (faster, chunked) | "streaming"
337
- "chunkSeconds": 30, // cut at a pause near this length; 0 = only at the end
356
+ "chunkSeconds": 15, // cut at a pause near this length; 0 = only at the end
338
357
  "silenceLevel": "auto", // "auto" derives the pause threshold per recording; a number pins it
339
- "idleUnloadMs": 0, // 0 = release after each recording (see above)
358
+ "idleUnloadMs": 60000, // hold the model this long after the queue empties; 0 = release at once
340
359
  "loadShedRatio": 0.8 // with residency on, release early once load per CPU reaches this
341
360
  },
342
361
  "keyterms": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sfgartland/better-pi-voice",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Streaming voice input for pi: Deepgram (nova-3, repo-aware keyterms) or local Parakeet, with Claude-Code-style recovery so speech is never lost.",
5
5
  "license": "MIT",
6
6
  "author": "Severin Gartland <sfgartland@hotmail.com>",
@@ -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();