@openparachute/vault 0.7.5-rc.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/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.ts +5 -1
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -175,6 +175,21 @@ import {
|
|
|
175
175
|
type CliBuildResult,
|
|
176
176
|
} from "./transcription/build.ts";
|
|
177
177
|
import { downloadTo } from "./transcription/download.ts";
|
|
178
|
+
import {
|
|
179
|
+
describeWhisperPlan,
|
|
180
|
+
planWhisperInstall,
|
|
181
|
+
} from "./transcription/install-whisper-cpp.ts";
|
|
182
|
+
import {
|
|
183
|
+
ensureBinaries,
|
|
184
|
+
ensureModel,
|
|
185
|
+
verifyTranscription,
|
|
186
|
+
} from "./transcription/install-whisper-exec.ts";
|
|
187
|
+
import {
|
|
188
|
+
binaryNameFor,
|
|
189
|
+
candidateBinDirs,
|
|
190
|
+
resolveCliBinary,
|
|
191
|
+
resolveFfmpeg,
|
|
192
|
+
} from "./transcription/resolve-binary.ts";
|
|
178
193
|
import { selectDefaultProvider, NOMINAL_SLACK_GB, type TierPlan } from "./transcription/tiers.ts";
|
|
179
194
|
import {
|
|
180
195
|
PYTHON_PROVIDERS,
|
|
@@ -3605,6 +3620,93 @@ function printTranscriptionPlan(plan: InstallPlan): void {
|
|
|
3605
3620
|
console.log(` ~${m.approxSizeMb}MB download, ~${m.approxRuntimeGb}GB peak RAM while transcribing`);
|
|
3606
3621
|
}
|
|
3607
3622
|
|
|
3623
|
+
|
|
3624
|
+
/**
|
|
3625
|
+
* `parachute-vault transcription install` — the whisper.cpp path.
|
|
3626
|
+
*
|
|
3627
|
+
* Plan → binaries → model → VERIFY → activate. The verify step is the
|
|
3628
|
+
* load-bearing one: the provider this replaces was activated by an install
|
|
3629
|
+
* that never checked whether what it configured could run, which is how
|
|
3630
|
+
* `TRANSCRIPTION_PROVIDER` came to point at a CLI that has never existed.
|
|
3631
|
+
* `TRANSCRIPTION_PROVIDER` flips only after a real CLI transcribes a real
|
|
3632
|
+
* file, so an install that reports success is one that works.
|
|
3633
|
+
*/
|
|
3634
|
+
async function runWhisperCppInstall(opts: {
|
|
3635
|
+
modelId?: string;
|
|
3636
|
+
dryRun: boolean;
|
|
3637
|
+
yes: boolean;
|
|
3638
|
+
}): Promise<void> {
|
|
3639
|
+
const plan = planWhisperInstall(opts.modelId, {
|
|
3640
|
+
resolveExisting: (engine) => resolveCliBinary(engine),
|
|
3641
|
+
});
|
|
3642
|
+
for (const line of describeWhisperPlan(plan)) console.log(line);
|
|
3643
|
+
|
|
3644
|
+
if (!plan.supported) {
|
|
3645
|
+
console.error("\nCan't install automatically on this host — see above.");
|
|
3646
|
+
process.exit(1);
|
|
3647
|
+
}
|
|
3648
|
+
if (opts.dryRun) {
|
|
3649
|
+
console.log("\n(dry run — nothing downloaded or changed)");
|
|
3650
|
+
return;
|
|
3651
|
+
}
|
|
3652
|
+
if (!opts.yes) {
|
|
3653
|
+
// Bun's global `confirm()` reads stdin. In a non-TTY — which is exactly
|
|
3654
|
+
// how the unified setup script and any CI invocation call this — it would
|
|
3655
|
+
// block forever, so require an explicit --yes there instead of hanging.
|
|
3656
|
+
if (!process.stdin.isTTY) {
|
|
3657
|
+
console.error(
|
|
3658
|
+
"\nNot a terminal — re-run with --yes to install without confirmation.",
|
|
3659
|
+
);
|
|
3660
|
+
process.exit(1);
|
|
3661
|
+
}
|
|
3662
|
+
if (!confirm("\nProceed?")) {
|
|
3663
|
+
console.log("Cancelled.");
|
|
3664
|
+
return;
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
const log = (l: string) => console.log(` ${l}`);
|
|
3669
|
+
|
|
3670
|
+
const bin = await ensureBinaries(plan, { log });
|
|
3671
|
+
console.log(`${bin.ok ? "✓" : "✗"} ${bin.message}`);
|
|
3672
|
+
if (!bin.ok) process.exit(1);
|
|
3673
|
+
|
|
3674
|
+
const model = await ensureModel(plan, { log });
|
|
3675
|
+
console.log(`${model.ok ? "✓" : "✗"} ${model.message}`);
|
|
3676
|
+
if (!model.ok) process.exit(1);
|
|
3677
|
+
|
|
3678
|
+
// Re-resolve AFTER install — a brew install just changed what's on disk.
|
|
3679
|
+
const binPath = resolveCliBinary(plan.model.engine);
|
|
3680
|
+
if (!binPath) {
|
|
3681
|
+
console.error(
|
|
3682
|
+
`✗ ${binaryNameFor(plan.model.engine)} still isn't resolvable after install. ` +
|
|
3683
|
+
`Searched: ${candidateBinDirs().slice(0, 5).join(", ")}`,
|
|
3684
|
+
);
|
|
3685
|
+
process.exit(1);
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
const verified = await verifyTranscription(plan, binPath, { log });
|
|
3689
|
+
console.log(`${verified.ok ? "✓" : "✗"} ${verified.message}`);
|
|
3690
|
+
if (!verified.ok) {
|
|
3691
|
+
console.error("\nNot activating — an install that can't transcribe isn't installed.");
|
|
3692
|
+
process.exit(1);
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
setEnvVar("TRANSCRIPTION_PROVIDER", "whisper-cpp");
|
|
3696
|
+
setEnvVar("TRANSCRIPTION_MODEL", plan.model.id);
|
|
3697
|
+
console.log(`\n✓ Activated: whisper-cpp with ${plan.model.label}.`);
|
|
3698
|
+
|
|
3699
|
+
if (!resolveFfmpeg()) {
|
|
3700
|
+
console.log(
|
|
3701
|
+
"\n! ffmpeg isn't installed. Audio has to be transcoded to 16 kHz mono WAV before\n" +
|
|
3702
|
+
" transcription, so voice memos will fail until you install it:\n" +
|
|
3703
|
+
" macOS: brew install ffmpeg\n" +
|
|
3704
|
+
" Debian: sudo apt install ffmpeg",
|
|
3705
|
+
);
|
|
3706
|
+
}
|
|
3707
|
+
console.log("\nRestart the vault to apply (`parachute restart vault`).");
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3608
3710
|
async function cmdTranscriptionInstall(args: string[]) {
|
|
3609
3711
|
const dryRun = args.includes("--dry-run") || args.includes("--plan");
|
|
3610
3712
|
const force = args.includes("--force");
|
|
@@ -3612,6 +3714,16 @@ async function cmdTranscriptionInstall(args: string[]) {
|
|
|
3612
3714
|
const overrideModel = takeArgValue(args, "--model").value;
|
|
3613
3715
|
const providerArg = takeArgValue(args, "--provider").value;
|
|
3614
3716
|
|
|
3717
|
+
// whisper-cpp is the local path now, and the DEFAULT when no --provider is
|
|
3718
|
+
// given. The legacy tier table below still serves an explicit
|
|
3719
|
+
// `--provider transcribe-cpp|parakeet-mlx|onnx-asr`, but nothing routes
|
|
3720
|
+
// there by default any more: transcribe-cpp's CLI has never shipped, and
|
|
3721
|
+
// the Python providers need a venv plus a multi-GB model.
|
|
3722
|
+
if (!providerArg || providerArg === "whisper-cpp") {
|
|
3723
|
+
await runWhisperCppInstall({ modelId: overrideModel, dryRun, yes });
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3615
3727
|
if (providerArg === "scribe-http") {
|
|
3616
3728
|
// Just flip config back to the remote provider — no download.
|
|
3617
3729
|
if (dryRun) {
|
|
@@ -3628,7 +3740,7 @@ async function cmdTranscriptionInstall(args: string[]) {
|
|
|
3628
3740
|
if (providerArg) {
|
|
3629
3741
|
if (!["transcribe-cpp", "parakeet-mlx", "onnx-asr"].includes(providerArg)) {
|
|
3630
3742
|
console.error(
|
|
3631
|
-
`Unknown --provider "${providerArg}". Valid: transcribe-cpp, parakeet-mlx, onnx-asr, scribe-http.`,
|
|
3743
|
+
`Unknown --provider "${providerArg}". Valid: whisper-cpp (default), transcribe-cpp, parakeet-mlx, onnx-asr, scribe-http.`,
|
|
3632
3744
|
);
|
|
3633
3745
|
process.exit(1);
|
|
3634
3746
|
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whisper.cpp install planner.
|
|
3
|
+
*
|
|
4
|
+
* Pure, so the whole host matrix is exercised without downloading anything.
|
|
5
|
+
* The cases that matter are the ones where a wrong answer is expensive: a
|
|
6
|
+
* platform with no prebuilt binaries must REFUSE with instructions rather than
|
|
7
|
+
* half-install, and an already-installed binary must short-circuit rather than
|
|
8
|
+
* triggering another package-manager round trip.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import { DEFAULT_MODEL_ID } from "./models.ts";
|
|
13
|
+
import {
|
|
14
|
+
describeWhisperPlan,
|
|
15
|
+
planBinaryStrategy,
|
|
16
|
+
planWhisperInstall,
|
|
17
|
+
WHISPER_CPP_VERSION,
|
|
18
|
+
} from "./install-whisper-cpp.ts";
|
|
19
|
+
|
|
20
|
+
const GB = 1024 * 1024 * 1024;
|
|
21
|
+
const env = { PARACHUTE_HOME: "/ph" } as NodeJS.ProcessEnv;
|
|
22
|
+
|
|
23
|
+
describe("planBinaryStrategy", () => {
|
|
24
|
+
test("macOS → Homebrew (upstream ships no macOS CLI tarball)", () => {
|
|
25
|
+
const s = planWhisperInstall(undefined, {
|
|
26
|
+
platform: "darwin",
|
|
27
|
+
arch: "arm64",
|
|
28
|
+
totalRamBytes: 16 * GB,
|
|
29
|
+
env,
|
|
30
|
+
}).binary;
|
|
31
|
+
expect(s.kind).toBe("homebrew");
|
|
32
|
+
expect(s.kind === "homebrew" && s.formula).toBe("whisper-cpp");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("macOS Intel also gets Homebrew — the formula covers both arches", () => {
|
|
36
|
+
const s = planWhisperInstall(undefined, {
|
|
37
|
+
platform: "darwin",
|
|
38
|
+
arch: "x64",
|
|
39
|
+
totalRamBytes: 16 * GB,
|
|
40
|
+
env,
|
|
41
|
+
}).binary;
|
|
42
|
+
expect(s.kind).toBe("homebrew");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("Linux x64 → the x64 release tarball, pinned to a version", () => {
|
|
46
|
+
const s = planWhisperInstall(undefined, {
|
|
47
|
+
platform: "linux",
|
|
48
|
+
arch: "x64",
|
|
49
|
+
totalRamBytes: 8 * GB,
|
|
50
|
+
env,
|
|
51
|
+
}).binary;
|
|
52
|
+
expect(s.kind).toBe("tarball");
|
|
53
|
+
if (s.kind === "tarball") {
|
|
54
|
+
expect(s.assetName).toBe("whisper-bin-ubuntu-x64.tar.gz");
|
|
55
|
+
expect(s.url).toContain(`v${WHISPER_CPP_VERSION}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("Linux arm64 → the arm64 tarball", () => {
|
|
60
|
+
const s = planWhisperInstall(undefined, {
|
|
61
|
+
platform: "linux",
|
|
62
|
+
arch: "arm64",
|
|
63
|
+
totalRamBytes: 8 * GB,
|
|
64
|
+
env,
|
|
65
|
+
}).binary;
|
|
66
|
+
expect(s.kind === "tarball" && s.assetName).toBe("whisper-bin-ubuntu-arm64.tar.gz");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("an exotic Linux arch REFUSES with a pointer at the override", () => {
|
|
70
|
+
const s = planWhisperInstall(undefined, {
|
|
71
|
+
platform: "linux",
|
|
72
|
+
arch: "riscv64",
|
|
73
|
+
totalRamBytes: 8 * GB,
|
|
74
|
+
env,
|
|
75
|
+
}).binary;
|
|
76
|
+
expect(s.kind).toBe("unsupported");
|
|
77
|
+
expect(s.kind === "unsupported" && s.reason).toMatch(/WHISPER_CPP_BIN_DIR/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("an unknown platform refuses rather than half-installing", () => {
|
|
81
|
+
const plan = planWhisperInstall(undefined, {
|
|
82
|
+
platform: "freebsd",
|
|
83
|
+
arch: "x64",
|
|
84
|
+
totalRamBytes: 8 * GB,
|
|
85
|
+
env,
|
|
86
|
+
});
|
|
87
|
+
expect(plan.supported).toBe(false);
|
|
88
|
+
expect(plan.binary.kind).toBe("unsupported");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("an already-installed binary short-circuits every package manager", () => {
|
|
92
|
+
const plan = planWhisperInstall(undefined, {
|
|
93
|
+
platform: "darwin",
|
|
94
|
+
arch: "arm64",
|
|
95
|
+
totalRamBytes: 16 * GB,
|
|
96
|
+
env,
|
|
97
|
+
resolveExisting: () => "/opt/homebrew/bin/parakeet-cli",
|
|
98
|
+
});
|
|
99
|
+
expect(plan.binary.kind).toBe("already-present");
|
|
100
|
+
expect(plan.supported).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("model selection", () => {
|
|
105
|
+
test("a capable box gets the recommended Parakeet", () => {
|
|
106
|
+
const p = planWhisperInstall(undefined, {
|
|
107
|
+
platform: "linux",
|
|
108
|
+
arch: "x64",
|
|
109
|
+
totalRamBytes: 8 * GB,
|
|
110
|
+
env,
|
|
111
|
+
});
|
|
112
|
+
expect(p.model.id).toBe(DEFAULT_MODEL_ID);
|
|
113
|
+
expect(p.modelExplicit).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a 2 GB VPS still gets Parakeet, not a step down to Whisper Tiny", () => {
|
|
117
|
+
// The common cheap-VPS tier. Parakeet q4 is 339 MB and comfortably fits;
|
|
118
|
+
// dropping to Whisper Tiny here would be a large accuracy loss for
|
|
119
|
+
// headroom the box didn't need.
|
|
120
|
+
const p = planWhisperInstall(undefined, {
|
|
121
|
+
platform: "linux",
|
|
122
|
+
arch: "x64",
|
|
123
|
+
totalRamBytes: 2 * GB,
|
|
124
|
+
env,
|
|
125
|
+
});
|
|
126
|
+
expect(p.model.engine).toBe("parakeet");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a 1 GB box steps down rather than swapping itself to death", () => {
|
|
130
|
+
const p = planWhisperInstall(undefined, {
|
|
131
|
+
platform: "linux",
|
|
132
|
+
arch: "x64",
|
|
133
|
+
totalRamBytes: 1 * GB,
|
|
134
|
+
env,
|
|
135
|
+
});
|
|
136
|
+
expect(p.model.sizeMb).toBeLessThan(150);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("an explicit model is honoured over the RAM pick, with a warning", () => {
|
|
140
|
+
const p = planWhisperInstall("whisper-large-v3-turbo", {
|
|
141
|
+
platform: "linux",
|
|
142
|
+
arch: "x64",
|
|
143
|
+
totalRamBytes: 2 * GB,
|
|
144
|
+
env,
|
|
145
|
+
});
|
|
146
|
+
expect(p.model.id).toBe("whisper-large-v3-turbo");
|
|
147
|
+
expect(p.modelExplicit).toBe(true);
|
|
148
|
+
// Honoured, but the operator is told what they're in for.
|
|
149
|
+
expect(p.ramWarning).toBeTruthy();
|
|
150
|
+
expect(p.ramWarning).toMatch(/may swap or run slowly/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("no warning when the box comfortably fits the model", () => {
|
|
154
|
+
const p = planWhisperInstall(undefined, {
|
|
155
|
+
platform: "linux",
|
|
156
|
+
arch: "x64",
|
|
157
|
+
totalRamBytes: 32 * GB,
|
|
158
|
+
env,
|
|
159
|
+
});
|
|
160
|
+
expect(p.ramWarning).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("an unknown model id is refused, NOT silently defaulted", () => {
|
|
164
|
+
// Silently installing something else would leave the operator convinced
|
|
165
|
+
// they're running a model they aren't.
|
|
166
|
+
const p = planWhisperInstall("whisper-enormous", {
|
|
167
|
+
platform: "linux",
|
|
168
|
+
arch: "x64",
|
|
169
|
+
totalRamBytes: 8 * GB,
|
|
170
|
+
env,
|
|
171
|
+
});
|
|
172
|
+
expect(p.supported).toBe(false);
|
|
173
|
+
expect(p.binary.kind === "unsupported" && p.binary.reason).toMatch(/unknown model id/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("paths honour PARACHUTE_HOME", () => {
|
|
177
|
+
const p = planWhisperInstall(undefined, {
|
|
178
|
+
platform: "linux",
|
|
179
|
+
arch: "x64",
|
|
180
|
+
totalRamBytes: 8 * GB,
|
|
181
|
+
env,
|
|
182
|
+
});
|
|
183
|
+
expect(p.modelPath.startsWith("/ph/transcription/models/")).toBe(true);
|
|
184
|
+
expect(p.binDir).toBe("/ph/transcription/bin");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("describeWhisperPlan", () => {
|
|
189
|
+
test("names the model, its size, and where it goes", () => {
|
|
190
|
+
const out = describeWhisperPlan(
|
|
191
|
+
planWhisperInstall(undefined, {
|
|
192
|
+
platform: "linux",
|
|
193
|
+
arch: "x64",
|
|
194
|
+
totalRamBytes: 8 * GB,
|
|
195
|
+
env,
|
|
196
|
+
}),
|
|
197
|
+
).join("\n");
|
|
198
|
+
expect(out).toMatch(/Parakeet/);
|
|
199
|
+
expect(out).toMatch(/396 MB/);
|
|
200
|
+
expect(out).toMatch(/\/ph\/transcription\/models/);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("an unsupported host explains itself rather than printing a plan", () => {
|
|
204
|
+
const out = describeWhisperPlan(
|
|
205
|
+
planWhisperInstall(undefined, {
|
|
206
|
+
platform: "freebsd",
|
|
207
|
+
arch: "x64",
|
|
208
|
+
totalRamBytes: 8 * GB,
|
|
209
|
+
env,
|
|
210
|
+
}),
|
|
211
|
+
).join("\n");
|
|
212
|
+
expect(out).toMatch(/UNSUPPORTED/);
|
|
213
|
+
expect(out).toMatch(/WHISPER_CPP_BIN_DIR/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("the macOS line tells you what brew actually gives you", () => {
|
|
217
|
+
const out = describeWhisperPlan(
|
|
218
|
+
planWhisperInstall(undefined, {
|
|
219
|
+
platform: "darwin",
|
|
220
|
+
arch: "arm64",
|
|
221
|
+
totalRamBytes: 16 * GB,
|
|
222
|
+
env,
|
|
223
|
+
}),
|
|
224
|
+
).join("\n");
|
|
225
|
+
expect(out).toMatch(/brew install whisper-cpp/);
|
|
226
|
+
expect(out).toMatch(/whisper-cli \+ parakeet-cli/);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parachute-vault transcription install` for the whisper.cpp provider.
|
|
3
|
+
*
|
|
4
|
+
* Split into a PURE planner and a thin executor. The planner takes the host's
|
|
5
|
+
* platform/arch/RAM and the operator's flags and answers "what would happen" —
|
|
6
|
+
* no network, no writes — so `--dry-run` and the tests exercise the whole
|
|
7
|
+
* decision matrix without downloading a byte.
|
|
8
|
+
*
|
|
9
|
+
* ## How the binaries arrive, per platform
|
|
10
|
+
*
|
|
11
|
+
* **macOS** — Homebrew, and only Homebrew. whisper.cpp publishes release
|
|
12
|
+
* tarballs for Linux and Windows but NOT macOS (the macOS artifact is an
|
|
13
|
+
* `.xcframework`, which is for embedding in an app, not a CLI to spawn). The
|
|
14
|
+
* formula is bottled for arm64 Tahoe/Sequoia/Sonoma, so `brew install
|
|
15
|
+
* whisper-cpp` is a fast binary download rather than a source build. With no
|
|
16
|
+
* brew on the box we refuse and say so plainly — pretending to have another
|
|
17
|
+
* route would just fail later and more confusingly.
|
|
18
|
+
*
|
|
19
|
+
* **Linux** — the release tarball, extracted into our own managed directory
|
|
20
|
+
* (`<root>/transcription/bin`) rather than anywhere system-wide. The tarball
|
|
21
|
+
* carries `whisper-cli`, `parakeet-cli`, and the `libggml*`/`libwhisper` shared
|
|
22
|
+
* objects they link against, so the whole set moves together and we never
|
|
23
|
+
* fight a distro's package manager.
|
|
24
|
+
*
|
|
25
|
+
* ## Why it verifies before declaring success
|
|
26
|
+
*
|
|
27
|
+
* The provider this replaces was configured by an install verb that never
|
|
28
|
+
* checked whether the thing it configured could run — which is exactly how
|
|
29
|
+
* `TRANSCRIPTION_PROVIDER=transcribe-cpp` came to point at a CLI that has
|
|
30
|
+
* never existed. So the last step here actually SPAWNS the CLI against a
|
|
31
|
+
* generated test clip and reads the transcript back. `TRANSCRIPTION_PROVIDER`
|
|
32
|
+
* flips only after that passes. An install that can't transcribe is an install
|
|
33
|
+
* that failed, and it should say so at install time rather than silently at
|
|
34
|
+
* the first voice memo.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { totalmem } from "os";
|
|
38
|
+
import { join } from "path";
|
|
39
|
+
import {
|
|
40
|
+
DEFAULT_MODEL_ID,
|
|
41
|
+
findModel,
|
|
42
|
+
pickDefaultModel,
|
|
43
|
+
type TranscriptionModel,
|
|
44
|
+
} from "./models.ts";
|
|
45
|
+
import { binaryNameFor, managedBinDir, managedModelDir } from "./resolve-binary.ts";
|
|
46
|
+
|
|
47
|
+
/** whisper.cpp release the Linux tarballs are pulled from. */
|
|
48
|
+
export const WHISPER_CPP_VERSION = "1.9.1";
|
|
49
|
+
|
|
50
|
+
/** How the CLI binaries get onto this box. */
|
|
51
|
+
export type BinaryStrategy =
|
|
52
|
+
| { kind: "homebrew"; formula: string }
|
|
53
|
+
| { kind: "tarball"; url: string; assetName: string }
|
|
54
|
+
| { kind: "already-present"; path: string }
|
|
55
|
+
| { kind: "unsupported"; reason: string };
|
|
56
|
+
|
|
57
|
+
export interface WhisperInstallPlan {
|
|
58
|
+
platform: string;
|
|
59
|
+
arch: string;
|
|
60
|
+
totalRamGb: number;
|
|
61
|
+
/** The model to fetch + activate. */
|
|
62
|
+
model: TranscriptionModel;
|
|
63
|
+
/** Whether the operator named the model explicitly (skips the RAM warning). */
|
|
64
|
+
modelExplicit: boolean;
|
|
65
|
+
/** How to obtain `whisper-cli` / `parakeet-cli`. */
|
|
66
|
+
binary: BinaryStrategy;
|
|
67
|
+
/** Absolute destination for the model file. */
|
|
68
|
+
modelPath: string;
|
|
69
|
+
/** Directory binaries land in (tarball strategy) or are found in. */
|
|
70
|
+
binDir: string;
|
|
71
|
+
/** True when the plan can proceed. */
|
|
72
|
+
supported: boolean;
|
|
73
|
+
/** Set when the box has less RAM than the chosen model wants. */
|
|
74
|
+
ramWarning?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PlanDeps {
|
|
78
|
+
platform?: string;
|
|
79
|
+
arch?: string;
|
|
80
|
+
totalRamBytes?: number;
|
|
81
|
+
env?: NodeJS.ProcessEnv;
|
|
82
|
+
/** Existing binary probe — lets the plan report "already present". */
|
|
83
|
+
resolveExisting?: (engine: "whisper" | "parakeet") => string | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Decide how to get the binaries for this host.
|
|
88
|
+
*
|
|
89
|
+
* `already-present` short-circuits everything: an operator who ran `brew
|
|
90
|
+
* install whisper-cpp` themselves, or who is re-running install, shouldn't
|
|
91
|
+
* trigger another package-manager round trip.
|
|
92
|
+
*/
|
|
93
|
+
export function planBinaryStrategy(
|
|
94
|
+
model: TranscriptionModel,
|
|
95
|
+
deps: PlanDeps = {},
|
|
96
|
+
): BinaryStrategy {
|
|
97
|
+
const platform = deps.platform ?? process.platform;
|
|
98
|
+
const arch = deps.arch ?? process.arch;
|
|
99
|
+
|
|
100
|
+
const existing = deps.resolveExisting?.(model.engine);
|
|
101
|
+
if (existing) return { kind: "already-present", path: existing };
|
|
102
|
+
|
|
103
|
+
if (platform === "darwin") {
|
|
104
|
+
// No macOS CLI tarball exists upstream — the macOS artifact is an
|
|
105
|
+
// xcframework for embedding, not a spawnable binary. Homebrew is the path.
|
|
106
|
+
return { kind: "homebrew", formula: "whisper-cpp" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (platform === "linux") {
|
|
110
|
+
const assetArch = arch === "arm64" || arch === "aarch64" ? "arm64" : arch === "x64" ? "x64" : null;
|
|
111
|
+
if (!assetArch) {
|
|
112
|
+
return {
|
|
113
|
+
kind: "unsupported",
|
|
114
|
+
reason: `no prebuilt whisper.cpp binaries for linux/${arch} — build whisper.cpp yourself and point WHISPER_CPP_BIN_DIR at the result`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const assetName = `whisper-bin-ubuntu-${assetArch}.tar.gz`;
|
|
118
|
+
return {
|
|
119
|
+
kind: "tarball",
|
|
120
|
+
assetName,
|
|
121
|
+
url: `https://github.com/ggml-org/whisper.cpp/releases/download/v${WHISPER_CPP_VERSION}/${assetName}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
kind: "unsupported",
|
|
127
|
+
reason: `${platform} isn't supported for automatic install — install whisper.cpp yourself and point WHISPER_CPP_BIN_DIR at the directory holding ${binaryNameFor(model.engine)}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the full plan. Pure — safe to call for `--dry-run`.
|
|
133
|
+
*
|
|
134
|
+
* `modelId` undefined means "pick for this box": `pickDefaultModel` steps down
|
|
135
|
+
* from the recommended Parakeet on a machine that can't comfortably hold it.
|
|
136
|
+
* An explicitly-named model is honoured even when it's a stretch for the RAM,
|
|
137
|
+
* with a warning — the operator may know something we don't.
|
|
138
|
+
*/
|
|
139
|
+
export function planWhisperInstall(
|
|
140
|
+
modelId: string | undefined,
|
|
141
|
+
deps: PlanDeps = {},
|
|
142
|
+
): WhisperInstallPlan {
|
|
143
|
+
const platform = deps.platform ?? process.platform;
|
|
144
|
+
const arch = deps.arch ?? process.arch;
|
|
145
|
+
const totalRamBytes = deps.totalRamBytes ?? totalmem();
|
|
146
|
+
const totalRamMb = Math.round(totalRamBytes / 1024 / 1024);
|
|
147
|
+
const env = deps.env ?? process.env;
|
|
148
|
+
|
|
149
|
+
const explicit = modelId !== undefined;
|
|
150
|
+
const model = explicit
|
|
151
|
+
? findModel(modelId)
|
|
152
|
+
: pickDefaultModel(totalRamMb);
|
|
153
|
+
|
|
154
|
+
if (!model) {
|
|
155
|
+
// Unknown id — surface it as unsupported rather than silently defaulting,
|
|
156
|
+
// so a typo doesn't quietly install something else.
|
|
157
|
+
const fallback = findModel(DEFAULT_MODEL_ID)!;
|
|
158
|
+
return {
|
|
159
|
+
platform,
|
|
160
|
+
arch,
|
|
161
|
+
totalRamGb: Math.round((totalRamMb / 1024) * 10) / 10,
|
|
162
|
+
model: fallback,
|
|
163
|
+
modelExplicit: true,
|
|
164
|
+
binary: { kind: "unsupported", reason: `unknown model id "${modelId}"` },
|
|
165
|
+
modelPath: join(managedModelDir(env), fallback.filename),
|
|
166
|
+
binDir: managedBinDir(env),
|
|
167
|
+
supported: false,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const binary = planBinaryStrategy(model, deps);
|
|
172
|
+
const ramWarning =
|
|
173
|
+
totalRamMb < model.minRamMb
|
|
174
|
+
? `${model.label} wants about ${Math.round(model.minRamMb / 1024)} GB of RAM and this box reports ${Math.round((totalRamMb / 1024) * 10) / 10} GB — it may swap or run slowly.`
|
|
175
|
+
: undefined;
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
platform,
|
|
179
|
+
arch,
|
|
180
|
+
totalRamGb: Math.round((totalRamMb / 1024) * 10) / 10,
|
|
181
|
+
model,
|
|
182
|
+
modelExplicit: explicit,
|
|
183
|
+
binary,
|
|
184
|
+
modelPath: join(managedModelDir(env), model.filename),
|
|
185
|
+
binDir:
|
|
186
|
+
binary.kind === "already-present"
|
|
187
|
+
? binary.path.slice(0, binary.path.lastIndexOf("/"))
|
|
188
|
+
: managedBinDir(env),
|
|
189
|
+
supported: binary.kind !== "unsupported",
|
|
190
|
+
ramWarning,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Human-readable plan, shared by `--dry-run` and the real run. */
|
|
195
|
+
export function describeWhisperPlan(plan: WhisperInstallPlan): string[] {
|
|
196
|
+
const lines: string[] = [];
|
|
197
|
+
lines.push(`Host: ${plan.platform}/${plan.arch}, ${plan.totalRamGb} GB RAM`);
|
|
198
|
+
lines.push(`Model: ${plan.model.label} (${plan.model.sizeMb} MB)`);
|
|
199
|
+
lines.push(` ${plan.model.note}`);
|
|
200
|
+
lines.push(` → ${plan.modelPath}`);
|
|
201
|
+
|
|
202
|
+
switch (plan.binary.kind) {
|
|
203
|
+
case "already-present":
|
|
204
|
+
lines.push(`Engine: ${binaryNameFor(plan.model.engine)} already installed at ${plan.binary.path}`);
|
|
205
|
+
break;
|
|
206
|
+
case "homebrew":
|
|
207
|
+
lines.push(`Engine: brew install ${plan.binary.formula} (installs whisper-cli + parakeet-cli)`);
|
|
208
|
+
break;
|
|
209
|
+
case "tarball":
|
|
210
|
+
lines.push(`Engine: download ${plan.binary.assetName}`);
|
|
211
|
+
lines.push(` → ${plan.binDir}`);
|
|
212
|
+
break;
|
|
213
|
+
case "unsupported":
|
|
214
|
+
lines.push(`Engine: UNSUPPORTED — ${plan.binary.reason}`);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (plan.ramWarning) lines.push(`Note: ${plan.ramWarning}`);
|
|
218
|
+
return lines;
|
|
219
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The install executor.
|
|
3
|
+
*
|
|
4
|
+
* Driven through the spawn/download seams so no package manager runs and
|
|
5
|
+
* nothing is fetched. The behaviours worth pinning are the ones that decide
|
|
6
|
+
* whether an operator ends up with a working install or a convincing-looking
|
|
7
|
+
* broken one: refusing when brew is absent instead of emitting exit 127,
|
|
8
|
+
* never trusting a partial download, and failing the whole install when
|
|
9
|
+
* verification can't transcribe.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { planWhisperInstall } from "./install-whisper-cpp.ts";
|
|
17
|
+
import {
|
|
18
|
+
ensureBinaries,
|
|
19
|
+
ensureModel,
|
|
20
|
+
silentWav16kMono,
|
|
21
|
+
verifyTranscription,
|
|
22
|
+
} from "./install-whisper-exec.ts";
|
|
23
|
+
import type { SpawnRunner } from "./providers/transcribe-cpp.ts";
|
|
24
|
+
|
|
25
|
+
const GB = 1024 * 1024 * 1024;
|
|
26
|
+
const env = { PARACHUTE_HOME: "/ph" } as NodeJS.ProcessEnv;
|
|
27
|
+
|
|
28
|
+
const macPlan = () =>
|
|
29
|
+
planWhisperInstall(undefined, { platform: "darwin", arch: "arm64", totalRamBytes: 16 * GB, env });
|
|
30
|
+
const tmpHomes: string[] = [];
|
|
31
|
+
/** A Linux plan rooted in a real temp dir — the executor genuinely mkdirs. */
|
|
32
|
+
const linuxPlan = () => {
|
|
33
|
+
const home = mkdtempSync(join(tmpdir(), "wcpp-install-"));
|
|
34
|
+
tmpHomes.push(home);
|
|
35
|
+
return planWhisperInstall(undefined, {
|
|
36
|
+
platform: "linux",
|
|
37
|
+
arch: "x64",
|
|
38
|
+
totalRamBytes: 8 * GB,
|
|
39
|
+
env: { PARACHUTE_HOME: home } as NodeJS.ProcessEnv,
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
for (const h of tmpHomes.splice(0)) rmSync(h, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** A spawn that answers per-command from a lookup. */
|
|
48
|
+
function spawnFor(map: Record<string, { exitCode?: number; stdout?: string; stderr?: string }>): SpawnRunner {
|
|
49
|
+
return async (cmd) => {
|
|
50
|
+
const key = Object.keys(map).find((k) => cmd.join(" ").includes(k));
|
|
51
|
+
const r = key ? map[key]! : {};
|
|
52
|
+
return { exitCode: r.exitCode ?? 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("ensureBinaries — macOS", () => {
|
|
57
|
+
test("no Homebrew → honest refusal, and brew install is never attempted", async () => {
|
|
58
|
+
// The alternative is an opaque exit 127 the operator has to decode.
|
|
59
|
+
let attempted = false;
|
|
60
|
+
const spawn: SpawnRunner = async (cmd) => {
|
|
61
|
+
if (cmd[1] === "install") attempted = true;
|
|
62
|
+
return { exitCode: cmd[1] === "--version" ? 1 : 0, stdout: "", stderr: "" };
|
|
63
|
+
};
|
|
64
|
+
const r = await ensureBinaries(macPlan(), { spawn });
|
|
65
|
+
expect(r.ok).toBe(false);
|
|
66
|
+
expect(r.message).toMatch(/Homebrew isn't installed/);
|
|
67
|
+
expect(r.message).toMatch(/brew\.sh/);
|
|
68
|
+
// And it explains WHY there's no alternative, so this doesn't read as laziness.
|
|
69
|
+
expect(r.message).toMatch(/no macOS CLI tarball|xcframework/);
|
|
70
|
+
expect(attempted).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("brew present → installs the formula", async () => {
|
|
74
|
+
const r = await ensureBinaries(macPlan(), { spawn: spawnFor({}) });
|
|
75
|
+
expect(r.ok).toBe(true);
|
|
76
|
+
expect(r.message).toMatch(/whisper-cli \+ parakeet-cli/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a failing brew install surfaces brew's own stderr", async () => {
|
|
80
|
+
const r = await ensureBinaries(macPlan(), {
|
|
81
|
+
spawn: spawnFor({ "brew install": { exitCode: 1, stderr: "No available formula" } }),
|
|
82
|
+
});
|
|
83
|
+
expect(r.ok).toBe(false);
|
|
84
|
+
expect(r.message).toMatch(/No available formula/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("ensureBinaries — Linux", () => {
|
|
89
|
+
test("downloads + extracts, then confirms the binary really landed", async () => {
|
|
90
|
+
const seen: string[] = [];
|
|
91
|
+
// Model the real sequence: the binary is ABSENT before extraction and
|
|
92
|
+
// present after. A flat `true` would short-circuit at the already-present
|
|
93
|
+
// check and never download at all.
|
|
94
|
+
let extracted = false;
|
|
95
|
+
const r = await ensureBinaries(linuxPlan(), {
|
|
96
|
+
spawn: async (cmd) => {
|
|
97
|
+
if (cmd[0] === "tar") extracted = true;
|
|
98
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
99
|
+
},
|
|
100
|
+
download: async (url) => {
|
|
101
|
+
seen.push(url);
|
|
102
|
+
},
|
|
103
|
+
existsImpl: (p) => extracted && p.endsWith("parakeet-cli"),
|
|
104
|
+
});
|
|
105
|
+
expect(r.ok).toBe(true);
|
|
106
|
+
expect(seen[0]).toContain("whisper-bin-ubuntu-x64.tar.gz");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("extraction that doesn't produce the binary is a FAILURE, not a shrug", async () => {
|
|
110
|
+
// Otherwise the install reports success and the first transcription is
|
|
111
|
+
// the thing that discovers the truth.
|
|
112
|
+
const r = await ensureBinaries(linuxPlan(), {
|
|
113
|
+
spawn: spawnFor({}),
|
|
114
|
+
download: async () => {},
|
|
115
|
+
existsImpl: () => false,
|
|
116
|
+
});
|
|
117
|
+
expect(r.ok).toBe(false);
|
|
118
|
+
expect(r.message).toMatch(/isn't in/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a tar failure surfaces tar's stderr", async () => {
|
|
122
|
+
const r = await ensureBinaries(linuxPlan(), {
|
|
123
|
+
spawn: spawnFor({ tar: { exitCode: 2, stderr: "unexpected EOF" } }),
|
|
124
|
+
download: async () => {},
|
|
125
|
+
existsImpl: () => false,
|
|
126
|
+
});
|
|
127
|
+
expect(r.ok).toBe(false);
|
|
128
|
+
expect(r.message).toMatch(/unexpected EOF/);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("ensureBinaries — short circuits", () => {
|
|
133
|
+
test("already-present skips every package manager", async () => {
|
|
134
|
+
let spawned = false;
|
|
135
|
+
const plan = planWhisperInstall(undefined, {
|
|
136
|
+
platform: "darwin",
|
|
137
|
+
arch: "arm64",
|
|
138
|
+
totalRamBytes: 16 * GB,
|
|
139
|
+
env,
|
|
140
|
+
resolveExisting: () => "/opt/homebrew/bin/parakeet-cli",
|
|
141
|
+
});
|
|
142
|
+
const r = await ensureBinaries(plan, {
|
|
143
|
+
spawn: async () => {
|
|
144
|
+
spawned = true;
|
|
145
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
expect(r.ok).toBe(true);
|
|
149
|
+
expect(spawned).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("an unsupported host fails with the planner's reason", async () => {
|
|
153
|
+
const plan = planWhisperInstall(undefined, {
|
|
154
|
+
platform: "freebsd",
|
|
155
|
+
arch: "x64",
|
|
156
|
+
totalRamBytes: 8 * GB,
|
|
157
|
+
env,
|
|
158
|
+
});
|
|
159
|
+
const r = await ensureBinaries(plan, { spawn: spawnFor({}) });
|
|
160
|
+
expect(r.ok).toBe(false);
|
|
161
|
+
expect(r.message).toMatch(/WHISPER_CPP_BIN_DIR/);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("ensureModel", () => {
|
|
166
|
+
test("an existing model isn't re-downloaded", async () => {
|
|
167
|
+
let downloads = 0;
|
|
168
|
+
const r = await ensureModel(linuxPlan(), {
|
|
169
|
+
existsImpl: () => true,
|
|
170
|
+
download: async () => {
|
|
171
|
+
downloads += 1;
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
expect(r.ok).toBe(true);
|
|
175
|
+
expect(downloads).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("downloads to a .part first — a truncated file must never look complete", async () => {
|
|
179
|
+
// A half-file at the real path would pass the readiness probe and then
|
|
180
|
+
// fail mysteriously at the first transcription.
|
|
181
|
+
const targets: string[] = [];
|
|
182
|
+
await ensureModel(linuxPlan(), {
|
|
183
|
+
existsImpl: () => false,
|
|
184
|
+
download: async (_url, dest) => {
|
|
185
|
+
targets.push(dest);
|
|
186
|
+
// Materialise it so the rename can succeed.
|
|
187
|
+
await Bun.write(dest, "x");
|
|
188
|
+
},
|
|
189
|
+
}).catch(() => {});
|
|
190
|
+
expect(targets[0]).toMatch(/\.part$/);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("verifyTranscription", () => {
|
|
195
|
+
test("a clean exit means verified", async () => {
|
|
196
|
+
const r = await verifyTranscription(linuxPlan(), "/bin/parakeet-cli", { spawn: spawnFor({}) });
|
|
197
|
+
expect(r.ok).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a non-zero exit FAILS the install and names the likely cause", async () => {
|
|
201
|
+
const r = await verifyTranscription(linuxPlan(), "/bin/parakeet-cli", {
|
|
202
|
+
spawn: spawnFor({ "parakeet-cli": { exitCode: 1, stderr: "failed to load model" } }),
|
|
203
|
+
});
|
|
204
|
+
expect(r.ok).toBe(false);
|
|
205
|
+
expect(r.message).toMatch(/truncated download or a model\/binary mismatch/);
|
|
206
|
+
expect(r.message).toMatch(/failed to load model/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("engine picks the flags — whisper needs -nt, parakeet has no such flag", async () => {
|
|
210
|
+
let seen: string[] = [];
|
|
211
|
+
const capture: SpawnRunner = async (cmd) => {
|
|
212
|
+
seen = cmd;
|
|
213
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
214
|
+
};
|
|
215
|
+
await verifyTranscription(
|
|
216
|
+
planWhisperInstall("whisper-base.en", { platform: "linux", arch: "x64", totalRamBytes: 8 * GB, env }),
|
|
217
|
+
"/bin/whisper-cli",
|
|
218
|
+
{ spawn: capture },
|
|
219
|
+
);
|
|
220
|
+
expect(seen).toContain("-nt");
|
|
221
|
+
|
|
222
|
+
await verifyTranscription(linuxPlan(), "/bin/parakeet-cli", { spawn: capture });
|
|
223
|
+
expect(seen).not.toContain("-nt");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("silentWav16kMono", () => {
|
|
228
|
+
test("is a valid RIFF/WAVE header at 16 kHz mono 16-bit", () => {
|
|
229
|
+
// Hand-built so verification doesn't depend on ffmpeg — the very tool
|
|
230
|
+
// whose absence we're trying to report clearly elsewhere.
|
|
231
|
+
const wav = silentWav16kMono(1);
|
|
232
|
+
const dv = new DataView(wav.buffer);
|
|
233
|
+
expect(String.fromCharCode(...wav.slice(0, 4))).toBe("RIFF");
|
|
234
|
+
expect(String.fromCharCode(...wav.slice(8, 12))).toBe("WAVE");
|
|
235
|
+
expect(dv.getUint16(22, true)).toBe(1); // mono
|
|
236
|
+
expect(dv.getUint32(24, true)).toBe(16000); // sample rate
|
|
237
|
+
expect(dv.getUint16(34, true)).toBe(16); // bits
|
|
238
|
+
expect(wav.length).toBe(44 + 16000 * 2);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("a zero/negative duration still yields a structurally valid file", () => {
|
|
242
|
+
expect(silentWav16kMono(0).length).toBeGreaterThan(44);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executing a {@link WhisperInstallPlan}.
|
|
3
|
+
*
|
|
4
|
+
* The planner decides; this does. Kept separate so every decision stays
|
|
5
|
+
* testable without a network, and so the one genuinely irreversible-feeling
|
|
6
|
+
* step — spawning a package manager — sits in a small, obvious place.
|
|
7
|
+
*
|
|
8
|
+
* ## The verification step is the point
|
|
9
|
+
*
|
|
10
|
+
* The provider this replaces was activated by an install verb that never
|
|
11
|
+
* checked whether what it configured could run. That is precisely how
|
|
12
|
+
* `TRANSCRIPTION_PROVIDER=transcribe-cpp` came to point at a CLI that has
|
|
13
|
+
* never shipped, and why local transcription silently did nothing for anyone
|
|
14
|
+
* who "installed" it.
|
|
15
|
+
*
|
|
16
|
+
* So {@link verifyTranscription} generates a real WAV, runs the real CLI
|
|
17
|
+
* against the real model, and requires a real exit code. `TRANSCRIPTION_
|
|
18
|
+
* PROVIDER` flips only after that passes. An install that cannot transcribe is
|
|
19
|
+
* a failed install, and it says so at install time rather than silently at the
|
|
20
|
+
* operator's first voice memo.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync, renameSync } from "fs";
|
|
24
|
+
import { tmpdir } from "os";
|
|
25
|
+
import { join } from "path";
|
|
26
|
+
import { downloadTo } from "./download.ts";
|
|
27
|
+
import type { WhisperInstallPlan } from "./install-whisper-cpp.ts";
|
|
28
|
+
import { binaryNameFor } from "./resolve-binary.ts";
|
|
29
|
+
import { defaultSpawnRunner, type SpawnRunner } from "./providers/transcribe-cpp.ts";
|
|
30
|
+
|
|
31
|
+
export interface ExecDeps {
|
|
32
|
+
spawn?: SpawnRunner;
|
|
33
|
+
download?: (url: string, dest: string) => Promise<void>;
|
|
34
|
+
existsImpl?: (p: string) => boolean;
|
|
35
|
+
log?: (line: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StepResult {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
/** What happened, for the operator. */
|
|
41
|
+
message: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ensure the CLI binaries exist.
|
|
46
|
+
*
|
|
47
|
+
* `already-present` is a no-op by design — re-running install shouldn't drag a
|
|
48
|
+
* package manager through a reinstall, and an operator who installed
|
|
49
|
+
* whisper.cpp themselves has already solved this.
|
|
50
|
+
*/
|
|
51
|
+
export async function ensureBinaries(
|
|
52
|
+
plan: WhisperInstallPlan,
|
|
53
|
+
deps: ExecDeps = {},
|
|
54
|
+
): Promise<StepResult> {
|
|
55
|
+
const spawn = deps.spawn ?? defaultSpawnRunner;
|
|
56
|
+
const exists = deps.existsImpl ?? existsSync;
|
|
57
|
+
const log = deps.log ?? (() => {});
|
|
58
|
+
|
|
59
|
+
switch (plan.binary.kind) {
|
|
60
|
+
case "already-present":
|
|
61
|
+
return { ok: true, message: `${binaryNameFor(plan.model.engine)} already installed at ${plan.binary.path}` };
|
|
62
|
+
|
|
63
|
+
case "unsupported":
|
|
64
|
+
return { ok: false, message: plan.binary.reason };
|
|
65
|
+
|
|
66
|
+
case "homebrew": {
|
|
67
|
+
// Probe brew first so a box without it gets an honest refusal rather
|
|
68
|
+
// than an opaque exit 127 from the install attempt.
|
|
69
|
+
const probe = await spawn(["brew", "--version"], { timeoutMs: 30_000 });
|
|
70
|
+
if (probe.exitCode !== 0) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
message:
|
|
74
|
+
"Homebrew isn't installed, and it's the only way to get whisper.cpp's CLIs on macOS " +
|
|
75
|
+
"(upstream ships no macOS CLI tarball — the macOS artifact is an xcframework for " +
|
|
76
|
+
"embedding). Install Homebrew from https://brew.sh, then re-run this. Or build " +
|
|
77
|
+
"whisper.cpp yourself and set WHISPER_CPP_BIN_DIR.",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
log(`Running: brew install ${plan.binary.formula} …`);
|
|
81
|
+
const res = await spawn(["brew", "install", plan.binary.formula], { timeoutMs: 900_000 });
|
|
82
|
+
if (res.exitCode !== 0) {
|
|
83
|
+
return { ok: false, message: `brew install ${plan.binary.formula} failed: ${tail(res.stderr)}` };
|
|
84
|
+
}
|
|
85
|
+
return { ok: true, message: `Installed ${plan.binary.formula} (whisper-cli + parakeet-cli)` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case "tarball": {
|
|
89
|
+
const download = deps.download ?? downloadTo;
|
|
90
|
+
mkdirSync(plan.binDir, { recursive: true });
|
|
91
|
+
const marker = join(plan.binDir, binaryNameFor(plan.model.engine));
|
|
92
|
+
if (exists(marker)) {
|
|
93
|
+
return { ok: true, message: `Binaries already present in ${plan.binDir}` };
|
|
94
|
+
}
|
|
95
|
+
const tmp = join(plan.binDir, `.${plan.binary.assetName}.part`);
|
|
96
|
+
log(`Downloading ${plan.binary.assetName} …`);
|
|
97
|
+
await download(plan.binary.url, tmp);
|
|
98
|
+
// Extract with --strip-components=1: the tarball nests everything under
|
|
99
|
+
// `whisper-bin-ubuntu-<arch>/`, and we want the binaries and their
|
|
100
|
+
// shared objects side by side in binDir so the loader finds them.
|
|
101
|
+
const res = await spawn(["tar", "xzf", tmp, "-C", plan.binDir, "--strip-components=1"], {
|
|
102
|
+
timeoutMs: 300_000,
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
const { unlinkSync } = await import("fs");
|
|
106
|
+
unlinkSync(tmp);
|
|
107
|
+
} catch {
|
|
108
|
+
/* best effort */
|
|
109
|
+
}
|
|
110
|
+
if (res.exitCode !== 0) {
|
|
111
|
+
return { ok: false, message: `extracting ${plan.binary.assetName} failed: ${tail(res.stderr)}` };
|
|
112
|
+
}
|
|
113
|
+
if (!exists(marker)) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
message: `${plan.binary.assetName} extracted but ${binaryNameFor(plan.model.engine)} isn't in ${plan.binDir}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
await spawn(["chmod", "+x", marker], { timeoutMs: 30_000 });
|
|
120
|
+
return { ok: true, message: `Installed whisper.cpp binaries to ${plan.binDir}` };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Ensure the model file is on disk.
|
|
127
|
+
*
|
|
128
|
+
* Downloads to a `.part` and renames on success, so an interrupted install
|
|
129
|
+
* never leaves a truncated file that looks complete to the readiness probe —
|
|
130
|
+
* which would present as a mystery CLI failure at the first transcription
|
|
131
|
+
* rather than an obvious missing download.
|
|
132
|
+
*/
|
|
133
|
+
export async function ensureModel(
|
|
134
|
+
plan: WhisperInstallPlan,
|
|
135
|
+
deps: ExecDeps = {},
|
|
136
|
+
): Promise<StepResult> {
|
|
137
|
+
const exists = deps.existsImpl ?? existsSync;
|
|
138
|
+
const download = deps.download ?? downloadTo;
|
|
139
|
+
const log = deps.log ?? (() => {});
|
|
140
|
+
|
|
141
|
+
if (exists(plan.modelPath)) {
|
|
142
|
+
return { ok: true, message: `Model already downloaded (${plan.model.label})` };
|
|
143
|
+
}
|
|
144
|
+
mkdirSync(dirOf(plan.modelPath), { recursive: true });
|
|
145
|
+
const part = `${plan.modelPath}.part`;
|
|
146
|
+
log(`Downloading ${plan.model.label} (${plan.model.sizeMb} MB) …`);
|
|
147
|
+
await download(plan.model.url, part);
|
|
148
|
+
renameSync(part, plan.modelPath);
|
|
149
|
+
return { ok: true, message: `Downloaded ${plan.model.label} to ${plan.modelPath}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Prove the install can actually transcribe.
|
|
154
|
+
*
|
|
155
|
+
* Generates a one-second silent 16 kHz mono WAV in-process (no ffmpeg needed —
|
|
156
|
+
* the CLIs take WAV directly, and this sidesteps making verification depend on
|
|
157
|
+
* a second tool) and runs the real CLI against the real model. We assert on the
|
|
158
|
+
* EXIT CODE, not on transcript content: silence legitimately produces no text,
|
|
159
|
+
* and a model that loads and runs cleanly is what we're checking.
|
|
160
|
+
*/
|
|
161
|
+
export async function verifyTranscription(
|
|
162
|
+
plan: WhisperInstallPlan,
|
|
163
|
+
binPath: string,
|
|
164
|
+
deps: ExecDeps = {},
|
|
165
|
+
): Promise<StepResult> {
|
|
166
|
+
const spawn = deps.spawn ?? defaultSpawnRunner;
|
|
167
|
+
const log = deps.log ?? (() => {});
|
|
168
|
+
// Scratch, so it belongs in tmp — NOT beside the models, where a leftover
|
|
169
|
+
// from an interrupted run would sit next to real downloads forever.
|
|
170
|
+
const wav = join(tmpdir(), `parachute-verify-${process.pid}-16k-mono.wav`);
|
|
171
|
+
try {
|
|
172
|
+
await Bun.write(wav, silentWav16kMono(1));
|
|
173
|
+
log("Verifying the install can transcribe …");
|
|
174
|
+
const args =
|
|
175
|
+
plan.model.engine === "whisper"
|
|
176
|
+
? [binPath, "-m", plan.modelPath, "-f", wav, "-np", "-nt"]
|
|
177
|
+
: [binPath, "-m", plan.modelPath, "-f", wav, "-np"];
|
|
178
|
+
const res = await spawn(args, { timeoutMs: 300_000 });
|
|
179
|
+
if (res.exitCode !== 0) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
message:
|
|
183
|
+
`${binaryNameFor(plan.model.engine)} could not run the model (exit ${res.exitCode}). ` +
|
|
184
|
+
`This usually means a truncated download or a model/binary mismatch: ${tail(res.stderr)}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return { ok: true, message: "Verified — the model loads and transcribes." };
|
|
188
|
+
} finally {
|
|
189
|
+
try {
|
|
190
|
+
const { unlinkSync } = await import("fs");
|
|
191
|
+
unlinkSync(wav);
|
|
192
|
+
} catch {
|
|
193
|
+
/* best effort */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A minimal valid RIFF/WAVE file: `seconds` of 16-bit PCM silence at 16 kHz
|
|
200
|
+
* mono — exactly the shape both CLIs want.
|
|
201
|
+
*
|
|
202
|
+
* Written by hand rather than shelling ffmpeg so verification doesn't depend
|
|
203
|
+
* on the very tool whose absence we're trying to report clearly elsewhere.
|
|
204
|
+
*/
|
|
205
|
+
export function silentWav16kMono(seconds: number): Uint8Array {
|
|
206
|
+
const sampleRate = 16000;
|
|
207
|
+
const samples = Math.max(1, Math.floor(sampleRate * seconds));
|
|
208
|
+
const dataBytes = samples * 2; // 16-bit mono
|
|
209
|
+
const buf = new ArrayBuffer(44 + dataBytes);
|
|
210
|
+
const view = new DataView(buf);
|
|
211
|
+
const ascii = (off: number, s: string) => {
|
|
212
|
+
for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
|
|
213
|
+
};
|
|
214
|
+
ascii(0, "RIFF");
|
|
215
|
+
view.setUint32(4, 36 + dataBytes, true);
|
|
216
|
+
ascii(8, "WAVE");
|
|
217
|
+
ascii(12, "fmt ");
|
|
218
|
+
view.setUint32(16, 16, true); // PCM chunk size
|
|
219
|
+
view.setUint16(20, 1, true); // PCM
|
|
220
|
+
view.setUint16(22, 1, true); // mono
|
|
221
|
+
view.setUint32(24, sampleRate, true);
|
|
222
|
+
view.setUint32(28, sampleRate * 2, true); // byte rate
|
|
223
|
+
view.setUint16(32, 2, true); // block align
|
|
224
|
+
view.setUint16(34, 16, true); // bits per sample
|
|
225
|
+
ascii(36, "data");
|
|
226
|
+
view.setUint32(40, dataBytes, true);
|
|
227
|
+
// Samples stay zero — silence.
|
|
228
|
+
return new Uint8Array(buf);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function dirOf(p: string): string {
|
|
232
|
+
return p.slice(0, p.lastIndexOf("/"));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function tail(s: string, lines = 3): string {
|
|
236
|
+
return s.trim().split("\n").slice(-lines).join(" | ").slice(0, 400);
|
|
237
|
+
}
|
|
@@ -108,7 +108,11 @@ export const TRANSCRIPTION_MODELS: readonly TranscriptionModel[] = [
|
|
|
108
108
|
url: "https://huggingface.co/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-q4_0.bin",
|
|
109
109
|
filename: "ggml-parakeet-tdt-0.6b-v3-q4_0.bin",
|
|
110
110
|
sizeMb: 339,
|
|
111
|
-
|
|
111
|
+
// 1.5 GB rather than 2: the q4 weights are 339 MB and peak usage lands
|
|
112
|
+
// well under 1 GB, so the common 2 GB VPS should get Parakeet rather than
|
|
113
|
+
// being stepped down to Whisper Tiny — a large accuracy loss to buy
|
|
114
|
+
// headroom it didn't need.
|
|
115
|
+
minRamMb: 1536,
|
|
112
116
|
note: "Parakeet at the smallest quantization. 25 European languages.",
|
|
113
117
|
},
|
|
114
118
|
{
|