@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
|
@@ -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
|
{
|