@tychenjiajun/pi-voice 0.7.0 → 0.8.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.
@@ -1,22 +1,28 @@
1
1
  import type {
2
+ BackendInfo,
2
3
  Capabilities,
4
+ LangIdResult,
3
5
  Session,
4
6
  Stream,
5
7
  TranscribeModel,
6
8
  } from "transcribe-cpp";
7
- import { convertChineseOutput, isChineseLanguage } from "./chinese.js";
8
- import { writeLog, type LogLevel } from "./log.js";
9
+ import { convertChineseOutput, shouldConvertChinese } from "./chinese.js";
10
+ import { describeError, log, writeLog, type LogLevel } from "./log.js";
9
11
  import type { ChineseOutput } from "./settings.js";
10
12
 
11
13
  export type TranscriptionOptions = {
12
14
  signal?: AbortSignal;
13
15
  language?: string;
14
16
  chineseOutput?: ChineseOutput;
17
+ /** Chinese is a preferred or chosen language: convert any Chinese text. */
18
+ chineseEnabled?: boolean;
15
19
  };
16
20
 
17
21
  export type DictationStream = {
18
- feed(chunk: Float32Array): Promise<void>;
19
- finalize(): Promise<string>;
22
+ /** `probs` are the chunk's Silero frame probabilities, when the take has them. */
23
+ feed(chunk: Float32Array, probs?: readonly number[]): Promise<void>;
24
+ /** `audio` is the whole take, for a stream that checks it saw all of it. */
25
+ finalize(audio?: Float32Array): Promise<string>;
20
26
  reset(): void;
21
27
  };
22
28
 
