@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 +41 -0
- package/README.md +32 -13
- package/package.json +1 -1
- package/src/backends/parakeet.ts +192 -85
- package/src/backends/pause-detector.ts +192 -0
- package/src/backends/vad.ts +162 -0
- package/src/controller.ts +501 -319
- package/src/model-download.ts +19 -0
- package/src/runtime.ts +13 -8
- package/src/session.ts +103 -0
- package/src/settings.ts +49 -10
- package/src/setup.ts +2 -1
- package/src/try-it.ts +1 -1
- package/src/types.ts +5 -0
- package/src/ui.ts +88 -22
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
|
|
256
|
-
utterance,
|
|
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
|
|
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 **
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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":
|
|
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":
|
|
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.
|
|
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>",
|
package/src/backends/parakeet.ts
CHANGED
|
@@ -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,
|
|
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
|
-
*
|
|
250
|
-
* measured,
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
450
|
-
|
|
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.
|
|
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
|
-
*
|
|
533
|
-
*
|
|
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
|
|
574
|
-
if (
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
787
|
-
|
|
891
|
+
chunkSeconds,
|
|
892
|
+
detector,
|
|
788
893
|
emit,
|
|
789
|
-
onReleased: () => this.
|
|
790
|
-
transcribe:
|
|
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
|
-
|
|
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();
|