@openparachute/vault 0.6.4 → 0.6.5-rc.10

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 (53) hide show
  1. package/core/src/__fixtures__/golden-vault.ts +125 -0
  2. package/core/src/__fixtures__/portable-export-golden.json +10 -0
  3. package/core/src/do-param-cap.test.ts +161 -0
  4. package/core/src/links.ts +8 -13
  5. package/core/src/mcp.ts +306 -314
  6. package/core/src/notes.ts +54 -62
  7. package/core/src/onboarding.ts +14 -296
  8. package/core/src/portable-md-batching.test.ts +67 -0
  9. package/core/src/portable-md-golden.test.ts +55 -0
  10. package/core/src/portable-md.ts +579 -435
  11. package/core/src/schema.ts +21 -43
  12. package/core/src/seed-packs.test.ts +191 -0
  13. package/core/src/seed-packs.ts +559 -0
  14. package/core/src/sql-in.test.ts +58 -0
  15. package/core/src/sql-in.ts +76 -0
  16. package/core/src/store.ts +14 -9
  17. package/core/src/transcription/provider.ts +141 -0
  18. package/core/src/txn.test.ts +229 -0
  19. package/core/src/txn.ts +105 -0
  20. package/core/src/types.ts +9 -0
  21. package/core/src/vault-projection.ts +1 -1
  22. package/core/src/wikilinks.ts +10 -4
  23. package/package.json +1 -1
  24. package/src/add-pack.test.ts +142 -0
  25. package/src/cli.ts +778 -7
  26. package/src/onboarding-seed.test.ts +126 -40
  27. package/src/onboarding-seed.ts +41 -46
  28. package/src/routes.ts +25 -7
  29. package/src/server.ts +108 -31
  30. package/src/transcription/build.test.ts +224 -0
  31. package/src/transcription/build.ts +252 -0
  32. package/src/transcription/capability.test.ts +118 -0
  33. package/src/transcription/capability.ts +96 -0
  34. package/src/transcription/install-python.test.ts +366 -0
  35. package/src/transcription/install-python.ts +471 -0
  36. package/src/transcription/install.test.ts +167 -0
  37. package/src/transcription/install.ts +296 -0
  38. package/src/transcription/providers/onnx-asr.test.ts +229 -0
  39. package/src/transcription/providers/onnx-asr.ts +239 -0
  40. package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
  41. package/src/transcription/providers/parakeet-mlx.ts +242 -0
  42. package/src/transcription/providers/scribe-http.test.ts +195 -0
  43. package/src/transcription/providers/scribe-http.ts +144 -0
  44. package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
  45. package/src/transcription/providers/transcribe-cpp.ts +293 -0
  46. package/src/transcription/select.test.ts +259 -0
  47. package/src/transcription/select.ts +334 -0
  48. package/src/transcription/tiers.test.ts +197 -0
  49. package/src/transcription/tiers.ts +184 -0
  50. package/src/transcription-worker.test.ts +44 -0
  51. package/src/transcription-worker.ts +57 -122
  52. package/src/vault-create.test.ts +38 -10
  53. package/src/vault.test.ts +48 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Install planning for the `transcribe-cpp` local transcription provider