@@ -33,18 +39,18 @@ function validateLanguage(
33
39
 
34
40
  /**
35
41
  * Shared final step for batch and streaming output: trim, then apply the
36
- * Chinese script preference when the detected (or configured) language is
37
- * Chinese. Keeping this in one place keeps the two paths from drifting.
42
+ * Chinese script preference (see shouldConvertChinese). Keeping this in one
43
+ * place keeps the two paths from drifting.
38
44
  */
39
45
  async function finishTranscript(
40
46
  text: string,
41
47
  detectedLanguage: string,
42
- configuredLanguage: string | undefined,
43
- chineseOutput: ChineseOutput,
48
+ options: TranscriptionOptions,
44
49
  ): Promise<string> {
45
50
  const trimmed = text.trim();
46
- return isChineseLanguage(detectedLanguage || configuredLanguage || "")
47
- ? convertChineseOutput(trimmed, chineseOutput)
51
+ const language = detectedLanguage || options.language || "";
52
+ return shouldConvertChinese(trimmed, { chineseEnabled: options.chineseEnabled ?? false, language })
53
+ ? convertChineseOutput(trimmed, options.chineseOutput ?? "simplified")
48
54
  : trimmed;
49
55
  }
50
56
 
@@ -54,8 +60,7 @@ class TranscribeCppDictationStream implements DictationStream {
54
60
  constructor(
55
61
  private readonly session: Session,
56
62
  private readonly stream: Stream,
57
- private readonly language: string | undefined,
58
- private readonly chineseOutput: ChineseOutput,
63
+ private readonly options: TranscriptionOptions,
59
64
  ) {}
60
65
 
61
66
  async feed(chunk: Float32Array): Promise<void> {
@@ -80,7 +85,7 @@ class TranscribeCppDictationStream implements DictationStream {
80
85
  this.close();
81
86
  }
82
87
 
83
- return finishTranscript(text, detectedLanguage, this.language, this.chineseOutput);
88
+ return finishTranscript(text, detectedLanguage, this.options);
84
89
  }
85
90
 
86
91
  reset(): void {
@@ -103,30 +108,60 @@ const TRANSCRIBE_LEVELS: Record<number, LogLevel> = { 1: "debug", 2: "warn", 3:
103
108
  const TRANSCRIBE_CONTINUATION = 5;
104
109
  let lastTranscribeLevel: LogLevel = "debug";
105
110
 
106
- function routeTranscribeLog(setLogHandler: typeof import("transcribe-cpp").setLogHandler): void {
111
+ export function routeTranscribeLog(setLogHandler: typeof import("transcribe-cpp").setLogHandler): void {
107
112
  setLogHandler((level, message) => {
108
113
  if (level !== TRANSCRIBE_CONTINUATION) lastTranscribeLevel = TRANSCRIBE_LEVELS[level] ?? "debug";
109
114
  writeLog(lastTranscribeLevel, "transcribe", message);
110
115
  });
111
116
  }
112
117
 
113
- /** A reusable loaded transcribe.cpp model. Calls must be scheduled sequentially. */
118
+ type TranscribeCppRuntime = Pick<
119
+ typeof import("transcribe-cpp"),
120
+ "getAvailableBackends" | "OutOfMemory" | "setLogHandler" | "TranscribeModel"
121
+ >;
122
+
123
+ type RuntimeLoader = () => Promise<TranscribeCppRuntime>;
124
+
125
+ // Best-effort: load failures don't report their device, so any non-CPU device is assumed to have been used.
126
+ function cpuFallbackDevice(runtime: TranscribeCppRuntime): BackendInfo | undefined {
127
+ const devices = runtime.getAvailableBackends();
128
+ const acceleratorAvailable = devices.some((device) => device.deviceType !== "cpu");
129
+ return acceleratorAvailable
130
+ ? devices.find((device) => device.deviceType === "cpu")
131
+ : undefined;
132
+ }
133
+
134
+ /**
135
+ * A reusable loaded transcribe.cpp model: an ASR model, or the language
136
+ * detector. Calls must be scheduled sequentially.
137
+ */
114
138
  export class TranscribeCppBackend {
115
139
  private model: TranscribeModel | undefined;
116
140
  private loading: Promise<TranscribeModel> | undefined;
117
141
  private disposed = false;
118
142
 
119
- constructor(private readonly modelPath: string) {}
143
+ constructor(
144
+ private readonly modelPath: string,
145
+ private readonly loadRuntime: RuntimeLoader = () => import("transcribe-cpp"),
146
+ ) {}
120
147
 
121
148
  async prepare(): Promise<void> {
122
149
  if (this.model) return;
123
150
  if (this.disposed) throw new Error("Transcription backend has been disposed");
124
151
 
125
152
  if (!this.loading) {
126
- this.loading = import("transcribe-cpp")
127
- .then(({ TranscribeModel, setLogHandler }) => {
128
- routeTranscribeLog(setLogHandler);
129
- return TranscribeModel.load(this.modelPath);
153
+ this.loading = this.loadRuntime()
154
+ .then(async (runtime) => {
155
+ routeTranscribeLog(runtime.setLogHandler);
156
+ try {
157
+ return await runtime.TranscribeModel.load(this.modelPath);
158
+ } catch (error) {
159
+ if (!(error instanceof runtime.OutOfMemory)) throw error;
160
+ const cpu = cpuFallbackDevice(runtime);
161
+ if (!cpu) throw error;
162
+ log.warn(`accelerator model load ran out of memory; retrying on CPU: ${describeError(error)}`);
163
+ return runtime.TranscribeModel.load(this.modelPath, { device: cpu });
164
+ }
130
165
  })
131
166
  .then((model) => {
132
167
  if (this.disposed) {
@@ -163,8 +198,7 @@ export class TranscribeCppBackend {
163
198
  return new TranscribeCppDictationStream(
164
199
  session,
165
200
  stream,
166
- options.language,
167
- options.chineseOutput ?? "simplified",
201
+ options,
168
202
  );
169
203
  } catch (error) {
170
204
  session.dispose();
@@ -189,11 +223,25 @@ export class TranscribeCppBackend {
189
223
  return finishTranscript(
190
224
  result.text,
191
225
  result.language,
192
- options.language,
193
- options.chineseOutput ?? "simplified",
226
+ options,
194
227
  );
195
228
  }
196
229
 
230
+ /** Rank `allowed` detector labels for one clip (a "langid" model only). */
231
+ async identifyLanguage(
232
+ pcm: Float32Array,
233
+ allowed: readonly string[],
234
+ signal?: AbortSignal,
235
+ ): Promise<LangIdResult> {
236
+ await this.prepare();
237
+ const session = this.model!.createLangIdSession();
238
+ try {
239
+ return await session.run(pcm, { allowed, signal });
240
+ } finally {
241
+ session.dispose();
242
+ }
243
+ }
244
+
197
245
  async dispose(): Promise<void> {
198
246
  this.disposed = true;
199
247
  await this.loading?.catch(() => undefined);
package/src/try-it.ts CHANGED
@@ -18,6 +18,7 @@ import type { TranscribeSettings } from "./settings.js";
18
18
  import { displayShortcut } from "./shortcut-core.js";
19
19
  import { TranscriptionService } from "./transcription-service.js";
20
20
  import { TranscriptPreview } from "./transcript-preview.js";
21
+ import { VoiceActivityDetection } from "./vad.js";
21
22
  import { editorBorder, onboardingHeader, PANEL_PADDING, panelBorder, paneRowBudget } from "./ui-components.js";
22
23
  import {
23
24
  formatTranscriptionSummary,
@@ -28,7 +29,7 @@ import {
28
29
 
29
30
  type UiTheme = ExtensionContext["ui"]["theme"];
30
31
 
31
- type TryItPaneOptions = Pick<DictationControllerOptions, "openCapture" | "now"> & {
32
+ type TryItPaneOptions = Pick<DictationControllerOptions, "openCapture" | "now" | "createSpeechFilter"> & {
32
33
  /** Shown only before the first recording attempt when macOS has not asked yet. */
33
34
  showMacPermissionNote?: boolean;
34
35
  };
@@ -83,6 +84,7 @@ export class TryItPane implements Component {
83
84
  this.dictation = new DictationController(service, {
84
85
  openCapture: options.openCapture,
85
86
  now: options.now,
87
+ createSpeechFilter: options.createSpeechFilter,
86
88
  onChange: () => this.refresh(),
87
89
  onAudio: (chunk) => {
88
90
  this.analyzer.push(chunk);
@@ -154,6 +156,10 @@ export class TryItPane implements Component {
154
156
  } else if (localModel && needsFasterModel(speechSeconds, transcribeSeconds)) {
155
157
  details = fg("warning", `Slow on this machine? Press ${this.keys.keyText("voice.tryIt.model")} to try another model.`);
156
158
  }
159
+ if (state.result.warning) {
160
+ const warning = fg("warning", state.result.warning);
161
+ details = details ? `${details}\n${warning}` : warning;
162
+ }
157
163
  } else if (state.phase === "error") {
158
164
  activity = fg("error", state.stage === "model" ? "Could not load the model" : state.stage === "capture" ? "Microphone capture failed" : "Transcription failed");
159
165
  const message = state.cause instanceof Error ? state.cause.message : String(state.cause);
@@ -311,17 +317,20 @@ export class TryItPane implements Component {
311
317
  }
312
318
 
313
319
  export async function tryVoice(ctx: ExtensionContext, settings: TranscribeSettings): Promise<TryItResult | undefined> {
314
- const service = new TranscriptionService();
320
+ const voiceActivity = new VoiceActivityDetection();
321
+ const service = new TranscriptionService(undefined, (pcm, signal) => voiceActivity.scoreClip(pcm, signal));
315
322
  let pane: TryItPane | undefined;
316
323
  try {
317
324
  return await ctx.ui.custom<TryItResult>((tui, theme, keybindings, done) =>
318
325
  (pane = new TryItPane(tui, theme, keybindings, settings, service, done, {
319
326
  openCapture: openMicrophone,
327
+ createSpeechFilter: (configured, onSpeech) => voiceActivity.createFilter(configured, onSpeech),
320
328
  showMacPermissionNote: process.platform === "darwin" && microphonePermission() === "not-determined",
321
329
  })),
322
330
  );
323
331
  } finally {
324
332
  await pane?.dispose();
325
333
  await service.shutdown();
334
+ await voiceActivity.dispose();
326
335
  }
327
336
  }
package/src/vad.ts ADDED
@@ -0,0 +1,301 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import type { TranscribeModel } from "transcribe-cpp";
3
+ import { CAPTURE_CHUNK_SAMPLES, CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
4
+ import { getCatalogModel } from "./catalog.js";
5
+ import { chunksTranscription } from "./chunking.js";
6
+ import type { OnSpeech, SpeechFilter } from "./dictation-controller.js";
7
+ import { describeError, log } from "./log.js";
8
+ import type { TranscribeSettings } from "./settings.js";
9
+
10
+ // Constants come from Handy (cjpais/handy, src-tauri/src/audio_toolkit/vad).
11
+ // It's SmoothedVad idea to improve basic VAD performance
12
+ const THRESHOLD = 0.3;
13
+ const PREFILL_MS = 450;
14
+ const ONSET_MS = 60;
15
+ const OFFLINE_HANGOVER_MS = 450;
16
+ const STREAMING_HANGOVER_MS = 1650;
17
+ const MODEL_PATH = fileURLToPath(new URL("../models/silero-vad-v6.2-F32.gguf", import.meta.url));
18
+
19
+ function framesFor(durationMs: number): number {
20
+ return Math.ceil((durationMs * CAPTURE_SAMPLE_RATE) / (CAPTURE_CHUNK_SAMPLES * 1000));
21
+ }
22
+
23
+ function concat(pieces: readonly Float32Array[]): Float32Array {
24
+ if (pieces.length === 1) return pieces[0]!;
25
+ const out = new Float32Array(pieces.reduce((sum, piece) => sum + piece.length, 0));
26
+ let offset = 0;
27
+ for (const piece of pieces) {
28
+ out.set(piece, offset);
29
+ offset += piece.length;
30
+ }
31
+ return out;
32
+ }
33
+
34
+ type BufferedFrame<T> = { frame: T; emitted: boolean };
35
+
36
+ // Handy's SmoothedVad
37
+ export class SpeechGate<T = Float32Array> {
38
+ private readonly buffer: BufferedFrame<T>[] = [];
39
+ private hangover = 0;
40
+ private onset = 0;
41
+ private speaking = false;
42
+
43
+ constructor(private readonly options: { prefillFrames: number; onsetFrames: number; hangoverFrames: number }) {}
44
+
45
+ get inSpeech(): boolean { return this.speaking; }
46
+
47
+ /** The frames to keep now, in order. */
48
+ push(frame: T, voiced: boolean): T[] {
49
+ const buffered: BufferedFrame<T> = { frame, emitted: false };
50
+ this.buffer.push(buffered);
51
+ while (this.buffer.length > this.options.prefillFrames + 1) this.buffer.shift();
52
+
53
+ if (!this.speaking) {
54
+ if (!voiced) {
55
+ this.onset = 0;
56
+ return [];
57
+ }
58
+ if (++this.onset < this.options.onsetFrames) return [];
59
+ this.speaking = true;
60
+ this.onset = 0;
61
+ this.hangover = this.options.hangoverFrames;
62
+ return this.takeWithheld();
63
+ }
64
+
65
+ if (voiced) this.hangover = this.options.hangoverFrames;
66
+ else if (this.hangover > 0) this.hangover--;
67
+ else {
68
+ this.speaking = false;
69
+ return [];
70
+ }
71
+ buffered.emitted = true;
72
+ return [frame];
73
+ }
74
+
75
+ takeWithheld(): T[] {
76
+ const out: T[] = [];
77
+ for (const buffered of this.buffer) {
78
+ if (buffered.emitted) continue;
79
+ buffered.emitted = true;
80
+ out.push(buffered.frame);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ finish(): T[] {
86
+ return this.onset > 0 ? this.takeWithheld() : [];
87
+ }
88
+ }
89
+
90
+ export type VadScorer = {
91
+ score(frame: Float32Array): Promise<number>;
92
+ dispose(): void;
93
+ };
94
+
95
+ type VoiceActivityFilterOptions = {
96
+ /** Opened on the first audio. */
97
+ openScorer: () => Promise<VadScorer>;
98
+ /** Receives kept audio, in order, as soon as it is decided, with its frame probabilities. */
99
+ onSpeech: OnSpeech;
100
+ hangoverMs: number;
101
+ /** False keeps every frame: the probabilities are still wanted, to cut chunks. */
102
+ dropSilence?: boolean;
103
+ };
104
+
105
+ /** A frame and its speech probability; undefined once the detector failed. */
106
+ type ScoredFrame = { samples: Float32Array; prob?: number };
107
+
108
+ /**
109
+ * One recording's VAD. Each pushed chunk is one frame; scoring is async, so
110
+ * frames queue and are decided in order. If the detector fails, nothing not
111
+ * yet dropped is lost and the rest of the recording passes through.
112
+ */
113
+ export class VoiceActivityFilter implements SpeechFilter {
114
+ private readonly gate: SpeechGate<ScoredFrame>;
115
+ private readonly dropSilence: boolean;
116
+ private readonly pending: Float32Array[] = [];
117
+ private readonly kept: Float32Array[] = [];
118
+ private recordedSamples = 0;
119
+ private scorer: VadScorer | undefined;
120
+ private running: Promise<void> | undefined;
121
+ private failed = false;
122
+ private finishing = false;
123
+ private discarded = false;
124
+
125
+ constructor(private readonly options: VoiceActivityFilterOptions) {
126
+ this.dropSilence = options.dropSilence ?? true;
127
+ this.gate = new SpeechGate({
128
+ prefillFrames: framesFor(PREFILL_MS),
129
+ onsetFrames: framesFor(ONSET_MS),
130
+ hangoverFrames: framesFor(options.hangoverMs),
131
+ });
132
+ }
133
+
134
+ push(chunk: Float32Array): void {
135
+ if (this.finishing || this.discarded) return;
136
+ this.pending.push(chunk);
137
+ this.kick();
138
+ }
139
+
140
+ async finish(): Promise<Float32Array> {
141
+ this.finishing = true;
142
+ while (this.running) await this.running;
143
+ if (this.discarded) return new Float32Array();
144
+ if (!this.failed) this.keep(this.gate.finish());
145
+ this.release();
146
+ const kept = concat(this.kept);
147
+ log.info(this.failed
148
+ ? "voice activity detection unavailable; kept the whole recording"
149
+ : `voice activity: kept ${(kept.length / CAPTURE_SAMPLE_RATE).toFixed(1)} s of ${(this.recordedSamples / CAPTURE_SAMPLE_RATE).toFixed(1)} s`);
150
+ return kept;
151
+ }
152
+
153
+ discard(): void {
154
+ if (this.discarded) return;
155
+ this.discarded = true;
156
+ this.pending.length = 0;
157
+ // A score may be in flight; free the session once the loop has stopped.
158
+ if (this.running) void this.running.then(() => this.release());
159
+ else this.release();
160
+ }
161
+
162
+ private kick(): void {
163
+ if (this.running || this.discarded || this.pending.length === 0) return;
164
+ const running = this.drain().catch((error: unknown) => this.fail(error)).finally(() => {
165
+ if (this.running === running) this.running = undefined;
166
+ this.kick();
167
+ });
168
+ this.running = running;
169
+ }
170
+
171
+ private async drain(): Promise<void> {
172
+ if (!this.scorer && !this.failed) {
173
+ try {
174
+ this.scorer = await this.options.openScorer();
175
+ } catch (error) {
176
+ this.fail(error);
177
+ }
178
+ if (this.discarded) return;
179
+ }
180
+ while (this.pending.length > 0 && !this.discarded) {
181
+ const frame = this.pending.shift()!;
182
+ this.recordedSamples += frame.length;
183
+ if (this.failed) {
184
+ this.keep([{ samples: frame }]);
185
+ continue;
186
+ }
187
+ // Only a recording's last chunk is short; it follows the current
188
+ // decision and has no probability of its own.
189
+ if (frame.length < CAPTURE_CHUNK_SAMPLES) {
190
+ if (this.gate.inSpeech || !this.dropSilence) this.keep([{ samples: frame }]);
191
+ continue;
192
+ }
193
+ let prob: number;
194
+ try {
195
+ prob = await this.scorer!.score(frame);
196
+ } catch (error) {
197
+ this.fail(error);
198
+ this.keep([{ samples: frame }]);
199
+ continue;
200
+ }
201
+ if (this.discarded) return;
202
+ const scored = { samples: frame, prob };
203
+ this.keep(this.dropSilence ? this.gate.push(scored, prob > THRESHOLD) : [scored]);
204
+ }
205
+ }
206
+
207
+ private fail(error: unknown): void {
208
+ if (this.failed) return;
209
+ this.failed = true;
210
+ log.warn(`voice activity detection unavailable, keeping all audio: ${describeError(error)}`);
211
+ this.keep(this.gate.takeWithheld());
212
+ this.release();
213
+ }
214
+
215
+ private keep(frames: ScoredFrame[]): void {
216
+ if (frames.length === 0 || this.discarded) return;
217
+ const chunk = concat(frames.map((frame) => frame.samples));
218
+ this.kept.push(chunk);
219
+ const whole = frames.filter((frame) => frame.samples.length === CAPTURE_CHUNK_SAMPLES);
220
+ const probs = whole.every((frame) => frame.prob !== undefined) ? whole.map((frame) => frame.prob!) : undefined;
221
+ try { this.options.onSpeech(chunk, probs); } catch { /* Still in the final transcript. */ }
222
+ }
223
+
224
+ private release(): void {
225
+ this.scorer?.dispose();
226
+ this.scorer = undefined;
227
+ }
228
+ }
229
+
230
+ /** Loads the bundled Silero model on first use and keeps it across recordings. */
231
+ export class VoiceActivityDetection {
232
+ private model: Promise<TranscribeModel> | undefined;
233
+ private disposed = false;
234
+
235
+ /**
236
+ * Undefined when the setting is off, unless the model is chunked: then
237
+ * every frame is kept, but scored for the chunk cuts.
238
+ */
239
+ createFilter(settings: TranscribeSettings, onSpeech: OnSpeech): SpeechFilter | undefined {
240
+ const chunked = chunksTranscription(settings);
241
+ if ((!settings.voiceActivityDetection && !chunked) || this.disposed) return undefined;
242
+ const streaming = settings.model
243
+ ? getCatalogModel(settings.model.id)?.capabilities.streaming ?? false
244
+ : false;
245
+ return new VoiceActivityFilter({
246
+ openScorer: () => this.openScorer(),
247
+ onSpeech,
248
+ hangoverMs: streaming ? STREAMING_HANGOVER_MS : OFFLINE_HANGOVER_MS,
249
+ dropSilence: settings.voiceActivityDetection,
250
+ });
251
+ }
252
+
253
+ /** Silero's probability for each 512-sample frame of a whole clip (the last zero padded). */
254
+ async scoreClip(pcm: Float32Array, signal?: AbortSignal): Promise<number[]> {
255
+ const session = (await this.loaded()).createVadSession();
256
+ try {
257
+ return (await session.run(pcm, { signal })).probs;
258
+ } finally {
259
+ session.dispose();
260
+ }
261
+ }
262
+
263
+ private async loaded(): Promise<TranscribeModel> {
264
+ if (this.disposed) throw new Error("Pi Voice is shutting down");
265
+ // A failed load is retried by the next use.
266
+ const loading = (this.model ??= loadModel().catch((error: unknown) => {
267
+ this.model = undefined;
268
+ throw error;
269
+ }));
270
+ const model = await loading;
271
+ if (this.disposed) throw new Error("Pi Voice is shutting down");
272
+ return model;
273
+ }
274
+
275
+ private async openScorer(): Promise<VadScorer> {
276
+ const session = (await this.loaded()).createVadSession({ nThreads: 1 });
277
+ return {
278
+ async score(frame) {
279
+ const { probs } = await session.streamFeed(frame);
280
+ if (probs.length !== 1) throw new Error(`Silero VAD returned ${probs.length} probabilities for one frame`);
281
+ return probs[0]!;
282
+ },
283
+ dispose: () => session.dispose(),
284
+ };
285
+ }
286
+
287
+ async dispose(): Promise<void> {
288
+ this.disposed = true;
289
+ const model = await this.model?.catch(() => undefined);
290
+ this.model = undefined;
291
+ model?.dispose();
292
+ }
293
+ }
294
+
295
+ async function loadModel(): Promise<TranscribeModel> {
296
+ // Route native logs into Pi Voice's log before the first native call.
297
+ const { routeTranscribeLog } = await import("./transcription.js");
298
+ const { TranscribeModel, setLogHandler } = await import("transcribe-cpp");
299
+ routeTranscribeLog(setLogHandler);
300
+ return TranscribeModel.load(MODEL_PATH);
301
+ }