@tychenjiajun/pi-voice 0.7.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.
@@ -3,8 +3,10 @@ import type {
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import { truncateToWidth } from "@earendil-works/pi-tui";
6
+ import { downloadCatalogModel, pendingDownloads } from "./models.js";
6
7
  import { microphonePermission, type Permission } from "./audio.js";
7
- import { displayLanguage, getCatalogModel } from "./catalog.js";
8
+ import { displayLanguage, getCatalogModel, LANGUAGE_DETECTION_MODEL } from "./catalog.js";
9
+ import { autoLanguagePolicy } from "./language-detection-policy.js";
8
10
  import { chineseOutputSummary, isChineseLanguage } from "./chinese.js";
9
11
  import {
10
12
  chooseLanguages,
@@ -19,6 +21,7 @@ import {
19
21
  import { runModelSelection } from "./onboarding.js";
20
22
  import { chooseBackendType, promptRemoteBackend, warnMissingApiKey } from "./remote-asr.js";
21
23
  import {
24
+ settingsWithPreferredLanguages,
22
25
  writeSettings,
23
26
  type BackendSettings,
24
27
  type ChineseOutput,
@@ -96,6 +99,29 @@ function polishSummary(polishEnabled: boolean | undefined, model: PolishModelRef
96
99
  return model ? `${model.provider}/${model.id}` : "Pi's current model";
97
100
  }
98
101
 
102
+ let detectorDownload: Promise<void> | undefined;
103
+
104
+ /**
105
+ * Fetch the language detector in the background; until it lands, Auto detect
106
+ * transcribes as `fallback`.
107
+ */
108
+ function downloadLanguageDetector(ctx: ExtensionContext, fallback: string): void {
109
+ if (detectorDownload) return;
110
+ ctx.ui.notify("Downloading language detector…", "info");
111
+ detectorDownload = downloadCatalogModel(LANGUAGE_DETECTION_MODEL)
112
+ .then(
113
+ () => ctx.ui.notify("Language detector downloaded. Auto detect is ready.", "info"),
114
+ (error: unknown) => ctx.ui.notify(
115
+ `Couldn't download the language detector (${error instanceof Error ? error.message : String(error)}). ` +
116
+ `Auto detect will use ${displayLanguage(fallback)} until it's downloaded. ` +
117
+ "Choose Auto detect again to retry the download.",
118
+ "error",
119
+ ),
120
+ )
121
+ .catch(() => undefined) // The session may be gone by the time it settles.
122
+ .finally(() => { detectorDownload = undefined; });
123
+ }
124
+
99
125
  export function settingsHomeChoices(
100
126
  configured: TranscribeSettings,
101
127
  permission: Permission,
@@ -533,7 +559,7 @@ export async function showSettingsMenu(
533
559
  await saveUpdatedSettings(
534
560
  ctx,
535
561
  configured,
536
- { ...configured, preferredLanguages: selection.languages },
562
+ settingsWithPreferredLanguages(configured, selection.languages),
537
563
  "Preferred languages saved",
538
564
  );
539
565
  continue;
@@ -550,10 +576,12 @@ export async function showSettingsMenu(
550
576
  backend: configured.backend,
551
577
  polishEnabled: configured.polishEnabled,
552
578
  polishModel: configured.polishModel,
579
+ voiceActivityDetection: configured.voiceActivityDetection,
580
+ longformChunking: configured.longformChunking,
553
581
  postActivation: "stay",
554
582
  onPreferredLanguagesChange: async (preferredLanguages) => {
555
583
  if (languagesEqual(preferredLanguages, configured.preferredLanguages)) return;
556
- const next = { ...configured, preferredLanguages };
584
+ const next = settingsWithPreferredLanguages(configured, preferredLanguages);
557
585
  await writeSettings(next);
558
586
  Object.assign(configured, next);
559
587
  ctx.ui.notify("Preferred languages saved", "info");
@@ -575,15 +603,18 @@ export async function showSettingsMenu(
575
603
 
576
604
  if (action === "transcription-language") {
577
605
  const transcriptionLanguage = await chooseTranscriptionLanguage(ctx, configured);
578
- if (
579
- !transcriptionLanguage ||
580
- transcriptionLanguage === configured.transcriptionLanguage
581
- ) {
582
- continue;
583
- }
606
+ if (!transcriptionLanguage) continue;
584
607
  const model = configured.model
585
608
  ? getCatalogModel(configured.model.id)
586
609
  : undefined;
610
+ // Auto needs the detector on disk; choosing Auto again retries a failure.
611
+ if (model) {
612
+ const policy = autoLanguagePolicy(model, configured.preferredLanguages);
613
+ if (transcriptionLanguage === "auto" && policy.kind === "detect" && pendingDownloads(model, true).length) {
614
+ downloadLanguageDetector(ctx, policy.languages[0]!);
615
+ }
616
+ }
617
+ if (transcriptionLanguage === configured.transcriptionLanguage) continue;
587
618
  const summary = model
588
619
  ? transcriptionLanguageSummary(transcriptionLanguage, model)
589
620
  : transcriptionLanguage;
package/src/settings.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  resolveModelLanguage,
6
6
  type CatalogModel,
7
7
  } from "./catalog.js";
8
+ import { autoLanguagePolicy } from "./language-detection-policy.js";
8
9
  import { DEFAULT_SHORTCUT, normalizeShortcut } from "./shortcut-core.js";
9
10
  import { legacySettingsPath, settingsPath } from "./settings-path.js";
10
11
 
@@ -42,7 +43,7 @@ function defaultChineseOutput(): ChineseOutput {
42
43
  return "simplified";
43
44
  }
44
45
 
45
- /** "auto" asks a capable model to detect the language; otherwise this is a language code. */
46
+ /** "auto" uses native detection or the external detector; otherwise a language code. */
46
47
  export type TranscriptionLanguage = string;
47
48
 
48
49
  export type TranscribeSettings = {
@@ -57,6 +58,10 @@ export type TranscribeSettings = {
57
58
  polishEnabled?: boolean;
58
59
  /** Optional post-processing model override; absent uses the agent's current model. */
59
60
  polishModel?: PolishModelRef;
61
+ /** Transcribe only the speech in a recording, not its silence and noise. */
62
+ voiceActivityDetection: boolean;
63
+ /** Hidden, settings.json only: false decodes long audio in one piece, without voice activity cuts. */
64
+ longformChunking: boolean;
60
65
  /**
61
66
  * The local catalog model. Present only in local mode (backend
62
67
  * `transcribe-cpp`); remote mode stores no local model.
@@ -112,7 +117,7 @@ function defaultTranscriptionLanguage(
112
117
  model: CatalogModel,
113
118
  preferredLanguages: readonly string[] = [],
114
119
  ): TranscriptionLanguage {
115
- if (model.capabilities.languageDetection) return "auto";
120
+ if (autoLanguagePolicy(model, preferredLanguages).kind !== "unsupported") return "auto";
116
121
  for (const language of preferredLanguages) {
117
122
  const code = resolveModelLanguage(model, language);
118
123
  if (code) return code;
@@ -127,7 +132,9 @@ export function transcriptionLanguageForModel(
127
132
  preferredLanguages: readonly string[] = [],
128
133
  ): TranscriptionLanguage {
129
134
  if (typeof value === "string") {
130
- if (value === "auto" && model.capabilities.languageDetection) return value;
135
+ if (value === "auto" && autoLanguagePolicy(model, preferredLanguages).kind !== "unsupported") {
136
+ return value;
137
+ }
131
138
  if (value !== "auto") {
132
139
  const code = resolveModelLanguage(model, value);
133
140
  if (code) return code;
@@ -220,6 +227,8 @@ function validateSettings(value: unknown): TranscribeSettings | undefined {
220
227
  microphone,
221
228
  polishEnabled: value.polishEnabled === true,
222
229
  ...(polishModel ? { polishModel } : {}),
230
+ voiceActivityDetection: value.voiceActivityDetection !== false,
231
+ longformChunking: value.longformChunking !== false,
223
232
  ...(backend.type === "transcribe-cpp"
224
233
  ? { model: { source: "catalog" as const, id: selected!.id, path: selected!.path } }
225
234
  : {}),
@@ -293,6 +302,8 @@ type ModelSettingsOptions = {
293
302
  polishEnabled?: boolean;
294
303
  /** Preserved across model re-selection; absent means the agent's current model. */
295
304
  polishModel?: PolishModelRef;
305
+ voiceActivityDetection?: boolean;
306
+ longformChunking?: boolean;
296
307
  };
297
308
 
298
309
  export function settingsForModel(
@@ -323,10 +334,13 @@ export function settingsForModel(
323
334
  microphone: { ...(options.microphone ?? DEFAULT_MICROPHONE) },
324
335
  polishEnabled: options.polishEnabled ?? false,
325
336
  ...(options.polishModel ? { polishModel: options.polishModel } : {}),
337
+ voiceActivityDetection: options.voiceActivityDetection ?? true,
338
+ longformChunking: options.longformChunking ?? true,
326
339
  model: { source: "catalog", id: modelId, path: modelPath },
327
340
  };
328
341
  }
329
342
 
343
+
330
344
  type RemoteSettingsOptions = Omit<ModelSettingsOptions, "backend"> & {
331
345
  backend: Extract<BackendSettings, { type: "openai-asr" }>;
332
346
  };
@@ -348,6 +362,8 @@ export function settingsRemoteOnly(options: RemoteSettingsOptions): TranscribeSe
348
362
  microphone: { ...(options.microphone ?? DEFAULT_MICROPHONE) },
349
363
  polishEnabled: options.polishEnabled ?? false,
350
364
  ...(options.polishModel ? { polishModel: options.polishModel } : {}),
365
+ voiceActivityDetection: options.voiceActivityDetection ?? true,
366
+ longformChunking: options.longformChunking ?? true,
351
367
  };
352
368
  }
353
369
 
@@ -356,3 +372,19 @@ export function transcriptionModelLabel(settings: TranscribeSettings): string {
356
372
  if (settings.model) return settings.model.id;
357
373
  return settings.backend.type === "openai-asr" ? settings.backend.model : "none";
358
374
  }
375
+
376
+ /** Preference edits re-resolve the language, so Auto never outlives support for it. */
377
+ export function settingsWithPreferredLanguages(
378
+ current: TranscribeSettings,
379
+ preferredLanguages: readonly string[],
380
+ ): TranscribeSettings {
381
+ // Remote settings carry no local model, so there is no per-model language to
382
+ // re-resolve; the endpoint detects the language itself.
383
+ if (!current.model) {
384
+ return {
385
+ ...current,
386
+ preferredLanguages: [...new Set(preferredLanguages.map(languageIdentity))],
387
+ };
388
+ }
389
+ return settingsForModel(current.model.id, current.model.path, { ...current, preferredLanguages });
390
+ }
@@ -1,4 +1,21 @@
1
+ import { LANGUAGE_DETECTION_MODEL, displayLanguage, getCatalogModel } from "./catalog.js";
2
+ import { isChineseLanguage } from "./chinese.js";
3
+ import {
4
+ ChunkedDictationStream,
5
+ ChunkedTranscriber,
6
+ chunksTranscription,
7
+ MAX_CHUNK_SAMPLES,
8
+ type ChunkDecoder,
9
+ } from "./chunking.js";
1
10
  import { Deferred } from "./deferred.js";
11
+ import {
12
+ detectLanguage,
13
+ missingDetectorWarning,
14
+ settingsLanguagePolicy,
15
+ undetectablePreferences,
16
+ type AutoLanguagePolicy,
17
+ } from "./language-detection-policy.js";
18
+ import { describeError, log } from "./log.js";
2
19
  import type { TranscribeSettings } from "./settings.js";
3
20
  import type {
4
21
  DictationStream,
@@ -6,11 +23,24 @@ import type {
6
23
  TranscriptionOptions,
7
24
  } from "./transcription.js";
8
25
 
26
+ /** A transcript, plus any language identification fallback or limitation. */
27
+ export type Transcript = { text: string; warning?: string };
28
+
29
+ /** Silero's probability for each 512-sample frame of a whole clip. */
30
+ export type ClipScorer = (pcm: Float32Array, signal: AbortSignal) => Promise<readonly number[]>;
31
+
32
+ /** Audio fed during recording, with its Silero frame probabilities when it has them. */
33
+ type FedChunk = { pcm: Float32Array; probs?: readonly number[] };
34
+
35
+ /** Decodes a take's audio in the language chosen from its start, once. */
36
+ type TakeDecoder = { decode: ChunkDecoder; readonly warning: string | undefined };
37
+
9
38
  type TranscriptionJob = {
10
39
  settings: TranscribeSettings;
40
+ policy: AutoLanguagePolicy;
11
41
  pcm: Float32Array;
12
42
  signal?: AbortSignal;
13
- resolve: (text: string) => void;
43
+ resolve: (transcript: Transcript) => void;
14
44
  reject: (error: unknown) => void;
15
45
  started: boolean;
16
46
  settled: boolean;
@@ -19,6 +49,7 @@ type TranscriptionJob = {
19
49
 
20
50
  type ReservationState = {
21
51
  settings: TranscribeSettings;
52
+ policy: AutoLanguagePolicy;
22
53
  accepting: boolean;
23
54
  submitted: boolean;
24
55
  cancelled: boolean;
@@ -27,12 +58,14 @@ type ReservationState = {
27
58
  ready: Deferred;
28
59
  /** Resolves when recording ends, whether by submit or cancel. */
29
60
  submission: Deferred;
30
- result: Deferred<string>;
61
+ result: Deferred<Transcript>;
31
62
  fullPcm?: Float32Array;
32
63
  signal?: AbortSignal;
33
64
  removeAbortListener?: () => void;
34
- chunks: Float32Array[];
65
+ chunks: FedChunk[];
35
66
  stream?: DictationStream;
67
+ /** The take's chunked stream, kept after reset until its decoding settles. */
68
+ chunked?: { stream: ChunkedDictationStream; decoder: TakeDecoder };
36
69
  drain?: Promise<void>;
37
70
  streamUnavailable: boolean;
38
71
  streamError?: unknown;
@@ -40,8 +73,8 @@ type ReservationState = {
40
73
 
41
74
  export type DictationReservation = {
42
75
  readonly ready: Promise<void>;
43
- feed(chunk: Float32Array): void;
44
- submit(pcm: Float32Array, signal?: AbortSignal): Promise<string>;
76
+ feed(chunk: Float32Array, probs?: readonly number[]): void;
77
+ submit(pcm: Float32Array, signal?: AbortSignal): Promise<Transcript>;
45
78
  cancel(): void;
46
79
  };
47
80
 
@@ -52,11 +85,12 @@ type ReusableBackend = Pick<
52
85
  startStream?: (
53
86
  options?: TranscriptionOptions,
54
87
  ) => Promise<DictationStream | undefined>;
88
+ identifyLanguage?: TranscribeCppBackend["identifyLanguage"];
55
89
  };
56
90
 
57
91
  type BackendFactory = (
58
92
  backendKey: string,
59
- settings: TranscribeSettings,
93
+ settings?: TranscribeSettings,
60
94
  ) => ReusableBackend | Promise<ReusableBackend>;
61
95
 
62
96
  /** Local backends key on the model file; remote keys on endpoint identity. */
@@ -87,18 +121,28 @@ function cancellationError(state: ReservationState): Error {
87
121
 
88
122
  function transcriptionOptions(
89
123
  settings: TranscribeSettings,
124
+ language: string | undefined,
90
125
  signal?: AbortSignal,
91
126
  ): TranscriptionOptions {
92
127
  return {
93
128
  signal,
94
- language:
95
- settings.transcriptionLanguage === "auto"
96
- ? undefined
97
- : settings.transcriptionLanguage,
129
+ language,
98
130
  chineseOutput: settings.chineseOutput,
131
+ chineseEnabled: isChineseLanguage(settings.transcriptionLanguage) ||
132
+ settings.preferredLanguages.some(isChineseLanguage),
99
133
  };
100
134
  }
101
135
 
136
+ function fixedLanguage(policy: AutoLanguagePolicy): string | undefined {
137
+ return policy.kind === "fixed" ? policy.language : undefined;
138
+ }
139
+
140
+ async function cachedDetectorPath(): Promise<string | undefined> {
141
+ // Keeps Hugging Face modules off the path of every other take.
142
+ const { findCachedCatalogModel } = await import("./models.js");
143
+ return findCachedCatalogModel(LANGUAGE_DETECTION_MODEL)?.path;
144
+ }
145
+
102
146
  /** Owns the loaded model and schedules dictation ahead of queued file jobs. */
103
147
  export class TranscriptionService {
104
148
  private backend: ReusableBackend | undefined;
@@ -110,31 +154,42 @@ export class TranscriptionService {
110
154
  private loop: Promise<void> | undefined;
111
155
  private shuttingDown = false;
112
156
  private readonly shutdownController = new AbortController();
157
+ /** The language detector, loaded on demand like the transcription model. */
158
+ private detector: ReusableBackend | undefined;
159
+ private detectorLoading: Promise<ReusableBackend> | undefined;
160
+ /** A missing detector warns on the first take only, not every take. */
161
+ private missingDetectorWarned = false;
113
162
 
114
163
  constructor(
115
- private readonly createBackend: BackendFactory = async (_key, settings) => {
116
- if (settings.backend.type === "openai-asr") {
164
+ private readonly createBackend: BackendFactory = async (key, settings) => {
165
+ if (settings?.backend.type === "openai-asr") {
117
166
  const { OpenAIAsrBackend } = await import("./openai-asr.js");
118
167
  return new OpenAIAsrBackend(settings.backend);
119
168
  }
120
169
  const { TranscribeCppBackend } = await import("./transcription.js");
121
- return new TranscribeCppBackend(requireLocalModel(settings).path);
170
+ // The key is the model file path for local backends, including the
171
+ // language detector (loaded without settings).
172
+ return new TranscribeCppBackend(key);
122
173
  },
174
+ /** Scores long clips for chunking; without it they decode in one piece. */
175
+ private readonly scoreClip?: ClipScorer,
123
176
  ) {}
124
177
 
125
178
  reserveDictation(settings: TranscribeSettings): DictationReservation {
126
179
  if (this.shuttingDown) throw new Error("Pi Voice is shutting down");
127
180
  if (this.reservation) throw new Error("A dictation reservation is already active");
128
181
 
182
+ const policy = settingsLanguagePolicy(settings);
129
183
  const state: ReservationState = {
130
184
  settings,
185
+ policy,
131
186
  accepting: true,
132
187
  submitted: false,
133
188
  cancelled: false,
134
189
  started: false,
135
190
  ready: new Deferred(),
136
191
  submission: new Deferred(),
137
- result: new Deferred<string>(),
192
+ result: new Deferred<Transcript>(),
138
193
  chunks: [],
139
194
  streamUnavailable: false,
140
195
  };
@@ -147,14 +202,14 @@ export class TranscriptionService {
147
202
 
148
203
  return {
149
204
  ready: state.ready.promise,
150
- feed: (chunk) => {
205
+ feed: (chunk, probs) => {
151
206
  if (
152
207
  !state.accepting ||
153
208
  state.streamUnavailable ||
154
209
  state.streamError !== undefined ||
155
210
  chunk.length === 0
156
211
  ) return;
157
- state.chunks.push(chunk);
212
+ state.chunks.push({ pcm: chunk, probs });
158
213
  this.startDrain(state);
159
214
  },
160
215
  submit: (pcm, signal) => {
@@ -196,13 +251,14 @@ export class TranscriptionService {
196
251
  settings: TranscribeSettings,
197
252
  pcm: Float32Array,
198
253
  signal?: AbortSignal,
199
- ): Promise<string> {
254
+ ): Promise<Transcript> {
200
255
  if (this.shuttingDown) return Promise.reject(new Error("Pi Voice is shutting down"));
201
256
  if (signal?.aborted) return Promise.reject(abortError(signal));
202
257
 
203
- const result = new Promise<string>((resolve, reject) => {
258
+ const result = new Promise<Transcript>((resolve, reject) => {
204
259
  const job: TranscriptionJob = {
205
260
  settings,
261
+ policy: settingsLanguagePolicy(settings),
206
262
  pcm,
207
263
  signal,
208
264
  resolve,
@@ -283,7 +339,7 @@ export class TranscriptionService {
283
339
  ) {
284
340
  const chunk = state.chunks.shift()!;
285
341
  try {
286
- await stream.feed(chunk);
342
+ await stream.feed(chunk.pcm, chunk.probs);
287
343
  } catch (error) {
288
344
  state.streamError = error;
289
345
  state.chunks.length = 0;
@@ -345,8 +401,7 @@ export class TranscriptionService {
345
401
  continue;
346
402
  }
347
403
 
348
- // A dispose failure here would otherwise reject the loop promise unobserved.
349
- await this.unloadModel().catch(() => undefined);
404
+ await this.unloadAll();
350
405
  return;
351
406
  }
352
407
  }
@@ -355,6 +410,8 @@ export class TranscriptionService {
355
410
  try {
356
411
  await this.runReservationWork(state);
357
412
  } finally {
413
+ // A reset chunked stream may still be decoding: keep the model until it stops.
414
+ await state.chunked?.stream.idle();
358
415
  state.removeAbortListener?.();
359
416
  this.reservationStates.delete(state);
360
417
  }
@@ -394,13 +451,20 @@ export class TranscriptionService {
394
451
  return;
395
452
  }
396
453
 
397
- // If recording already ended while the model loaded (or while the stream
398
- // opened), avoid replaying the complete clip through a newly opened stream
399
- // and use the batch path.
400
- if (!state.submitted && backend.startStream) {
454
+ if (chunksTranscription(state.settings)) {
455
+ // Only buffers until a chunk is due, so a take that already ended still uses it.
456
+ const abort = new AbortController();
457
+ const decoder = this.takeDecoder(backend, state.settings, state.policy, this.withShutdown(abort.signal));
458
+ const stream = new ChunkedDictationStream(decoder.decode, () => abort.abort(cancellationError(state)));
459
+ state.chunked = { stream, decoder };
460
+ state.stream = stream;
461
+ } else if (!state.submitted && !state.streamUnavailable && backend.startStream) {
462
+ // If recording already ended while the model loaded (or while the stream
463
+ // opened), avoid replaying the complete clip through a newly opened
464
+ // stream and use the batch path.
401
465
  try {
402
466
  const stream = await backend.startStream(
403
- transcriptionOptions(state.settings),
467
+ transcriptionOptions(state.settings, fixedLanguage(state.policy)),
404
468
  );
405
469
  if (stream && state.submitted) {
406
470
  stream.reset();
@@ -425,6 +489,8 @@ export class TranscriptionService {
425
489
 
426
490
  state.ready.resolve();
427
491
  this.startDrain(state);
492
+ // Warm the detector while the user speaks; a failure resurfaces at detection.
493
+ if (state.policy.kind === "detect") await this.ensureDetector().catch(() => undefined);
428
494
  await state.submission.promise;
429
495
 
430
496
  if (!state.submitted) {
@@ -446,9 +512,10 @@ export class TranscriptionService {
446
512
  // streaming existed, instead of finalizing a stream that was never fed.
447
513
  if (stream && state.streamError === undefined && pcm.length > 0) {
448
514
  try {
449
- const text = await stream.finalize();
515
+ const text = await stream.finalize(pcm);
450
516
  state.signal?.throwIfAborted();
451
- state.result.resolve(text);
517
+ const warning = state.chunked?.stream === stream ? state.chunked.decoder.warning : undefined;
518
+ state.result.resolve(warning ? { text, warning } : { text });
452
519
  return;
453
520
  } catch (error) {
454
521
  if (state.signal?.aborted) throw abortError(state.signal);
@@ -461,16 +528,18 @@ export class TranscriptionService {
461
528
  }
462
529
  }
463
530
 
464
- // Stream setup/feed/finalize failure: reset precedes the batch fallback.
531
+ // Stream setup/feed/finalize failure: reset precedes the batch fallback,
532
+ // and a live chunk still decoding must stop before the model is reused.
533
+ await state.chunked?.stream.idle();
465
534
  this.resetStream(state);
466
535
  state.chunks.length = 0;
467
- const text = await this.transcribeBatch(
536
+ state.result.resolve(await this.transcribeBatch(
468
537
  backend,
469
538
  state.settings,
539
+ state.policy,
470
540
  pcm,
471
541
  this.withShutdown(state.signal),
472
- );
473
- state.result.resolve(text);
542
+ ));
474
543
  } catch (error) {
475
544
  this.resetStream(state);
476
545
  state.result.reject(
@@ -489,11 +558,87 @@ export class TranscriptionService {
489
558
  private async transcribeBatch(
490
559
  backend: ReusableBackend,
491
560
  settings: TranscribeSettings,
561
+ policy: AutoLanguagePolicy,
492
562
  pcm: Float32Array,
493
563
  signal: AbortSignal,
494
- ): Promise<string> {
564
+ ): Promise<Transcript> {
495
565
  signal.throwIfAborted();
496
- return backend.transcribe(pcm, transcriptionOptions(settings, signal));
566
+ const decoder = this.takeDecoder(backend, settings, policy, signal);
567
+ let text: string;
568
+ if (chunksTranscription(settings) && pcm.length > MAX_CHUNK_SAMPLES) {
569
+ const transcriber = new ChunkedTranscriber(decoder.decode);
570
+ transcriber.push(pcm, await this.scoreLongClip(pcm, signal));
571
+ text = await transcriber.finish();
572
+ } else {
573
+ text = await decoder.decode(pcm, pcm);
574
+ }
575
+ const warning = decoder.warning;
576
+ return warning ? { text, warning } : { text };
577
+ }
578
+
579
+ /** Scores for a clip too long for one chunk; undefined decodes it in one piece. */
580
+ private async scoreLongClip(pcm: Float32Array, signal: AbortSignal): Promise<readonly number[] | undefined> {
581
+ if (!this.scoreClip) {
582
+ log.warn("no voice activity detection; decoding the clip in one piece");
583
+ return undefined;
584
+ }
585
+ try {
586
+ return await this.scoreClip(pcm, signal);
587
+ } catch (error) {
588
+ signal.throwIfAborted();
589
+ log.warn(`voice activity detection failed, decoding the clip in one piece: ${describeError(error)}`);
590
+ return undefined;
591
+ }
592
+ }
593
+
594
+ /**
595
+ * A decoder for one take or file: the first chunk chooses the language from
596
+ * the start of the clip, and every chunk uses it.
597
+ */
598
+ private takeDecoder(
599
+ backend: ReusableBackend,
600
+ settings: TranscribeSettings,
601
+ policy: AutoLanguagePolicy,
602
+ signal: AbortSignal,
603
+ ): TakeDecoder {
604
+ let choice: Promise<{ language?: string; warning?: string }> | undefined;
605
+ let warning: string | undefined;
606
+ return {
607
+ decode: async (chunk, clip) => {
608
+ signal.throwIfAborted();
609
+ const chosen = await (choice ??= this.chooseLanguage(settings, policy, clip, signal));
610
+ warning = chosen.warning;
611
+ signal.throwIfAborted();
612
+ return backend.transcribe(chunk, transcriptionOptions(settings, chosen.language, signal));
613
+ },
614
+ get warning() { return warning; },
615
+ };
616
+ }
617
+
618
+ private async chooseLanguage(
619
+ settings: TranscribeSettings,
620
+ policy: AutoLanguagePolicy,
621
+ clip: Float32Array,
622
+ signal: AbortSignal,
623
+ ): Promise<{ language?: string; warning?: string }> {
624
+ if (policy.kind === "detect" && !this.detector && !(await cachedDetectorPath())) {
625
+ const language = policy.languages[0]!;
626
+ const warning = this.missingDetectorWarned ? undefined : missingDetectorWarning(language);
627
+ this.missingDetectorWarned = true;
628
+ return { language, warning };
629
+ }
630
+ if (policy.kind === "detect") {
631
+ return detectLanguage(policy, clip, async (head, allowed) =>
632
+ (await this.ensureDetector()).identifyLanguage!(head, allowed, signal));
633
+ }
634
+ if (policy.kind === "unsupported") {
635
+ // Settings normalization drops Auto first; this guards stale settings.
636
+ const model = settings.model ? getCatalogModel(settings.model.id) : undefined;
637
+ const skipped = model ? undetectablePreferences(model, settings.preferredLanguages) : [];
638
+ const which = skipped.length ? ` (${skipped.map(displayLanguage).join(", ")})` : "";
639
+ throw new Error(`Auto detect doesn't work with your current languages${which}. Choose a language in /voice-settings.`);
640
+ }
641
+ return { language: fixedLanguage(policy) };
497
642
  }
498
643
 
499
644
  private async runJob(job: TranscriptionJob): Promise<void> {
@@ -504,13 +649,26 @@ export class TranscriptionService {
504
649
  try {
505
650
  signal.throwIfAborted();
506
651
  const backend = await this.ensureBackend(job.settings);
507
- const text = await this.transcribeBatch(backend, job.settings, job.pcm, signal);
508
- this.settleJob(job, () => job.resolve(text));
652
+ const transcript = await this.transcribeBatch(backend, job.settings, job.policy, job.pcm, signal);
653
+ this.settleJob(job, () => job.resolve(transcript));
509
654
  } catch (error) {
510
655
  this.settleJob(job, () => job.reject(error));
511
656
  }
512
657
  }
513
658
 
659
+ /** Concurrent callers (prewarm, a live chunk) share one load. */
660
+ private ensureDetector(): Promise<ReusableBackend> {
661
+ return (this.detectorLoading ??= (async () => {
662
+ if (!this.detector) {
663
+ const path = await cachedDetectorPath();
664
+ if (!path) throw new Error("the language detector isn't downloaded");
665
+ this.detector = await this.createBackend(path);
666
+ }
667
+ await this.detector.prepare();
668
+ return this.detector;
669
+ })().finally(() => { this.detectorLoading = undefined; }));
670
+ }
671
+
514
672
  private async ensureBackend(settings: TranscribeSettings): Promise<ReusableBackend> {
515
673
  const key = backendKeyFor(settings);
516
674
  if (this.backend && this.backendKey === key) {
@@ -543,6 +701,16 @@ export class TranscriptionService {
543
701
  await backend.dispose();
544
702
  }
545
703
 
704
+ /** Never rejects: a dispose failure would otherwise go unobserved. */
705
+ private async unloadAll(): Promise<void> {
706
+ const detector = this.detector;
707
+ this.detector = undefined;
708
+ await Promise.all([
709
+ this.unloadModel().catch(() => undefined),
710
+ detector?.dispose().catch(() => undefined),
711
+ ]);
712
+ }
713
+
546
714
  async shutdown(): Promise<void> {
547
715
  if (!this.shuttingDown) {
548
716
  this.shuttingDown = true;
@@ -567,6 +735,6 @@ export class TranscriptionService {
567
735
 
568
736
  await this.loop?.catch(() => undefined);
569
737
  this.reservationStates.clear();
570
- await this.unloadModel().catch(() => undefined);
738
+ await this.unloadAll();
571
739
  }
572
740
  }