3
+ * (scribe-fold Phase 2a).
4
+ *
5
+ * This module is the PURE decision layer for `parachute-vault transcription
6
+ * install`: given the host OS/arch and total RAM, which prebuilt
7
+ * `transcribe-cli` release asset and which GGUF model should we fetch? It has
8
+ * NO side effects (no network, no fs writes) so the CLI's `--dry-run`/plan path
9
+ * — and the tests — exercise the full selection matrix without downloading a
10
+ * byte.
11
+ *
12
+ * ## Why transcribe.cpp, and why the SUBPROCESS path
13
+ *
14
+ * Ratified 2026-07-03 (Aaron): adopt **transcribe.cpp**
15
+ * (github.com/handy-computer/transcribe.cpp — CJ Pais / Mozilla.ai, MIT,
16
+ * "llama.cpp for STT") as the recommended low-RAM / no-Python local provider,
17
+ * ahead of whisper.cpp. Its in-process N-API/TypeScript binding crashes on Bun,
18
+ * so we drive a `transcribe-cli` as a subprocess instead — structurally like
19
+ * the `scribe-http` provider but local. See `providers/transcribe-cpp.ts`.
20
+ *
21
+ * ## What v0.1.1 actually ships (live-verified 2026-07-03)
22
+ *
23
+ * IMPORTANT: the v0.1.1 macOS/Linux release tarballs contain a **library** —
24
+ * `libtranscribe.{dylib,so}` + `libggml*.{dylib,so}` + `contract.json` +
25
+ * licenses — and **NO prebuilt `transcribe-cli` executable** (the CLI is
26
+ * build-from-source in v0.1.1). So the install verb fetches the tarball for its
27
+ * runtime shared libraries, downloads the GGUF model, and then honestly reports
28
+ * the CLI-acquisition gap: activate transcribe-cpp only when a runnable CLI is
29
+ * available (`TRANSCRIBE_CPP_BIN`, or a binary a future asset includes, or one
30
+ * built from source against these libs). GGUF models ARE published under the
31
+ * handy-computer HuggingFace org and download normally.
32
+ *
33
+ * ## The RAM-tier matrix (scribe#82 fix)
34
+ *
35
+ * scribe#82: today scribe auto-installs Parakeet-0.6b at 2GB and OOMs on long
36
+ * audio. The floor here is the fix — Parakeet is gated to ≥4GB; 2–4GB gets
37
+ * whisper-small, 1–2GB whisper-tiny, and <1GB is refused (steer to the
38
+ * scribe-http remote provider). Model picks below are the quantized GGUF
39
+ * variants published under the handy-computer HuggingFace org.
40
+ */
41
+
42
+ import { existsSync, readFileSync } from "fs";
43
+ import { totalmem } from "os";
44
+
45
+ /**
46
+ * Pinned upstream release. The prebuilt `transcribe-cli` bundles + the GGUF
47
+ * model quants are pulled from this tag. Bump deliberately (a newer CLI may
48
+ * change flags/output) — the provider's arg + output parsing is validated
49
+ * against this version.
50
+ */
51
+ export const TRANSCRIBE_CPP_VERSION = "0.1.1";
52
+
53
+ const RELEASE_BASE = `https://github.com/handy-computer/transcribe.cpp/releases/download/v${TRANSCRIBE_CPP_VERSION}`;
54
+ const HF_BASE = "https://huggingface.co/handy-computer";
55
+
56
+ const GB = 2 ** 30;
57
+
58
+ /** A concrete GGUF model choice: where to fetch it and its rough footprint. */
59
+ export interface ModelChoice {
60
+ /** Human/config id (e.g. "whisper-tiny.en"). Used for `--model` + the manifest. */
61
+ name: string;
62
+ /** HuggingFace repo under handy-computer. */
63
+ repo: string;
64
+ /** The GGUF filename within the repo (a quantized variant). */
65
+ file: string;
66
+ /** Full resolve URL for the GGUF. */
67
+ url: string;
68
+ /** Approx on-disk size of the GGUF, MB (for the "footprint" print). */
69
+ approxSizeMb: number;
70
+ /** Approx peak process RAM while transcribing, GB — the tier gate rationale. */
71
+ approxRuntimeGb: number;
72
+ /** Short human quality/latency note surfaced in the plan. */
73
+ note: string;
74
+ }
75
+
76
+ function model(
77
+ name: string,
78
+ repo: string,
79
+ file: string,
80
+ approxSizeMb: number,
81
+ approxRuntimeGb: number,
82
+ note: string,
83
+ ): ModelChoice {
84
+ return { name, repo, file, url: `${HF_BASE}/${repo}/resolve/main/${file}`, approxSizeMb, approxRuntimeGb, note };
85
+ }
86
+
87
+ /**
88
+ * The selectable models, keyed by `--model` id. Quantized (Q5_K_M / Q8_0)
89
+ * variants keep the footprint honest for the low-RAM tiers. Only models
90
+ * actually published under handy-computer are listed — whisper-base is NOT
91
+ * on that org, so the 2–4GB tier uses whisper-small.
92
+ */
93
+ export const MODELS: Record<string, ModelChoice> = {
94
+ "whisper-tiny.en": model(
95
+ "whisper-tiny.en",
96
+ "whisper-tiny.en-gguf",
97
+ "whisper-tiny.en-Q5_K_M.gguf",
98
+ 42,
99
+ 0.4,
100
+ "fastest, lowest quality — English only",
101
+ ),
102
+ "whisper-small.en": model(
103
+ "whisper-small.en",
104
+ "whisper-small.en-gguf",
105
+ "whisper-small.en-Q5_K_M.gguf",
106
+ 190,
107
+ 1.3,
108
+ "good quality/speed balance — English only",
109
+ ),
110
+ "parakeet-tdt-0.6b-v3": model(
111
+ "parakeet-tdt-0.6b-v3",
112
+ "parakeet-tdt-0.6b-v3-gguf",
113
+ "parakeet-tdt-0.6b-v3-Q8_0.gguf",
114
+ 660,
115
+ 3.5,
116
+ "highest quality (NVIDIA Parakeet) — needs headroom for long audio",
117
+ ),
118
+ };
119
+
120
+ /**
121
+ * RAM tiers, richest-first. A host with `total RAM >= minGb` gets `model`.
122
+ * Below the smallest tier (<1GB) local transcription is refused and the
123
+ * operator is steered to the scribe-http remote provider.
124
+ *
125
+ * The Parakeet floor at 4GB is the scribe#82 fix: it used to auto-install at
126
+ * 2GB and OOM on long audio.
127
+ */
128
+ export const RAM_TIERS: ReadonlyArray<{ minGb: number; model: string }> = [
129
+ { minGb: 4, model: "parakeet-tdt-0.6b-v3" },
130
+ { minGb: 2, model: "whisper-small.en" },
131
+ { minGb: 1, model: "whisper-tiny.en" },
132
+ ];
133
+
134
+ /** The lowest RAM (bytes) at which we'll still install a local model. */
135
+ export const MIN_LOCAL_RAM_BYTES = 1 * GB;
136
+
137
+ /**
138
+ * Pick the tier-appropriate model for `totalRamBytes`, or `null` when there
139
+ * isn't enough RAM to run any local model (steer to remote). Selection is by
140
+ * TOTAL RAM (not free) — a 2GB box shouldn't pick a 4GB model just because it's
141
+ * momentarily idle.
142
+ */
143
+ export function selectModelForRam(totalRamBytes: number): ModelChoice | null {
144
+ const gb = totalRamBytes / GB;
145
+ for (const tier of RAM_TIERS) {
146
+ if (gb >= tier.minGb) return MODELS[tier.model]!;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /** A release asset: its filename + full download URL. */
152
+ export interface AssetChoice {
153
+ asset: string;
154
+ url: string;
155
+ }
156
+
157
+ /**
158
+ * Does `name` look like a shared library we keep from the release tarball?
159
+ * transcribe.cpp v0.1.1's macOS/Linux assets ship `libtranscribe.{dylib,so}` +
160
+ * `libggml*.{dylib,so}` (the ggml runtime the CLI links against) — NOT a
161
+ * prebuilt `transcribe-cli`. The install verb extracts these so a
162
+ * build-from-source or `TRANSCRIBE_CPP_BIN` CLI can link against them.
163
+ */
164
+ export function isSharedLibFile(name: string): boolean {
165
+ return /\.(dylib|so)(\.\d+)*$/i.test(name);
166
+ }
167
+
168
+ /**
169
+ * Map a Node `platform`/`arch` to the matching `transcribe-native` release
170
+ * asset, or `null` when no asset exists for the host (→ steer to the scribe-http
171
+ * remote provider). Asset names follow the upstream
172
+ * `transcribe-native-<version>-<platform>-<backend>.tar.gz` convention. NOTE: in
173
+ * v0.1.1 this asset is a **library** bundle (see the module header), not a
174
+ * prebuilt CLI.
175
+ *
176
+ * `arch` is Node's `os.arch()` value: `"x64"` / `"arm64"`.
177
+ */
178
+ export function selectAsset(platform: string, arch: string): AssetChoice | null {
179
+ const v = TRANSCRIBE_CPP_VERSION;
180
+ let asset: string | null = null;
181
+ if (platform === "linux") {
182
+ if (arch === "x64") asset = `transcribe-native-${v}-linux-x86_64-cpu-vulkan.tar.gz`;
183
+ else if (arch === "arm64") asset = `transcribe-native-${v}-linux-aarch64-cpu-vulkan.tar.gz`;
184
+ } else if (platform === "darwin") {
185
+ if (arch === "arm64") asset = `transcribe-native-${v}-macos-arm64-metal.tar.gz`;
186
+ else if (arch === "x64") asset = `transcribe-native-${v}-macos-x86_64-cpu.tar.gz`;
187
+ }
188
+ if (!asset) return null;
189
+ return { asset, url: `${RELEASE_BASE}/${asset}` };
190
+ }
191
+
192
+ /** The resolved install plan — what `transcription install` would fetch. */
193
+ export interface InstallPlan {
194
+ /** True when a binary + model were selected and the install can proceed. */
195
+ supported: boolean;
196
+ /** Host platform (Node `os.platform()`). */
197
+ platform: string;
198
+ /** Host arch (Node `os.arch()`). */
199
+ arch: string;
200
+ /** Detected total RAM, GB (rounded to 1dp) for the human print. */
201
+ totalRamGb: number;
202
+ /** Set when `supported` is false: why, and (for refused) the remote steer. */
203
+ reason?: string;
204
+ /**
205
+ * True when the host COULD run a binary but has too little RAM for any
206
+ * model (<1GB) — distinct from an unsupported platform. Steer to remote.
207
+ */
208
+ refused?: boolean;
209
+ /** The prebuilt binary asset (present whenever the platform is supported). */
210
+ asset?: AssetChoice;
211
+ /** The chosen GGUF model (absent when refused/unsupported). */
212
+ model?: ModelChoice;
213
+ /** Approx total download footprint, MB (model GGUF; the binary is small). */
214
+ footprintMb?: number;
215
+ }
216
+
217
+ export interface PlanInput {
218
+ platform: string;
219
+ arch: string;
220
+ totalRamBytes: number;
221
+ /** Operator `--model <id>` override; must key into `MODELS`. */
222
+ overrideModel?: string;
223
+ }
224
+
225
+ /**
226
+ * Resolve the full install plan from host facts. Pure — the CLI prints this on
227
+ * the `--dry-run`/plan path and only fetches when the operator confirms.
228
+ *
229
+ * Order of refusal:
230
+ * 1. Unsupported platform/arch (no prebuilt) → `supported:false`, steer remote.
231
+ * 2. `--model` override naming an unknown model → `supported:false`.
232
+ * 3. RAM below the 1GB floor with no override → `supported:false, refused:true`.
233
+ */
234
+ export function planInstall(input: PlanInput): InstallPlan {
235
+ const { platform, arch, totalRamBytes, overrideModel } = input;
236
+ const totalRamGb = Math.round((totalRamBytes / GB) * 10) / 10;
237
+ const base = { platform, arch, totalRamGb };
238
+
239
+ const asset = selectAsset(platform, arch);
240
+ if (!asset) {
241
+ return {
242
+ ...base,
243
+ supported: false,
244
+ reason: `no transcribe.cpp release asset for ${platform}/${arch}. Use the scribe-http remote provider (set TRANSCRIPTION_PROVIDER=scribe-http and point at a scribe install).`,
245
+ };
246
+ }
247
+
248
+ let chosen: ModelChoice | null;
249
+ if (overrideModel) {
250
+ chosen = MODELS[overrideModel] ?? null;
251
+ if (!chosen) {
252
+ return {
253
+ ...base,
254
+ supported: false,
255
+ asset,
256
+ reason: `unknown model "${overrideModel}". Choose one of: ${Object.keys(MODELS).join(", ")}.`,
257
+ };
258
+ }
259
+ } else {
260
+ chosen = selectModelForRam(totalRamBytes);
261
+ if (!chosen) {
262
+ return {
263
+ ...base,
264
+ supported: false,
265
+ refused: true,
266
+ asset,
267
+ reason: `only ${totalRamGb}GB RAM detected — local transcription needs at least 1GB. Use the scribe-http remote provider instead (set TRANSCRIPTION_PROVIDER=scribe-http).`,
268
+ };
269
+ }
270
+ }
271
+
272
+ return {
273
+ ...base,
274
+ supported: true,
275
+ asset,
276
+ model: chosen,
277
+ footprintMb: chosen.approxSizeMb,
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Detect total physical RAM in bytes. Prefers `/proc/meminfo` on Linux (the
283
+ * source `os.totalmem()` reads there anyway, but reading it directly is the
284
+ * explicit contract) and falls back to `os.totalmem()` everywhere else.
285
+ */
286
+ export function detectTotalRamBytes(): number {
287
+ try {
288
+ if (process.platform === "linux" && existsSync("/proc/meminfo")) {
289
+ const m = readFileSync("/proc/meminfo", "utf8").match(/MemTotal:\s+(\d+)\s+kB/);
290
+ if (m && m[1]) return parseInt(m[1], 10) * 1024;
291
+ }
292
+ } catch {
293
+ // fall through to os.totalmem()
294
+ }
295
+ return totalmem();
296
+ }
@@ -0,0 +1,229 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ OnnxAsrProvider,
4
+ buildOnnxAsrArgs,
5
+ buildOnnxFfmpegArgs,
6
+ parseOnnxAsrOutput,
7
+ } from "./onnx-asr.ts";
8
+ import type { SpawnRunner, SubprocessResult } from "./transcribe-cpp.ts";
9
+ import { TranscriptionError } from "../../../core/src/transcription/provider.ts";
10
+ import { DEFAULT_ONNX_ASR_MODEL } from "../select.ts";
11
+
12
+ /**
13
+ * Conformance tests for the `onnx-asr` local provider (scribe-fold Phase 2b).
14
+ * The subprocess is mocked so no real binary runs — we pin the argv shapes
15
+ * (model + `--vad silero`, the pcm_s16le transcode), the timestamped-output
16
+ * parse ported from scribe, and the terminal-vs-retriable error mapping.
17
+ */
18
+
19
+ const AUDIO = new Uint8Array([1, 2, 3, 4]);
20
+
21
+ function recordingSpawn(results: SubprocessResult[]): { spawn: SpawnRunner; calls: string[][] } {
22
+ const calls: string[][] = [];
23
+ let i = 0;
24
+ const spawn: SpawnRunner = async (cmd) => {
25
+ calls.push(cmd);
26
+ return results[Math.min(i++, results.length - 1)]!;
27
+ };
28
+ return { spawn, calls };
29
+ }
30
+
31
+ const ok = (stdout: string): SubprocessResult => ({ exitCode: 0, stdout, stderr: "" });
32
+
33
+ function provider(spawn: SpawnRunner, opts: Partial<{ existsImpl: (p: string) => boolean; model: string }> = {}) {
34
+ return new OnnxAsrProvider({
35
+ binPath: "/venv/bin/onnx-asr",
36
+ model: opts.model,
37
+ ffmpegPath: "ffmpeg",
38
+ spawn,
39
+ existsImpl: opts.existsImpl ?? (() => true),
40
+ });
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Pure helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe("buildOnnxAsrArgs", () => {
48
+ test("matches scribe's proven invocation: <bin> <model> --vad silero <wav>", () => {
49
+ expect(buildOnnxAsrArgs("/bin/onnx-asr", "nemo-parakeet-tdt-0.6b-v3", "/a.wav")).toEqual([
50
+ "/bin/onnx-asr",
51
+ "nemo-parakeet-tdt-0.6b-v3",
52
+ "--vad",
53
+ "silero",
54
+ "/a.wav",
55
+ ]);
56
+ });
57
+ });
58
+
59
+ describe("buildOnnxFfmpegArgs", () => {
60
+ test("transcodes to 16kHz mono pcm_s16le WAV (onnx-asr's input contract)", () => {
61
+ const args = buildOnnxFfmpegArgs("ffmpeg", "/in.webm", "/out.wav");
62
+ expect(args[args.indexOf("-ar") + 1]).toBe("16000");
63
+ expect(args[args.indexOf("-ac") + 1]).toBe("1");
64
+ expect(args[args.indexOf("-c:a") + 1]).toBe("pcm_s16le");
65
+ expect(args[args.length - 1]).toBe("/out.wav");
66
+ });
67
+ });
68
+
69
+ describe("parseOnnxAsrOutput (ported from scribe)", () => {
70
+ test("strips --vad timestamps and joins segments with spaces", () => {
71
+ const raw = "[ 0.0, 3.2]: hello there\n[ 3.5, 7.1]: second segment\n";
72
+ expect(parseOnnxAsrOutput(raw)).toBe("hello there second segment");
73
+ });
74
+ test("untimestamped output (short clip) passes through", () => {
75
+ expect(parseOnnxAsrOutput("plain transcript line\n")).toBe("plain transcript line");
76
+ });
77
+ test("mixed timestamped + blank lines", () => {
78
+ expect(parseOnnxAsrOutput("[ 0.0, 1.0]: a\n\n[ 1.2, 2.0]: b\n")).toBe("a b");
79
+ });
80
+ test("empty / whitespace stdout → empty string", () => {
81
+ expect(parseOnnxAsrOutput(" \n \n")).toBe("");
82
+ });
83
+ test("timestamped lines with empty text are dropped", () => {
84
+ expect(parseOnnxAsrOutput("[ 0.0, 1.0]: \n[ 1.0, 2.0]: kept\n")).toBe("kept");
85
+ });
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // available() — cheap, no spawn
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe("OnnxAsrProvider.available", () => {
93
+ test("name is stable 'onnx-asr'", () => {
94
+ expect(provider(recordingSpawn([]).spawn).name).toBe("onnx-asr");
95
+ });
96
+
97
+ test("ok:true when the binary exists — and NEVER spawns", async () => {
98
+ const { spawn, calls } = recordingSpawn([]);
99
+ const p = provider(spawn);
100
+ expect(await p.available()).toEqual({ ok: true });
101
+ expect(calls.length).toBe(0);
102
+ });
103
+
104
+ test("ok:false with an actionable reason when the binary is missing", async () => {
105
+ const { spawn } = recordingSpawn([]);
106
+ const p = provider(spawn, { existsImpl: () => false });
107
+ const avail = await p.available();
108
+ expect(avail.ok).toBe(false);
109
+ expect(avail.reason).toContain("transcription install");
110
+ });
111
+
112
+ test("unconfigured (no binPath) → ok:false, no throw", async () => {
113
+ const p = new OnnxAsrProvider({});
114
+ expect((await p.available()).ok).toBe(false);
115
+ });
116
+ });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // transcribe() — happy paths
120
+ // ---------------------------------------------------------------------------
121
+
122
+ describe("OnnxAsrProvider.transcribe — happy path", () => {
123
+ test("transcodes via ffmpeg THEN runs onnx-asr with the default model", async () => {
124
+ const { spawn, calls } = recordingSpawn([ok(""), ok("[ 0.0, 3.0]: the transcript\n")]);
125
+ const p = provider(spawn);
126
+ const res = await p.transcribe({ audio: AUDIO, filename: "memo.webm", mimeType: "audio/webm" });
127
+
128
+ expect(res.text).toBe("the transcript");
129
+ expect(calls.length).toBe(2);
130
+ expect(calls[0]![0]).toBe("ffmpeg");
131
+ expect(calls[0]).toContain("pcm_s16le");
132
+ expect(calls[1]![0]).toBe("/venv/bin/onnx-asr");
133
+ expect(calls[1]![1]).toBe(DEFAULT_ONNX_ASR_MODEL);
134
+ expect(calls[1]).toContain("--vad");
135
+ expect(calls[1]![calls[1]!.length - 1]).toMatch(/\.16k\.wav$/);
136
+ });
137
+
138
+ test("WAV input ALSO transcodes (an incoming WAV's sample rate can't be trusted)", async () => {
139
+ // Deviation from scribe (which copied .wav straight through), inheriting
140
+ // the transcribe-cpp live-verify lesson: always normalize via ffmpeg.
141
+ const { spawn, calls } = recordingSpawn([ok(""), ok("wav text")]);
142
+ const p = provider(spawn);
143
+ const res = await p.transcribe({ audio: AUDIO, filename: "clip.wav", mimeType: "audio/wav" });
144
+ expect(res.text).toBe("wav text");
145
+ expect(calls.length).toBe(2);
146
+ expect(calls[0]![0]).toBe("ffmpeg");
147
+ });
148
+
149
+ test("a configured model overrides the default", async () => {
150
+ const { spawn, calls } = recordingSpawn([ok(""), ok("t")]);
151
+ const p = provider(spawn, { model: "whisper-base" });
152
+ await p.transcribe({ audio: AUDIO, filename: "a.ogg", mimeType: "audio/ogg" });
153
+ expect(calls[1]![1]).toBe("whisper-base");
154
+ });
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // transcribe() — error mapping
159
+ // ---------------------------------------------------------------------------
160
+
161
+ describe("OnnxAsrProvider.transcribe — error mapping", () => {
162
+ async function catchErr(fn: () => Promise<unknown>): Promise<unknown> {
163
+ try {
164
+ await fn();
165
+ } catch (err) {
166
+ return err;
167
+ }
168
+ throw new Error("expected transcribe() to throw");
169
+ }
170
+
171
+ test("binary missing → non-retriable missing_provider, NEVER spawns", async () => {
172
+ const { spawn, calls } = recordingSpawn([ok("nope")]);
173
+ const p = provider(spawn, { existsImpl: () => false });
174
+ const err = (await catchErr(() =>
175
+ p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
176
+ )) as TranscriptionError;
177
+ expect(err).toBeInstanceOf(TranscriptionError);
178
+ expect(err.code).toBe("missing_provider");
179
+ expect(err.retriable).toBe(false);
180
+ expect(calls.length).toBe(0);
181
+ });
182
+
183
+ test("ffmpeg not found (exit 127) → non-retriable ffmpeg_missing", async () => {
184
+ const { spawn } = recordingSpawn([{ exitCode: 127, stdout: "", stderr: "not found" }]);
185
+ const err = (await catchErr(() =>
186
+ provider(spawn).transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
187
+ )) as TranscriptionError;
188
+ expect(err.code).toBe("ffmpeg_missing");
189
+ expect(err.retriable).toBe(false);
190
+ });
191
+
192
+ test("ffmpeg transcode failure (non-127) → non-retriable transcode_failed", async () => {
193
+ const { spawn } = recordingSpawn([{ exitCode: 1, stdout: "", stderr: "bad input" }]);
194
+ const err = (await catchErr(() =>
195
+ provider(spawn).transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
196
+ )) as TranscriptionError;
197
+ expect(err.code).toBe("transcode_failed");
198
+ expect(err.retriable).toBe(false);
199
+ });
200
+
201
+ test("onnx-asr non-zero exit → RETRIABLE onnx_asr_error", async () => {
202
+ const { spawn } = recordingSpawn([ok(""), { exitCode: 2, stdout: "", stderr: "model error" }]);
203
+ const err = (await catchErr(() =>
204
+ provider(spawn).transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" }),
205
+ )) as TranscriptionError;
206
+ expect(err).toBeInstanceOf(TranscriptionError);
207
+ expect(err.code).toBe("onnx_asr_error");
208
+ expect(err.retriable).toBe(true);
209
+ });
210
+
211
+ test("onnx-asr launch failure (127) → non-retriable missing_provider", async () => {
212
+ const { spawn } = recordingSpawn([ok(""), { exitCode: 127, stdout: "", stderr: "gone" }]);
213
+ const err = (await catchErr(() =>
214
+ provider(spawn).transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" }),
215
+ )) as TranscriptionError;
216
+ expect(err.code).toBe("missing_provider");
217
+ expect(err.retriable).toBe(false);
218
+ });
219
+
220
+ test("empty stdout → plain Error (retriable via worker), not TranscriptionError", async () => {
221
+ const { spawn } = recordingSpawn([ok(""), ok(" \n")]);
222
+ const err = (await catchErr(() =>
223
+ provider(spawn).transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" }),
224
+ )) as Error;
225
+ expect(err).toBeInstanceOf(Error);
226
+ expect(err).not.toBeInstanceOf(TranscriptionError);
227
+ expect(err.message).toContain("no transcript text");
228
+ });
229
+ });