@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.
- package/README.md +23 -1
- 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 +40 -9
- package/src/settings.ts +35 -3
- package/src/transcription-service.ts +205 -37
- package/src/transcription.ts +72 -24
- package/src/try-it.ts +11 -2
- package/src/vad.ts +301 -0
- package/src/catalog.generated.ts +0 -1305
package/src/settings-menu.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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"
|
|
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.
|
|
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.
|
|
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: (
|
|
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<
|
|
61
|
+
result: Deferred<Transcript>;
|
|
31
62
|
fullPcm?: Float32Array;
|
|
32
63
|
signal?: AbortSignal;
|
|
33
64
|
removeAbortListener?: () => void;
|
|
34
|
-
chunks:
|
|
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<
|
|
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
|
|
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 (
|
|
116
|
-
if (settings
|
|
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
|
-
|
|
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<
|
|
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<
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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.
|
|
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
|
-
|
|
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<
|
|
564
|
+
): Promise<Transcript> {
|
|
495
565
|
signal.throwIfAborted();
|
|
496
|
-
|
|
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
|
|
508
|
-
this.settleJob(job, () => job.resolve(
|
|
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.
|
|
738
|
+
await this.unloadAll();
|
|
571
739
|
}
|
|
572
740
|
}
|