@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.
- package/core/src/__fixtures__/golden-vault.ts +125 -0
- package/core/src/__fixtures__/portable-export-golden.json +10 -0
- package/core/src/do-param-cap.test.ts +161 -0
- package/core/src/links.ts +8 -13
- package/core/src/mcp.ts +306 -314
- package/core/src/notes.ts +54 -62
- package/core/src/onboarding.ts +14 -296
- package/core/src/portable-md-batching.test.ts +67 -0
- package/core/src/portable-md-golden.test.ts +55 -0
- package/core/src/portable-md.ts +579 -435
- package/core/src/schema.ts +21 -43
- package/core/src/seed-packs.test.ts +191 -0
- package/core/src/seed-packs.ts +559 -0
- package/core/src/sql-in.test.ts +58 -0
- package/core/src/sql-in.ts +76 -0
- package/core/src/store.ts +14 -9
- package/core/src/transcription/provider.ts +141 -0
- package/core/src/txn.test.ts +229 -0
- package/core/src/txn.ts +105 -0
- package/core/src/types.ts +9 -0
- package/core/src/vault-projection.ts +1 -1
- package/core/src/wikilinks.ts +10 -4
- package/package.json +1 -1
- package/src/add-pack.test.ts +142 -0
- package/src/cli.ts +778 -7
- package/src/onboarding-seed.test.ts +126 -40
- package/src/onboarding-seed.ts +41 -46
- package/src/routes.ts +25 -7
- package/src/server.ts +108 -31
- package/src/transcription/build.test.ts +224 -0
- package/src/transcription/build.ts +252 -0
- package/src/transcription/capability.test.ts +118 -0
- package/src/transcription/capability.ts +96 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/install.test.ts +167 -0
- package/src/transcription/install.ts +296 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/providers/scribe-http.test.ts +195 -0
- package/src/transcription/providers/scribe-http.ts +144 -0
- package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
- package/src/transcription/providers/transcribe-cpp.ts +293 -0
- package/src/transcription/select.test.ts +259 -0
- package/src/transcription/select.ts +334 -0
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/transcription-worker.test.ts +44 -0
- package/src/transcription-worker.ts +57 -122
- package/src/vault-create.test.ts +38 -10
- package/src/vault.test.ts +48 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
TranscribeCppProvider,
|
|
4
|
+
buildTranscribeArgs,
|
|
5
|
+
buildFfmpegArgs,
|
|
6
|
+
parseTranscribeCliOutput,
|
|
7
|
+
type SpawnRunner,
|
|
8
|
+
type SubprocessResult,
|
|
9
|
+
} from "./transcribe-cpp.ts";
|
|
10
|
+
import { TranscriptionError } from "../../../core/src/transcription/provider.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Conformance tests for the `transcribe-cpp` local provider (scribe-fold
|
|
14
|
+
* Phase 2a). The subprocess (`Bun.spawn`) is mocked so no real binary runs and
|
|
15
|
+
* no bytes are downloaded — we pin the argv shape, the WAV/transcode decision,
|
|
16
|
+
* the stdout parse, and the terminal-vs-retriable error mapping the worker
|
|
17
|
+
* depends on.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const AUDIO = new Uint8Array([1, 2, 3, 4]);
|
|
21
|
+
|
|
22
|
+
/** A spawn mock that records every argv and returns canned results per call. */
|
|
23
|
+
function recordingSpawn(results: SubprocessResult[] | ((cmd: string[]) => SubprocessResult)): {
|
|
24
|
+
spawn: SpawnRunner;
|
|
25
|
+
calls: string[][];
|
|
26
|
+
} {
|
|
27
|
+
const calls: string[][] = [];
|
|
28
|
+
let i = 0;
|
|
29
|
+
const spawn: SpawnRunner = async (cmd) => {
|
|
30
|
+
calls.push(cmd);
|
|
31
|
+
if (typeof results === "function") return results(cmd);
|
|
32
|
+
return results[Math.min(i++, results.length - 1)]!;
|
|
33
|
+
};
|
|
34
|
+
return { spawn, calls };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ok = (stdout: string): SubprocessResult => ({ exitCode: 0, stdout, stderr: "" });
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A real `transcribe-cli` v0.1.1 stdout report (macOS arm64, whisper-tiny.en).
|
|
41
|
+
* The transcript is the single `text:` line; library `[info]`/`[debug]` logs go
|
|
42
|
+
* to STDERR (not shown here). Build one with a given `text:` value.
|
|
43
|
+
*/
|
|
44
|
+
function cliReport(text: string): string {
|
|
45
|
+
return [
|
|
46
|
+
"audio: test.wav",
|
|
47
|
+
" samples: 48422",
|
|
48
|
+
" duration: 3.026 s",
|
|
49
|
+
" sample rate 16000 Hz mono float32",
|
|
50
|
+
"model: /models/whisper-tiny.en-Q5_K_M.gguf -> ok",
|
|
51
|
+
" backend: MTL0",
|
|
52
|
+
" name: Whisper Tiny (English)",
|
|
53
|
+
" license: apache-2.0",
|
|
54
|
+
" max audio: unbounded (long audio chunked internally)",
|
|
55
|
+
"run: ok",
|
|
56
|
+
`text: ${text}`,
|
|
57
|
+
"segments: 1",
|
|
58
|
+
` [ 0.00 -> 3.00] ${text}`,
|
|
59
|
+
" realtime: 9x (332.1 ms for 3.0 s)",
|
|
60
|
+
"",
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A provider whose binary + model both "exist", with an injected spawn. */
|
|
65
|
+
function provider(spawn: SpawnRunner, opts: Partial<{ existsImpl: (p: string) => boolean }> = {}) {
|
|
66
|
+
return new TranscribeCppProvider({
|
|
67
|
+
binPath: "/tc/bin/transcribe-cli",
|
|
68
|
+
modelPath: "/tc/models/whisper-tiny.en-Q5_K_M.gguf",
|
|
69
|
+
ffmpegPath: "ffmpeg",
|
|
70
|
+
spawn,
|
|
71
|
+
existsImpl: opts.existsImpl ?? (() => true),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Pure helpers
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
describe("buildTranscribeArgs", () => {
|
|
80
|
+
test("matches upstream usage: transcribe-cli -m <model> <wav>", () => {
|
|
81
|
+
expect(buildTranscribeArgs("/bin/transcribe-cli", "/m.gguf", "/a.wav")).toEqual([
|
|
82
|
+
"/bin/transcribe-cli",
|
|
83
|
+
"-m",
|
|
84
|
+
"/m.gguf",
|
|
85
|
+
"/a.wav",
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("buildFfmpegArgs", () => {
|
|
91
|
+
test("transcodes to 16kHz mono WAV", () => {
|
|
92
|
+
const args = buildFfmpegArgs("ffmpeg", "/in.webm", "/out.wav");
|
|
93
|
+
expect(args).toContain("-ar");
|
|
94
|
+
expect(args[args.indexOf("-ar") + 1]).toBe("16000");
|
|
95
|
+
expect(args).toContain("-ac");
|
|
96
|
+
expect(args[args.indexOf("-ac") + 1]).toBe("1");
|
|
97
|
+
expect(args[args.length - 1]).toBe("/out.wav");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("parseTranscribeCliOutput", () => {
|
|
102
|
+
test("extracts exactly the `text:` line from the real v0.1.1 report", () => {
|
|
103
|
+
const report = cliReport("Hello parachute. Testing transcribe CPP.");
|
|
104
|
+
expect(parseTranscribeCliOutput(report)).toBe("Hello parachute. Testing transcribe CPP.");
|
|
105
|
+
});
|
|
106
|
+
test("ignores the indented per-segment line (starts with a bracket, not `text:`)", () => {
|
|
107
|
+
// The segment line repeats the transcript but is indented + bracketed; only
|
|
108
|
+
// the column-0 `text:` line is the transcript, and we return it once.
|
|
109
|
+
const report = cliReport("just once");
|
|
110
|
+
expect(parseTranscribeCliOutput(report)).toBe("just once");
|
|
111
|
+
});
|
|
112
|
+
test("the `(empty)` no-result sentinel maps to \"\"", () => {
|
|
113
|
+
expect(parseTranscribeCliOutput(cliReport("(empty)"))).toBe("");
|
|
114
|
+
});
|
|
115
|
+
test("no `text:` line at all → empty string", () => {
|
|
116
|
+
expect(parseTranscribeCliOutput("audio: test.wav\nrun: ok\nsegments: 0\n")).toBe("");
|
|
117
|
+
});
|
|
118
|
+
test("empty / whitespace stdout → empty string", () => {
|
|
119
|
+
expect(parseTranscribeCliOutput(" \n ")).toBe("");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// available() — cheap, no spawn
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
describe("TranscribeCppProvider.available", () => {
|
|
128
|
+
test("name is stable 'transcribe-cpp'", () => {
|
|
129
|
+
expect(provider(recordingSpawn([]).spawn).name).toBe("transcribe-cpp");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("ok:true when binary + model exist — and NEVER spawns", async () => {
|
|
133
|
+
const { spawn, calls } = recordingSpawn([]);
|
|
134
|
+
const p = provider(spawn);
|
|
135
|
+
expect(await p.available()).toEqual({ ok: true });
|
|
136
|
+
expect(calls.length).toBe(0); // no subprocess on the hot path
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("ok:false with reason when the binary is missing", async () => {
|
|
140
|
+
const { spawn, calls } = recordingSpawn([]);
|
|
141
|
+
const p = provider(spawn, { existsImpl: (path) => !path.includes("transcribe-cli") });
|
|
142
|
+
const avail = await p.available();
|
|
143
|
+
expect(avail.ok).toBe(false);
|
|
144
|
+
expect(avail.reason).toContain("transcribe-cli");
|
|
145
|
+
expect(calls.length).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("ok:false when the model is missing", async () => {
|
|
149
|
+
const { spawn } = recordingSpawn([]);
|
|
150
|
+
const p = provider(spawn, { existsImpl: (path) => !path.endsWith(".gguf") });
|
|
151
|
+
expect((await p.available()).ok).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("dylib-only install (model exists, no runnable CLI) → ok:false (honesty)", async () => {
|
|
155
|
+
// v0.1.1 ships a library, not a CLI: libs + model land on disk but no
|
|
156
|
+
// transcribe-cli. available() must gate on the binary so the capability
|
|
157
|
+
// flag reports enabled:false rather than a capability we can't deliver.
|
|
158
|
+
const { spawn } = recordingSpawn([]);
|
|
159
|
+
const p = provider(spawn, { existsImpl: (path) => path.endsWith(".gguf") }); // only the model exists
|
|
160
|
+
const avail = await p.available();
|
|
161
|
+
expect(avail.ok).toBe(false);
|
|
162
|
+
expect(avail.reason).toContain("transcribe-cli");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("unconfigured (no binPath) → ok:false, no throw", async () => {
|
|
166
|
+
const p = new TranscribeCppProvider({});
|
|
167
|
+
expect((await p.available()).ok).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("caches the once-true result (no repeated existsSync after ok)", async () => {
|
|
171
|
+
let calls = 0;
|
|
172
|
+
const p = provider(recordingSpawn([]).spawn, {
|
|
173
|
+
existsImpl: () => {
|
|
174
|
+
calls++;
|
|
175
|
+
return true;
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
await p.available();
|
|
179
|
+
const afterFirst = calls;
|
|
180
|
+
await p.available();
|
|
181
|
+
expect(calls).toBe(afterFirst); // second call short-circuits the cache
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// transcribe() — happy paths
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
describe("TranscribeCppProvider.transcribe — happy path", () => {
|
|
190
|
+
test("non-WAV input transcodes via ffmpeg THEN runs transcribe-cli", async () => {
|
|
191
|
+
const { spawn, calls } = recordingSpawn([ok(""), ok(cliReport("the transcript"))]);
|
|
192
|
+
const p = provider(spawn);
|
|
193
|
+
const res = await p.transcribe({ audio: AUDIO, filename: "memo.webm", mimeType: "audio/webm" });
|
|
194
|
+
|
|
195
|
+
expect(res.text).toBe("the transcript");
|
|
196
|
+
expect(calls.length).toBe(2);
|
|
197
|
+
// First call: ffmpeg transcode to 16kHz mono WAV.
|
|
198
|
+
expect(calls[0]![0]).toBe("ffmpeg");
|
|
199
|
+
expect(calls[0]).toContain("16000");
|
|
200
|
+
// Second call: transcribe-cli -m <model> <wav>.
|
|
201
|
+
expect(calls[1]![0]).toBe("/tc/bin/transcribe-cli");
|
|
202
|
+
expect(calls[1]![1]).toBe("-m");
|
|
203
|
+
expect(calls[1]![2]).toBe("/tc/models/whisper-tiny.en-Q5_K_M.gguf");
|
|
204
|
+
expect(calls[1]![3]).toMatch(/\.16k\.wav$/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("WAV input ALSO transcodes (strict 16kHz — we can't trust its rate)", async () => {
|
|
208
|
+
// Regression for the live-verified bug: a 44.1kHz .wav made transcribe-cli
|
|
209
|
+
// exit 1. We now ALWAYS run ffmpeg first, even for a .wav → two spawns, and
|
|
210
|
+
// the CLI reads the 16k .wav ffmpeg produced (not the raw input).
|
|
211
|
+
const { spawn, calls } = recordingSpawn([ok(""), ok(cliReport("wav text"))]);
|
|
212
|
+
const p = provider(spawn);
|
|
213
|
+
const res = await p.transcribe({ audio: AUDIO, filename: "clip.wav", mimeType: "audio/wav" });
|
|
214
|
+
|
|
215
|
+
expect(res.text).toBe("wav text");
|
|
216
|
+
expect(calls.length).toBe(2);
|
|
217
|
+
expect(calls[0]![0]).toBe("ffmpeg");
|
|
218
|
+
expect(calls[0]).toContain("16000");
|
|
219
|
+
expect(calls[0]).toContain("1"); // -ac 1 mono
|
|
220
|
+
expect(calls[1]![0]).toBe("/tc/bin/transcribe-cli");
|
|
221
|
+
expect(calls[1]![3]).toMatch(/\.16k\.wav$/);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// transcribe() — error mapping
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
describe("TranscribeCppProvider.transcribe — error mapping", () => {
|
|
230
|
+
async function catchErr(fn: () => Promise<unknown>): Promise<unknown> {
|
|
231
|
+
try {
|
|
232
|
+
await fn();
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return err;
|
|
235
|
+
}
|
|
236
|
+
throw new Error("expected transcribe() to throw");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
test("binary missing → non-retriable missing_provider, NEVER spawns", async () => {
|
|
240
|
+
const { spawn, calls } = recordingSpawn([ok("nope")]);
|
|
241
|
+
const p = provider(spawn, { existsImpl: (path) => !path.includes("transcribe-cli") });
|
|
242
|
+
const err = (await catchErr(() =>
|
|
243
|
+
p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
|
|
244
|
+
)) as TranscriptionError;
|
|
245
|
+
expect(err).toBeInstanceOf(TranscriptionError);
|
|
246
|
+
expect(err.code).toBe("missing_provider");
|
|
247
|
+
expect(err.retriable).toBe(false);
|
|
248
|
+
expect(calls.length).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("model missing → non-retriable missing_provider", async () => {
|
|
252
|
+
const { spawn } = recordingSpawn([ok("nope")]);
|
|
253
|
+
const p = provider(spawn, { existsImpl: (path) => !path.endsWith(".gguf") });
|
|
254
|
+
const err = (await catchErr(() =>
|
|
255
|
+
p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
|
|
256
|
+
)) as TranscriptionError;
|
|
257
|
+
expect(err.code).toBe("missing_provider");
|
|
258
|
+
expect(err.retriable).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("ffmpeg not found (exit 127) → non-retriable ffmpeg_missing", async () => {
|
|
262
|
+
const { spawn } = recordingSpawn([{ exitCode: 127, stdout: "", stderr: "not found" }]);
|
|
263
|
+
const p = provider(spawn);
|
|
264
|
+
const err = (await catchErr(() =>
|
|
265
|
+
p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
|
|
266
|
+
)) as TranscriptionError;
|
|
267
|
+
expect(err.code).toBe("ffmpeg_missing");
|
|
268
|
+
expect(err.retriable).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("ffmpeg transcode failure (non-127) → non-retriable transcode_failed", async () => {
|
|
272
|
+
const { spawn } = recordingSpawn([{ exitCode: 1, stdout: "", stderr: "bad input" }]);
|
|
273
|
+
const p = provider(spawn);
|
|
274
|
+
const err = (await catchErr(() =>
|
|
275
|
+
p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
|
|
276
|
+
)) as TranscriptionError;
|
|
277
|
+
expect(err.code).toBe("transcode_failed");
|
|
278
|
+
expect(err.retriable).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("transcribe-cli non-zero exit → RETRIABLE transcribe_cli_error", async () => {
|
|
282
|
+
// ffmpeg succeeds; transcribe-cli then fails with a generic exit code.
|
|
283
|
+
const { spawn } = recordingSpawn([ok(""), { exitCode: 2, stdout: "", stderr: "decode error" }]);
|
|
284
|
+
const p = provider(spawn);
|
|
285
|
+
const err = (await catchErr(() =>
|
|
286
|
+
p.transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" }),
|
|
287
|
+
)) as TranscriptionError;
|
|
288
|
+
expect(err).toBeInstanceOf(TranscriptionError);
|
|
289
|
+
expect(err.code).toBe("transcribe_cli_error");
|
|
290
|
+
expect(err.retriable).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("transcribe-cli launch failure (127) → non-retriable missing_provider", async () => {
|
|
294
|
+
const { spawn } = recordingSpawn([ok(""), { exitCode: 127, stdout: "", stderr: "gone" }]);
|
|
295
|
+
const p = provider(spawn);
|
|
296
|
+
const err = (await catchErr(() =>
|
|
297
|
+
p.transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" }),
|
|
298
|
+
)) as TranscriptionError;
|
|
299
|
+
expect(err.code).toBe("missing_provider");
|
|
300
|
+
expect(err.retriable).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("`text: (empty)` no-result → plain Error (retriable via worker), not TranscriptionError", async () => {
|
|
304
|
+
// ffmpeg ok; transcribe-cli returns its (empty) sentinel → parses to "".
|
|
305
|
+
const { spawn } = recordingSpawn([ok(""), ok(cliReport("(empty)"))]);
|
|
306
|
+
const p = provider(spawn);
|
|
307
|
+
const err = (await catchErr(() =>
|
|
308
|
+
p.transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" }),
|
|
309
|
+
)) as Error;
|
|
310
|
+
expect(err).toBeInstanceOf(Error);
|
|
311
|
+
expect(err).not.toBeInstanceOf(TranscriptionError);
|
|
312
|
+
expect(err.message).toContain("no transcript text");
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `transcribe-cpp` — the local, no-Python transcription provider
|
|
3
|
+
* (scribe-fold Phase 2a).
|
|
4
|
+
*
|
|
5
|
+
* Turns audio into text by subprocessing the prebuilt `transcribe-cli` from
|
|
6
|
+
* transcribe.cpp (github.com/handy-computer/transcribe.cpp — CJ Pais /
|
|
7
|
+
* Mozilla.ai, MIT). transcribe.cpp's in-process N-API binding crashes on Bun,
|
|
8
|
+
* so we drive the CLI as a subprocess — structurally like the `scribe-http`
|
|
9
|
+
* provider, but local. It's the recommended low-RAM / no-Python provider,
|
|
10
|
+
* opt-in via `parachute-vault transcription install` + `TRANSCRIPTION_PROVIDER`.
|
|
11
|
+
*
|
|
12
|
+
* ## The audio path
|
|
13
|
+
*
|
|
14
|
+
* `transcribe-cli` STRICTLY requires **16kHz mono WAV** input — it rejects other
|
|
15
|
+
* sample rates (a 44.1kHz .wav exits non-zero; verified against v0.1.1). So we
|
|
16
|
+
* ALWAYS transcode the incoming audio through `ffmpeg -ar 16000 -ac 1` first,
|
|
17
|
+
* even when it's already a `.wav` — we can't trust an incoming WAV's sample
|
|
18
|
+
* rate. The install verb prints an ffmpeg hint when it's missing; here a missing
|
|
19
|
+
* ffmpeg surfaces as a terminal `ffmpeg_missing` error the operator must fix.
|
|
20
|
+
*
|
|
21
|
+
* ## Reading the output
|
|
22
|
+
*
|
|
23
|
+
* `transcribe-cli` prints a human-readable report to STDOUT (audio/model/run
|
|
24
|
+
* lines, a `text: <transcript>` line, then per-segment lines) and sends its
|
|
25
|
+
* library `[info]`/`[debug]` logs to STDERR. The transcript is the single
|
|
26
|
+
* `text:` line; `text: (empty)` is its no-result sentinel. See
|
|
27
|
+
* `parseTranscribeCliOutput`.
|
|
28
|
+
*
|
|
29
|
+
* ## Error mapping (mirrors scribe-http's terminal-vs-retriable contract)
|
|
30
|
+
*
|
|
31
|
+
* - binary / model not installed → non-retriable `missing_provider` (operator
|
|
32
|
+
* runs `transcription install`); never spawns.
|
|
33
|
+
* - ffmpeg not found → non-retriable `ffmpeg_missing`.
|
|
34
|
+
* - ffmpeg transcode non-zero exit → non-retriable `transcode_failed`
|
|
35
|
+
* (a re-run on the same undecodable blob won't help).
|
|
36
|
+
* - `transcribe-cli` non-zero exit → RETRIABLE `transcribe_cli_error` (could
|
|
37
|
+
* be a transient resource blip; the worker backs off, and a deterministic
|
|
38
|
+
* failure still terminates after maxAttempts).
|
|
39
|
+
* - empty/unparseable stdout → plain `Error` (retriable via the worker),
|
|
40
|
+
* matching scribe-http's "missing text field".
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { randomUUID } from "crypto";
|
|
44
|
+
import { existsSync } from "fs";
|
|
45
|
+
import { writeFile, unlink } from "fs/promises";
|
|
46
|
+
import { tmpdir } from "os";
|
|
47
|
+
import { join } from "path";
|
|
48
|
+
import {
|
|
49
|
+
TranscriptionError,
|
|
50
|
+
type TranscriptionProvider,
|
|
51
|
+
type TranscribeInput,
|
|
52
|
+
type TranscribeResult,
|
|
53
|
+
type ProviderAvailability,
|
|
54
|
+
} from "../../../core/src/transcription/provider.ts";
|
|
55
|
+
|
|
56
|
+
/** transcribe-cli can be slow on CPU for long audio; generous default. */
|
|
57
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
58
|
+
|
|
59
|
+
/** Result of running a subprocess: exit code + captured streams (as text). */
|
|
60
|
+
export interface SubprocessResult {
|
|
61
|
+
exitCode: number;
|
|
62
|
+
stdout: string;
|
|
63
|
+
stderr: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Injection seam for the subprocess call. Production uses `defaultSpawnRunner`
|
|
68
|
+
* (Bun.spawn); tests pass a mock so no real binary runs. A launch failure
|
|
69
|
+
* (binary not found) resolves to `exitCode: 127` rather than throwing, so the
|
|
70
|
+
* provider maps it to a friendly terminal error.
|
|
71
|
+
*/
|
|
72
|
+
export type SpawnRunner = (
|
|
73
|
+
cmd: string[],
|
|
74
|
+
opts?: { timeoutMs?: number },
|
|
75
|
+
) => Promise<SubprocessResult>;
|
|
76
|
+
|
|
77
|
+
/** Production spawn runner over `Bun.spawn`. */
|
|
78
|
+
export const defaultSpawnRunner: SpawnRunner = async (cmd, opts) => {
|
|
79
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
80
|
+
try {
|
|
81
|
+
proc = Bun.spawn(cmd, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// ENOENT etc. — the binary couldn't be launched at all.
|
|
84
|
+
return { exitCode: 127, stdout: "", stderr: err instanceof Error ? err.message : String(err) };
|
|
85
|
+
}
|
|
86
|
+
let timedOut = false;
|
|
87
|
+
const timer = opts?.timeoutMs
|
|
88
|
+
? setTimeout(() => {
|
|
89
|
+
timedOut = true;
|
|
90
|
+
proc.kill();
|
|
91
|
+
}, opts.timeoutMs)
|
|
92
|
+
: null;
|
|
93
|
+
try {
|
|
94
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
95
|
+
new Response(proc.stdout as ReadableStream).text(),
|
|
96
|
+
new Response(proc.stderr as ReadableStream).text(),
|
|
97
|
+
proc.exited,
|
|
98
|
+
]);
|
|
99
|
+
if (timedOut) {
|
|
100
|
+
return { exitCode: 124, stdout, stderr: `${stderr}\n[transcribe-cli timed out]` };
|
|
101
|
+
}
|
|
102
|
+
return { exitCode, stdout, stderr };
|
|
103
|
+
} finally {
|
|
104
|
+
if (timer) clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export interface TranscribeCppProviderOpts {
|
|
109
|
+
/** Path to the `transcribe-cli` binary. Undefined ⇒ unavailable + terminal. */
|
|
110
|
+
binPath?: string;
|
|
111
|
+
/** Path to the GGUF model. Undefined ⇒ unavailable + terminal. */
|
|
112
|
+
modelPath?: string;
|
|
113
|
+
/** ffmpeg binary (default "ffmpeg"; resolved on PATH). */
|
|
114
|
+
ffmpegPath?: string;
|
|
115
|
+
/** Per-transcription timeout (ms). Default 600s. */
|
|
116
|
+
timeoutMs?: number;
|
|
117
|
+
/** Subprocess runner (tests inject a mock). Default `defaultSpawnRunner`. */
|
|
118
|
+
spawn?: SpawnRunner;
|
|
119
|
+
/** Existence probe (tests inject). Default `fs.existsSync`. */
|
|
120
|
+
existsImpl?: (p: string) => boolean;
|
|
121
|
+
/** Scratch dir for the temp audio/wav files. Default `os.tmpdir()`. */
|
|
122
|
+
tmpDir?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the `transcribe-cli` argv. Upstream usage:
|
|
127
|
+
* `transcribe-cli -m <model.gguf> <audio.wav>`
|
|
128
|
+
* (model via `-m`, audio as a positional). Exported so a test pins the shape.
|
|
129
|
+
*/
|
|
130
|
+
export function buildTranscribeArgs(binPath: string, modelPath: string, wavPath: string): string[] {
|
|
131
|
+
return [binPath, "-m", modelPath, wavPath];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Build the ffmpeg transcode argv → 16kHz mono WAV. Exported for tests. */
|
|
135
|
+
export function buildFfmpegArgs(ffmpegPath: string, inputPath: string, wavPath: string): string[] {
|
|
136
|
+
return [ffmpegPath, "-nostdin", "-y", "-i", inputPath, "-ar", "16000", "-ac", "1", wavPath];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Extract the transcript from `transcribe-cli`'s stdout report. The CLI prints a
|
|
141
|
+
* human report whose transcript is the single `text: <transcript>` line at
|
|
142
|
+
* column 0 (library `[info]`/`[debug]` logs go to STDERR, and the indented
|
|
143
|
+
* per-segment lines start with a bracket, not `text:`). We return the first
|
|
144
|
+
* `text:` line trimmed; the `text: (empty)` no-result sentinel maps to "" so the
|
|
145
|
+
* caller treats it as a retriable empty-output failure. Returns "" when no
|
|
146
|
+
* `text:` line is present. Validated against transcribe.cpp v0.1.1. Exported for
|
|
147
|
+
* tests.
|
|
148
|
+
*/
|
|
149
|
+
export function parseTranscribeCliOutput(stdout: string): string {
|
|
150
|
+
for (const line of stdout.split("\n")) {
|
|
151
|
+
const m = line.match(/^text:\s?(.*)$/);
|
|
152
|
+
if (m) {
|
|
153
|
+
const t = m[1]!.trim();
|
|
154
|
+
return t === "(empty)" ? "" : t;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return "";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export class TranscribeCppProvider implements TranscriptionProvider {
|
|
161
|
+
readonly name = "transcribe-cpp";
|
|
162
|
+
|
|
163
|
+
private readonly binPath: string | undefined;
|
|
164
|
+
private readonly modelPath: string | undefined;
|
|
165
|
+
private readonly ffmpegPath: string;
|
|
166
|
+
private readonly timeoutMs: number;
|
|
167
|
+
private readonly spawn: SpawnRunner;
|
|
168
|
+
private readonly existsImpl: (p: string) => boolean;
|
|
169
|
+
private readonly tmpDir: string;
|
|
170
|
+
/** Once available, stays available for this process — avoids re-statting. */
|
|
171
|
+
private cachedAvailable = false;
|
|
172
|
+
|
|
173
|
+
constructor(opts: TranscribeCppProviderOpts = {}) {
|
|
174
|
+
this.binPath = opts.binPath;
|
|
175
|
+
this.modelPath = opts.modelPath;
|
|
176
|
+
this.ffmpegPath = opts.ffmpegPath ?? "ffmpeg";
|
|
177
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
178
|
+
this.spawn = opts.spawn ?? defaultSpawnRunner;
|
|
179
|
+
this.existsImpl = opts.existsImpl ?? existsSync;
|
|
180
|
+
this.tmpDir = opts.tmpDir ?? tmpdir();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Cheap readiness probe — a runnable `transcribe-cli` binary AND the model
|
|
185
|
+
* both present on disk. NEVER spawns (it gates the capability flag on every
|
|
186
|
+
* `/api/vault` GET). Memoizes the once-true result so repeated landing reads
|
|
187
|
+
* don't re-stat.
|
|
188
|
+
*
|
|
189
|
+
* The binary check is load-bearing for honesty: transcribe.cpp v0.1.1 ships a
|
|
190
|
+
* *library*, not a prebuilt CLI, so `transcription install` can leave the libs
|
|
191
|
+
* + model in place with no runnable CLI. `binPath` resolves to
|
|
192
|
+
* `TRANSCRIBE_CPP_BIN` or a located binary; when neither exists we report
|
|
193
|
+
* `ok:false` (capability `enabled:false`) rather than claiming a capability we
|
|
194
|
+
* can't deliver.
|
|
195
|
+
*/
|
|
196
|
+
async available(): Promise<ProviderAvailability> {
|
|
197
|
+
if (this.cachedAvailable) return { ok: true };
|
|
198
|
+
if (!this.binPath || !this.existsImpl(this.binPath)) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
reason:
|
|
202
|
+
"no runnable transcribe-cli (run `parachute-vault transcription install` to build one against the prebuilt libs — needs a C++ compiler — or set TRANSCRIBE_CPP_BIN; see `parachute-vault transcription status`)",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (!this.modelPath || !this.existsImpl(this.modelPath)) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
reason: "no GGUF model installed (run `parachute-vault transcription install`)",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
this.cachedAvailable = true;
|
|
212
|
+
return { ok: true };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async transcribe(input: TranscribeInput): Promise<TranscribeResult> {
|
|
216
|
+
if (!this.binPath || !this.existsImpl(this.binPath)) {
|
|
217
|
+
throw new TranscriptionError("transcribe-cli not installed", {
|
|
218
|
+
code: "missing_provider",
|
|
219
|
+
retriable: false,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (!this.modelPath || !this.existsImpl(this.modelPath)) {
|
|
223
|
+
throw new TranscriptionError("no GGUF model installed for transcribe-cpp", {
|
|
224
|
+
code: "missing_provider",
|
|
225
|
+
retriable: false,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const id = randomUUID();
|
|
230
|
+
const inExt = extFor(input.filename, input.mimeType);
|
|
231
|
+
const inputPath = join(this.tmpDir, `parachute-tc-${id}.${inExt}`);
|
|
232
|
+
const wavPath = join(this.tmpDir, `parachute-tc-${id}.16k.wav`);
|
|
233
|
+
const cleanup: string[] = [inputPath, wavPath];
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
await writeFile(inputPath, input.audio);
|
|
237
|
+
|
|
238
|
+
// transcribe-cli STRICTLY requires 16kHz mono WAV and rejects other sample
|
|
239
|
+
// rates (a 44.1kHz .wav exits non-zero). ALWAYS transcode — even an
|
|
240
|
+
// incoming .wav, whose sample rate we can't trust — via ffmpeg.
|
|
241
|
+
const ff = await this.spawn(buildFfmpegArgs(this.ffmpegPath, inputPath, wavPath), {
|
|
242
|
+
timeoutMs: this.timeoutMs,
|
|
243
|
+
});
|
|
244
|
+
if (ff.exitCode !== 0) {
|
|
245
|
+
const missing = ff.exitCode === 127;
|
|
246
|
+
throw new TranscriptionError(
|
|
247
|
+
missing
|
|
248
|
+
? "ffmpeg not found — install it to transcode audio to 16kHz mono WAV for transcribe-cpp (apt install ffmpeg / brew install ffmpeg)"
|
|
249
|
+
: `ffmpeg transcode failed (exit ${ff.exitCode}): ${ff.stderr.trim().slice(0, 500)}`,
|
|
250
|
+
{ code: missing ? "ffmpeg_missing" : "transcode_failed", retriable: false },
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const res = await this.spawn(buildTranscribeArgs(this.binPath, this.modelPath, wavPath), {
|
|
255
|
+
timeoutMs: this.timeoutMs,
|
|
256
|
+
});
|
|
257
|
+
if (res.exitCode !== 0) {
|
|
258
|
+
// Launch failure (127) means the binary vanished under us despite the
|
|
259
|
+
// existsSync above — terminal. Any other non-zero is retriable so a
|
|
260
|
+
// transient resource blip backs off; a deterministic failure still
|
|
261
|
+
// terminates after the worker's maxAttempts.
|
|
262
|
+
if (res.exitCode === 127) {
|
|
263
|
+
throw new TranscriptionError(`transcribe-cli could not be launched: ${res.stderr.trim()}`, {
|
|
264
|
+
code: "missing_provider",
|
|
265
|
+
retriable: false,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
throw new TranscriptionError(
|
|
269
|
+
`transcribe-cli exited ${res.exitCode}: ${res.stderr.trim().slice(0, 500)}`,
|
|
270
|
+
{ code: "transcribe_cli_error", retriable: true },
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const text = parseTranscribeCliOutput(res.stdout);
|
|
275
|
+
if (!text) {
|
|
276
|
+
// Empty/unparseable — plain Error is treated as retriable by the worker.
|
|
277
|
+
throw new Error("transcribe-cli produced no transcript text");
|
|
278
|
+
}
|
|
279
|
+
return { text };
|
|
280
|
+
} finally {
|
|
281
|
+
await Promise.all(cleanup.map((p) => unlink(p).catch(() => {})));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Choose a temp-file extension from the filename or mime type. */
|
|
287
|
+
function extFor(filename: string, mimeType: string): string {
|
|
288
|
+
const fromName = filename.split(".").pop();
|
|
289
|
+
if (fromName && fromName.length <= 5 && /^[a-z0-9]+$/i.test(fromName)) return fromName.toLowerCase();
|
|
290
|
+
const sub = mimeType.split("/")[1]?.split(";")[0]?.trim();
|
|
291
|
+
if (sub && /^[a-z0-9]+$/i.test(sub)) return sub.toLowerCase();
|
|
292
|
+
return "bin";
|
|
293
|
+
}
|