@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,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `onnx-asr` — the local Linux-tier transcription provider
|
|
3
|
+
* (scribe-fold Phase 2b).
|
|
4
|
+
*
|
|
5
|
+
* Turns audio into text by subprocessing the `onnx-asr` CLI
|
|
6
|
+
* (github.com/istupakov/onnx-asr — ONNX Runtime ASR, CPU-friendly). Ported
|
|
7
|
+
* from scribe's proven `src/transcribe/onnx-asr.ts`; structurally a sibling
|
|
8
|
+
* of the `transcribe-cpp` provider (same SpawnRunner seam, same
|
|
9
|
+
* terminal-vs-retriable error contract). The ratified default model is
|
|
10
|
+
* `nemo-parakeet-tdt-0.6b-v3` (int8 ONNX, ~670MB, fetched into the HF cache
|
|
11
|
+
* on first use / warm-pulled at install).
|
|
12
|
+
*
|
|
13
|
+
* ## The audio path
|
|
14
|
+
*
|
|
15
|
+
* onnx-asr only accepts WAV, so we ALWAYS transcode through
|
|
16
|
+
* `ffmpeg -ar 16000 -ac 1 -c:a pcm_s16le` first. (Scribe passed a `.wav`
|
|
17
|
+
* input straight through; we transcode even those — the transcribe-cpp
|
|
18
|
+
* live-verify taught us an incoming WAV's sample rate can't be trusted, and
|
|
19
|
+
* re-encoding a WAV is harmless.) Then:
|
|
20
|
+
*
|
|
21
|
+
* onnx-asr <model> --vad silero <wav>
|
|
22
|
+
*
|
|
23
|
+
* `--vad silero` chunks audio longer than Parakeet's ~20s context window —
|
|
24
|
+
* required for meeting-length audio (scribe's proven flag).
|
|
25
|
+
*
|
|
26
|
+
* ## Reading the output
|
|
27
|
+
*
|
|
28
|
+
* With `--vad`, stdout is timestamped lines like `[ 0.0, 3.2]: text`; we
|
|
29
|
+
* strip the timestamps and join. Untimestamped output (short clips / future
|
|
30
|
+
* CLI changes) passes through unchanged. See `parseOnnxAsrOutput`.
|
|
31
|
+
*
|
|
32
|
+
* ## Error mapping (mirrors transcribe-cpp)
|
|
33
|
+
*
|
|
34
|
+
* - binary not installed → non-retriable `missing_provider` (never spawns).
|
|
35
|
+
* - ffmpeg not found (127) → non-retriable `ffmpeg_missing`.
|
|
36
|
+
* - ffmpeg transcode non-zero exit → non-retriable `transcode_failed`.
|
|
37
|
+
* - `onnx-asr` launch failure (127) → non-retriable `missing_provider`.
|
|
38
|
+
* - `onnx-asr` non-zero exit → RETRIABLE `onnx_asr_error`.
|
|
39
|
+
* - empty stdout → plain `Error` (retriable via the worker).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { randomUUID } from "crypto";
|
|
43
|
+
import { existsSync } from "fs";
|
|
44
|
+
import { writeFile, unlink } from "fs/promises";
|
|
45
|
+
import { tmpdir } from "os";
|
|
46
|
+
import { join } from "path";
|
|
47
|
+
import {
|
|
48
|
+
TranscriptionError,
|
|
49
|
+
type TranscriptionProvider,
|
|
50
|
+
type TranscribeInput,
|
|
51
|
+
type TranscribeResult,
|
|
52
|
+
type ProviderAvailability,
|
|
53
|
+
} from "../../../core/src/transcription/provider.ts";
|
|
54
|
+
import { defaultSpawnRunner, type SpawnRunner } from "./transcribe-cpp.ts";
|
|
55
|
+
import { DEFAULT_ONNX_ASR_MODEL } from "../select.ts";
|
|
56
|
+
|
|
57
|
+
/** First-use model download + CPU inference can be slow; generous default. */
|
|
58
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
59
|
+
|
|
60
|
+
export interface OnnxAsrProviderOpts {
|
|
61
|
+
/** Path to the `onnx-asr` binary. Undefined ⇒ unavailable + terminal. */
|
|
62
|
+
binPath?: string;
|
|
63
|
+
/** onnx-asr model id (default: the ratified parakeet-tdt-0.6b-v3). */
|
|
64
|
+
model?: string;
|
|
65
|
+
/** ffmpeg binary (default "ffmpeg"; resolved on PATH). */
|
|
66
|
+
ffmpegPath?: string;
|
|
67
|
+
/** Per-transcription timeout (ms). Default 600s. */
|
|
68
|
+
timeoutMs?: number;
|
|
69
|
+
/** Subprocess runner (tests inject a mock). Default `defaultSpawnRunner`. */
|
|
70
|
+
spawn?: SpawnRunner;
|
|
71
|
+
/** Existence probe (tests inject). Default `fs.existsSync`. */
|
|
72
|
+
existsImpl?: (p: string) => boolean;
|
|
73
|
+
/** Scratch dir for the temp audio/wav files. Default `os.tmpdir()`. */
|
|
74
|
+
tmpDir?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the `onnx-asr` argv. Scribe's proven invocation:
|
|
79
|
+
* `onnx-asr <model> --vad silero <wav>`
|
|
80
|
+
* Exported so a test pins the shape.
|
|
81
|
+
*/
|
|
82
|
+
export function buildOnnxAsrArgs(binPath: string, model: string, wavPath: string): string[] {
|
|
83
|
+
return [binPath, model, "--vad", "silero", wavPath];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the ffmpeg transcode argv → 16kHz mono PCM-s16le WAV (onnx-asr's
|
|
88
|
+
* input contract; `pcm_s16le` is scribe's proven codec pick). Exported for
|
|
89
|
+
* tests.
|
|
90
|
+
*/
|
|
91
|
+
export function buildOnnxFfmpegArgs(
|
|
92
|
+
ffmpegPath: string,
|
|
93
|
+
inputPath: string,
|
|
94
|
+
wavPath: string,
|
|
95
|
+
): string[] {
|
|
96
|
+
return [
|
|
97
|
+
ffmpegPath,
|
|
98
|
+
"-nostdin",
|
|
99
|
+
"-y",
|
|
100
|
+
"-i",
|
|
101
|
+
inputPath,
|
|
102
|
+
"-ar",
|
|
103
|
+
"16000",
|
|
104
|
+
"-ac",
|
|
105
|
+
"1",
|
|
106
|
+
"-c:a",
|
|
107
|
+
"pcm_s16le",
|
|
108
|
+
wavPath,
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Extract the transcript from onnx-asr's stdout. With `--vad silero` the
|
|
114
|
+
* output is timestamped lines (`[ 0.0, 3.2]: text`); we strip the
|
|
115
|
+
* timestamp prefix per line and join with spaces. Lines without the prefix
|
|
116
|
+
* pass through unchanged, so untimestamped output (short clips) still parses.
|
|
117
|
+
* Returns "" for empty/blank stdout. Exported for tests.
|
|
118
|
+
*/
|
|
119
|
+
export function parseOnnxAsrOutput(stdout: string): string {
|
|
120
|
+
const lines = stdout
|
|
121
|
+
.trim()
|
|
122
|
+
.split("\n")
|
|
123
|
+
.filter((l) => l.trim());
|
|
124
|
+
return lines
|
|
125
|
+
.map((l) => l.replace(/^\[\s*[\d.]+,\s*[\d.]+\]:\s*/, "").trim())
|
|
126
|
+
.filter((l) => l.length > 0)
|
|
127
|
+
.join(" ")
|
|
128
|
+
.trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class OnnxAsrProvider implements TranscriptionProvider {
|
|
132
|
+
readonly name = "onnx-asr";
|
|
133
|
+
|
|
134
|
+
private readonly binPath: string | undefined;
|
|
135
|
+
private readonly model: string;
|
|
136
|
+
private readonly ffmpegPath: string;
|
|
137
|
+
private readonly timeoutMs: number;
|
|
138
|
+
private readonly spawn: SpawnRunner;
|
|
139
|
+
private readonly existsImpl: (p: string) => boolean;
|
|
140
|
+
private readonly tmpDir: string;
|
|
141
|
+
/** Once available, stays available for this process — avoids re-statting. */
|
|
142
|
+
private cachedAvailable = false;
|
|
143
|
+
|
|
144
|
+
constructor(opts: OnnxAsrProviderOpts = {}) {
|
|
145
|
+
this.binPath = opts.binPath;
|
|
146
|
+
this.model = opts.model ?? DEFAULT_ONNX_ASR_MODEL;
|
|
147
|
+
this.ffmpegPath = opts.ffmpegPath ?? "ffmpeg";
|
|
148
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
149
|
+
this.spawn = opts.spawn ?? defaultSpawnRunner;
|
|
150
|
+
this.existsImpl = opts.existsImpl ?? existsSync;
|
|
151
|
+
this.tmpDir = opts.tmpDir ?? tmpdir();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Cheap readiness probe — a runnable `onnx-asr` binary on disk. NEVER
|
|
156
|
+
* spawns. The MODEL is not checked (HF cache, downloads on first use), so
|
|
157
|
+
* binary presence is the honest cheap signal. Memoizes the once-true result.
|
|
158
|
+
*/
|
|
159
|
+
async available(): Promise<ProviderAvailability> {
|
|
160
|
+
if (this.cachedAvailable) return { ok: true };
|
|
161
|
+
if (!this.binPath || !this.existsImpl(this.binPath)) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
reason:
|
|
165
|
+
"onnx-asr is not installed (run `parachute-vault transcription install`, or set ONNX_ASR_BIN)",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
this.cachedAvailable = true;
|
|
169
|
+
return { ok: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async transcribe(input: TranscribeInput): Promise<TranscribeResult> {
|
|
173
|
+
if (!this.binPath || !this.existsImpl(this.binPath)) {
|
|
174
|
+
throw new TranscriptionError("onnx-asr not installed", {
|
|
175
|
+
code: "missing_provider",
|
|
176
|
+
retriable: false,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const id = randomUUID();
|
|
181
|
+
const inExt = extFor(input.filename, input.mimeType);
|
|
182
|
+
const inputPath = join(this.tmpDir, `parachute-ox-${id}.${inExt}`);
|
|
183
|
+
const wavPath = join(this.tmpDir, `parachute-ox-${id}.16k.wav`);
|
|
184
|
+
const cleanup: string[] = [inputPath, wavPath];
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
await writeFile(inputPath, input.audio);
|
|
188
|
+
|
|
189
|
+
// ALWAYS transcode to 16kHz mono s16le WAV — even an incoming .wav,
|
|
190
|
+
// whose sample rate we can't trust (see the module header).
|
|
191
|
+
const ff = await this.spawn(buildOnnxFfmpegArgs(this.ffmpegPath, inputPath, wavPath), {
|
|
192
|
+
timeoutMs: this.timeoutMs,
|
|
193
|
+
});
|
|
194
|
+
if (ff.exitCode !== 0) {
|
|
195
|
+
const missing = ff.exitCode === 127;
|
|
196
|
+
throw new TranscriptionError(
|
|
197
|
+
missing
|
|
198
|
+
? "ffmpeg not found — install it to transcode audio to 16kHz mono WAV for onnx-asr (apt install ffmpeg / brew install ffmpeg)"
|
|
199
|
+
: `ffmpeg transcode failed (exit ${ff.exitCode}): ${ff.stderr.trim().slice(0, 500)}`,
|
|
200
|
+
{ code: missing ? "ffmpeg_missing" : "transcode_failed", retriable: false },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const res = await this.spawn(buildOnnxAsrArgs(this.binPath, this.model, wavPath), {
|
|
205
|
+
timeoutMs: this.timeoutMs,
|
|
206
|
+
});
|
|
207
|
+
if (res.exitCode !== 0) {
|
|
208
|
+
if (res.exitCode === 127) {
|
|
209
|
+
throw new TranscriptionError(`onnx-asr could not be launched: ${res.stderr.trim()}`, {
|
|
210
|
+
code: "missing_provider",
|
|
211
|
+
retriable: false,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
throw new TranscriptionError(
|
|
215
|
+
`onnx-asr exited ${res.exitCode}: ${res.stderr.trim().slice(0, 500)}`,
|
|
216
|
+
{ code: "onnx_asr_error", retriable: true },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const text = parseOnnxAsrOutput(res.stdout);
|
|
221
|
+
if (!text) {
|
|
222
|
+
// Empty/unparseable — plain Error is treated as retriable by the worker.
|
|
223
|
+
throw new Error("onnx-asr produced no transcript text");
|
|
224
|
+
}
|
|
225
|
+
return { text };
|
|
226
|
+
} finally {
|
|
227
|
+
await Promise.all(cleanup.map((p) => unlink(p).catch(() => {})));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Choose a temp-file extension from the filename or mime type. */
|
|
233
|
+
function extFor(filename: string, mimeType: string): string {
|
|
234
|
+
const fromName = filename.split(".").pop();
|
|
235
|
+
if (fromName && fromName.length <= 5 && /^[a-z0-9]+$/i.test(fromName)) return fromName.toLowerCase();
|
|
236
|
+
const sub = mimeType.split("/")[1]?.split(";")[0]?.trim();
|
|
237
|
+
if (sub && /^[a-z0-9]+$/i.test(sub)) return sub.toLowerCase();
|
|
238
|
+
return "bin";
|
|
239
|
+
}
|
|
@@ -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
|
+
});
|