@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.
- package/README.md +49 -1
- package/assets/demo.gif +0 -0
- package/assets/demo.mp4 +0 -0
- package/assets/preview.png +0 -0
- package/assets/preview.svg +47 -0
- package/assets/social-og.png +0 -0
- package/assets/social.svg +37 -0
- package/catalog/catalog.json +1501 -0
- package/catalog/recommendations.json +67 -27
- package/models/README.md +25 -0
- package/models/silero-vad-v6.2-F32.gguf +0 -0
- package/package.json +21 -10
- package/src/audio-constants.ts +2 -0
- package/src/audio.ts +2 -5
- package/src/catalog.ts +6 -2
- package/src/chinese.ts +17 -6
- package/src/chunking.ts +319 -0
- package/src/dictation-controller.ts +61 -18
- package/src/file-audio.ts +2 -2
- package/src/file-transcription.ts +6 -3
- package/src/language-detection-policy.ts +127 -0
- package/src/languages.ts +4 -3
- package/src/model-activation.ts +50 -4
- package/src/model-cells.ts +28 -6
- package/src/model-picker.ts +29 -12
- package/src/model-selection-controller.ts +14 -9
- package/src/models.ts +15 -1
- package/src/onboarding.ts +13 -1
- package/src/pcm-chunker.ts +14 -4
- package/src/recommendation-picker.ts +9 -7
- package/src/recommendations.ts +4 -2
- package/src/runtime.ts +9 -1
- package/src/settings-menu.ts +40 -9
- package/src/settings.ts +35 -3
- package/src/transcription-service.ts +205 -37
- package/src/transcription.ts +72 -24
- package/src/try-it.ts +11 -2
- package/src/vad.ts +301 -0
- package/src/catalog.generated.ts +0 -1305
package/src/transcription.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
37
|
-
*
|
|
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
|
-
|
|
43
|
-
chineseOutput: ChineseOutput,
|
|
48
|
+
options: TranscriptionOptions,
|
|
44
49
|
): Promise<string> {
|
|
45
50
|
const trimmed = text.trim();
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
127
|
-
.then((
|
|
128
|
-
routeTranscribeLog(setLogHandler);
|
|
129
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|