@openparachute/vault 0.6.5-rc.9 → 0.6.5
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/.parachute/module.json +1 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/mcp.ts +1 -1
- package/core/src/schema.ts +5 -5
- package/core/src/seed-packs.test.ts +222 -56
- package/core/src/seed-packs.ts +334 -70
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/types.ts +1 -1
- package/package.json +1 -1
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +317 -53
- package/src/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +201 -0
- package/src/module-manifest.ts +8 -0
- package/src/onboarding-seed.test.ts +82 -20
- package/src/onboarding-seed.ts +2 -2
- package/src/routes.ts +3 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +18 -6
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +56 -0
- package/src/services-manifest.ts +8 -0
- package/src/subscriptions.ts +100 -42
- package/src/tag-scope.ts +3 -3
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/token-store.ts +2 -2
- package/src/transcription/build.test.ts +86 -5
- package/src/transcription/build.ts +87 -7
- package/src/transcription/capability.test.ts +25 -0
- package/src/transcription/capability.ts +27 -2
- package/src/transcription/download.test.ts +101 -0
- package/src/transcription/download.ts +71 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -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/select.test.ts +166 -1
- package/src/transcription/select.ts +234 -1
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/vault-create.test.ts +19 -14
- package/src/vault.test.ts +1 -1
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -248
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import {
|
|
6
|
+
ParakeetMlxProvider,
|
|
7
|
+
buildParakeetMlxArgs,
|
|
8
|
+
looksLikeFfmpegMissing,
|
|
9
|
+
} from "./parakeet-mlx.ts";
|
|
10
|
+
import type { SpawnRunner, SubprocessResult } from "./transcribe-cpp.ts";
|
|
11
|
+
import { TranscriptionError } from "../../../core/src/transcription/provider.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Conformance tests for the `parakeet-mlx` local provider (scribe-fold
|
|
15
|
+
* Phase 2b). The subprocess is mocked so no real binary runs; the temp
|
|
16
|
+
* input/output files are REAL (in a per-test tmp dir) so the output-discovery
|
|
17
|
+
* logic — including scribe's proven "the tool may name the .txt after its own
|
|
18
|
+
* stem" fallback and the ffmpeg-missing exit-0 mask — is exercised for real.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const AUDIO = new Uint8Array([1, 2, 3, 4]);
|
|
22
|
+
|
|
23
|
+
const tmpDirs: string[] = [];
|
|
24
|
+
function freshTmp(): string {
|
|
25
|
+
const d = mkdtempSync(join(tmpdir(), "pk-test-"));
|
|
26
|
+
tmpDirs.push(d);
|
|
27
|
+
return d;
|
|
28
|
+
}
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** A spawn mock that records argv and lets the test act on the out dir. */
|
|
34
|
+
function recordingSpawn(
|
|
35
|
+
handler: (cmd: string[]) => SubprocessResult,
|
|
36
|
+
): { spawn: SpawnRunner; calls: string[][] } {
|
|
37
|
+
const calls: string[][] = [];
|
|
38
|
+
const spawn: SpawnRunner = async (cmd) => {
|
|
39
|
+
calls.push(cmd);
|
|
40
|
+
return handler(cmd);
|
|
41
|
+
};
|
|
42
|
+
return { spawn, calls };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Extract the --output-dir value from a recorded argv. */
|
|
46
|
+
function outDirOf(cmd: string[]): string {
|
|
47
|
+
return cmd[cmd.indexOf("--output-dir") + 1]!;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ok: SubprocessResult = { exitCode: 0, stdout: "", stderr: "" };
|
|
51
|
+
|
|
52
|
+
function provider(
|
|
53
|
+
spawn: SpawnRunner,
|
|
54
|
+
tmpDir: string,
|
|
55
|
+
opts: Partial<{ existsImpl: (p: string) => boolean; model: string; binPath: string | undefined }> = {},
|
|
56
|
+
) {
|
|
57
|
+
return new ParakeetMlxProvider({
|
|
58
|
+
binPath: "binPath" in opts ? opts.binPath : "/venv/bin/parakeet-mlx",
|
|
59
|
+
model: opts.model,
|
|
60
|
+
spawn,
|
|
61
|
+
tmpDir,
|
|
62
|
+
// Binary existence is stubbed; output-file discovery uses the REAL fs.
|
|
63
|
+
existsImpl: opts.existsImpl ?? ((p) => (p.includes("parakeet-mlx") ? true : existsSync(p))),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Pure helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe("buildParakeetMlxArgs", () => {
|
|
72
|
+
test("matches scribe's proven invocation: <bin> <audio> --output-format txt --output-dir <dir>", () => {
|
|
73
|
+
expect(buildParakeetMlxArgs("/bin/parakeet-mlx", "/a.webm", "/out")).toEqual([
|
|
74
|
+
"/bin/parakeet-mlx",
|
|
75
|
+
"/a.webm",
|
|
76
|
+
"--output-format",
|
|
77
|
+
"txt",
|
|
78
|
+
"--output-dir",
|
|
79
|
+
"/out",
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("appends --model when a model id is set", () => {
|
|
84
|
+
const args = buildParakeetMlxArgs("/bin/pk", "/a.wav", "/out", "mlx-community/parakeet-tdt-0.6b-v3");
|
|
85
|
+
expect(args).toContain("--model");
|
|
86
|
+
expect(args[args.indexOf("--model") + 1]).toBe("mlx-community/parakeet-tdt-0.6b-v3");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("looksLikeFfmpegMissing (ported from scribe backend-error.ts)", () => {
|
|
91
|
+
test("classic python ENOENT traceback", () => {
|
|
92
|
+
expect(looksLikeFfmpegMissing("[Errno 2] No such file or directory: 'ffmpeg'")).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
test("shell not-found phrasing", () => {
|
|
95
|
+
expect(looksLikeFfmpegMissing("ffmpeg: command not found")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
test("multi-line co-occurrence matches", () => {
|
|
98
|
+
expect(looksLikeFfmpegMissing("error decoding audio\nffmpeg is required\nnot installed on PATH")).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
test("ffmpeg mentioned without a missing phrasing → false", () => {
|
|
101
|
+
expect(looksLikeFfmpegMissing("ffmpeg version 6.0 Copyright")).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
test("empty output → false", () => {
|
|
104
|
+
expect(looksLikeFfmpegMissing("")).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// available() — cheap, no spawn
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
describe("ParakeetMlxProvider.available", () => {
|
|
113
|
+
test("name is stable 'parakeet-mlx'", () => {
|
|
114
|
+
expect(provider(recordingSpawn(() => ok).spawn, freshTmp()).name).toBe("parakeet-mlx");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("ok:true when the binary exists — and NEVER spawns", async () => {
|
|
118
|
+
const { spawn, calls } = recordingSpawn(() => ok);
|
|
119
|
+
const p = provider(spawn, freshTmp());
|
|
120
|
+
expect(await p.available()).toEqual({ ok: true });
|
|
121
|
+
expect(calls.length).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("ok:false with an actionable reason when the binary is missing", async () => {
|
|
125
|
+
const { spawn, calls } = recordingSpawn(() => ok);
|
|
126
|
+
const p = provider(spawn, freshTmp(), { existsImpl: () => false });
|
|
127
|
+
const avail = await p.available();
|
|
128
|
+
expect(avail.ok).toBe(false);
|
|
129
|
+
expect(avail.reason).toContain("transcription install");
|
|
130
|
+
expect(calls.length).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("unconfigured (no binPath) → ok:false, no throw", async () => {
|
|
134
|
+
const p = provider(recordingSpawn(() => ok).spawn, freshTmp(), { binPath: undefined });
|
|
135
|
+
expect((await p.available()).ok).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// transcribe() — happy paths
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
describe("ParakeetMlxProvider.transcribe — happy path", () => {
|
|
144
|
+
test("hands the raw audio file over (no pre-transcode) and reads <stem>.txt", async () => {
|
|
145
|
+
const tmp = freshTmp();
|
|
146
|
+
const { spawn, calls } = recordingSpawn((cmd) => {
|
|
147
|
+
// Simulate the tool writing the transcript named after the input stem.
|
|
148
|
+
const outDir = outDirOf(cmd);
|
|
149
|
+
const stem = cmd[1]!.split("/").pop()!.replace(/\.webm$/, "");
|
|
150
|
+
writeFileSync(join(outDir, `${stem}.txt`), "hello from parakeet\n");
|
|
151
|
+
return ok;
|
|
152
|
+
});
|
|
153
|
+
const p = provider(spawn, tmp);
|
|
154
|
+
const res = await p.transcribe({ audio: AUDIO, filename: "memo.webm", mimeType: "audio/webm" });
|
|
155
|
+
|
|
156
|
+
expect(res.text).toBe("hello from parakeet");
|
|
157
|
+
// Exactly ONE spawn — parakeet-mlx decodes internally; no ffmpeg pre-pass.
|
|
158
|
+
expect(calls.length).toBe(1);
|
|
159
|
+
expect(calls[0]![0]).toBe("/venv/bin/parakeet-mlx");
|
|
160
|
+
expect(calls[0]![1]).toMatch(/\.webm$/); // the raw input, not a transcoded wav
|
|
161
|
+
expect(calls[0]).toContain("--output-format");
|
|
162
|
+
expect(calls[0]).toContain("txt");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("falls back to any .txt when the tool uses its own output stem", async () => {
|
|
166
|
+
const tmp = freshTmp();
|
|
167
|
+
const { spawn } = recordingSpawn((cmd) => {
|
|
168
|
+
writeFileSync(join(outDirOf(cmd), "some-other-stem.txt"), " fallback transcript ");
|
|
169
|
+
return ok;
|
|
170
|
+
});
|
|
171
|
+
const p = provider(spawn, tmp);
|
|
172
|
+
const res = await p.transcribe({ audio: AUDIO, filename: "a.m4a", mimeType: "audio/mp4" });
|
|
173
|
+
expect(res.text).toBe("fallback transcript");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("passes --model when configured", async () => {
|
|
177
|
+
const tmp = freshTmp();
|
|
178
|
+
const { spawn, calls } = recordingSpawn((cmd) => {
|
|
179
|
+
writeFileSync(join(outDirOf(cmd), "x.txt"), "t");
|
|
180
|
+
return ok;
|
|
181
|
+
});
|
|
182
|
+
const p = provider(spawn, tmp, { model: "mlx-community/parakeet-tdt-0.6b-v3" });
|
|
183
|
+
await p.transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" });
|
|
184
|
+
expect(calls[0]).toContain("--model");
|
|
185
|
+
expect(calls[0]).toContain("mlx-community/parakeet-tdt-0.6b-v3");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("cleans up the temp input + output dir afterwards", async () => {
|
|
189
|
+
const tmp = freshTmp();
|
|
190
|
+
let seenOutDir = "";
|
|
191
|
+
const { spawn, calls } = recordingSpawn((cmd) => {
|
|
192
|
+
seenOutDir = outDirOf(cmd);
|
|
193
|
+
writeFileSync(join(seenOutDir, "x.txt"), "t");
|
|
194
|
+
return ok;
|
|
195
|
+
});
|
|
196
|
+
await provider(spawn, tmp).transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" });
|
|
197
|
+
expect(existsSync(calls[0]![1]!)).toBe(false);
|
|
198
|
+
expect(existsSync(seenOutDir)).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// transcribe() — error mapping
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
describe("ParakeetMlxProvider.transcribe — error mapping", () => {
|
|
207
|
+
async function catchErr(fn: () => Promise<unknown>): Promise<unknown> {
|
|
208
|
+
try {
|
|
209
|
+
await fn();
|
|
210
|
+
} catch (err) {
|
|
211
|
+
return err;
|
|
212
|
+
}
|
|
213
|
+
throw new Error("expected transcribe() to throw");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
test("binary missing → non-retriable missing_provider, NEVER spawns", async () => {
|
|
217
|
+
const { spawn, calls } = recordingSpawn(() => ok);
|
|
218
|
+
const p = provider(spawn, freshTmp(), { existsImpl: () => false });
|
|
219
|
+
const err = (await catchErr(() =>
|
|
220
|
+
p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
|
|
221
|
+
)) as TranscriptionError;
|
|
222
|
+
expect(err).toBeInstanceOf(TranscriptionError);
|
|
223
|
+
expect(err.code).toBe("missing_provider");
|
|
224
|
+
expect(err.retriable).toBe(false);
|
|
225
|
+
expect(calls.length).toBe(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("launch failure (127) → non-retriable missing_provider", async () => {
|
|
229
|
+
const { spawn } = recordingSpawn(() => ({ exitCode: 127, stdout: "", stderr: "gone" }));
|
|
230
|
+
const p = provider(spawn, freshTmp());
|
|
231
|
+
const err = (await catchErr(() =>
|
|
232
|
+
p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
|
|
233
|
+
)) as TranscriptionError;
|
|
234
|
+
expect(err.code).toBe("missing_provider");
|
|
235
|
+
expect(err.retriable).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("non-zero exit → RETRIABLE parakeet_mlx_error", async () => {
|
|
239
|
+
const { spawn } = recordingSpawn(() => ({ exitCode: 1, stdout: "", stderr: "boom" }));
|
|
240
|
+
const p = provider(spawn, freshTmp());
|
|
241
|
+
const err = (await catchErr(() =>
|
|
242
|
+
p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
|
|
243
|
+
)) as TranscriptionError;
|
|
244
|
+
expect(err).toBeInstanceOf(TranscriptionError);
|
|
245
|
+
expect(err.code).toBe("parakeet_mlx_error");
|
|
246
|
+
expect(err.retriable).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("exit 0 + no output + ffmpeg signature → non-retriable ffmpeg_missing (the scribe mask)", async () => {
|
|
250
|
+
// parakeet-mlx shells to ffmpeg internally; when ffmpeg is missing it
|
|
251
|
+
// prints the error then EXITS 0 with no output file. The signature in the
|
|
252
|
+
// combined output is the only signal.
|
|
253
|
+
const { spawn } = recordingSpawn(() => ({
|
|
254
|
+
exitCode: 0,
|
|
255
|
+
stdout: "",
|
|
256
|
+
stderr: "[Errno 2] No such file or directory: 'ffmpeg'",
|
|
257
|
+
}));
|
|
258
|
+
const p = provider(spawn, freshTmp());
|
|
259
|
+
const err = (await catchErr(() =>
|
|
260
|
+
p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
|
|
261
|
+
)) as TranscriptionError;
|
|
262
|
+
expect(err).toBeInstanceOf(TranscriptionError);
|
|
263
|
+
expect(err.code).toBe("ffmpeg_missing");
|
|
264
|
+
expect(err.retriable).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("exit 0 + no output, no ffmpeg signature → plain Error (retriable via worker)", async () => {
|
|
268
|
+
const { spawn } = recordingSpawn(() => ({ exitCode: 0, stdout: "done?", stderr: "" }));
|
|
269
|
+
const p = provider(spawn, freshTmp());
|
|
270
|
+
const err = (await catchErr(() =>
|
|
271
|
+
p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
|
|
272
|
+
)) as Error;
|
|
273
|
+
expect(err).toBeInstanceOf(Error);
|
|
274
|
+
expect(err).not.toBeInstanceOf(TranscriptionError);
|
|
275
|
+
expect(err.message).toContain("no transcript output");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("empty transcript file → plain Error (retriable via worker)", async () => {
|
|
279
|
+
const { spawn } = recordingSpawn((cmd) => {
|
|
280
|
+
writeFileSync(join(outDirOf(cmd), "x.txt"), " \n ");
|
|
281
|
+
return ok;
|
|
282
|
+
});
|
|
283
|
+
const p = provider(spawn, freshTmp());
|
|
284
|
+
const err = (await catchErr(() =>
|
|
285
|
+
p.transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" }),
|
|
286
|
+
)) as Error;
|
|
287
|
+
expect(err).not.toBeInstanceOf(TranscriptionError);
|
|
288
|
+
expect(err.message).toContain("no transcript text");
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parakeet-mlx` — the local Apple-Silicon transcription provider
|
|
3
|
+
* (scribe-fold Phase 2b).
|
|
4
|
+
*
|
|
5
|
+
* Turns audio into text by subprocessing the `parakeet-mlx` CLI
|
|
6
|
+
* (github.com/senstella/parakeet-mlx — NVIDIA Parakeet on Apple's MLX,
|
|
7
|
+
* macOS/Apple-Silicon only). Ported from scribe's proven
|
|
8
|
+
* `src/transcribe/parakeet-mlx.ts`; structurally a sibling of the
|
|
9
|
+
* `transcribe-cpp` provider (same SpawnRunner seam, same terminal-vs-retriable
|
|
10
|
+
* error contract).
|
|
11
|
+
*
|
|
12
|
+
* ## The audio path
|
|
13
|
+
*
|
|
14
|
+
* `parakeet-mlx` decodes the input itself (shelling to ffmpeg internally for
|
|
15
|
+
* non-WAV audio), so unlike transcribe-cpp we do NOT pre-transcode — the raw
|
|
16
|
+
* capture bytes are written to a temp file and handed over directly:
|
|
17
|
+
*
|
|
18
|
+
* parakeet-mlx <audio> --output-format txt --output-dir <tmpout> [--model <id>]
|
|
19
|
+
*
|
|
20
|
+
* The transcript comes back as a `.txt` file in the output dir — named after
|
|
21
|
+
* the input file's stem, though some versions use their own stem, so we fall
|
|
22
|
+
* back to "any .txt in the dir" (scribe's proven fallback).
|
|
23
|
+
*
|
|
24
|
+
* ## The ffmpeg-missing mask (scribe's classic silent failure)
|
|
25
|
+
*
|
|
26
|
+
* parakeet-mlx shells to ffmpeg internally and, when ffmpeg is missing,
|
|
27
|
+
* prints the error then **exits 0** with no output file — the exitCode gate
|
|
28
|
+
* never fires. The signature in the combined stdout+stderr is the only
|
|
29
|
+
* signal, so "no output + ffmpeg-missing signature" maps to a terminal
|
|
30
|
+
* `ffmpeg_missing` error the operator can actually fix (ported from scribe's
|
|
31
|
+
* `looksLikeFfmpegMissing`).
|
|
32
|
+
*
|
|
33
|
+
* ## Error mapping (mirrors transcribe-cpp)
|
|
34
|
+
*
|
|
35
|
+
* - binary not installed → non-retriable `missing_provider` (never spawns).
|
|
36
|
+
* - launch failure (127) → non-retriable `missing_provider`.
|
|
37
|
+
* - non-zero exit → RETRIABLE `parakeet_mlx_error` (transient resource blip
|
|
38
|
+
* backs off; a deterministic failure still terminates after maxAttempts).
|
|
39
|
+
* - exit 0 + no output + ffmpeg signature → non-retriable `ffmpeg_missing`.
|
|
40
|
+
* - exit 0 + no output otherwise / empty transcript → plain `Error`
|
|
41
|
+
* (retriable via the worker).
|
|
42
|
+
*
|
|
43
|
+
* NOTE on first use: the model (~2.5GB for parakeet-tdt-0.6b-v3) downloads
|
|
44
|
+
* into the HuggingFace cache on the first transcription. A slow first run may
|
|
45
|
+
* hit the subprocess timeout; the download resumes on the worker's retry.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { randomUUID } from "crypto";
|
|
49
|
+
import { existsSync, mkdirSync, readdirSync } from "fs";
|
|
50
|
+
import { writeFile, readFile, rm } from "fs/promises";
|
|
51
|
+
import { tmpdir } from "os";
|
|
52
|
+
import { join } from "path";
|
|
53
|
+
import {
|
|
54
|
+
TranscriptionError,
|
|
55
|
+
type TranscriptionProvider,
|
|
56
|
+
type TranscribeInput,
|
|
57
|
+
type TranscribeResult,
|
|
58
|
+
type ProviderAvailability,
|
|
59
|
+
} from "../../../core/src/transcription/provider.ts";
|
|
60
|
+
import { defaultSpawnRunner, type SpawnRunner } from "./transcribe-cpp.ts";
|
|
61
|
+
|
|
62
|
+
/** Model download on first use + MLX inference can be slow; generous default. */
|
|
63
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Detect an ffmpeg-missing signature in a tool's combined stdout+stderr.
|
|
67
|
+
* Ported verbatim from scribe's `transcribe/backend-error.ts`: tolerant by
|
|
68
|
+
* design — requires the co-occurrence of an `ffmpeg` mention with a "not
|
|
69
|
+
* installed / not found / no such file" phrasing anywhere in the output
|
|
70
|
+
* (e.g. `[Errno 2] No such file or directory: 'ffmpeg'`).
|
|
71
|
+
*/
|
|
72
|
+
const FFMPEG_TOKEN = /ffmpeg/i;
|
|
73
|
+
const NOT_PRESENT = /not (?:installed|found)|no such file/i;
|
|
74
|
+
|
|
75
|
+
export function looksLikeFfmpegMissing(combinedOutput: string): boolean {
|
|
76
|
+
if (!combinedOutput) return false;
|
|
77
|
+
return FFMPEG_TOKEN.test(combinedOutput) && NOT_PRESENT.test(combinedOutput);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ParakeetMlxProviderOpts {
|
|
81
|
+
/** Path to the `parakeet-mlx` binary. Undefined ⇒ unavailable + terminal. */
|
|
82
|
+
binPath?: string;
|
|
83
|
+
/** HF model repo id passed as `--model`. Undefined ⇒ the CLI's own default. */
|
|
84
|
+
model?: string;
|
|
85
|
+
/** Per-transcription timeout (ms). Default 600s. */
|
|
86
|
+
timeoutMs?: number;
|
|
87
|
+
/** Subprocess runner (tests inject a mock). Default `defaultSpawnRunner`. */
|
|
88
|
+
spawn?: SpawnRunner;
|
|
89
|
+
/** Existence probe (tests inject). Default `fs.existsSync`. */
|
|
90
|
+
existsImpl?: (p: string) => boolean;
|
|
91
|
+
/** Scratch dir for the temp audio + output files. Default `os.tmpdir()`. */
|
|
92
|
+
tmpDir?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the `parakeet-mlx` argv. Scribe's proven invocation:
|
|
97
|
+
* `parakeet-mlx <audio> --output-format txt --output-dir <dir>`
|
|
98
|
+
* plus an optional `--model <hf-repo>` (the ratified default is
|
|
99
|
+
* mlx-community/parakeet-tdt-0.6b-v3). Exported so a test pins the shape.
|
|
100
|
+
*/
|
|
101
|
+
export function buildParakeetMlxArgs(
|
|
102
|
+
binPath: string,
|
|
103
|
+
audioPath: string,
|
|
104
|
+
outDir: string,
|
|
105
|
+
model?: string,
|
|
106
|
+
): string[] {
|
|
107
|
+
const args = [binPath, audioPath, "--output-format", "txt", "--output-dir", outDir];
|
|
108
|
+
if (model) args.push("--model", model);
|
|
109
|
+
return args;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class ParakeetMlxProvider implements TranscriptionProvider {
|
|
113
|
+
readonly name = "parakeet-mlx";
|
|
114
|
+
|
|
115
|
+
private readonly binPath: string | undefined;
|
|
116
|
+
private readonly model: string | undefined;
|
|
117
|
+
private readonly timeoutMs: number;
|
|
118
|
+
private readonly spawn: SpawnRunner;
|
|
119
|
+
private readonly existsImpl: (p: string) => boolean;
|
|
120
|
+
private readonly tmpDir: string;
|
|
121
|
+
/** Once available, stays available for this process — avoids re-statting. */
|
|
122
|
+
private cachedAvailable = false;
|
|
123
|
+
|
|
124
|
+
constructor(opts: ParakeetMlxProviderOpts = {}) {
|
|
125
|
+
this.binPath = opts.binPath;
|
|
126
|
+
this.model = opts.model;
|
|
127
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
128
|
+
this.spawn = opts.spawn ?? defaultSpawnRunner;
|
|
129
|
+
this.existsImpl = opts.existsImpl ?? existsSync;
|
|
130
|
+
this.tmpDir = opts.tmpDir ?? tmpdir();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Cheap readiness probe — a runnable `parakeet-mlx` binary on disk. NEVER
|
|
135
|
+
* spawns (it gates the capability flag on every `/api/vault` GET). The
|
|
136
|
+
* MODEL is not checked: it lives in the HF cache and downloads on first
|
|
137
|
+
* use, so binary presence is the honest cheap signal. Memoizes the
|
|
138
|
+
* once-true result so repeated landing reads don't re-stat.
|
|
139
|
+
*/
|
|
140
|
+
async available(): Promise<ProviderAvailability> {
|
|
141
|
+
if (this.cachedAvailable) return { ok: true };
|
|
142
|
+
if (!this.binPath || !this.existsImpl(this.binPath)) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
reason:
|
|
146
|
+
"parakeet-mlx is not installed (run `parachute-vault transcription install` on this Mac, or set PARAKEET_MLX_BIN)",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
this.cachedAvailable = true;
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async transcribe(input: TranscribeInput): Promise<TranscribeResult> {
|
|
154
|
+
if (!this.binPath || !this.existsImpl(this.binPath)) {
|
|
155
|
+
throw new TranscriptionError("parakeet-mlx not installed", {
|
|
156
|
+
code: "missing_provider",
|
|
157
|
+
retriable: false,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const id = randomUUID();
|
|
162
|
+
const inExt = extFor(input.filename, input.mimeType);
|
|
163
|
+
const stem = `parachute-pk-${id}`;
|
|
164
|
+
const inputPath = join(this.tmpDir, `${stem}.${inExt}`);
|
|
165
|
+
const outDir = join(this.tmpDir, `${stem}-out`);
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await writeFile(inputPath, input.audio);
|
|
169
|
+
mkdirSync(outDir, { recursive: true });
|
|
170
|
+
|
|
171
|
+
const res = await this.spawn(
|
|
172
|
+
buildParakeetMlxArgs(this.binPath, inputPath, outDir, this.model),
|
|
173
|
+
{ timeoutMs: this.timeoutMs },
|
|
174
|
+
);
|
|
175
|
+
const combined = `${res.stdout}\n${res.stderr}`;
|
|
176
|
+
|
|
177
|
+
if (res.exitCode !== 0) {
|
|
178
|
+
if (res.exitCode === 127) {
|
|
179
|
+
throw new TranscriptionError(
|
|
180
|
+
`parakeet-mlx could not be launched: ${res.stderr.trim()}`,
|
|
181
|
+
{ code: "missing_provider", retriable: false },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
throw new TranscriptionError(
|
|
185
|
+
`parakeet-mlx exited ${res.exitCode}: ${res.stderr.trim().slice(0, 500)}`,
|
|
186
|
+
{ code: "parakeet_mlx_error", retriable: true },
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Find the transcript: <stem>.txt, else any .txt the tool wrote (some
|
|
191
|
+
// versions name outputs after their own stem — scribe's proven fallback).
|
|
192
|
+
const expected = join(outDir, `${stem}.txt`);
|
|
193
|
+
let outFile: string | undefined = this.existsImpl(expected) ? expected : undefined;
|
|
194
|
+
if (!outFile) {
|
|
195
|
+
const txts = safeListTxt(outDir);
|
|
196
|
+
if (txts.length > 0) outFile = join(outDir, txts[0]!);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!outFile) {
|
|
200
|
+
// No output AND exit 0 — the classic ffmpeg-missing mask. Promote to a
|
|
201
|
+
// terminal, actionable error instead of an opaque retry loop.
|
|
202
|
+
if (looksLikeFfmpegMissing(combined)) {
|
|
203
|
+
throw new TranscriptionError(
|
|
204
|
+
"parakeet-mlx needs `ffmpeg` to decode this audio, but ffmpeg isn't installed or isn't on the daemon's PATH (brew install ffmpeg)",
|
|
205
|
+
{ code: "ffmpeg_missing", retriable: false },
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
// Plain Error is treated as retriable by the worker.
|
|
209
|
+
throw new Error("parakeet-mlx produced no transcript output");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const text = (await readFile(outFile, "utf8")).trim();
|
|
213
|
+
if (!text) {
|
|
214
|
+
throw new Error("parakeet-mlx produced no transcript text");
|
|
215
|
+
}
|
|
216
|
+
return { text };
|
|
217
|
+
} finally {
|
|
218
|
+
await Promise.all([
|
|
219
|
+
rm(inputPath, { force: true }).catch(() => {}),
|
|
220
|
+
rm(outDir, { recursive: true, force: true }).catch(() => {}),
|
|
221
|
+
]);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** List `.txt` files in a dir, tolerating a dir the tool never created. */
|
|
227
|
+
function safeListTxt(dir: string): string[] {
|
|
228
|
+
try {
|
|
229
|
+
return (readdirSync(dir) as string[]).filter((f) => f.endsWith(".txt"));
|
|
230
|
+
} catch {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Choose a temp-file extension from the filename or mime type. */
|
|
236
|
+
function extFor(filename: string, mimeType: string): string {
|
|
237
|
+
const fromName = filename.split(".").pop();
|
|
238
|
+
if (fromName && fromName.length <= 5 && /^[a-z0-9]+$/i.test(fromName)) return fromName.toLowerCase();
|
|
239
|
+
const sub = mimeType.split("/")[1]?.split(";")[0]?.trim();
|
|
240
|
+
if (sub && /^[a-z0-9]+$/i.test(sub)) return sub.toLowerCase();
|
|
241
|
+
return "bin";
|
|
242
|
+
}
|