@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.5-rc.4",
3
+ "version": "0.7.5-rc.5",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
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
- minRamMb: 2048,
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
  {