@tychenjiajun/pi-voice 0.0.0-stage → 0.2.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -2
  3. package/catalog/recommendations.json +710 -0
  4. package/index.ts +1 -0
  5. package/package.json +68 -4
  6. package/src/async-limiter.ts +77 -0
  7. package/src/audio-constants.ts +1 -0
  8. package/src/audio-errors.ts +14 -0
  9. package/src/audio.ts +109 -0
  10. package/src/catalog.generated.ts +1305 -0
  11. package/src/catalog.ts +89 -0
  12. package/src/chinese.ts +52 -0
  13. package/src/deferred.ts +30 -0
  14. package/src/dictation-controller.ts +315 -0
  15. package/src/file-audio.ts +164 -0
  16. package/src/file-transcription.ts +212 -0
  17. package/src/index.ts +135 -0
  18. package/src/install-migration.ts +47 -0
  19. package/src/keybindings.ts +119 -0
  20. package/src/languages.ts +22 -0
  21. package/src/log.ts +171 -0
  22. package/src/microphone-picker.ts +95 -0
  23. package/src/model-activation.ts +74 -0
  24. package/src/model-cells.ts +218 -0
  25. package/src/model-picker.ts +1026 -0
  26. package/src/model-ratings-help.md +41 -0
  27. package/src/model-ratings-help.ts +146 -0
  28. package/src/model-selection-controller.ts +185 -0
  29. package/src/models.ts +263 -0
  30. package/src/onboarding.ts +319 -0
  31. package/src/openai-asr.ts +247 -0
  32. package/src/pcm-chunker.ts +46 -0
  33. package/src/recommendation-picker.ts +512 -0
  34. package/src/recommendations.ts +423 -0
  35. package/src/runtime.ts +524 -0
  36. package/src/settings-menu.ts +638 -0
  37. package/src/settings-path.ts +19 -0
  38. package/src/settings.ts +273 -0
  39. package/src/shortcut-core.ts +85 -0
  40. package/src/shortcuts.ts +167 -0
  41. package/src/startup-shortcut.ts +24 -0
  42. package/src/transcript-preview.ts +52 -0
  43. package/src/transcription-service.ts +570 -0
  44. package/src/transcription.ts +203 -0
  45. package/src/try-it.ts +318 -0
  46. package/src/ui-components.ts +432 -0
  47. package/src/visualizer.ts +271 -0
