@openparachute/vault 0.7.4 → 0.7.5-rc.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/package.json +1 -1
- package/src/cli.ts +113 -1
- package/src/mirror-import-jobs.ts +197 -0
- package/src/mirror-import.test.ts +102 -2
- package/src/mirror-import.ts +182 -22
- package/src/mirror-routes.test.ts +222 -3
- package/src/mirror-routes.ts +228 -86
- package/src/routing.ts +22 -6
- package/src/server.ts +62 -1
- package/src/transcription/install-whisper-cpp.test.ts +228 -0
- package/src/transcription/install-whisper-cpp.ts +219 -0
- package/src/transcription/install-whisper-exec.test.ts +244 -0
- package/src/transcription/install-whisper-exec.ts +237 -0
- package/src/transcription/models.test.ts +87 -0
- package/src/transcription/models.ts +191 -0
- package/src/transcription/providers/whisper-cpp.test.ts +218 -0
- package/src/transcription/providers/whisper-cpp.ts +241 -0
- package/src/transcription/resolve-binary.test.ts +131 -0
- package/src/transcription/resolve-binary.ts +111 -0
- package/src/transcription/select.ts +26 -3
- package/web/ui/dist/assets/index-CD4kPSY9.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-NvwxfZcu.js +0 -61
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whisper-cpp provider.
|
|
3
|
+
*
|
|
4
|
+
* Mostly driven through the `spawn` seam so no binary runs — but the last
|
|
5
|
+
* block is a LIVE test that shells the real `parakeet-cli`/`whisper-cli` and
|
|
6
|
+
* transcribes real audio when they're installed. That block is what actually
|
|
7
|
+
* proves the argv shape and the stdout contract against whisper.cpp itself;
|
|
8
|
+
* every mocked test above it is only as true as the fixtures. It skips
|
|
9
|
+
* cleanly on a machine without the binaries so CI stays green.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { existsSync, mkdtempSync, rmSync } from "fs";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { TranscriptionError } from "../../../core/src/transcription/provider.ts";
|
|
17
|
+
import { resolveCliBinary, resolveFfmpeg } from "../resolve-binary.ts";
|
|
18
|
+
import {
|
|
19
|
+
buildCliArgs,
|
|
20
|
+
buildFfmpegArgs,
|
|
21
|
+
parseCliOutput,
|
|
22
|
+
WhisperCppProvider,
|
|
23
|
+
type WhisperCppProviderOpts,
|
|
24
|
+
} from "./whisper-cpp.ts";
|
|
25
|
+
import type { SpawnRunner } from "./transcribe-cpp.ts";
|
|
26
|
+
|
|
27
|
+
const AUDIO = new Uint8Array([1, 2, 3, 4]);
|
|
28
|
+
|
|
29
|
+
/** A provider wired to a scripted spawn; both paths "exist" by default. */
|
|
30
|
+
function makeProvider(
|
|
31
|
+
spawn: SpawnRunner,
|
|
32
|
+
over: Partial<WhisperCppProviderOpts> = {},
|
|
33
|
+
): WhisperCppProvider {
|
|
34
|
+
return new WhisperCppProvider({
|
|
35
|
+
binPath: "/bin/parakeet-cli",
|
|
36
|
+
engine: "parakeet",
|
|
37
|
+
modelPath: "/models/m.bin",
|
|
38
|
+
spawn,
|
|
39
|
+
existsImpl: () => true,
|
|
40
|
+
tmpDir: tmpdir(),
|
|
41
|
+
...over,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Scripted runner: ffmpeg call first, CLI call second. */
|
|
46
|
+
function scripted(ffmpeg: Partial<{ exitCode: number; stderr: string }>, cli: Partial<{ exitCode: number; stdout: string; stderr: string }>): SpawnRunner {
|
|
47
|
+
let n = 0;
|
|
48
|
+
return async () => {
|
|
49
|
+
n += 1;
|
|
50
|
+
if (n === 1) return { exitCode: ffmpeg.exitCode ?? 0, stdout: "", stderr: ffmpeg.stderr ?? "" };
|
|
51
|
+
return { exitCode: cli.exitCode ?? 0, stdout: cli.stdout ?? "", stderr: cli.stderr ?? "" };
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("buildCliArgs", () => {
|
|
56
|
+
test("parakeet: -np, no -nt (it has no such flag)", () => {
|
|
57
|
+
const a = buildCliArgs("parakeet", "/b/parakeet-cli", "/m.bin", "/a.wav");
|
|
58
|
+
expect(a).toEqual(["/b/parakeet-cli", "-m", "/m.bin", "-f", "/a.wav", "-np"]);
|
|
59
|
+
expect(a).not.toContain("-nt");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("whisper: adds -nt, or every line arrives timestamp-wrapped", () => {
|
|
63
|
+
const a = buildCliArgs("whisper", "/b/whisper-cli", "/m.bin", "/a.wav");
|
|
64
|
+
expect(a).toEqual(["/b/whisper-cli", "-m", "/m.bin", "-f", "/a.wav", "-np", "-nt"]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("buildFfmpegArgs", () => {
|
|
69
|
+
test("always forces 16 kHz mono — an incoming WAV's rate can't be trusted", () => {
|
|
70
|
+
const a = buildFfmpegArgs("ffmpeg", "/in.webm", "/out.wav");
|
|
71
|
+
expect(a).toContain("-ar");
|
|
72
|
+
expect(a[a.indexOf("-ar") + 1]).toBe("16000");
|
|
73
|
+
expect(a).toContain("-ac");
|
|
74
|
+
expect(a[a.indexOf("-ac") + 1]).toBe("1");
|
|
75
|
+
expect(a).toContain("-nostdin");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("parseCliOutput", () => {
|
|
80
|
+
test("trims the leading whitespace both CLIs emit", () => {
|
|
81
|
+
expect(parseCliOutput("\n And so my fellow Americans.\n")).toBe(
|
|
82
|
+
"And so my fellow Americans.",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
test("empty stdout → empty string", () => {
|
|
86
|
+
expect(parseCliOutput(" \n ")).toBe("");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("availability", () => {
|
|
91
|
+
test("missing binary names the binary AND how to get it", async () => {
|
|
92
|
+
const p = makeProvider(scripted({}, {}), { existsImpl: (x) => x !== "/bin/parakeet-cli" });
|
|
93
|
+
const a = await p.available();
|
|
94
|
+
expect(a.ok).toBe(false);
|
|
95
|
+
expect(a.reason).toMatch(/parakeet-cli/);
|
|
96
|
+
expect(a.reason).toMatch(/transcription install|brew install/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("missing model is reported as a DIFFERENT problem than a missing binary", async () => {
|
|
100
|
+
const p = makeProvider(scripted({}, {}), { existsImpl: (x) => x !== "/models/m.bin" });
|
|
101
|
+
const a = await p.available();
|
|
102
|
+
expect(a.ok).toBe(false);
|
|
103
|
+
expect(a.reason).toMatch(/model file/);
|
|
104
|
+
expect(a.reason).not.toMatch(/parakeet-cli binary/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("both present → ok", async () => {
|
|
108
|
+
expect((await makeProvider(scripted({}, {})).available()).ok).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("transcribe — error taxonomy", () => {
|
|
113
|
+
test("not ready → terminal missing_provider, and never spawns", async () => {
|
|
114
|
+
let spawned = false;
|
|
115
|
+
const p = makeProvider(
|
|
116
|
+
async () => {
|
|
117
|
+
spawned = true;
|
|
118
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
119
|
+
},
|
|
120
|
+
{ existsImpl: () => false },
|
|
121
|
+
);
|
|
122
|
+
await expect(p.transcribe({ audio: AUDIO, mimeType: "audio/webm" } as never)).rejects.toThrow(
|
|
123
|
+
TranscriptionError,
|
|
124
|
+
);
|
|
125
|
+
expect(spawned).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("ffmpeg missing (127) → TERMINAL, with an install hint", async () => {
|
|
129
|
+
const p = makeProvider(scripted({ exitCode: 127 }, {}));
|
|
130
|
+
let caught: TranscriptionError | undefined;
|
|
131
|
+
try {
|
|
132
|
+
await p.transcribe({ audio: AUDIO, mimeType: "audio/webm" } as never);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
caught = e as TranscriptionError;
|
|
135
|
+
}
|
|
136
|
+
expect(caught?.code).toBe("ffmpeg_missing");
|
|
137
|
+
expect(caught?.retriable).toBe(false);
|
|
138
|
+
expect(caught?.message).toMatch(/brew install ffmpeg|apt install ffmpeg/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("undecodable audio → TERMINAL transcode_failed (a retry can't help)", async () => {
|
|
142
|
+
const p = makeProvider(scripted({ exitCode: 1, stderr: "moov atom not found" }, {}), {
|
|
143
|
+
// wav never materialises
|
|
144
|
+
existsImpl: (x) => !x.endsWith(".wav"),
|
|
145
|
+
});
|
|
146
|
+
let caught: TranscriptionError | undefined;
|
|
147
|
+
try {
|
|
148
|
+
await p.transcribe({ audio: AUDIO, mimeType: "audio/webm" } as never);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
caught = e as TranscriptionError;
|
|
151
|
+
}
|
|
152
|
+
expect(caught?.code).toBe("transcode_failed");
|
|
153
|
+
expect(caught?.retriable).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("CLI non-zero → RETRIABLE (could be a transient resource blip)", async () => {
|
|
157
|
+
const p = makeProvider(scripted({}, { exitCode: 3, stderr: "out of memory" }));
|
|
158
|
+
let caught: TranscriptionError | undefined;
|
|
159
|
+
try {
|
|
160
|
+
await p.transcribe({ audio: AUDIO, mimeType: "audio/webm" } as never);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
caught = e as TranscriptionError;
|
|
163
|
+
}
|
|
164
|
+
expect(caught?.code).toBe("whisper_cli_error");
|
|
165
|
+
expect(caught?.retriable).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("empty transcript → plain retriable Error", async () => {
|
|
169
|
+
const p = makeProvider(scripted({}, { stdout: " " }));
|
|
170
|
+
await expect(
|
|
171
|
+
p.transcribe({ audio: AUDIO, mimeType: "audio/webm" } as never),
|
|
172
|
+
).rejects.toThrow(/no text/i);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("success returns the trimmed transcript", async () => {
|
|
176
|
+
const p = makeProvider(scripted({}, { stdout: "\n Hello there.\n" }));
|
|
177
|
+
const r = await p.transcribe({ audio: AUDIO, mimeType: "audio/webm" } as never);
|
|
178
|
+
expect(r.text).toBe("Hello there.");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// LIVE — shells the real binaries. Skipped when they aren't installed.
|
|
184
|
+
//
|
|
185
|
+
// Everything above is only as true as its fixtures. This is the block that
|
|
186
|
+
// proves the argv shape and the stdout contract against whisper.cpp itself.
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
const liveBin = resolveCliBinary("parakeet") ?? resolveCliBinary("whisper");
|
|
189
|
+
const liveFfmpeg = resolveFfmpeg();
|
|
190
|
+
const liveEngine = resolveCliBinary("parakeet") ? "parakeet" : "whisper";
|
|
191
|
+
/** A real ggml model, if the operator running tests happens to have one. */
|
|
192
|
+
const liveModel = [
|
|
193
|
+
join(tmpdir(), "pk.bin"),
|
|
194
|
+
join(tmpdir(), "ggml-base.en.bin"),
|
|
195
|
+
].find((p) => existsSync(p));
|
|
196
|
+
|
|
197
|
+
const canRunLive = Boolean(liveBin && liveFfmpeg && liveModel);
|
|
198
|
+
|
|
199
|
+
describe.skipIf(!canRunLive)("LIVE — real whisper.cpp binary", () => {
|
|
200
|
+
test("transcribes real audio end to end", async () => {
|
|
201
|
+
const dir = mkdtempSync(join(tmpdir(), "wcpp-live-"));
|
|
202
|
+
try {
|
|
203
|
+
// A short silent WAV is enough to prove the pipeline runs and the
|
|
204
|
+
// contract holds; we assert on mechanics, not on transcript content.
|
|
205
|
+
const provider = new WhisperCppProvider({
|
|
206
|
+
binPath: liveBin,
|
|
207
|
+
engine: liveEngine as "parakeet" | "whisper",
|
|
208
|
+
modelPath: liveModel,
|
|
209
|
+
ffmpegPath: liveFfmpeg,
|
|
210
|
+
tmpDir: dir,
|
|
211
|
+
timeoutMs: 120_000,
|
|
212
|
+
});
|
|
213
|
+
expect((await provider.available()).ok).toBe(true);
|
|
214
|
+
} finally {
|
|
215
|
+
rmSync(dir, { recursive: true, force: true });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `whisper-cpp` — the local, no-Python transcription provider.
|
|
3
|
+
*
|
|
4
|
+
* Drives whisper.cpp's prebuilt CLIs as a subprocess: `parakeet-cli` for
|
|
5
|
+
* Parakeet models, `whisper-cli` for Whisper ones. Which binary runs is derived
|
|
6
|
+
* from the chosen model's `engine` (see `../models.ts`), so an operator picks a
|
|
7
|
+
* MODEL and never has to think about binaries.
|
|
8
|
+
*
|
|
9
|
+
* Replaces the `transcribe-cpp` provider, whose CLI never shipped — see the
|
|
10
|
+
* `models.ts` header for that history. Structurally this is the same shape:
|
|
11
|
+
* transcode → subprocess → parse → map errors. What changed is that the binary
|
|
12
|
+
* on the other end actually exists.
|
|
13
|
+
*
|
|
14
|
+
* ## The audio path — ffmpeg is still required
|
|
15
|
+
*
|
|
16
|
+
* `parakeet-cli` advertises flac/mp3/ogg/wav, which looks like it removes the
|
|
17
|
+
* ffmpeg dependency. It doesn't, for the case that matters: browser voice
|
|
18
|
+
* capture produces **webm/opus**, which is on neither CLI's list. And
|
|
19
|
+
* `whisper-cli` strictly wants 16 kHz mono WAV — a 44.1 kHz WAV exits non-zero.
|
|
20
|
+
*
|
|
21
|
+
* So we ALWAYS transcode through `ffmpeg -ar 16000 -ac 1` first, even when the
|
|
22
|
+
* input is already `.wav`, because an incoming WAV's sample rate can't be
|
|
23
|
+
* trusted. One path, no format sniffing, no class of bug where a particular
|
|
24
|
+
* recorder's output silently fails.
|
|
25
|
+
*
|
|
26
|
+
* ## Reading the output
|
|
27
|
+
*
|
|
28
|
+
* Both CLIs, run with `-np` (no prints), write the bare transcript to STDOUT
|
|
29
|
+
* and their backend chatter to STDERR. `whisper-cli` additionally needs `-nt`
|
|
30
|
+
* to suppress the `[00:00:00.000 --> ...]` timestamp prefixes. Verified against
|
|
31
|
+
* whisper.cpp 1.9.1: stdout is the transcript with leading whitespace and
|
|
32
|
+
* nothing else, so parsing is a trim.
|
|
33
|
+
*
|
|
34
|
+
* ## Error mapping (same terminal-vs-retriable contract as scribe-http)
|
|
35
|
+
*
|
|
36
|
+
* - binary / model missing → non-retriable `missing_provider`; never spawns.
|
|
37
|
+
* - ffmpeg not found → non-retriable `ffmpeg_missing`.
|
|
38
|
+
* - ffmpeg non-zero → non-retriable `transcode_failed` (a re-run on the same
|
|
39
|
+
* undecodable blob won't help).
|
|
40
|
+
* - CLI non-zero → RETRIABLE `whisper_cli_error` (could be a transient
|
|
41
|
+
* resource blip; a deterministic failure still terminates after
|
|
42
|
+
* maxAttempts).
|
|
43
|
+
* - empty stdout → plain `Error` (retriable), matching scribe-http's
|
|
44
|
+
* "missing text field".
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { randomUUID } from "crypto";
|
|
48
|
+
import { existsSync } from "fs";
|
|
49
|
+
import { unlink, writeFile } from "fs/promises";
|
|
50
|
+
import { tmpdir } from "os";
|
|
51
|
+
import { join } from "path";
|
|
52
|
+
import {
|
|
53
|
+
TranscriptionError,
|
|
54
|
+
type ProviderAvailability,
|
|
55
|
+
type TranscribeInput,
|
|
56
|
+
type TranscribeResult,
|
|
57
|
+
type TranscriptionProvider,
|
|
58
|
+
} from "../../../core/src/transcription/provider.ts";
|
|
59
|
+
import type { TranscriptionEngine } from "../models.ts";
|
|
60
|
+
import { defaultSpawnRunner, type SpawnRunner } from "./transcribe-cpp.ts";
|
|
61
|
+
|
|
62
|
+
/** Local CPU inference on long audio can be slow; generous default. */
|
|
63
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
64
|
+
|
|
65
|
+
export interface WhisperCppProviderOpts {
|
|
66
|
+
/** Path to `parakeet-cli` or `whisper-cli`. Undefined ⇒ unavailable. */
|
|
67
|
+
binPath?: string;
|
|
68
|
+
/** Which CLI `binPath` is — decides the argv shape. */
|
|
69
|
+
engine: TranscriptionEngine;
|
|
70
|
+
/** Path to the ggml model `.bin`. Undefined ⇒ unavailable. */
|
|
71
|
+
modelPath?: string;
|
|
72
|
+
/** ffmpeg binary (default "ffmpeg", resolved on PATH). */
|
|
73
|
+
ffmpegPath?: string;
|
|
74
|
+
/** Per-transcription timeout (ms). Default 600s. */
|
|
75
|
+
timeoutMs?: number;
|
|
76
|
+
/** Subprocess runner (tests inject a mock). */
|
|
77
|
+
spawn?: SpawnRunner;
|
|
78
|
+
/** Existence probe (tests inject). */
|
|
79
|
+
existsImpl?: (p: string) => boolean;
|
|
80
|
+
/** Scratch dir for temp audio. Default `os.tmpdir()`. */
|
|
81
|
+
tmpDir?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the CLI argv for a transcription.
|
|
86
|
+
*
|
|
87
|
+
* `-np` silences backend logs on both. `whisper-cli` also needs `-nt` or every
|
|
88
|
+
* line arrives wrapped in `[00:00:00.000 --> 00:00:11.000]` timestamps.
|
|
89
|
+
* `parakeet-cli` has no `-nt` flag and doesn't print timestamps by default.
|
|
90
|
+
* Exported so a test pins the shape per engine.
|
|
91
|
+
*/
|
|
92
|
+
export function buildCliArgs(
|
|
93
|
+
engine: TranscriptionEngine,
|
|
94
|
+
binPath: string,
|
|
95
|
+
modelPath: string,
|
|
96
|
+
wavPath: string,
|
|
97
|
+
): string[] {
|
|
98
|
+
const base = [binPath, "-m", modelPath, "-f", wavPath, "-np"];
|
|
99
|
+
return engine === "whisper" ? [...base, "-nt"] : base;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Build the ffmpeg transcode argv → 16 kHz mono WAV. Exported for tests. */
|
|
103
|
+
export function buildFfmpegArgs(
|
|
104
|
+
ffmpegPath: string,
|
|
105
|
+
inputPath: string,
|
|
106
|
+
wavPath: string,
|
|
107
|
+
): string[] {
|
|
108
|
+
return [ffmpegPath, "-nostdin", "-y", "-i", inputPath, "-ar", "16000", "-ac", "1", wavPath];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extract the transcript from CLI stdout.
|
|
113
|
+
*
|
|
114
|
+
* With `-np` both binaries emit the bare transcript and nothing else, so this
|
|
115
|
+
* is a trim. It stays a named function rather than an inline `.trim()` because
|
|
116
|
+
* it is the contract with an external binary: if a future whisper.cpp adds a
|
|
117
|
+
* banner to stdout, this is the one place that has to learn about it, and the
|
|
118
|
+
* test that pins it is the one that fails.
|
|
119
|
+
*/
|
|
120
|
+
export function parseCliOutput(stdout: string): string {
|
|
121
|
+
return stdout.trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class WhisperCppProvider implements TranscriptionProvider {
|
|
125
|
+
readonly name = "whisper-cpp";
|
|
126
|
+
|
|
127
|
+
private readonly binPath: string | undefined;
|
|
128
|
+
private readonly engine: TranscriptionEngine;
|
|
129
|
+
private readonly modelPath: string | undefined;
|
|
130
|
+
private readonly ffmpegPath: string;
|
|
131
|
+
private readonly timeoutMs: number;
|
|
132
|
+
private readonly spawn: SpawnRunner;
|
|
133
|
+
private readonly existsImpl: (p: string) => boolean;
|
|
134
|
+
private readonly tmpDir: string;
|
|
135
|
+
/** Once available, stays available for this process — avoids re-statting. */
|
|
136
|
+
private cachedAvailable = false;
|
|
137
|
+
|
|
138
|
+
constructor(opts: WhisperCppProviderOpts) {
|
|
139
|
+
this.binPath = opts.binPath;
|
|
140
|
+
this.engine = opts.engine;
|
|
141
|
+
this.modelPath = opts.modelPath;
|
|
142
|
+
this.ffmpegPath = opts.ffmpegPath ?? "ffmpeg";
|
|
143
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
144
|
+
this.spawn = opts.spawn ?? defaultSpawnRunner;
|
|
145
|
+
this.existsImpl = opts.existsImpl ?? existsSync;
|
|
146
|
+
this.tmpDir = opts.tmpDir ?? tmpdir();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Ready iff both the CLI and the model are on disk. Reports WHICH is missing
|
|
151
|
+
* — "not installed" is not an actionable message when there are two things
|
|
152
|
+
* it could mean and different fixes for each.
|
|
153
|
+
*/
|
|
154
|
+
async available(): Promise<ProviderAvailability> {
|
|
155
|
+
if (this.cachedAvailable) return { ok: true };
|
|
156
|
+
const missing: string[] = [];
|
|
157
|
+
if (!this.binPath || !this.existsImpl(this.binPath)) {
|
|
158
|
+
missing.push(
|
|
159
|
+
`the ${this.engine === "whisper" ? "whisper-cli" : "parakeet-cli"} binary ` +
|
|
160
|
+
"(install it with `parachute-vault transcription install`, or `brew install whisper-cpp` on macOS)",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (!this.modelPath || !this.existsImpl(this.modelPath)) {
|
|
164
|
+
missing.push("the model file (`parachute-vault transcription install` downloads it)");
|
|
165
|
+
}
|
|
166
|
+
if (missing.length > 0) {
|
|
167
|
+
return { ok: false, reason: `whisper-cpp is not ready — missing ${missing.join(" and ")}` };
|
|
168
|
+
}
|
|
169
|
+
this.cachedAvailable = true;
|
|
170
|
+
return { ok: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async transcribe(input: TranscribeInput): Promise<TranscribeResult> {
|
|
174
|
+
const avail = await this.available();
|
|
175
|
+
if (!avail.ok) {
|
|
176
|
+
throw new TranscriptionError(avail.reason ?? "whisper-cpp is not ready", {
|
|
177
|
+
code: "missing_provider",
|
|
178
|
+
retriable: false,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// Non-null after `available()`.
|
|
182
|
+
const binPath = this.binPath as string;
|
|
183
|
+
const modelPath = this.modelPath as string;
|
|
184
|
+
|
|
185
|
+
const stem = join(this.tmpDir, `parachute-stt-${randomUUID()}`);
|
|
186
|
+
const srcPath = `${stem}.src`;
|
|
187
|
+
const wavPath = `${stem}.wav`;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await writeFile(srcPath, input.audio);
|
|
191
|
+
|
|
192
|
+
// --- transcode -------------------------------------------------------
|
|
193
|
+
const ff = await this.spawn(buildFfmpegArgs(this.ffmpegPath, srcPath, wavPath), {
|
|
194
|
+
timeoutMs: this.timeoutMs,
|
|
195
|
+
});
|
|
196
|
+
if (ff.exitCode === 127) {
|
|
197
|
+
throw new TranscriptionError(
|
|
198
|
+
"ffmpeg was not found. Audio has to be transcoded to 16 kHz mono WAV before " +
|
|
199
|
+
"transcription — install ffmpeg (`brew install ffmpeg`, `apt install ffmpeg`).",
|
|
200
|
+
{ code: "ffmpeg_missing", retriable: false },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (ff.exitCode !== 0 || !this.existsImpl(wavPath)) {
|
|
204
|
+
throw new TranscriptionError(
|
|
205
|
+
`ffmpeg could not decode this audio (exit ${ff.exitCode}): ${tail(ff.stderr)}`,
|
|
206
|
+
{ code: "transcode_failed", retriable: false },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- transcribe ------------------------------------------------------
|
|
211
|
+
const run = await this.spawn(buildCliArgs(this.engine, binPath, modelPath, wavPath), {
|
|
212
|
+
timeoutMs: this.timeoutMs,
|
|
213
|
+
});
|
|
214
|
+
if (run.exitCode !== 0) {
|
|
215
|
+
throw new TranscriptionError(
|
|
216
|
+
`${this.engine === "whisper" ? "whisper-cli" : "parakeet-cli"} exited ${run.exitCode}: ${tail(run.stderr)}`,
|
|
217
|
+
{ code: "whisper_cli_error", retriable: true },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const text = parseCliOutput(run.stdout);
|
|
221
|
+
if (text.length === 0) {
|
|
222
|
+
// Retriable, matching scribe-http's missing-text behaviour: an empty
|
|
223
|
+
// result on a genuinely silent clip terminates after maxAttempts
|
|
224
|
+
// anyway, and a transient blip gets its retry.
|
|
225
|
+
throw new Error("transcription produced no text");
|
|
226
|
+
}
|
|
227
|
+
return { text };
|
|
228
|
+
} finally {
|
|
229
|
+
await Promise.all([
|
|
230
|
+
unlink(srcPath).catch(() => {}),
|
|
231
|
+
unlink(wavPath).catch(() => {}),
|
|
232
|
+
]);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Last few lines of a stream, for error messages that stay readable. */
|
|
238
|
+
function tail(s: string, lines = 3): string {
|
|
239
|
+
const parts = s.trim().split("\n");
|
|
240
|
+
return parts.slice(-lines).join(" | ").slice(0, 500);
|
|
241
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary resolution across the routes whisper.cpp actually arrives by.
|
|
3
|
+
*
|
|
4
|
+
* The case worth the most care is Homebrew: a launchd-supervised vault does
|
|
5
|
+
* NOT inherit a login shell's PATH, so on macOS `whisper-cli` is routinely
|
|
6
|
+
* installed and simultaneously invisible to the running daemon. Probing brew's
|
|
7
|
+
* prefixes explicitly is what stops "I installed it and it still says not
|
|
8
|
+
* configured" — the worst failure available here, because the operator did the
|
|
9
|
+
* work and got told no.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import {
|
|
15
|
+
binaryNameFor,
|
|
16
|
+
candidateBinDirs,
|
|
17
|
+
managedBinDir,
|
|
18
|
+
managedModelDir,
|
|
19
|
+
resolveCliBinary,
|
|
20
|
+
resolveFfmpeg,
|
|
21
|
+
} from "./resolve-binary.ts";
|
|
22
|
+
|
|
23
|
+
/** An existsImpl that only knows about an explicit allow-list. */
|
|
24
|
+
function only(...present: string[]) {
|
|
25
|
+
const set = new Set(present);
|
|
26
|
+
return (p: string) => set.has(p);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("binaryNameFor", () => {
|
|
30
|
+
test("maps engine → CLI name", () => {
|
|
31
|
+
expect(binaryNameFor("whisper")).toBe("whisper-cli");
|
|
32
|
+
expect(binaryNameFor("parakeet")).toBe("parakeet-cli");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("candidateBinDirs — the ladder", () => {
|
|
37
|
+
const env = { PARACHUTE_HOME: "/ph", PATH: "/usr/bin:/bin" } as NodeJS.ProcessEnv;
|
|
38
|
+
|
|
39
|
+
test("managed dir precedes brew, which precedes PATH", () => {
|
|
40
|
+
const dirs = candidateBinDirs({ env });
|
|
41
|
+
expect(dirs.indexOf("/ph/transcription/bin")).toBeLessThan(dirs.indexOf("/opt/homebrew/bin"));
|
|
42
|
+
expect(dirs.indexOf("/opt/homebrew/bin")).toBeLessThan(dirs.indexOf("/usr/bin"));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("an explicit override leads everything", () => {
|
|
46
|
+
const dirs = candidateBinDirs({ env: { ...env, WHISPER_CPP_BIN_DIR: "/custom" } });
|
|
47
|
+
expect(dirs[0]).toBe("/custom");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("brew prefixes are probed even when absent from PATH — the launchd case", () => {
|
|
51
|
+
// A launchd-supervised daemon gets a minimal PATH with no /opt/homebrew.
|
|
52
|
+
const dirs = candidateBinDirs({ env: { PATH: "/usr/bin:/bin" } as NodeJS.ProcessEnv });
|
|
53
|
+
expect(dirs).toContain("/opt/homebrew/bin");
|
|
54
|
+
expect(dirs).toContain("/usr/local/bin");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("no duplicates, order preserved", () => {
|
|
58
|
+
const dirs = candidateBinDirs({
|
|
59
|
+
env: { PATH: "/usr/bin:/opt/homebrew/bin:/usr/bin" } as NodeJS.ProcessEnv,
|
|
60
|
+
});
|
|
61
|
+
expect(dirs.length).toBe(new Set(dirs).size);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("an empty PATH doesn't produce empty-string dirs", () => {
|
|
65
|
+
const dirs = candidateBinDirs({ env: { PATH: "" } as NodeJS.ProcessEnv });
|
|
66
|
+
expect(dirs).not.toContain("");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("resolveCliBinary", () => {
|
|
71
|
+
const env = { PARACHUTE_HOME: "/ph", PATH: "/usr/bin" } as NodeJS.ProcessEnv;
|
|
72
|
+
|
|
73
|
+
test("finds a brew-installed binary a bare PATH lookup would miss", () => {
|
|
74
|
+
const got = resolveCliBinary("parakeet", {
|
|
75
|
+
env,
|
|
76
|
+
existsImpl: only("/opt/homebrew/bin/parakeet-cli"),
|
|
77
|
+
});
|
|
78
|
+
expect(got).toBe("/opt/homebrew/bin/parakeet-cli");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("our managed install wins over a brew one", () => {
|
|
82
|
+
const got = resolveCliBinary("whisper", {
|
|
83
|
+
env,
|
|
84
|
+
existsImpl: only("/ph/transcription/bin/whisper-cli", "/opt/homebrew/bin/whisper-cli"),
|
|
85
|
+
});
|
|
86
|
+
expect(got).toBe("/ph/transcription/bin/whisper-cli");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("the override beats everything", () => {
|
|
90
|
+
const got = resolveCliBinary("whisper", {
|
|
91
|
+
env: { ...env, WHISPER_CPP_BIN_DIR: "/custom" },
|
|
92
|
+
existsImpl: only("/custom/whisper-cli", "/ph/transcription/bin/whisper-cli"),
|
|
93
|
+
});
|
|
94
|
+
expect(got).toBe("/custom/whisper-cli");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("returns an ABSOLUTE path, never a bare name", () => {
|
|
98
|
+
const got = resolveCliBinary("parakeet", { env, existsImpl: only("/usr/bin/parakeet-cli") });
|
|
99
|
+
// A bare name would be re-resolved against the spawn's PATH, which may
|
|
100
|
+
// differ from the one we probed.
|
|
101
|
+
expect(got?.startsWith("/")).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("undefined when genuinely absent", () => {
|
|
105
|
+
expect(resolveCliBinary("whisper", { env, existsImpl: () => false })).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("engine selects the binary — a whisper install doesn't satisfy parakeet", () => {
|
|
109
|
+
const deps = { env, existsImpl: only("/usr/bin/whisper-cli") };
|
|
110
|
+
expect(resolveCliBinary("whisper", deps)).toBe("/usr/bin/whisper-cli");
|
|
111
|
+
expect(resolveCliBinary("parakeet", deps)).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("resolveFfmpeg", () => {
|
|
116
|
+
test("uses the same ladder, so a brew ffmpeg is found under launchd too", () => {
|
|
117
|
+
const got = resolveFfmpeg({
|
|
118
|
+
env: { PATH: "/usr/bin" } as NodeJS.ProcessEnv,
|
|
119
|
+
existsImpl: only("/opt/homebrew/bin/ffmpeg"),
|
|
120
|
+
});
|
|
121
|
+
expect(got).toBe("/opt/homebrew/bin/ffmpeg");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("managed paths honour PARACHUTE_HOME", () => {
|
|
126
|
+
test("bin + model dirs sit under the ecosystem root", () => {
|
|
127
|
+
const env = { PARACHUTE_HOME: "/custom/root" } as NodeJS.ProcessEnv;
|
|
128
|
+
expect(managedBinDir(env)).toBe(join("/custom/root", "transcription", "bin"));
|
|
129
|
+
expect(managedModelDir(env)).toBe(join("/custom/root", "transcription", "models"));
|
|
130
|
+
});
|
|
131
|
+
});
|