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 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 — every dictation makes one extra model call; the last N conversation turns (default 2) are sent with it, and no conversation context at all when the turn count is zero. Disable with `/voice-polish off` |
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 one extra model call. When
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(pcmData: Buffer, config: VoiceConfig): Promise<string> {
1091
- const {
1092
- initSherpa,
1093
- isSherpaAvailable,
1094
- getSherpaError,
1095
- getOrCreateRecognizer,
1096
- transcribeBuffer,
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
- // Qwen3-ASR caps context at 512 tokens (~18s); segment long audio via VAD before decode.
1120
- if (model.sherpaModel.type === "qwen3_asr") {
1121
- return transcribeBufferSegmented(pcmData, recognizer);
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. */