@tychenjiajun/pi-voice 0.6.0 → 0.8.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/README.md +32 -2
- 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 +8 -6
- 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 +49 -11
- 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/chunking.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { CAPTURE_CHUNK_SAMPLES as FRAME, CAPTURE_SAMPLE_RATE as RATE } from "./audio-constants.js";
|
|
2
|
+
import { getCatalogModel, type CatalogModel } from "./catalog.js";
|
|
3
|
+
import { log } from "./log.js";
|
|
4
|
+
import type { TranscribeSettings } from "./settings.js";
|
|
5
|
+
import type { DictationStream } from "./transcription.js";
|
|
6
|
+
|
|
7
|
+
// Long audio is decoded in chunks cut at pauses, except for models that handle
|
|
8
|
+
// long audio themselves and streaming models, which never see a whole clip.
|
|
9
|
+
const TARGET_SECONDS = 30;
|
|
10
|
+
/** Audio up to this many samples is decoded in one piece; longer audio is cut. */
|
|
11
|
+
export const MAX_CHUNK_SAMPLES = 40 * RATE;
|
|
12
|
+
const MIN_CHUNK = 5 * RATE;
|
|
13
|
+
// Live, a cut is only final once MIN_CHUNK past the window is scored: then no
|
|
14
|
+
// cut it allows can leave a short rest, so live cuts match the offline plan.
|
|
15
|
+
const LOOKAHEAD = MIN_CHUNK;
|
|
16
|
+
|
|
17
|
+
// Silero's segmentation, as VadSession.run applies it.
|
|
18
|
+
const THRESHOLD = 0.5;
|
|
19
|
+
const NEG_THRESHOLD = THRESHOLD - 0.15;
|
|
20
|
+
const MIN_SPEECH = (250 * RATE) / 1000;
|
|
21
|
+
const MIN_SILENCE = (100 * RATE) / 1000;
|
|
22
|
+
const SPEECH_PAD = (30 * RATE) / 1000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Models or families that window or chunk long audio themselves
|
|
26
|
+
*/
|
|
27
|
+
const HANDLES_LONG_AUDIO: ReadonlyArray<{ family: string } | { id: string }> = [
|
|
28
|
+
{ family: "whisper" },
|
|
29
|
+
{ id: "parakeet-ultra" }, // Other Parakeets don't.
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function handlesLongAudio(model: CatalogModel): boolean {
|
|
33
|
+
return HANDLES_LONG_AUDIO.some((entry) =>
|
|
34
|
+
"family" in entry ? entry.family === model.family : entry.id === model.id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Models outside the catalog are chunked: nothing says they handle long audio. */
|
|
38
|
+
export function chunksTranscription(settings: Pick<TranscribeSettings, "longformChunking"> & { model?: { id: string } }): boolean {
|
|
39
|
+
if (!settings.longformChunking) return false;
|
|
40
|
+
// Remote settings carry no local model and never chunk.
|
|
41
|
+
if (!settings.model) return false;
|
|
42
|
+
const model = getCatalogModel(settings.model.id);
|
|
43
|
+
if (!model) return true;
|
|
44
|
+
return !handlesLongAudio(model) && !model.capabilities.streaming;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SpeechSegment = { start: number; end: number };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Speech segments, in samples, from per-frame Silero probabilities: Silero
|
|
51
|
+
* 6.2.3's get_speech_timestamps as transcribe.cpp ports it
|
|
52
|
+
* (vad_probs_to_segments), without a maximum speech length.
|
|
53
|
+
*/
|
|
54
|
+
export function speechSegments(probs: ArrayLike<number>, samples: number): SpeechSegment[] {
|
|
55
|
+
const segments: SpeechSegment[] = [];
|
|
56
|
+
let triggered = false;
|
|
57
|
+
let start = 0;
|
|
58
|
+
let tempEnd = 0;
|
|
59
|
+
for (let i = 0; i < probs.length; i++) {
|
|
60
|
+
const prob = probs[i]!;
|
|
61
|
+
const at = i * FRAME;
|
|
62
|
+
if (prob >= THRESHOLD && tempEnd !== 0) tempEnd = 0;
|
|
63
|
+
if (prob >= THRESHOLD && !triggered) {
|
|
64
|
+
triggered = true;
|
|
65
|
+
start = at;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (prob < NEG_THRESHOLD && triggered) {
|
|
69
|
+
if (tempEnd === 0) tempEnd = at;
|
|
70
|
+
if (at - tempEnd < MIN_SILENCE) continue;
|
|
71
|
+
if (tempEnd - start > MIN_SPEECH) segments.push({ start, end: tempEnd });
|
|
72
|
+
triggered = false;
|
|
73
|
+
tempEnd = 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (triggered && samples - start > MIN_SPEECH) segments.push({ start, end: samples });
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < segments.length; i++) {
|
|
79
|
+
const segment = segments[i]!;
|
|
80
|
+
if (i === 0) segment.start = Math.max(0, segment.start - SPEECH_PAD);
|
|
81
|
+
const next = segments[i + 1];
|
|
82
|
+
if (!next) {
|
|
83
|
+
segment.end = Math.min(samples, segment.end + SPEECH_PAD);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const silence = next.start - segment.end;
|
|
87
|
+
const pad = silence < 2 * SPEECH_PAD ? Math.floor(silence / 2) : SPEECH_PAD;
|
|
88
|
+
segment.end = Math.min(samples, segment.end + pad);
|
|
89
|
+
next.start = Math.max(0, next.start - pad);
|
|
90
|
+
}
|
|
91
|
+
return segments;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type CutCandidate = { cut: number; gapSeconds: number };
|
|
95
|
+
|
|
96
|
+
/** One candidate in the middle of each pause, lead-in and tail included. */
|
|
97
|
+
export function cutCandidates(segments: readonly SpeechSegment[], samples: number): CutCandidate[] {
|
|
98
|
+
const candidates: CutCandidate[] = [];
|
|
99
|
+
let silenceStart = 0;
|
|
100
|
+
for (const { start, end } of [...segments, { start: samples, end: samples }]) {
|
|
101
|
+
if (start > silenceStart) {
|
|
102
|
+
candidates.push({ cut: Math.floor((silenceStart + start) / 2), gapSeconds: (start - silenceStart) / RATE });
|
|
103
|
+
}
|
|
104
|
+
silenceStart = Math.max(silenceStart, end);
|
|
105
|
+
}
|
|
106
|
+
return candidates;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Greedy, left to right from `from`: each cut is the best-scoring pause
|
|
111
|
+
* 5-40 s after the previous one, preferring long pauses near 30 s and ones
|
|
112
|
+
* that leave at least 5 s after them. With no pause, it is a hard cut at 40 s,
|
|
113
|
+
* or halfway when that would leave under 5 s. Audio of 40 s or less is never cut.
|
|
114
|
+
*/
|
|
115
|
+
export function planCuts(candidates: readonly CutCandidate[], samples: number, from = 0): number[] {
|
|
116
|
+
const cuts: number[] = [];
|
|
117
|
+
let start = from;
|
|
118
|
+
// Candidates are in order, and each window starts after the last.
|
|
119
|
+
let first = 0;
|
|
120
|
+
while (samples - start > MAX_CHUNK_SAMPLES) {
|
|
121
|
+
while (first < candidates.length && candidates[first]!.cut < start + MIN_CHUNK) first++;
|
|
122
|
+
// A pause that leaves under 5 s is only used when no other pause will do.
|
|
123
|
+
let best: number | undefined;
|
|
124
|
+
let bestScore = -Infinity;
|
|
125
|
+
let fallback: number | undefined;
|
|
126
|
+
let fallbackScore = -Infinity;
|
|
127
|
+
for (let i = first; i < candidates.length && candidates[i]!.cut <= start + MAX_CHUNK_SAMPLES; i++) {
|
|
128
|
+
const { cut, gapSeconds } = candidates[i]!;
|
|
129
|
+
const position = (cut - start) / RATE;
|
|
130
|
+
const score = Math.min(gapSeconds, 1) - (position > TARGET_SECONDS
|
|
131
|
+
? 0.1 * (position - TARGET_SECONDS)
|
|
132
|
+
: 0.05 * (TARGET_SECONDS - position));
|
|
133
|
+
if (samples - cut >= MIN_CHUNK && score > bestScore) {
|
|
134
|
+
best = cut;
|
|
135
|
+
bestScore = score;
|
|
136
|
+
}
|
|
137
|
+
if (score > fallbackScore) {
|
|
138
|
+
fallback = cut;
|
|
139
|
+
fallbackScore = score;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const hardCut = samples - start < MAX_CHUNK_SAMPLES + MIN_CHUNK
|
|
143
|
+
? start + Math.floor((samples - start) / 2)
|
|
144
|
+
: start + MAX_CHUNK_SAMPLES;
|
|
145
|
+
start = best ?? fallback ?? hardCut;
|
|
146
|
+
cuts.push(start);
|
|
147
|
+
}
|
|
148
|
+
return cuts;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Scripts written without spaces between words, and CJK punctuation.
|
|
152
|
+
const UNSPACED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Bopomofo}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}\u3000-\u303f\uff00-\uffef]/u;
|
|
153
|
+
|
|
154
|
+
/** Chunk texts in order: a space between two chunks unless either side is unspaced script. */
|
|
155
|
+
export function joinTranscripts(texts: readonly string[]): string {
|
|
156
|
+
let joined = "";
|
|
157
|
+
// Whole code points: astral Han (CJK Extension B+) is a surrogate pair.
|
|
158
|
+
let last: string | undefined;
|
|
159
|
+
for (const raw of texts) {
|
|
160
|
+
const text = raw.trim();
|
|
161
|
+
if (!text) continue;
|
|
162
|
+
if (last !== undefined && !UNSPACED.test(last) && !UNSPACED.test(String.fromCodePoint(text.codePointAt(0)!))) {
|
|
163
|
+
joined += " ";
|
|
164
|
+
}
|
|
165
|
+
joined += text;
|
|
166
|
+
last = Array.from(text).at(-1);
|
|
167
|
+
}
|
|
168
|
+
return joined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Decodes one chunk; `clip` is all audio so far, for choosing a language. */
|
|
172
|
+
export type ChunkDecoder = (chunk: Float32Array, clip: Float32Array) => Promise<string>;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Buffers audio and its Silero probabilities and decodes it in chunks, in
|
|
176
|
+
* order, one at a time. Pushed live, each chunk is decoded as soon as its cut
|
|
177
|
+
* is certain; `finish` cuts and decodes the rest. Pushed all at once, the
|
|
178
|
+
* cuts are exactly the offline plan's.
|
|
179
|
+
*/
|
|
180
|
+
export class ChunkedTranscriber {
|
|
181
|
+
private audio: Float32Array = new Float32Array(0);
|
|
182
|
+
private length = 0;
|
|
183
|
+
private readonly probs: number[] = [];
|
|
184
|
+
/** False once audio arrives without probabilities: no more cuts. */
|
|
185
|
+
private scored = true;
|
|
186
|
+
private start = 0;
|
|
187
|
+
private readonly texts: string[] = [];
|
|
188
|
+
private decoding: Promise<void> = Promise.resolve();
|
|
189
|
+
private failure: { error: unknown } | undefined;
|
|
190
|
+
|
|
191
|
+
constructor(private readonly decode: ChunkDecoder) {}
|
|
192
|
+
|
|
193
|
+
get samples(): number { return this.length; }
|
|
194
|
+
|
|
195
|
+
/** `probs` scores each whole 512-sample frame of `pcm`, in order. */
|
|
196
|
+
push(pcm: Float32Array, probs?: readonly number[]): void {
|
|
197
|
+
if (pcm.length === 0) return;
|
|
198
|
+
this.trackScores(pcm.length, probs);
|
|
199
|
+
this.append(pcm);
|
|
200
|
+
if (!this.scored || this.length - this.start <= MAX_CHUNK_SAMPLES + LOOKAHEAD) return;
|
|
201
|
+
const segments = speechSegments(this.probs, this.length);
|
|
202
|
+
// A pause still open at the end moves its midpoint as it grows: wait for speech.
|
|
203
|
+
if (segments.at(-1)?.end !== this.length) return;
|
|
204
|
+
// One plan, committed while each cut's whole window plus the lookahead is scored.
|
|
205
|
+
for (const cut of planCuts(cutCandidates(segments, this.length), this.length, this.start)) {
|
|
206
|
+
if (this.length - this.start <= MAX_CHUNK_SAMPLES + LOOKAHEAD) break;
|
|
207
|
+
this.commit(cut);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The joined transcript, once every chunk is decoded. */
|
|
212
|
+
async finish(): Promise<string> {
|
|
213
|
+
const samples = this.length;
|
|
214
|
+
if (!this.scored && samples - this.start > MAX_CHUNK_SAMPLES) {
|
|
215
|
+
log.info(`no voice activity for the last ${seconds(samples - this.start)} s; decoding it in one piece`);
|
|
216
|
+
}
|
|
217
|
+
const cuts = this.scored ? planCuts(this.candidates(), samples, this.start) : [];
|
|
218
|
+
for (const cut of cuts) this.commit(cut);
|
|
219
|
+
// An empty clip still reaches the decoder, which rejects it.
|
|
220
|
+
if (samples > this.start || this.texts.length === 0) this.commit(samples);
|
|
221
|
+
await this.decoding;
|
|
222
|
+
if (this.failure) throw this.failure.error;
|
|
223
|
+
if (this.texts.length > 1) log.info(`decoded ${seconds(samples)} s in ${this.texts.length} chunks`);
|
|
224
|
+
return joinTranscripts(this.texts);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Settles once no chunk is decoding; never rejects. */
|
|
228
|
+
idle(): Promise<void> {
|
|
229
|
+
return this.decoding;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private trackScores(samples: number, probs: readonly number[] | undefined): void {
|
|
233
|
+
if (!this.scored) return;
|
|
234
|
+
const aligned = this.length % FRAME === 0 && probs !== undefined &&
|
|
235
|
+
(probs.length === Math.floor(samples / FRAME) || probs.length === Math.ceil(samples / FRAME));
|
|
236
|
+
if (aligned) {
|
|
237
|
+
for (const prob of probs) this.probs.push(prob);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
this.scored = false;
|
|
241
|
+
log.debug(`voice activity missing after ${seconds(this.length)} s; no more chunk cuts`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private candidates(): CutCandidate[] {
|
|
245
|
+
return cutCandidates(speechSegments(this.probs, this.length), this.length);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private append(pcm: Float32Array): void {
|
|
249
|
+
if (this.length === 0 && this.audio.length === 0) {
|
|
250
|
+
this.audio = pcm; // One push of a whole clip is never copied.
|
|
251
|
+
this.length = pcm.length;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (this.length + pcm.length > this.audio.length) {
|
|
255
|
+
const grown = new Float32Array(Math.max(this.length + pcm.length, this.audio.length * 2, RATE * 60));
|
|
256
|
+
grown.set(this.audio.subarray(0, this.length));
|
|
257
|
+
this.audio = grown;
|
|
258
|
+
}
|
|
259
|
+
this.audio.set(pcm, this.length);
|
|
260
|
+
this.length += pcm.length;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private commit(end: number): void {
|
|
264
|
+
const index = this.texts.length;
|
|
265
|
+
const chunk = this.audio.subarray(this.start, end);
|
|
266
|
+
const clip = this.audio.subarray(0, this.length);
|
|
267
|
+
log.debug(`chunk ${index + 1}: ${seconds(this.start)}-${seconds(end)} s`);
|
|
268
|
+
this.start = end;
|
|
269
|
+
this.texts.push("");
|
|
270
|
+
this.decoding = this.decoding.then(async () => {
|
|
271
|
+
if (this.failure) return;
|
|
272
|
+
try {
|
|
273
|
+
this.texts[index] = await this.decode(chunk, clip);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
this.failure = { error };
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Dictation through a ChunkedTranscriber: chunks decode while the user speaks. */
|
|
282
|
+
export class ChunkedDictationStream implements DictationStream {
|
|
283
|
+
private readonly transcriber: ChunkedTranscriber;
|
|
284
|
+
private closed = false;
|
|
285
|
+
|
|
286
|
+
constructor(decode: ChunkDecoder, private readonly abort: () => void) {
|
|
287
|
+
this.transcriber = new ChunkedTranscriber(decode);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async feed(chunk: Float32Array, probs?: readonly number[]): Promise<void> {
|
|
291
|
+
if (this.closed) throw new Error("Dictation stream is closed");
|
|
292
|
+
this.transcriber.push(chunk, probs);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async finalize(audio?: Float32Array): Promise<string> {
|
|
296
|
+
if (this.closed) throw new Error("Dictation stream is closed");
|
|
297
|
+
this.closed = true;
|
|
298
|
+
// Feeding is optional, so the submitted take can hold audio this stream
|
|
299
|
+
// never saw; the batch path then transcribes the whole take.
|
|
300
|
+
if (audio && audio.length !== this.transcriber.samples) {
|
|
301
|
+
throw new Error(`the stream got ${this.transcriber.samples} of ${audio.length} samples`);
|
|
302
|
+
}
|
|
303
|
+
return this.transcriber.finish();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
reset(): void {
|
|
307
|
+
this.closed = true;
|
|
308
|
+
this.abort();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Settles once no chunk is decoding, so the model is free. */
|
|
312
|
+
idle(): Promise<void> {
|
|
313
|
+
return this.transcriber.idle();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function seconds(samples: number): string {
|
|
318
|
+
return (samples / RATE).toFixed(1);
|
|
319
|
+
}
|
|
@@ -17,6 +17,19 @@ export type CaptureSinks = {
|
|
|
17
17
|
onAudio(chunk: Float32Array): void;
|
|
18
18
|
onFailure(error: Error): void;
|
|
19
19
|
};
|
|
20
|
+
/** Voice activity detection for one take: passes on only the speech. */
|
|
21
|
+
export type SpeechFilter = {
|
|
22
|
+
push(chunk: Float32Array): void;
|
|
23
|
+
/** All kept audio, once every pushed chunk is decided. */
|
|
24
|
+
finish(): Promise<Float32Array>;
|
|
25
|
+
discard(): void;
|
|
26
|
+
};
|
|
27
|
+
/** Kept audio, with its Silero frame probabilities when it has them. */
|
|
28
|
+
export type OnSpeech = (chunk: Float32Array, probs?: readonly number[]) => void;
|
|
29
|
+
export type CreateSpeechFilter = (
|
|
30
|
+
settings: TranscribeSettings,
|
|
31
|
+
onSpeech: OnSpeech,
|
|
32
|
+
) => SpeechFilter | undefined;
|
|
20
33
|
/** Opens a microphone and starts recording; resolves once audio is flowing. */
|
|
21
34
|
export type OpenCapture = (microphone: MicrophoneSetting, sinks: CaptureSinks) => Promise<Capture>;
|
|
22
35
|
export type DictationResult = {
|
|
@@ -27,6 +40,8 @@ export type DictationResult = {
|
|
|
27
40
|
interruption?: Error;
|
|
28
41
|
/** Audio lost inside the recording because it wasn't read in time. */
|
|
29
42
|
droppedFrames?: number;
|
|
43
|
+
/** Language detection fallback, never part of the text. */
|
|
44
|
+
warning?: string;
|
|
30
45
|
};
|
|
31
46
|
export type DictationState =
|
|
32
47
|
| { phase: "idle" | "ready" | "starting" | "listening" | "transcribing" | "cancelling" | "disposed" }
|
|
@@ -37,12 +52,15 @@ export type DictationControllerOptions = {
|
|
|
37
52
|
now?: () => number;
|
|
38
53
|
onChange?: (state: DictationState) => void;
|
|
39
54
|
onAudio?: (chunk: Float32Array) => void;
|
|
55
|
+
/** Without a filter, every chunk is transcribed. */
|
|
56
|
+
createSpeechFilter?: CreateSpeechFilter;
|
|
40
57
|
};
|
|
41
58
|
type Take = {
|
|
42
59
|
settings: TranscribeSettings;
|
|
43
60
|
reservation: DictationReservation;
|
|
44
61
|
abort: AbortController;
|
|
45
62
|
chunker: PcmChunker;
|
|
63
|
+
filter?: SpeechFilter;
|
|
46
64
|
capture?: Capture;
|
|
47
65
|
interruption?: Error;
|
|
48
66
|
stopping?: Promise<CapturedAudio>;
|
|
@@ -106,8 +124,13 @@ export class DictationController {
|
|
|
106
124
|
}
|
|
107
125
|
const take: Take = {
|
|
108
126
|
settings, reservation, abort: new AbortController(),
|
|
109
|
-
chunker: new PcmChunker((chunk) => reservation.feed(chunk)),
|
|
127
|
+
chunker: new PcmChunker((chunk, probs) => reservation.feed(chunk, probs)),
|
|
110
128
|
};
|
|
129
|
+
try {
|
|
130
|
+
take.filter = this.options.createSpeechFilter?.(settings, (chunk, probs) => this.feedStream(take, chunk, probs));
|
|
131
|
+
} catch (cause) {
|
|
132
|
+
log.warn(`voice activity detection unavailable: ${describeError(cause)}`);
|
|
133
|
+
}
|
|
111
134
|
this.take = take;
|
|
112
135
|
void reservation.ready.then(
|
|
113
136
|
() => {
|
|
@@ -133,7 +156,7 @@ export class DictationController {
|
|
|
133
156
|
prepare(settings: TranscribeSettings): void {
|
|
134
157
|
if (this.disposed || ["starting", "listening", "transcribing", "cancelling"].includes(this.current.phase)) return;
|
|
135
158
|
if (this.take?.settings === settings && this.readiness !== "failed") return;
|
|
136
|
-
this.take
|
|
159
|
+
if (this.take) this.release(this.take);
|
|
137
160
|
this.take = undefined;
|
|
138
161
|
if (this.reserve(settings)) this.setState({ phase: "ready" });
|
|
139
162
|
}
|
|
@@ -149,7 +172,7 @@ export class DictationController {
|
|
|
149
172
|
if (["listening", "transcribing", "cancelling"].includes(this.current.phase)) return Promise.resolve();
|
|
150
173
|
let take = this.take?.settings === settings && this.readiness !== "failed" ? this.take : undefined;
|
|
151
174
|
if (!take) {
|
|
152
|
-
this.take
|
|
175
|
+
if (this.take) this.release(this.take);
|
|
153
176
|
this.take = undefined;
|
|
154
177
|
}
|
|
155
178
|
const generation = this.generation;
|
|
@@ -183,8 +206,7 @@ export class DictationController {
|
|
|
183
206
|
if (!current()) return; // Cancelled meanwhile; cancel() released the take.
|
|
184
207
|
if (take) {
|
|
185
208
|
this.take = undefined;
|
|
186
|
-
|
|
187
|
-
take.reservation.cancel();
|
|
209
|
+
this.release(take);
|
|
188
210
|
}
|
|
189
211
|
this.setState({ phase: "error", stage: "capture", cause });
|
|
190
212
|
return;
|
|
@@ -221,8 +243,20 @@ export class DictationController {
|
|
|
221
243
|
}
|
|
222
244
|
|
|
223
245
|
private feed(take: Take, chunk: Float32Array): void {
|
|
246
|
+
if (take.filter) take.filter.push(chunk);
|
|
247
|
+
else this.feedStream(take, chunk);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private feedStream(take: Take, chunk: Float32Array, probs?: readonly number[]): void {
|
|
224
251
|
// A failed stream feed falls back to submitting the whole recording.
|
|
225
|
-
try { take.chunker.push(chunk); } catch { /* The recording keeps the audio. */ }
|
|
252
|
+
try { take.chunker.push(chunk, probs); } catch { /* The recording keeps the audio. */ }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Drops a take that will never be submitted. */
|
|
256
|
+
private release(take: Take): void {
|
|
257
|
+
take.filter?.discard();
|
|
258
|
+
take.chunker.discard();
|
|
259
|
+
take.reservation.cancel();
|
|
226
260
|
}
|
|
227
261
|
|
|
228
262
|
private stopCapture(take: Take): Promise<CapturedAudio> {
|
|
@@ -248,30 +282,40 @@ export class DictationController {
|
|
|
248
282
|
take.submission = this.stopCapture(take).then(async (captured) => {
|
|
249
283
|
// Cancellation while the native microphone is stopping must never submit.
|
|
250
284
|
if (this.take !== take || take.abort.signal.aborted) return undefined;
|
|
251
|
-
const
|
|
285
|
+
const recorded = captured.pcm;
|
|
252
286
|
const interruption = captured.interruption ?? take.interruption;
|
|
253
|
-
if (interruption &&
|
|
287
|
+
if (interruption && recorded.length === 0) throw interruption;
|
|
288
|
+
const pcm = take.filter ? await take.filter.finish() : recorded;
|
|
289
|
+
if (this.take !== take || take.abort.signal.aborted) return undefined;
|
|
254
290
|
take.chunker.flush();
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
291
|
+
let text: string;
|
|
292
|
+
let warning: string | undefined;
|
|
293
|
+
if (take.filter && pcm.length === 0) {
|
|
294
|
+
take.reservation.cancel(); // No speech, nothing to transcribe.
|
|
295
|
+
text = "";
|
|
296
|
+
} else {
|
|
297
|
+
stage = "transcription";
|
|
298
|
+
logStep("transcribing", `${(pcm.length / CAPTURE_SAMPLE_RATE).toFixed(1)} s of audio`);
|
|
299
|
+
({ text, warning } = await take.reservation.submit(pcm, take.abort.signal));
|
|
300
|
+
}
|
|
258
301
|
if (this.disposed || this.take !== take || take.abort.signal.aborted) return undefined;
|
|
259
302
|
const result = {
|
|
260
303
|
text,
|
|
261
|
-
speechSeconds:
|
|
304
|
+
speechSeconds: recorded.length / CAPTURE_SAMPLE_RATE,
|
|
262
305
|
transcribeSeconds: Math.max(0, (this.now() - stoppedAt) / 1000),
|
|
263
306
|
...(interruption ? { interruption } : {}),
|
|
264
307
|
...(captured.droppedFrames ? { droppedFrames: captured.droppedFrames } : {}),
|
|
308
|
+
...(warning ? { warning } : {}),
|
|
265
309
|
};
|
|
266
310
|
this.take = undefined;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
);
|
|
311
|
+
log.info(text
|
|
312
|
+
? `transcribed ${(pcm.length / CAPTURE_SAMPLE_RATE).toFixed(1)} s of audio in ${result.transcribeSeconds.toFixed(1)} s`
|
|
313
|
+
: "no speech detected");
|
|
271
314
|
logStep("idle");
|
|
272
315
|
this.setState({ phase: "result", result });
|
|
273
316
|
return result;
|
|
274
317
|
}).catch((cause: unknown) => {
|
|
318
|
+
take.filter?.discard();
|
|
275
319
|
take.reservation.cancel(); // Releases the lane if stop failed before submit.
|
|
276
320
|
if (this.disposed || this.take !== take || take.abort.signal.aborted) return undefined;
|
|
277
321
|
this.take = undefined;
|
|
@@ -291,8 +335,7 @@ export class DictationController {
|
|
|
291
335
|
if (!take && !opening) return this.cleanup;
|
|
292
336
|
if (take) {
|
|
293
337
|
take.abort.abort();
|
|
294
|
-
|
|
295
|
-
take.reservation.cancel();
|
|
338
|
+
this.release(take);
|
|
296
339
|
}
|
|
297
340
|
if (opening) log.info("start cancelled");
|
|
298
341
|
else if (take?.submission) log.info("transcription cancelled");
|
package/src/file-audio.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
export const FILE_SAMPLE_RATE = 16_000;
|
|
4
4
|
|
|
5
5
|
const BYTES_PER_SAMPLE = Float32Array.BYTES_PER_ELEMENT;
|
|
6
|
-
const MAX_DECODED_BYTES =
|
|
6
|
+
const MAX_DECODED_BYTES = 512 * 1024 * 1024;
|
|
7
7
|
const MAX_STDERR_CHARS = 8 * 1024;
|
|
8
8
|
|
|
9
9
|
type FfmpegConfiguration = {
|
|
@@ -109,7 +109,7 @@ export async function decodeFileAudio(
|
|
|
109
109
|
byteLength += chunk.length;
|
|
110
110
|
if (byteLength > MAX_DECODED_BYTES) {
|
|
111
111
|
terminalError = new Error(
|
|
112
|
-
"Decoded audio exceeds the
|
|
112
|
+
"Decoded audio exceeds the 512 MiB safety limit (about 2.3 hours at 16 kHz mono). Split the media file into smaller parts and retry.",
|
|
113
113
|
);
|
|
114
114
|
child.kill("SIGTERM");
|
|
115
115
|
return;
|
|
@@ -25,6 +25,7 @@ type FileTranscriptionDetails = {
|
|
|
25
25
|
seconds: number;
|
|
26
26
|
truncation?: TruncationResult;
|
|
27
27
|
fullTranscriptPath?: string;
|
|
28
|
+
warning?: string;
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
type FileTranscriptionOptions = {
|
|
@@ -155,7 +156,7 @@ export function registerFileTranscriptionTool(
|
|
|
155
156
|
seconds: audio.seconds,
|
|
156
157
|
},
|
|
157
158
|
});
|
|
158
|
-
const rawTranscript = await service.transcribeFile(
|
|
159
|
+
const { text: rawTranscript, warning } = await service.transcribeFile(
|
|
159
160
|
configured,
|
|
160
161
|
audio.pcm,
|
|
161
162
|
operationSignal,
|
|
@@ -173,14 +174,16 @@ export function registerFileTranscriptionTool(
|
|
|
173
174
|
inputPath,
|
|
174
175
|
modelId: modelLabel,
|
|
175
176
|
seconds: audio.seconds,
|
|
177
|
+
...(warning ? { warning } : {}),
|
|
176
178
|
};
|
|
179
|
+
const warningText = warning ? `\n\n[Language detection: ${warning}]` : "";
|
|
177
180
|
|
|
178
181
|
if (!transcript) {
|
|
179
182
|
return {
|
|
180
183
|
content: [
|
|
181
184
|
{
|
|
182
185
|
type: "text" as const,
|
|
183
|
-
text: `No speech detected in ${audio.seconds.toFixed(1)}s of audio from ${inputPath}`,
|
|
186
|
+
text: `No speech detected in ${audio.seconds.toFixed(1)}s of audio from ${inputPath}${warningText}`,
|
|
184
187
|
},
|
|
185
188
|
],
|
|
186
189
|
details,
|
|
@@ -204,7 +207,7 @@ export function registerFileTranscriptionTool(
|
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
return {
|
|
207
|
-
content: [{ type: "text" as const, text: resultText }],
|
|
210
|
+
content: [{ type: "text" as const, text: resultText + warningText }],
|
|
208
211
|
details,
|
|
209
212
|
};
|
|
210
213
|
}, operationSignal);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { LangIdResult } from "transcribe-cpp";
|
|
2
|
+
import { CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
|
|
3
|
+
import { LANGUAGE_DETECTION_MODEL, displayLanguage, getCatalogModel, languageIdentity, resolveModelLanguage } from "./catalog.js";
|
|
4
|
+
import { describeError, log } from "./log.js";
|
|
5
|
+
import type { TranscribeSettings } from "./settings.js";
|
|
6
|
+
|
|
7
|
+
type LanguageModel = {
|
|
8
|
+
readonly languages: readonly string[];
|
|
9
|
+
readonly capabilities: { readonly languageDetection: boolean };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type DetectPolicy = { kind: "detect"; languages: string[]; allowed: string[] };
|
|
13
|
+
/** What a take does about language. Auto is never `fixed`; `unsupported` hides it. */
|
|
14
|
+
export type AutoLanguagePolicy =
|
|
15
|
+
| { kind: "native" }
|
|
16
|
+
| { kind: "fixed"; language: string }
|
|
17
|
+
| DetectPolicy
|
|
18
|
+
| { kind: "unsupported" };
|
|
19
|
+
|
|
20
|
+
const detectorCodes = new Map<string, string>(
|
|
21
|
+
LANGUAGE_DETECTION_MODEL.languages.map((code) => [languageIdentity(code), code]),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/** Return the detector's own label (iw/jw/tl/no included). */
|
|
25
|
+
export function detectorLanguageCode(language: string): string | undefined {
|
|
26
|
+
return detectorCodes.get(languageIdentity(language));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether this ASR model needs the external detector for Auto, independent of preferences. */
|
|
30
|
+
export function requiresLanguageDetector(model: LanguageModel): boolean {
|
|
31
|
+
return !model.capabilities.languageDetection &&
|
|
32
|
+
new Set(model.languages.map(languageIdentity)).size > 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Preferred languages this model supports, in preference order, one per identity. */
|
|
36
|
+
function compatiblePreferences(model: LanguageModel, preferred: readonly string[]): string[] {
|
|
37
|
+
const byIdentity = new Map<string, string>();
|
|
38
|
+
for (const preference of preferred) {
|
|
39
|
+
const language = resolveModelLanguage(model, preference);
|
|
40
|
+
if (language !== undefined && !byIdentity.has(languageIdentity(language))) {
|
|
41
|
+
byIdentity.set(languageIdentity(language), language);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...byIdentity.values()];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Supported preferred languages the external detector cannot identify (e.g. Irish). */
|
|
48
|
+
export function undetectablePreferences(model: LanguageModel, preferred: readonly string[]): string[] {
|
|
49
|
+
if (!requiresLanguageDetector(model)) return [];
|
|
50
|
+
return compatiblePreferences(model, preferred).filter((language) => detectorLanguageCode(language) === undefined);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve Auto. Without native detection, Auto exists only when at least two
|
|
55
|
+
* preferred languages are supported and every one of them is detectable.
|
|
56
|
+
*/
|
|
57
|
+
export function autoLanguagePolicy(
|
|
58
|
+
model: LanguageModel,
|
|
59
|
+
preferred: readonly string[],
|
|
60
|
+
): AutoLanguagePolicy {
|
|
61
|
+
// Native detection retains its existing unrestricted semantics.
|
|
62
|
+
if (model.capabilities.languageDetection) return { kind: "native" };
|
|
63
|
+
|
|
64
|
+
const languages = compatiblePreferences(model, preferred);
|
|
65
|
+
const allowed = languages.map(detectorLanguageCode);
|
|
66
|
+
if (languages.length < 2 || allowed.includes(undefined)) return { kind: "unsupported" };
|
|
67
|
+
return { kind: "detect", languages, allowed: allowed as string[] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The policy a take runs with: an explicit language is fixed. */
|
|
71
|
+
export function settingsLanguagePolicy(settings: TranscribeSettings): AutoLanguagePolicy {
|
|
72
|
+
if (settings.transcriptionLanguage !== "auto") {
|
|
73
|
+
return { kind: "fixed", language: settings.transcriptionLanguage };
|
|
74
|
+
}
|
|
75
|
+
const model = settings.model ? getCatalogModel(settings.model.id) : undefined;
|
|
76
|
+
// Test/injected backends may use noncatalog ids. Persisted settings never do.
|
|
77
|
+
// Remote settings carry no model; the endpoint detects the language itself.
|
|
78
|
+
return model ? autoLanguagePolicy(model, settings.preferredLanguages) : { kind: "native" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const WINDOW_SAMPLES = 7 * CAPTURE_SAMPLE_RATE;
|
|
82
|
+
const MIN_SAMPLES = CAPTURE_SAMPLE_RATE / 2;
|
|
83
|
+
// Log a pick as uncertain when it is less than 1.5x as likely as the
|
|
84
|
+
// runner-up (with two languages: p < 0.6). The pick is used either way.
|
|
85
|
+
const UNSURE_RATIO = 1.5;
|
|
86
|
+
|
|
87
|
+
/** Shown once per session when Auto runs without the detector on disk. */
|
|
88
|
+
export function missingDetectorWarning(fallback: string): string {
|
|
89
|
+
return `The language detector isn't downloaded. Transcribed as ${displayLanguage(fallback)}. ` +
|
|
90
|
+
"To download it, open /voice-settings and choose Auto detect as the transcription language.";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Pick one language for a whole clip from its first seven seconds: always the
|
|
95
|
+
* top candidate. Skipped (< 500 ms), failed, or malformed detection falls back
|
|
96
|
+
* to the first preferred language; only the last two warn.
|
|
97
|
+
*/
|
|
98
|
+
export async function detectLanguage(
|
|
99
|
+
policy: DetectPolicy,
|
|
100
|
+
pcm: Float32Array,
|
|
101
|
+
identify: (pcm: Float32Array, allowed: readonly string[]) => Promise<LangIdResult>,
|
|
102
|
+
): Promise<{ language: string; warning?: string }> {
|
|
103
|
+
const fallback = policy.languages[0]!;
|
|
104
|
+
const seconds = (pcm.length / CAPTURE_SAMPLE_RATE).toFixed(2);
|
|
105
|
+
if (pcm.length < MIN_SAMPLES) {
|
|
106
|
+
log.info(`language detection: skipped, the ${seconds} s clip is under the ` +
|
|
107
|
+
`${MIN_SAMPLES / CAPTURE_SAMPLE_RATE} s minimum; transcribing as ${fallback}, the first preferred language`);
|
|
108
|
+
return { language: fallback };
|
|
109
|
+
}
|
|
110
|
+
const transcribedAs = `Transcribed as ${displayLanguage(fallback)}.`;
|
|
111
|
+
try {
|
|
112
|
+
const result = await identify(pcm.subarray(0, WINDOW_SAMPLES), policy.allowed);
|
|
113
|
+
const [first, second] = result.candidates;
|
|
114
|
+
const language = policy.languages[policy.allowed.indexOf(result.code ?? "")];
|
|
115
|
+
const scores = result.candidates.map((c) => `${c.code} ${(c.p * 100).toFixed(1)}%`).join(", ");
|
|
116
|
+
const unsure = first && second && first.p < UNSURE_RATIO * second.p ? ", uncertain" : "";
|
|
117
|
+
log.info(`language detection: ${language ?? "none"} (${scores}; ` +
|
|
118
|
+
`${seconds} s, allowed mass ${(result.allowedMass * 100).toFixed(1)}%${unsure})`);
|
|
119
|
+
if (!language || !first || !Number.isFinite(first.p)) {
|
|
120
|
+
return { language: fallback, warning: `Couldn't detect the language. ${transcribedAs}` };
|
|
121
|
+
}
|
|
122
|
+
return { language };
|
|
123
|
+
} catch (error) {
|
|
124
|
+
log.warn(`Language detection failed: ${describeError(error)}`);
|
|
125
|
+
return { language: fallback, warning: `Language detection failed (${describeError(error)}). ${transcribedAs}` };
|
|
126
|
+
}
|
|
127
|
+
}
|