@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,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
|
+
});
|
|
@@ -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
|
+
}
|