pi-voicekit 0.2.3 → 0.3.1
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/README.md +46 -3
- package/extensions/voice/local.ts +31 -23
- package/extensions/voice/post-process-queue.ts +532 -0
- package/extensions/voice/post-process.ts +42 -21
- package/extensions/voice/sherpa-engine.ts +41 -17
- package/extensions/voice.ts +367 -55
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -260,6 +260,34 @@ Models from [Handy](https://github.com/cjpais/handy) (`~/Library/Application Sup
|
|
|
260
260
|
|
|
261
261
|
---
|
|
262
262
|
|
|
263
|
+
## Performance
|
|
264
|
+
|
|
265
|
+
Measured on the maintainer's machine, local CPU, no network, over 70 published utterances
|
|
266
|
+
(28 Chinese, 28 English, 14 mixed Chinese–English). RTF is processing time divided by audio
|
|
267
|
+
duration — lower is better, and below 1.0 is faster than real time.
|
|
268
|
+
|
|
269
|
+
| Recogniser | Chinese RTF | English RTF | Mixed RTF | Characters per second |
|
|
270
|
+
| -------------------- | ----------- | ----------- | --------- | --------------------- |
|
|
271
|
+
| **paraformer-zh** | 0.014 | 0.013 | 0.016 | 247–983 |
|
|
272
|
+
| **sensevoice-small** | 0.028 | 0.029 | 0.040 | 134–451 |
|
|
273
|
+
| **whisper-turbo** | 0.372 | 0.369 | 0.395 | 7–27 |
|
|
274
|
+
|
|
275
|
+
End to end — local recognition plus the remote polish call — four real dictations of
|
|
276
|
+
8.1–28.0 s came back in an estimated 0.56–2.98 s, an RTF of 0.07–0.15 (recognition time is
|
|
277
|
+
derived from the measured recognition RTF, not timed per dictation: see docs/BENCHMARKS.md).
|
|
278
|
+
The segmented polish pass is what
|
|
279
|
+
keeps that together: the 0.2.x line polished in one call over the whole transcript, so one
|
|
280
|
+
slow call returned the dictation unpolished, while against a degraded endpoint the segmented
|
|
281
|
+
pipeline polished 35 of 35 segments where the old path fell back on 100% of the run.
|
|
282
|
+
`whisper-turbo` is the slowest of the three by an order of magnitude and the least accurate
|
|
283
|
+
on this corpus — measured for comparison, not recommended for CPU-only use.
|
|
284
|
+
|
|
285
|
+
Protocol, all result tables, reproduction commands and the honest limits live in
|
|
286
|
+
[docs/BENCHMARKS.md](docs/BENCHMARKS.md) — GitHub only, because npm ships the extension and
|
|
287
|
+
this README.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
263
291
|
## Features
|
|
264
292
|
|
|
265
293
|
| Feature | Description |
|
|
@@ -274,7 +302,7 @@ Models from [Handy](https://github.com/cjpais/handy) (`~/Library/Application Sup
|
|
|
274
302
|
| **Pre-recording** | Audio capture starts during warmup — you never miss the first word |
|
|
275
303
|
| **Tail recording** | Keeps recording 1.5s after release so your last word isn't clipped |
|
|
276
304
|
| **Live streaming** | Deepgram Nova 3 WebSocket (Nova 2 for Chinese locales) — live interim transcripts |
|
|
277
|
-
| **Transcript polish** | Optional post-ASR cleanup —
|
|
305
|
+
| **Transcript polish** | Optional post-ASR cleanup — the local backend polishes each recogniser segment as it is decoded (up to three calls in flight); the last N conversation turns (default 2) are sent with the first segment, and no conversation context at all when the turn count is zero. Disable with `/voice-polish off` |
|
|
278
306
|
| **56+ languages** | Deepgram: 56+ with live streaming. Local: up to 57 depending on model. |
|
|
279
307
|
| **Continuous dictation** | `/voice dictate` for long-form input without holding keys |
|
|
280
308
|
| **Typing cooldown** | Space holds within 400ms of typing are ignored |
|
|
@@ -367,7 +395,7 @@ Hold-to-talk delay defaults to **700 ms** (`/voice-hold-delay` accepts 200–300
|
|
|
367
395
|
|
|
368
396
|
### Transcript polish
|
|
369
397
|
|
|
370
|
-
Transcript polish is on by default: every dictation runs
|
|
398
|
+
Transcript polish is on by default: every dictation runs an extra model pass. When
|
|
371
399
|
the selected model is a cloud provider, the text that leaves your machine is:
|
|
372
400
|
|
|
373
401
|
- the transcript of the dictation;
|
|
@@ -389,6 +417,19 @@ punctuation), while shorter ones keep it, because there it costs almost nothing
|
|
|
389
417
|
terms and self-corrections better. The field only reaches OpenAI-compatible providers; one that
|
|
390
418
|
ignores it behaves exactly as before.
|
|
391
419
|
|
|
420
|
+
On the local backend, polish no longer waits for the whole transcript: each recogniser
|
|
421
|
+
segment — roughly 10 s of speech — is polished as it is decoded, with up to three segment
|
|
422
|
+
calls in flight. A long dictation may come back partly polished, and that is deliberate: a
|
|
423
|
+
segment that times out is retried once with thinking disabled for that retry, and if it still
|
|
424
|
+
fails, that segment keeps its raw text while its neighbours keep their polished text — so one
|
|
425
|
+
slow call no longer costs the rest of the dictation. The conversation context is attached to
|
|
426
|
+
the first segment only; a later segment sees just the previous segment's raw text.
|
|
427
|
+
|
|
428
|
+
The gain is measurable: on 79.6 s of corpus audio (35 segments) against a degraded endpoint,
|
|
429
|
+
the old single-call path fell back on 100% of the run, while the segmented path polished all
|
|
430
|
+
35 segments. Real dictations after the change: 4 of 4 applied, with polish taking 0.4–2.3 s
|
|
431
|
+
for 8–28 s of audio — roughly 5–13% of the audio duration.
|
|
432
|
+
|
|
392
433
|
Assistant text can contain anything the conversation contained — file paths,
|
|
393
434
|
identifiers, values the agent echoed. The character limits bound how much is sent,
|
|
394
435
|
not how sensitive it is. With the local backend, nothing else leaves your machine,
|
|
@@ -402,7 +443,9 @@ analysis, and they do keep the raw text on disk for as long as the session file
|
|
|
402
443
|
Each entry also records how the pass was configured: the transcript length on its own
|
|
403
444
|
(separate from any text already in the editor), whether thinking was turned off for it,
|
|
404
445
|
and the output-token cap it carried, plus the audio seconds it covered and which recogniser
|
|
405
|
-
produced it — which is what makes polish time readable as a speedup.
|
|
446
|
+
produced it — which is what makes polish time readable as a speedup. A segmented pass adds a
|
|
447
|
+
`segments` summary to the entry: how many segments were polished, how many kept their raw
|
|
448
|
+
text, and how many were retried.
|
|
406
449
|
|
|
407
450
|
| Setting | Scope | Default | Notes |
|
|
408
451
|
| ------------------------- | ------------------ | ----------- | ------------------------------------------------------- |
|
|
@@ -816,6 +816,21 @@ export function localLanguageDisplayName(code: string): string {
|
|
|
816
816
|
|
|
817
817
|
// ─── Local session type ──────────────────────────────────────────────────────
|
|
818
818
|
|
|
819
|
+
/**
|
|
820
|
+
* Callbacks a local recording session accepts. `onSegment` is optional, so callers that do
|
|
821
|
+
* not pipeline recogniser segments keep today's transcript-only behaviour.
|
|
822
|
+
*/
|
|
823
|
+
export interface LocalSessionCallbacks {
|
|
824
|
+
onTranscript: (interim: string, finals: string[]) => void;
|
|
825
|
+
onDone: (fullText: string, meta: { hadAudio: boolean; hadSpeech: boolean }) => void;
|
|
826
|
+
onError: (err: string) => void;
|
|
827
|
+
/**
|
|
828
|
+
* Called as each recogniser segment decodes, before the next segment starts, with the
|
|
829
|
+
* segment's zero-based index. Observational: it never changes the transcript.
|
|
830
|
+
*/
|
|
831
|
+
onSegment?: (text: string, index: number) => void;
|
|
832
|
+
}
|
|
833
|
+
|
|
819
834
|
export interface LocalSession {
|
|
820
835
|
backend: "local";
|
|
821
836
|
recProcess: ChildProcess;
|
|
@@ -825,6 +840,8 @@ export interface LocalSession {
|
|
|
825
840
|
onTranscript: (interim: string, finals: string[]) => void;
|
|
826
841
|
onDone: (fullText: string, meta: { hadAudio: boolean; hadSpeech: boolean }) => void;
|
|
827
842
|
onError: (err: string) => void;
|
|
843
|
+
/** Optional per-segment observer; forwarded to the in-process recogniser on stop. */
|
|
844
|
+
onSegment?: (text: string, index: number) => void;
|
|
828
845
|
}
|
|
829
846
|
|
|
830
847
|
// ─── WAV encoding ────────────────────────────────────────────────────────────
|
|
@@ -956,14 +973,7 @@ export async function transcribeWithServer(wavBuffer: Buffer, config: VoiceConfi
|
|
|
956
973
|
* Start a local recording session. Audio is buffered in memory.
|
|
957
974
|
* Transcription happens when stopLocalSession() is called.
|
|
958
975
|
*/
|
|
959
|
-
export function startLocalSession(
|
|
960
|
-
recProcess: ChildProcess,
|
|
961
|
-
callbacks: {
|
|
962
|
-
onTranscript: (interim: string, finals: string[]) => void;
|
|
963
|
-
onDone: (fullText: string, meta: { hadAudio: boolean; hadSpeech: boolean }) => void;
|
|
964
|
-
onError: (err: string) => void;
|
|
965
|
-
}
|
|
966
|
-
): LocalSession {
|
|
976
|
+
export function startLocalSession(recProcess: ChildProcess, callbacks: LocalSessionCallbacks): LocalSession {
|
|
967
977
|
const session: LocalSession = {
|
|
968
978
|
backend: "local",
|
|
969
979
|
recProcess,
|
|
@@ -973,6 +983,7 @@ export function startLocalSession(
|
|
|
973
983
|
onTranscript: callbacks.onTranscript,
|
|
974
984
|
onDone: callbacks.onDone,
|
|
975
985
|
onError: callbacks.onError,
|
|
986
|
+
onSegment: callbacks.onSegment,
|
|
976
987
|
};
|
|
977
988
|
|
|
978
989
|
recProcess.stdout?.on("data", (chunk: Buffer) => {
|
|
@@ -1042,7 +1053,7 @@ export async function stopLocalSession(session: LocalSession, config: VoiceConfi
|
|
|
1042
1053
|
// In-process via sherpa-onnx (default, 120s timeout)
|
|
1043
1054
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
1044
1055
|
text = await Promise.race([
|
|
1045
|
-
transcribeInProcess(pcmData, config),
|
|
1056
|
+
transcribeInProcess(pcmData, config, session.onSegment),
|
|
1046
1057
|
new Promise<never>((_, reject) => {
|
|
1047
1058
|
timeoutHandle = setTimeout(() => reject(new Error("Transcription timed out (120s)")), 120_000);
|
|
1048
1059
|
}),
|
|
@@ -1087,15 +1098,13 @@ export function abortLocalSession(session: LocalSession | null): void {
|
|
|
1087
1098
|
* Transcribe PCM audio using sherpa-onnx in-process.
|
|
1088
1099
|
* Auto-downloads model on first use.
|
|
1089
1100
|
*/
|
|
1090
|
-
async function transcribeInProcess(
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
transcribeBufferSegmented,
|
|
1098
|
-
} = await import("./sherpa-engine");
|
|
1101
|
+
async function transcribeInProcess(
|
|
1102
|
+
pcmData: Buffer,
|
|
1103
|
+
config: VoiceConfig,
|
|
1104
|
+
onSegment?: (text: string, index: number) => void
|
|
1105
|
+
): Promise<string> {
|
|
1106
|
+
const { initSherpa, isSherpaAvailable, getSherpaError, getOrCreateRecognizer, transcribeBufferSegmented } =
|
|
1107
|
+
await import("./sherpa-engine");
|
|
1099
1108
|
const { ensureModelDownloaded } = await import("./model-download");
|
|
1100
1109
|
|
|
1101
1110
|
// Initialize sherpa if needed
|
|
@@ -1116,11 +1125,10 @@ async function transcribeInProcess(pcmData: Buffer, config: VoiceConfig): Promis
|
|
|
1116
1125
|
|
|
1117
1126
|
// Create/reuse recognizer and transcribe
|
|
1118
1127
|
const recognizer = getOrCreateRecognizer(model, modelDir, config.language || "en");
|
|
1119
|
-
//
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
return transcribeBuffer(pcmData, recognizer);
|
|
1128
|
+
// Every in-process model decodes long audio in VAD-sized pieces, so a pipelining caller can
|
|
1129
|
+
// polish one piece while the rest still decodes; audio at or below the segment threshold takes
|
|
1130
|
+
// the same single-decode fast path as before and the concatenation is unchanged.
|
|
1131
|
+
return transcribeBufferSegmented(pcmData, recognizer, undefined, onSegment);
|
|
1124
1132
|
}
|
|
1125
1133
|
|
|
1126
1134
|
/** Check if a local transcription server is reachable. */
|