@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.
@@ -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?.reservation.cancel();
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?.reservation.cancel();
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
- take.chunker.discard();
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 { pcm } = captured;
285
+ const recorded = captured.pcm;
252
286
  const interruption = captured.interruption ?? take.interruption;
253
- if (interruption && pcm.length === 0) throw 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
- stage = "transcription";
256
- logStep("transcribing", `${(pcm.length / CAPTURE_SAMPLE_RATE).toFixed(1)} s of audio`);
257
- const text = await take.reservation.submit(pcm, take.abort.signal);
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: pcm.length / CAPTURE_SAMPLE_RATE,
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
- const empty = text ? "" : "; no speech detected";
268
- log.info(
269
- `transcribed ${result.speechSeconds.toFixed(1)} s of audio in ${result.transcribeSeconds.toFixed(1)} s${empty}`,
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
- take.chunker.discard();
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 = 128 * 1024 * 1024;
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 128 MiB safety limit (about 35 minutes at 16 kHz mono). Split the media file into smaller parts and retry.",
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
+ }