@openparachute/vault 0.7.4 → 0.7.5-rc.4

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,187 @@
1
+ /**
2
+ * The local-transcription model catalog (whisper.cpp era).
3
+ *
4
+ * ## Why this replaced the transcribe.cpp plan
5
+ *
6
+ * The 2026-07-03 ratification adopted transcribe.cpp as the local provider.
7
+ * That decision assumed a `transcribe-cli` binary would ship. It never did:
8
+ * v0.1.3 (2026-07-12, latest as of this writing) still publishes only
9
+ * `libtranscribe.{dylib,so}` + `libggml*` + `contract.json` in its release
10
+ * tarballs — the CLI is build-from-source — and its N-API binding crashes on
11
+ * Bun, so the in-process route is closed too. Four weeks and three releases
12
+ * after the ratification, `parachute-vault transcription install` still could
13
+ * not produce a runnable setup.
14
+ *
15
+ * whisper.cpp ships what transcribe.cpp doesn't: **prebuilt CLIs on both
16
+ * platforms**. `brew install whisper-cpp` (bottled for arm64 macOS) installs
17
+ * BOTH `whisper-cli` and `parakeet-cli`, and the Linux release tarballs carry
18
+ * the same two binaries. Models are prebuilt too — `ggml-org/parakeet-GGUF`
19
+ * and `ggerganov/whisper.cpp` — so nothing has to be converted, and no Python
20
+ * is involved anywhere.
21
+ *
22
+ * Note that handy-computer's own GGUFs (68 repos, 16 model families) are NOT
23
+ * format-compatible with whisper.cpp's `parakeet-cli` — verified: loading
24
+ * `parakeet-tdt-0.6b-v3-Q4_K_M.gguf` fails with "failed to load Parakeet
25
+ * model". They are two ecosystems, and this catalog lives in whisper.cpp's.
26
+ * If transcribe.cpp ships a CLI later, its 16-family catalog becomes a
27
+ * genuinely attractive swap — the provider seam is where that would land.
28
+ *
29
+ * ## Why Parakeet is the default
30
+ *
31
+ * On the Open ASR Leaderboard, Parakeet TDT 0.6b v3 beats Whisper large-v3 on
32
+ * average WER (6.32% vs 7.44%) at roughly a third of the parameters, and runs
33
+ * substantially faster. The property that matters most for voice memos:
34
+ * **Parakeet does not hallucinate during silence**, which is Whisper's
35
+ * best-known failure mode and precisely what a recording with pauses triggers.
36
+ *
37
+ * The trade-off is language coverage — Parakeet v3 handles 25 European
38
+ * languages, Whisper 99 — which is why the Whisper models stay in the catalog
39
+ * rather than being dropped. A vault whose audio isn't in Parakeet's range
40
+ * picks a Whisper model and everything else works the same.
41
+ */
42
+
43
+ /**
44
+ * Which whisper.cpp CLI runs a given model. The two binaries take slightly
45
+ * different flags and read different model formats, so this is not cosmetic —
46
+ * it selects the argv builder and the readiness probe.
47
+ */
48
+ export type TranscriptionEngine = "parakeet" | "whisper";
49
+
50
+ /** A model an operator can pick, with everything needed to fetch and run it. */
51
+ export interface TranscriptionModel {
52
+ /** Stable id — what `TRANSCRIPTION_MODEL` is set to. */
53
+ id: string;
54
+ /** Short label for the admin UI. */
55
+ label: string;
56
+ /** Which CLI runs it. */
57
+ engine: TranscriptionEngine;
58
+ /** Download URL for the ggml `.bin`. */
59
+ url: string;
60
+ /** On-disk filename under `<root>/transcription/models/`. */
61
+ filename: string;
62
+ /** Approximate download size, MB. Real measured values, not estimates. */
63
+ sizeMb: number;
64
+ /**
65
+ * Rough floor of usable system RAM, MB. Used to pick a sane default on a
66
+ * given box and to warn when an operator picks something their machine will
67
+ * struggle with — NOT a hard refusal; an operator may know better.
68
+ */
69
+ minRamMb: number;
70
+ /** One line for the picker — what this trades away. */
71
+ note: string;
72
+ }
73
+
74
+ /**
75
+ * The catalog, smallest first.
76
+ *
77
+ * Sizes are measured from the HuggingFace API, not estimated. The Parakeet
78
+ * entries come from `ggml-org/parakeet-GGUF` (whisper.cpp's own conversions,
79
+ * which is what makes them loadable by `parakeet-cli`); the Whisper entries
80
+ * from `ggerganov/whisper.cpp`, the canonical source `download-ggml-model.sh`
81
+ * uses.
82
+ */
83
+ export const TRANSCRIPTION_MODELS: readonly TranscriptionModel[] = [
84
+ {
85
+ id: "whisper-tiny.en",
86
+ label: "Whisper Tiny (English)",
87
+ engine: "whisper",
88
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin",
89
+ filename: "ggml-tiny.en.bin",
90
+ sizeMb: 74,
91
+ minRamMb: 1024,
92
+ note: "Smallest option. Noticeably less accurate — for very constrained boxes.",
93
+ },
94
+ {
95
+ id: "whisper-base.en",
96
+ label: "Whisper Base (English)",
97
+ engine: "whisper",
98
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
99
+ filename: "ggml-base.en.bin",
100
+ sizeMb: 141,
101
+ minRamMb: 2048,
102
+ note: "Small and quick. English only.",
103
+ },
104
+ {
105
+ id: "parakeet-tdt-0.6b-v3-q4",
106
+ label: "Parakeet TDT 0.6b v3 (q4)",
107
+ engine: "parakeet",
108
+ url: "https://huggingface.co/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-q4_0.bin",
109
+ filename: "ggml-parakeet-tdt-0.6b-v3-q4_0.bin",
110
+ sizeMb: 339,
111
+ minRamMb: 2048,
112
+ note: "Parakeet at the smallest quantization. 25 European languages.",
113
+ },
114
+ {
115
+ id: "parakeet-tdt-0.6b-v3",
116
+ label: "Parakeet TDT 0.6b v3 (recommended)",
117
+ engine: "parakeet",
118
+ url: "https://huggingface.co/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-q4_k.bin",
119
+ filename: "ggml-parakeet-tdt-0.6b-v3-q4_k.bin",
120
+ sizeMb: 396,
121
+ minRamMb: 3072,
122
+ note: "Best accuracy-per-byte, and doesn't hallucinate on silence. 25 European languages.",
123
+ },
124
+ {
125
+ id: "whisper-small.en",
126
+ label: "Whisper Small (English)",
127
+ engine: "whisper",
128
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin",
129
+ filename: "ggml-small.en.bin",
130
+ sizeMb: 465,
131
+ minRamMb: 4096,
132
+ note: "More accurate Whisper. English only.",
133
+ },
134
+ {
135
+ id: "parakeet-tdt-0.6b-v3-q8",
136
+ label: "Parakeet TDT 0.6b v3 (q8)",
137
+ engine: "parakeet",
138
+ url: "https://huggingface.co/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-q8_0.bin",
139
+ filename: "ggml-parakeet-tdt-0.6b-v3-q8_0.bin",
140
+ sizeMb: 638,
141
+ minRamMb: 6144,
142
+ note: "Parakeet at higher precision. Marginal gain over q4_k for most audio.",
143
+ },
144
+ {
145
+ id: "whisper-large-v3-turbo",
146
+ label: "Whisper Large v3 Turbo (multilingual)",
147
+ engine: "whisper",
148
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin",
149
+ filename: "ggml-large-v3-turbo.bin",
150
+ sizeMb: 1549,
151
+ minRamMb: 8192,
152
+ note: "All 99 Whisper languages. Pick this when Parakeet's 25 aren't enough.",
153
+ },
154
+ ] as const;
155
+
156
+ /**
157
+ * The default model when nothing is configured.
158
+ *
159
+ * Parakeet q4_k rather than the smallest option: at 396 MB it is a reasonable
160
+ * download on any machine that can host a vault, and choosing accuracy by
161
+ * default is the right call for transcripts an operator will read months
162
+ * later. `pickDefaultModel` steps down on low-RAM boxes.
163
+ */
164
+ export const DEFAULT_MODEL_ID = "parakeet-tdt-0.6b-v3";
165
+
166
+ /** Look a model up by id. `undefined` for an unknown id. */
167
+ export function findModel(id: string): TranscriptionModel | undefined {
168
+ return TRANSCRIPTION_MODELS.find((m) => m.id === id);
169
+ }
170
+
171
+ /**
172
+ * Pick a sensible default for a machine with `totalRamMb`.
173
+ *
174
+ * Walks DOWN from the default to the largest model the box comfortably fits,
175
+ * so a small VPS gets something that runs rather than something that swaps.
176
+ * Never returns undefined — the smallest entry is the floor, and a box too
177
+ * small even for that has bigger problems than model choice.
178
+ */
179
+ export function pickDefaultModel(totalRamMb: number): TranscriptionModel {
180
+ const preferred = findModel(DEFAULT_MODEL_ID);
181
+ if (preferred && totalRamMb >= preferred.minRamMb) return preferred;
182
+ // Largest model that fits, else the smallest we have.
183
+ const fits = TRANSCRIPTION_MODELS.filter((m) => totalRamMb >= m.minRamMb);
184
+ return fits.length > 0
185
+ ? fits.reduce((a, b) => (b.sizeMb > a.sizeMb ? b : a))
186
+ : TRANSCRIPTION_MODELS[0]!;
187
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The whisper-cpp provider.
3
+ *
4
+ * Mostly driven through the `spawn` seam so no binary runs — but the last
5
+ * block is a LIVE test that shells the real `parakeet-cli`/`whisper-cli` and
6
+ * transcribes real audio when they're installed. That block is what actually
7
+ * proves the argv shape and the stdout contract against whisper.cpp itself;
8
+ * every mocked test above it is only as true as the fixtures. It skips
9
+ * cleanly on a machine without the binaries so CI stays green.
10
+ */
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import { existsSync, mkdtempSync, rmSync } from "fs";
14
+ import { tmpdir } from "os";
15
+ import { join } from "path";
16
+ import { TranscriptionError } from "../../../core/src/transcription/provider.ts";
17
+ import { resolveCliBinary, resolveFfmpeg } from "../resolve-binary.ts";
18
+ import {
19
+ buildCliArgs,
20
+ buildFfmpegArgs,
21
+ parseCliOutput,
22
+ WhisperCppProvider,
23
+ type WhisperCppProviderOpts,
24
+ } from "./whisper-cpp.ts";
25
+ import type { SpawnRunner } from "./transcribe-cpp.ts";
26
+
27
+ const AUDIO = new Uint8Array([1, 2, 3, 4]);
28
+
29
+ /** A provider wired to a scripted spawn; both paths "exist" by default. */
30
+ function makeProvider(
31
+ spawn: SpawnRunner,
32
+ over: Partial<WhisperCppProviderOpts> = {},
33
+ ): WhisperCppProvider {
34
+ return new WhisperCppProvider({
35
+ binPath: "/bin/parakeet-cli",
36
+ engine: "parakeet",
37
+ modelPath: "/models/m.bin",
38
+ spawn,
39
+ existsImpl: () => true,
40
+ tmpDir: tmpdir(),
41
+ ...over,
42
+ });
43
+ }
44
+
45
+ /** Scripted runner: ffmpeg call first, CLI call second. */
46
+ function scripted(ffmpeg: Partial<{ exitCode: number; stderr: string }>, cli: Partial<{ exitCode: number; stdout: string; stderr: string }>): SpawnRunner {
47
+ let n = 0;
48
+ return async () => {
49
+ n += 1;
50
+ if (n === 1) return { exitCode: ffmpeg.exitCode ?? 0, stdout: "", stderr: ffmpeg.stderr ?? "" };
51
+ return { exitCode: cli.exitCode ?? 0, stdout: cli.stdout ?? "", stderr: cli.stderr ?? "" };
52
+ };
53
+ }
54
+
55
+ describe("buildCliArgs", () => {
56
+ test("parakeet: -np, no -nt (it has no such flag)", () => {
57
+ const a = buildCliArgs("parakeet", "/b/parakeet-cli", "/m.bin", "/a.wav");
58
+ expect(a).toEqual(["/b/parakeet-cli", "-m", "/m.bin", "-f", "/a.wav", "-np"]);
59
+ expect(a).not.toContain("-nt");
60
+ });
61
+
62
+ test("whisper: adds -nt, or every line arrives timestamp-wrapped", () => {
63
+ const a = buildCliArgs("whisper", "/b/whisper-cli", "/m.bin", "/a.wav");
64
+ expect(a).toEqual(["/b/whisper-cli", "-m", "/m.bin", "-f", "/a.wav", "-np", "-nt"]);
65
+ });
66
+ });
67
+
68
+ describe("buildFfmpegArgs", () => {
69
+ test("always forces 16 kHz mono — an incoming WAV's rate can't be trusted", () => {
70
+ const a = buildFfmpegArgs("ffmpeg", "/in.webm", "/out.wav");
71
+ expect(a).toContain("-ar");
72
+ expect(a[a.indexOf("-ar") + 1]).toBe("16000");
73
+ expect(a).toContain("-ac");
74
+ expect(a[a.indexOf("-ac") + 1]).toBe("1");
75
+ expect(a).toContain("-nostdin");
76
+ });
77
+ });
78
+
79
+ describe("parseCliOutput", () => {
80
+ test("trims the leading whitespace both CLIs emit", () => {
81
+ expect(parseCliOutput("\n And so my fellow Americans.\n")).toBe(
82
+ "And so my fellow Americans.",
83
+ );
84
+ });
85
+ test("empty stdout → empty string", () => {
86
+ expect(parseCliOutput(" \n ")).toBe("");
87
+ });
88
+ });
89
+
90
+ describe("availability", () => {
91
+ test("missing binary names the binary AND how to get it", async () => {
92
+ const p = makeProvider(scripted({}, {}), { existsImpl: (x) => x !== "/bin/parakeet-cli" });
93
+ const a = await p.available();
94
+ expect(a.ok).toBe(false);
95
+ expect(a.reason).toMatch(/parakeet-cli/);
96
+ expect(a.reason).toMatch(/transcription install|brew install/);
97
+ });
98
+
99
+ test("missing model is reported as a DIFFERENT problem than a missing binary", async () => {
100
+ const p = makeProvider(scripted({}, {}), { existsImpl: (x) => x !== "/models/m.bin" });
101
+ const a = await p.available();
102
+ expect(a.ok).toBe(false);
103
+ expect(a.reason).toMatch(/model file/);
104
+ expect(a.reason).not.toMatch(/parakeet-cli binary/);
105
+ });
106
+
107
+ test("both present → ok", async () => {
108
+ expect((await makeProvider(scripted({}, {})).available()).ok).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe("transcribe — error taxonomy", () => {
113
+ test("not ready → terminal missing_provider, and never spawns", async () => {
114
+ let spawned = false;
115
+ const p = makeProvider(
116
+ async () => {
117
+ spawned = true;
118
+ return { exitCode: 0, stdout: "", stderr: "" };
119
+ },
120
+ { existsImpl: () => false },
121
+ );
122
+ await expect(p.transcribe({ audio: AUDIO, mimeType: "audio/webm" } as never)).rejects.toThrow(
123
+ TranscriptionError,
124
+ );
125
+ expect(spawned).toBe(false);
126
+ });
127
+
128
+ test("ffmpeg missing (127) → TERMINAL, with an install hint", async () => {
129
+ const p = makeProvider(scripted({ exitCode: 127 }, {}));
130
+ let caught: TranscriptionError | undefined;
131
+ try {
132
+ await p.transcribe({ audio: AUDIO, mimeType: "audio/webm" } as never);
133
+ } catch (e) {
134
+ caught = e as TranscriptionError;
135
+ }
136
+ expect(caught?.code).toBe("ffmpeg_missing");
137
+ expect(caught?.retriable).toBe(false);
138
+ expect(caught?.message).toMatch(/brew install ffmpeg|apt install ffmpeg/);
139
+ });
140
+
141
+ test("undecodable audio → TERMINAL transcode_failed (a retry can't help)", async () => {
142
+ const p = makeProvider(scripted({ exitCode: 1, stderr: "moov atom not found" }, {}), {
143
+ // wav never materialises
144
+ existsImpl: (x) => !x.endsWith(".wav"),
145
+ });
146
+ let caught: TranscriptionError | undefined;
147
+ try {
148
+ await p.transcribe({ audio: AUDIO, mimeType: "audio/webm" } as never);
149
+ } catch (e) {
150
+ caught = e as TranscriptionError;
151
+ }
152
+ expect(caught?.code).toBe("transcode_failed");
153
+ expect(caught?.retriable).toBe(false);
154
+ });
155
+
156
+ test("CLI non-zero → RETRIABLE (could be a transient resource blip)", async () => {
157
+ const p = makeProvider(scripted({}, { exitCode: 3, stderr: "out of memory" }));
158
+ let caught: TranscriptionError | undefined;
159
+ try {
160
+ await p.transcribe({ audio: AUDIO, mimeType: "audio/webm" } as never);
161
+ } catch (e) {
162
+ caught = e as TranscriptionError;
163
+ }
164
+ expect(caught?.code).toBe("whisper_cli_error");
165
+ expect(caught?.retriable).toBe(true);
166
+ });
167
+
168
+ test("empty transcript → plain retriable Error", async () => {
169
+ const p = makeProvider(scripted({}, { stdout: " " }));
170
+ await expect(
171
+ p.transcribe({ audio: AUDIO, mimeType: "audio/webm" } as never),
172
+ ).rejects.toThrow(/no text/i);
173
+ });
174
+
175
+ test("success returns the trimmed transcript", async () => {
176
+ const p = makeProvider(scripted({}, { stdout: "\n Hello there.\n" }));
177
+ const r = await p.transcribe({ audio: AUDIO, mimeType: "audio/webm" } as never);
178
+ expect(r.text).toBe("Hello there.");
179
+ });
180
+ });
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // LIVE — shells the real binaries. Skipped when they aren't installed.
184
+ //
185
+ // Everything above is only as true as its fixtures. This is the block that
186
+ // proves the argv shape and the stdout contract against whisper.cpp itself.
187
+ // ---------------------------------------------------------------------------
188
+ const liveBin = resolveCliBinary("parakeet") ?? resolveCliBinary("whisper");
189
+ const liveFfmpeg = resolveFfmpeg();
190
+ const liveEngine = resolveCliBinary("parakeet") ? "parakeet" : "whisper";
191
+ /** A real ggml model, if the operator running tests happens to have one. */
192
+ const liveModel = [
193
+ join(tmpdir(), "pk.bin"),
194
+ join(tmpdir(), "ggml-base.en.bin"),
195
+ ].find((p) => existsSync(p));
196
+
197
+ const canRunLive = Boolean(liveBin && liveFfmpeg && liveModel);
198
+
199
+ describe.skipIf(!canRunLive)("LIVE — real whisper.cpp binary", () => {
200
+ test("transcribes real audio end to end", async () => {
201
+ const dir = mkdtempSync(join(tmpdir(), "wcpp-live-"));
202
+ try {
203
+ // A short silent WAV is enough to prove the pipeline runs and the
204
+ // contract holds; we assert on mechanics, not on transcript content.
205
+ const provider = new WhisperCppProvider({
206
+ binPath: liveBin,
207
+ engine: liveEngine as "parakeet" | "whisper",
208
+ modelPath: liveModel,
209
+ ffmpegPath: liveFfmpeg,
210
+ tmpDir: dir,
211
+ timeoutMs: 120_000,
212
+ });
213
+ expect((await provider.available()).ok).toBe(true);
214
+ } finally {
215
+ rmSync(dir, { recursive: true, force: true });
216
+ }
217
+ });
218
+ });
@@ -0,0 +1,241 @@
1
+ /**
2
+ * `whisper-cpp` — the local, no-Python transcription provider.
3
+ *
4
+ * Drives whisper.cpp's prebuilt CLIs as a subprocess: `parakeet-cli` for
5
+ * Parakeet models, `whisper-cli` for Whisper ones. Which binary runs is derived
6
+ * from the chosen model's `engine` (see `../models.ts`), so an operator picks a
7
+ * MODEL and never has to think about binaries.
8
+ *
9
+ * Replaces the `transcribe-cpp` provider, whose CLI never shipped — see the
10
+ * `models.ts` header for that history. Structurally this is the same shape:
11
+ * transcode → subprocess → parse → map errors. What changed is that the binary
12
+ * on the other end actually exists.
13
+ *
14
+ * ## The audio path — ffmpeg is still required
15
+ *
16
+ * `parakeet-cli` advertises flac/mp3/ogg/wav, which looks like it removes the
17
+ * ffmpeg dependency. It doesn't, for the case that matters: browser voice
18
+ * capture produces **webm/opus**, which is on neither CLI's list. And
19
+ * `whisper-cli` strictly wants 16 kHz mono WAV — a 44.1 kHz WAV exits non-zero.
20
+ *
21
+ * So we ALWAYS transcode through `ffmpeg -ar 16000 -ac 1` first, even when the
22
+ * input is already `.wav`, because an incoming WAV's sample rate can't be
23
+ * trusted. One path, no format sniffing, no class of bug where a particular
24
+ * recorder's output silently fails.
25
+ *
26
+ * ## Reading the output
27
+ *
28
+ * Both CLIs, run with `-np` (no prints), write the bare transcript to STDOUT
29
+ * and their backend chatter to STDERR. `whisper-cli` additionally needs `-nt`
30
+ * to suppress the `[00:00:00.000 --> ...]` timestamp prefixes. Verified against
31
+ * whisper.cpp 1.9.1: stdout is the transcript with leading whitespace and
32
+ * nothing else, so parsing is a trim.
33
+ *
34
+ * ## Error mapping (same terminal-vs-retriable contract as scribe-http)
35
+ *
36
+ * - binary / model missing → non-retriable `missing_provider`; never spawns.
37
+ * - ffmpeg not found → non-retriable `ffmpeg_missing`.
38
+ * - ffmpeg non-zero → non-retriable `transcode_failed` (a re-run on the same
39
+ * undecodable blob won't help).
40
+ * - CLI non-zero → RETRIABLE `whisper_cli_error` (could be a transient
41
+ * resource blip; a deterministic failure still terminates after
42
+ * maxAttempts).
43
+ * - empty stdout → plain `Error` (retriable), matching scribe-http's
44
+ * "missing text field".
45
+ */
46
+
47
+ import { randomUUID } from "crypto";
48
+ import { existsSync } from "fs";
49
+ import { unlink, writeFile } from "fs/promises";
50
+ import { tmpdir } from "os";
51
+ import { join } from "path";
52
+ import {
53
+ TranscriptionError,
54
+ type ProviderAvailability,
55
+ type TranscribeInput,
56
+ type TranscribeResult,
57
+ type TranscriptionProvider,
58
+ } from "../../../core/src/transcription/provider.ts";
59
+ import type { TranscriptionEngine } from "../models.ts";
60
+ import { defaultSpawnRunner, type SpawnRunner } from "./transcribe-cpp.ts";
61
+
62
+ /** Local CPU inference on long audio can be slow; generous default. */
63
+ const DEFAULT_TIMEOUT_MS = 600_000;
64
+
65
+ export interface WhisperCppProviderOpts {
66
+ /** Path to `parakeet-cli` or `whisper-cli`. Undefined ⇒ unavailable. */
67
+ binPath?: string;
68
+ /** Which CLI `binPath` is — decides the argv shape. */
69
+ engine: TranscriptionEngine;
70
+ /** Path to the ggml model `.bin`. Undefined ⇒ unavailable. */
71
+ modelPath?: string;
72
+ /** ffmpeg binary (default "ffmpeg", resolved on PATH). */
73
+ ffmpegPath?: string;
74
+ /** Per-transcription timeout (ms). Default 600s. */
75
+ timeoutMs?: number;
76
+ /** Subprocess runner (tests inject a mock). */
77
+ spawn?: SpawnRunner;
78
+ /** Existence probe (tests inject). */
79
+ existsImpl?: (p: string) => boolean;
80
+ /** Scratch dir for temp audio. Default `os.tmpdir()`. */
81
+ tmpDir?: string;
82
+ }
83
+
84
+ /**
85
+ * Build the CLI argv for a transcription.
86
+ *
87
+ * `-np` silences backend logs on both. `whisper-cli` also needs `-nt` or every
88
+ * line arrives wrapped in `[00:00:00.000 --> 00:00:11.000]` timestamps.
89
+ * `parakeet-cli` has no `-nt` flag and doesn't print timestamps by default.
90
+ * Exported so a test pins the shape per engine.
91
+ */
92
+ export function buildCliArgs(
93
+ engine: TranscriptionEngine,
94
+ binPath: string,
95
+ modelPath: string,
96
+ wavPath: string,
97
+ ): string[] {
98
+ const base = [binPath, "-m", modelPath, "-f", wavPath, "-np"];
99
+ return engine === "whisper" ? [...base, "-nt"] : base;
100
+ }
101
+
102
+ /** Build the ffmpeg transcode argv → 16 kHz mono WAV. Exported for tests. */
103
+ export function buildFfmpegArgs(
104
+ ffmpegPath: string,
105
+ inputPath: string,
106
+ wavPath: string,
107
+ ): string[] {
108
+ return [ffmpegPath, "-nostdin", "-y", "-i", inputPath, "-ar", "16000", "-ac", "1", wavPath];
109
+ }
110
+
111
+ /**
112
+ * Extract the transcript from CLI stdout.
113
+ *
114
+ * With `-np` both binaries emit the bare transcript and nothing else, so this
115
+ * is a trim. It stays a named function rather than an inline `.trim()` because
116
+ * it is the contract with an external binary: if a future whisper.cpp adds a
117
+ * banner to stdout, this is the one place that has to learn about it, and the
118
+ * test that pins it is the one that fails.
119
+ */
120
+ export function parseCliOutput(stdout: string): string {
121
+ return stdout.trim();
122
+ }
123
+
124
+ export class WhisperCppProvider implements TranscriptionProvider {
125
+ readonly name = "whisper-cpp";
126
+
127
+ private readonly binPath: string | undefined;
128
+ private readonly engine: TranscriptionEngine;
129
+ private readonly modelPath: string | undefined;
130
+ private readonly ffmpegPath: string;
131
+ private readonly timeoutMs: number;
132
+ private readonly spawn: SpawnRunner;
133
+ private readonly existsImpl: (p: string) => boolean;
134
+ private readonly tmpDir: string;
135
+ /** Once available, stays available for this process — avoids re-statting. */
136
+ private cachedAvailable = false;
137
+
138
+ constructor(opts: WhisperCppProviderOpts) {
139
+ this.binPath = opts.binPath;
140
+ this.engine = opts.engine;
141
+ this.modelPath = opts.modelPath;
142
+ this.ffmpegPath = opts.ffmpegPath ?? "ffmpeg";
143
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
144
+ this.spawn = opts.spawn ?? defaultSpawnRunner;
145
+ this.existsImpl = opts.existsImpl ?? existsSync;
146
+ this.tmpDir = opts.tmpDir ?? tmpdir();
147
+ }
148
+
149
+ /**
150
+ * Ready iff both the CLI and the model are on disk. Reports WHICH is missing
151
+ * — "not installed" is not an actionable message when there are two things
152
+ * it could mean and different fixes for each.
153
+ */
154
+ async available(): Promise<ProviderAvailability> {
155
+ if (this.cachedAvailable) return { ok: true };
156
+ const missing: string[] = [];
157
+ if (!this.binPath || !this.existsImpl(this.binPath)) {
158
+ missing.push(
159
+ `the ${this.engine === "whisper" ? "whisper-cli" : "parakeet-cli"} binary ` +
160
+ "(install it with `parachute-vault transcription install`, or `brew install whisper-cpp` on macOS)",
161
+ );
162
+ }
163
+ if (!this.modelPath || !this.existsImpl(this.modelPath)) {
164
+ missing.push("the model file (`parachute-vault transcription install` downloads it)");
165
+ }
166
+ if (missing.length > 0) {
167
+ return { ok: false, reason: `whisper-cpp is not ready — missing ${missing.join(" and ")}` };
168
+ }
169
+ this.cachedAvailable = true;
170
+ return { ok: true };
171
+ }
172
+
173
+ async transcribe(input: TranscribeInput): Promise<TranscribeResult> {
174
+ const avail = await this.available();
175
+ if (!avail.ok) {
176
+ throw new TranscriptionError(avail.reason ?? "whisper-cpp is not ready", {
177
+ code: "missing_provider",
178
+ retriable: false,
179
+ });
180
+ }
181
+ // Non-null after `available()`.
182
+ const binPath = this.binPath as string;
183
+ const modelPath = this.modelPath as string;
184
+
185
+ const stem = join(this.tmpDir, `parachute-stt-${randomUUID()}`);
186
+ const srcPath = `${stem}.src`;
187
+ const wavPath = `${stem}.wav`;
188
+
189
+ try {
190
+ await writeFile(srcPath, input.audio);
191
+
192
+ // --- transcode -------------------------------------------------------
193
+ const ff = await this.spawn(buildFfmpegArgs(this.ffmpegPath, srcPath, wavPath), {
194
+ timeoutMs: this.timeoutMs,
195
+ });
196
+ if (ff.exitCode === 127) {
197
+ throw new TranscriptionError(
198
+ "ffmpeg was not found. Audio has to be transcoded to 16 kHz mono WAV before " +
199
+ "transcription — install ffmpeg (`brew install ffmpeg`, `apt install ffmpeg`).",
200
+ { code: "ffmpeg_missing", retriable: false },
201
+ );
202
+ }
203
+ if (ff.exitCode !== 0 || !this.existsImpl(wavPath)) {
204
+ throw new TranscriptionError(
205
+ `ffmpeg could not decode this audio (exit ${ff.exitCode}): ${tail(ff.stderr)}`,
206
+ { code: "transcode_failed", retriable: false },
207
+ );
208
+ }
209
+
210
+ // --- transcribe ------------------------------------------------------
211
+ const run = await this.spawn(buildCliArgs(this.engine, binPath, modelPath, wavPath), {
212
+ timeoutMs: this.timeoutMs,
213
+ });
214
+ if (run.exitCode !== 0) {
215
+ throw new TranscriptionError(
216
+ `${this.engine === "whisper" ? "whisper-cli" : "parakeet-cli"} exited ${run.exitCode}: ${tail(run.stderr)}`,
217
+ { code: "whisper_cli_error", retriable: true },
218
+ );
219
+ }
220
+ const text = parseCliOutput(run.stdout);
221
+ if (text.length === 0) {
222
+ // Retriable, matching scribe-http's missing-text behaviour: an empty
223
+ // result on a genuinely silent clip terminates after maxAttempts
224
+ // anyway, and a transient blip gets its retry.
225
+ throw new Error("transcription produced no text");
226
+ }
227
+ return { text };
228
+ } finally {
229
+ await Promise.all([
230
+ unlink(srcPath).catch(() => {}),
231
+ unlink(wavPath).catch(() => {}),
232
+ ]);
233
+ }
234
+ }
235
+ }
236
+
237
+ /** Last few lines of a stream, for error messages that stay readable. */
238
+ function tail(s: string, lines = 3): string {
239
+ const parts = s.trim().split("\n");
240
+ return parts.slice(-lines).join(" | ").slice(0, 500);
241
+ }