package/src/catalog.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { CATALOG_MODELS_GENERATED } from "./catalog.generated.js";
2
+ import { canonicalLanguage, languageIdentity, resolveModelLanguage } from "./languages.js";
3
+ export { canonicalLanguage, languageIdentity, resolveModelLanguage } from "./languages.js";
4
+
5
+ export type CatalogModel = {
6
+ readonly id: string;
7
+ readonly name: string;
8
+ readonly description: string;
9
+ readonly repository: string;
10
+ readonly revision: string;
11
+ readonly license: string;
12
+ readonly family: string;
13
+ readonly parameters: string | null;
14
+ readonly languages: readonly string[];
15
+ readonly capabilities: {
16
+ readonly streaming: boolean;
17
+ readonly translate: boolean;
18
+ readonly languageDetection: boolean;
19
+ };
20
+ readonly quant: string;
21
+ readonly filename: string;
22
+ readonly size: number;
23
+ readonly sha256: string;
24
+ };
25
+
26
+ export const CATALOG_MODELS: readonly CatalogModel[] = CATALOG_MODELS_GENERATED;
27
+
28
+ const languageNames = new Intl.DisplayNames(["en"], { type: "language" });
29
+
30
+ export function displayLanguage(language: string): string {
31
+ // ASR catalogs conventionally use zh for Mandarin and list Cantonese as yue.
32
+ // Make that spoken-language distinction explicit without losing the familiar
33
+ // umbrella term users look for.
34
+ if (canonicalLanguage(language) === "zh") return "Mandarin (Chinese)";
35
+ try {
36
+ return languageNames.of(language) ?? language;
37
+ } catch {
38
+ return language;
39
+ }
40
+ }
41
+
42
+ export function modelMatchesLanguage(model: CatalogModel, language: string): boolean {
43
+ return resolveModelLanguage(model, language) !== undefined;
44
+ }
45
+
46
+ function preferredLanguageMatchCount(
47
+ model: CatalogModel,
48
+ preferredLanguages: readonly string[],
49
+ ): number {
50
+ return [...new Set(preferredLanguages.map(languageIdentity))].filter((language) =>
51
+ modelMatchesLanguage(model, language),
52
+ ).length;
53
+ }
54
+
55
+ // The catalog carries no editorial rank or score: the pickers order measured
56
+ // models by benchmark, so names only settle models without one.
57
+ export function rankCatalogModels(
58
+ models: readonly CatalogModel[],
59
+ preferredLanguages: readonly string[] = [],
60
+ isDownloaded: (model: CatalogModel) => boolean = () => false,
61
+ ): CatalogModel[] {
62
+ return [...models].sort(
63
+ (left, right) =>
64
+ preferredLanguageMatchCount(right, preferredLanguages) -
65
+ preferredLanguageMatchCount(left, preferredLanguages) ||
66
+ Number(isDownloaded(right)) - Number(isDownloaded(left)) ||
67
+ left.name.localeCompare(right.name),
68
+ );
69
+ }
70
+
71
+ /**
72
+ * What a catalog search runs against. Languages and capabilities are left
73
+ * out on purpose: the pickers already scope and grade by language, and a
74
+ * long haystack made short queries match nearly everything.
75
+ */
76
+ export function catalogModelSearchText(model: CatalogModel): string {
77
+ return [model.id, model.name, model.family, model.parameters ?? ""].join(" ").toLowerCase();
78
+ }
79
+
80
+ export function getCatalogModel(id: string): CatalogModel | undefined {
81
+ return CATALOG_MODELS.find((model) => model.id === id);
82
+ }
83
+
84
+ export function formatBinarySize(bytes: number): string {
85
+ if (bytes >= 1024 ** 3) {
86
+ return `${(bytes / 1024 ** 3).toFixed(bytes >= 10 * 1024 ** 3 ? 0 : 1)} GiB`;
87
+ }
88
+ return `${Math.round(bytes / (1024 * 1024))} MiB`;
89
+ }
package/src/chinese.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { ChineseOutput } from "./settings.js";
2
+
3
+ type Converter = (text: string) => string;
4
+
5
+ const converters = new Map<ChineseOutput, Promise<Converter>>();
6
+
7
+ async function createConverter(output: ChineseOutput): Promise<Converter> {
8
+ const { default: OpenCC } = await import("opencc-js");
9
+ switch (output) {
10
+ case "simplified":
11
+ return OpenCC.Converter({ from: "t", to: "cn" });
12
+ case "traditional-taiwan":
13
+ return OpenCC.Converter({ from: "cn", to: "tw" });
14
+ case "traditional-hong-kong":
15
+ return OpenCC.Converter({ from: "cn", to: "hk" });
16
+ }
17
+ }
18
+
19
+ function converterFor(output: ChineseOutput): Promise<Converter> {
20
+ const existing = converters.get(output);
21
+ if (existing) return existing;
22
+
23
+ const loading = createConverter(output);
24
+ converters.set(output, loading);
25
+ void loading.catch(() => {
26
+ if (converters.get(output) === loading) converters.delete(output);
27
+ });
28
+ return loading;
29
+ }
30
+
31
+ export function isChineseLanguage(language: string): boolean {
32
+ const base = language.toLowerCase().split("-", 1)[0];
33
+ return base === "zh" || base === "yue";
34
+ }
35
+
36
+ export async function convertChineseOutput(
37
+ text: string,
38
+ output: ChineseOutput,
39
+ ): Promise<string> {
40
+ return (await converterFor(output))(text);
41
+ }
42
+
43
+ export function chineseOutputSummary(output: ChineseOutput): string {
44
+ switch (output) {
45
+ case "simplified":
46
+ return "Simplified";
47
+ case "traditional-taiwan":
48
+ return "Traditional (Taiwan)";
49
+ case "traditional-hong-kong":
50
+ return "Traditional (Hong Kong)";
51
+ }
52
+ }
@@ -0,0 +1,30 @@
1
+ /** A promise with external settle controls. Settling is idempotent. */
2
+ export class Deferred<T = void> {
3
+ readonly promise: Promise<T>;
4
+ private resolvePromise!: (value: T) => void;
5
+ private rejectPromise!: (error: unknown) => void;
6
+ private done = false;
7
+
8
+ constructor() {
9
+ this.promise = new Promise<T>((resolve, reject) => {
10
+ this.resolvePromise = resolve;
11
+ this.rejectPromise = reject;
12
+ });
13
+ }
14
+
15
+ get settled(): boolean {
16
+ return this.done;
17
+ }
18
+
19
+ resolve(value: T): void {
20
+ if (this.done) return;
21
+ this.done = true;
22
+ this.resolvePromise(value);
23
+ }
24
+
25
+ reject(error: unknown): void {
26
+ if (this.done) return;
27
+ this.done = true;
28
+ this.rejectPromise(error);
29
+ }
30
+ }
@@ -0,0 +1,315 @@
1
+ import { CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
2
+ import { describeError, log, logStep, sinceKeyPress } from "./log.js";
3
+ import { PcmChunker } from "./pcm-chunker.js";
4
+ import type { MicrophoneSetting, TranscribeSettings } from "./settings.js";
5
+ import type { DictationReservation, TranscriptionService } from "./transcription-service.js";
6
+
7
+ /** What a recording captured. `interruption` is why it ended early, if it did. */
8
+ export type CapturedAudio = { pcm: Float32Array; interruption?: Error; droppedFrames?: number };
9
+ /** A running recording. `stop` ends it and releases the device. */
10
+ export type Capture = { stop(): Promise<CapturedAudio> };
11
+ /** Where a recording delivers audio, in order, until its `stop` resolves. */
12
+ export type CaptureSinks = {
13
+ onAudio(chunk: Float32Array): void;
14
+ onFailure(error: Error): void;
15
+ };
16
+ /** Opens a microphone and starts recording; resolves once audio is flowing. */
17
+ export type OpenCapture = (microphone: MicrophoneSetting, sinks: CaptureSinks) => Promise<Capture>;
18
+ export type DictationResult = {
19
+ text: string;
20
+ speechSeconds: number;
21
+ transcribeSeconds: number;
22
+ /** The microphone failed mid-recording; `text` covers the audio before it. */
23
+ interruption?: Error;
24
+ /** Audio lost inside the recording because it wasn't read in time. */
25
+ droppedFrames?: number;
26
+ };
27
+ export type DictationState =
28
+ | { phase: "idle" | "ready" | "starting" | "listening" | "transcribing" | "cancelling" | "disposed" }
29
+ | { phase: "result"; result: DictationResult }
30
+ | { phase: "error"; stage: "model" | "capture" | "transcription"; cause: unknown };
31
+ export type DictationControllerOptions = {
32
+ openCapture: OpenCapture;
33
+ now?: () => number;
34
+ onChange?: (state: DictationState) => void;
35
+ onAudio?: (chunk: Float32Array) => void;
36
+ };
37
+ type Take = {
38
+ settings: TranscribeSettings;
39
+ reservation: DictationReservation;
40
+ abort: AbortController;
41
+ chunker: PcmChunker;
42
+ capture?: Capture;
43
+ interruption?: Error;
44
+ stopping?: Promise<CapturedAudio>;
45
+ submission?: Promise<DictationResult | undefined>;
46
+ };
47
+
48
+ /** Owns one capture/reservation lifecycle, never the injected service itself. */
49
+ export class DictationController {
50
+ private current: DictationState = { phase: "idle" };
51
+ private take: Take | undefined;
52
+ private disposed = false;
53
+ private cleanup: Promise<void> = Promise.resolve();
54
+ private starting: Promise<void> | undefined;
55
+ /** Bumped by cancel, so a start still opening the microphone knows it lost. */
56
+ private generation = 0;
57
+ private opening = false;
58
+ private startedAt = 0;
59
+ private readonly now: () => number;
60
+ private readiness: "loading" | "ready" | "failed" = "loading";
61
+
62
+ constructor(
63
+ private readonly service: Pick<TranscriptionService, "reserveDictation">,
64
+ private readonly options: DictationControllerOptions,
65
+ ) {
66
+ this.now = options.now ?? (() => performance.now());
67
+ }
68
+
69
+ get state(): DictationState { return this.current; }
70
+ get modelState(): "loading" | "ready" | "failed" { return this.readiness; }
71
+ get elapsedMs(): number { return Math.max(0, this.now() - this.startedAt); }
72
+ /** Set when the microphone failed during the current recording. */
73
+ get interruption(): Error | undefined { return this.take?.interruption; }
74
+
75
+ private notify(): void {
76
+ if (this.disposed) return;
77
+ // Presentation must not strand the reservation or drop recorded audio.
78
+ try { this.options.onChange?.(this.current); } catch { /* UI owns rendering errors. */ }
79
+ }
80
+ private setState(state: DictationState): void {
81
+ if (this.disposed) return;
82
+ this.current = state;
83
+ if (state.phase === "error") {
84
+ log.error(`${state.stage} failed: ${describeError(state.cause)}`);
85
+ logStep("idle");
86
+ }
87
+ this.notify();
88
+ }
89
+
90
+ /** Reserves the model for a take; on failure the state says why. */
91
+ private reserve(settings: TranscribeSettings): Take | undefined {
92
+ this.readiness = "loading";
93
+ logStep("reserving model", settings.model.id);
94
+ const reservedAt = this.now();
95
+ let reservation: DictationReservation;
96
+ try {
97
+ reservation = this.service.reserveDictation(settings);
98
+ } catch (cause) {
99
+ this.readiness = "failed";
100
+ this.setState({ phase: "error", stage: "model", cause });
101
+ return undefined;
102
+ }
103
+ const take: Take = {
104
+ settings, reservation, abort: new AbortController(),
105
+ chunker: new PcmChunker((chunk) => reservation.feed(chunk)),
106
+ };
107
+ this.take = take;
108
+ void reservation.ready.then(
109
+ () => {
110
+ if (this.disposed || this.take !== take) return;
111
+ log.debug(`model ready ${Math.round(this.now() - reservedAt)} ms after reserving`);
112
+ this.readiness = "ready";
113
+ this.notify();
114
+ },
115
+ (cause: unknown) => {
116
+ if (this.disposed || this.take !== take) return;
117
+ this.readiness = "failed";
118
+ if (this.current.phase === "ready") this.setState({ phase: "error", stage: "model", cause });
119
+ else {
120
+ log.error(`model failed to load: ${describeError(cause)}`);
121
+ this.notify(); // Keep capturing; submission will report the error.
122
+ }
123
+ },
124
+ );
125
+ return take;
126
+ }
127
+
128
+ /** Optional prewarming. Model preparation overlaps with reading or recording. */
129
+ prepare(settings: TranscribeSettings): void {
130
+ if (this.disposed || ["starting", "listening", "transcribing", "cancelling"].includes(this.current.phase)) return;
131
+ if (this.take?.settings === settings && this.readiness !== "failed") return;
132
+ this.take?.reservation.cancel();
133
+ this.take = undefined;
134
+ if (this.reserve(settings)) this.setState({ phase: "ready" });
135
+ }
136
+
137
+ /**
138
+ * Starts recording. Without a prewarmed take, the model is reserved once
139
+ * the microphone is listening: loading it runs synchronous native setup,
140
+ * which must not hold up the start of the recording.
141
+ */
142
+ start(settings: TranscribeSettings): Promise<void> {
143
+ if (this.disposed) return Promise.resolve();
144
+ if (this.current.phase === "starting") return this.starting ?? Promise.resolve();
145
+ if (["listening", "transcribing", "cancelling"].includes(this.current.phase)) return Promise.resolve();
146
+ let take = this.take?.settings === settings && this.readiness !== "failed" ? this.take : undefined;
147
+ if (!take) {
148
+ this.take?.reservation.cancel();
149
+ this.take = undefined;
150
+ }
151
+ const generation = this.generation;
152
+ this.opening = true;
153
+ this.setState({ phase: "starting" });
154
+ const current = () => !this.disposed && this.generation === generation && (!take || this.take === take);
155
+ const work = this.cleanup.then(async () => {
156
+ if (!current()) return;
157
+ const openedAt = this.now();
158
+ logStep("opening microphone");
159
+ // Audio that arrives before the take exists; delivered to it in order.
160
+ const early: Float32Array[] = [];
161
+ let earlyFailure: Error | undefined;
162
+ let capture: Capture;
163
+ try {
164
+ capture = await this.options.openCapture(settings.microphone, {
165
+ onAudio: (chunk) => {
166
+ if (!current()) return;
167
+ if (take) this.feed(take, chunk);
168
+ else early.push(chunk);
169
+ try { this.options.onAudio?.(chunk); } catch { /* Audio is already fed. */ }
170
+ },
171
+ onFailure: (error) => {
172
+ if (!current()) return;
173
+ if (take) take.interruption = error;
174
+ else earlyFailure = error;
175
+ this.notify();
176
+ },
177
+ });
178
+ } catch (cause) {
179
+ if (!current()) return; // Cancelled meanwhile; cancel() released the take.
180
+ if (take) {
181
+ this.take = undefined;
182
+ take.chunker.discard();
183
+ take.reservation.cancel();
184
+ }
185
+ this.setState({ phase: "error", stage: "capture", cause });
186
+ return;
187
+ }
188
+ const opened = this.now() - openedAt;
189
+ if (current() && !take) {
190
+ take = this.reserve(settings);
191
+ if (take) {
192
+ for (const chunk of early) this.feed(take, chunk);
193
+ take.interruption = earlyFailure;
194
+ }
195
+ }
196
+ if (!take || !current()) {
197
+ // Cancelled or disposed while opening (cancel() waits on this
198
+ // release), or the model couldn't be reserved (a retry waits on it).
199
+ log.debug(`microphone opened in ${Math.round(opened)} ms, then released`);
200
+ const release = capture.stop().then(() => undefined, () => undefined);
201
+ if (!take && current()) this.cleanup = this.cleanup.then(() => release);
202
+ await release;
203
+ return;
204
+ }
205
+ take.capture = capture;
206
+ this.startedAt = this.now();
207
+ const sincePress = sinceKeyPress();
208
+ const press = sincePress === undefined ? "" : `, ${Math.round(sincePress)} ms after the key press`;
209
+ log.info(`listening: microphone opened in ${Math.round(opened)} ms${press}; model ${this.readiness}`);
210
+ logStep("listening");
211
+ this.setState({ phase: "listening" });
212
+ }).finally(() => {
213
+ if (this.generation === generation) this.opening = false;
214
+ });
215
+ this.starting = work;
216
+ return work;
217
+ }
218
+
219
+ private feed(take: Take, chunk: Float32Array): void {
220
+ // A failed stream feed falls back to submitting the whole recording.
221
+ try { take.chunker.push(chunk); } catch { /* The recording keeps the audio. */ }
222
+ }
223
+
224
+ private stopCapture(take: Take): Promise<CapturedAudio> {
225
+ if (take.stopping) return take.stopping;
226
+ const capture = take.capture;
227
+ take.capture = undefined;
228
+ if (!capture) return Promise.resolve({ pcm: new Float32Array() });
229
+ // Normalize synchronous failures too; native implementations normally reject.
230
+ try { take.stopping = capture.stop(); }
231
+ catch (error) { take.stopping = Promise.reject(error); }
232
+ return take.stopping;
233
+ }
234
+
235
+ stop(): Promise<DictationResult | undefined> {
236
+ const take = this.take;
237
+ if (!take || this.disposed) return Promise.resolve(undefined);
238
+ if (take.submission) return take.submission;
239
+ if (this.current.phase !== "listening") return Promise.resolve(undefined);
240
+ const stoppedAt = this.now();
241
+ logStep("stopping microphone");
242
+ this.setState({ phase: "transcribing" });
243
+ let stage: "capture" | "transcription" = "capture";
244
+ take.submission = this.stopCapture(take).then(async (captured) => {
245
+ // Cancellation while the native microphone is stopping must never submit.
246
+ if (this.take !== take || take.abort.signal.aborted) return undefined;
247
+ const { pcm } = captured;
248
+ const interruption = captured.interruption ?? take.interruption;
249
+ if (interruption && pcm.length === 0) throw interruption;
250
+ take.chunker.flush();
251
+ stage = "transcription";
252
+ logStep("transcribing", `${(pcm.length / CAPTURE_SAMPLE_RATE).toFixed(1)} s of audio`);
253
+ const text = await take.reservation.submit(pcm, take.abort.signal);
254
+ if (this.disposed || this.take !== take || take.abort.signal.aborted) return undefined;
255
+ const result = {
256
+ text,
257
+ speechSeconds: pcm.length / CAPTURE_SAMPLE_RATE,
258
+ transcribeSeconds: Math.max(0, (this.now() - stoppedAt) / 1000),
259
+ ...(interruption ? { interruption } : {}),
260
+ ...(captured.droppedFrames ? { droppedFrames: captured.droppedFrames } : {}),
261
+ };
262
+ this.take = undefined;
263
+ const empty = text ? "" : "; no speech detected";
264
+ log.info(
265
+ `transcribed ${result.speechSeconds.toFixed(1)} s of audio in ${result.transcribeSeconds.toFixed(1)} s${empty}`,
266
+ );
267
+ logStep("idle");
268
+ this.setState({ phase: "result", result });
269
+ return result;
270
+ }).catch((cause: unknown) => {
271
+ take.reservation.cancel(); // Releases the lane if stop failed before submit.
272
+ if (this.disposed || this.take !== take || take.abort.signal.aborted) return undefined;
273
+ this.take = undefined;
274
+ this.setState({ phase: "error", stage, cause });
275
+ return undefined;
276
+ });
277
+ return take.submission;
278
+ }
279
+
280
+ /** Cancel is serialized with capture teardown, so retries never overlap devices. */
281
+ cancel(): Promise<void> {
282
+ const take = this.take;
283
+ this.take = undefined; // Invalidate callbacks before touching native resources.
284
+ this.generation++;
285
+ const opening = this.opening;
286
+ this.opening = false;
287
+ if (!take && !opening) return this.cleanup;
288
+ if (take) {
289
+ take.abort.abort();
290
+ take.chunker.discard();
291
+ take.reservation.cancel();
292
+ }
293
+ if (opening) log.info("start cancelled");
294
+ else if (take?.submission) log.info("transcription cancelled");
295
+ else if (take?.capture) log.info("recording discarded");
296
+ logStep("idle");
297
+ if (!this.disposed) this.setState({ phase: "cancelling" });
298
+ const cleanup = Promise.all([
299
+ this.cleanup,
300
+ take && this.stopCapture(take).catch(() => undefined),
301
+ take?.submission,
302
+ this.starting,
303
+ ]).then(() => {
304
+ if (this.cleanup === cleanup) this.setState({ phase: "idle" });
305
+ });
306
+ this.cleanup = cleanup;
307
+ return cleanup;
308
+ }
309
+
310
+ dispose(): Promise<void> {
311
+ this.disposed = true;
312
+ this.current = { phase: "disposed" };
313
+ return this.cancel();
314
+ }
315
+ }
@@ -0,0 +1,164 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export const FILE_SAMPLE_RATE = 16_000;
4
+
5
+ const BYTES_PER_SAMPLE = Float32Array.BYTES_PER_ELEMENT;
6
+ const MAX_DECODED_BYTES = 128 * 1024 * 1024;
7
+ const MAX_STDERR_CHARS = 8 * 1024;
8
+
9
+ type FfmpegConfiguration = {
10
+ executable: string;
11
+ variable?: "PI_VOICE_FFMPEG_PATH" | "PI_TRANSCRIBE_FFMPEG_PATH";
12
+ };
13
+
14
+ function ffmpegConfiguration(): FfmpegConfiguration {
15
+ const current = process.env.PI_VOICE_FFMPEG_PATH?.trim();
16
+ if (current) return { executable: current, variable: "PI_VOICE_FFMPEG_PATH" };
17
+ const legacy = process.env.PI_TRANSCRIBE_FFMPEG_PATH?.trim();
18
+ if (legacy) return { executable: legacy, variable: "PI_TRANSCRIBE_FFMPEG_PATH" };
19
+ return { executable: "ffmpeg" };
20
+ }
21
+
22
+ function installHint(): string {
23
+ switch (process.platform) {
24
+ case "darwin":
25
+ return "On macOS with Homebrew: brew install ffmpeg";
26
+ case "win32":
27
+ return "On Windows: winget install Gyan.FFmpeg";
28
+ default:
29
+ return "On Debian/Ubuntu: sudo apt install ffmpeg (use your distribution's package manager elsewhere)";
30
+ }
31
+ }
32
+
33
+ function missingFfmpegError(configuration: FfmpegConfiguration): Error {
34
+ const locationHelp = configuration.variable
35
+ ? `The configured ${configuration.variable} (${configuration.executable}) could not be found. Correct it or unset it to use PATH.`
36
+ : "The ffmpeg executable was not found on PATH.";
37
+ return new Error(
38
+ [
39
+ "File transcription requires FFmpeg.",
40
+ locationHelp,
41
+ installHint(),
42
+ "Explain this requirement to the user and ask permission before installing system software, then retry transcribe_file.",
43
+ ].join(" "),
44
+ );
45
+ }
46
+
47
+ function abortReason(signal: AbortSignal): Error {
48
+ return signal.reason instanceof Error
49
+ ? signal.reason
50
+ : new Error("File audio decoding was cancelled");
51
+ }
52
+
53
+ export type DecodedFileAudio = {
54
+ pcm: Float32Array;
55
+ seconds: number;
56
+ };
57
+
58
+ /** Decode the first audio stream in a local media file to transcribe.cpp PCM. */
59
+ export async function decodeFileAudio(
60
+ path: string,
61
+ signal?: AbortSignal,
62
+ ): Promise<DecodedFileAudio> {
63
+ signal?.throwIfAborted();
64
+ const configuration = ffmpegConfiguration();
65
+ const executable = configuration.executable;
66
+ const child = spawn(
67
+ executable,
68
+ [
69
+ "-hide_banner",
70
+ "-loglevel",
71
+ "error",
72
+ "-nostdin",
73
+ "-protocol_whitelist",
74
+ "file,pipe",
75
+ "-i",
76
+ path,
77
+ "-map",
78
+ "0:a:0",
79
+ "-vn",
80
+ "-sn",
81
+ "-dn",
82
+ "-ac",
83
+ "1",
84
+ "-ar",
85
+ String(FILE_SAMPLE_RATE),
86
+ "-acodec",
87
+ "pcm_f32le",
88
+ "-f",
89
+ "f32le",
90
+ "pipe:1",
91
+ ],
92
+ { stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
93
+ );
94
+
95
+ return new Promise<DecodedFileAudio>((resolve, reject) => {
96
+ const chunks: Buffer[] = [];
97
+ let byteLength = 0;
98
+ let stderr = "";
99
+ let terminalError: Error | undefined;
100
+
101
+ const onAbort = (): void => {
102
+ terminalError = abortReason(signal!);
103
+ child.kill("SIGTERM");
104
+ };
105
+ signal?.addEventListener("abort", onAbort, { once: true });
106
+
107
+ child.stdout.on("data", (chunk: Buffer) => {
108
+ if (terminalError) return;
109
+ byteLength += chunk.length;
110
+ if (byteLength > MAX_DECODED_BYTES) {
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.",
113
+ );
114
+ child.kill("SIGTERM");
115
+ return;
116
+ }
117
+ chunks.push(chunk);
118
+ });
119
+
120
+ child.stderr.on("data", (chunk: Buffer) => {
121
+ stderr += chunk.toString("utf8");
122
+ if (stderr.length > MAX_STDERR_CHARS) stderr = stderr.slice(-MAX_STDERR_CHARS);
123
+ });
124
+
125
+ child.once("error", (error: NodeJS.ErrnoException) => {
126
+ terminalError =
127
+ error.code === "ENOENT"
128
+ ? missingFfmpegError(configuration)
129
+ : new Error(`Could not start FFmpeg (${executable}): ${error.message}`);
130
+ });
131
+
132
+ child.once("close", (code, closeSignal) => {
133
+ signal?.removeEventListener("abort", onAbort);
134
+ if (terminalError) {
135
+ reject(terminalError);
136
+ return;
137
+ }
138
+ if (code !== 0) {
139
+ const detail = stderr.trim() || `process exited with code ${code ?? "unknown"}`;
140
+ reject(
141
+ new Error(
142
+ `FFmpeg could not decode the file${closeSignal ? ` (signal ${closeSignal})` : ""}: ${detail}`,
143
+ ),
144
+ );
145
+ return;
146
+ }
147
+ if (byteLength === 0) {
148
+ reject(new Error("FFmpeg decoded no audio samples; the file may not contain an audio stream"));
149
+ return;
150
+ }
151
+ if (byteLength % BYTES_PER_SAMPLE !== 0) {
152
+ reject(new Error(`FFmpeg returned ${byteLength} bytes of incomplete float32 PCM`));
153
+ return;
154
+ }
155
+
156
+ const output = Buffer.concat(chunks, byteLength);
157
+ const pcm =
158
+ output.byteOffset % BYTES_PER_SAMPLE === 0
159
+ ? new Float32Array(output.buffer, output.byteOffset, output.byteLength / BYTES_PER_SAMPLE)
160
+ : new Float32Array(Uint8Array.from(output).buffer);
161
+ resolve({ pcm, seconds: pcm.length / FILE_SAMPLE_RATE });
162
+ });
163
+ });
164
+ }