@openparachute/vault 0.7.5-rc.4 → 0.7.5-rc.6
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/web/ui/dist/assets/{index-CD4kPSY9.js → index-cdAcASvO.js} +14 -14
- package/web/ui/dist/index.html +1 -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
|
+
}
|