@openparachute/vault 0.7.4 → 0.7.5-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.ts +113 -1
- package/src/mirror-import-jobs.ts +197 -0
- package/src/mirror-import.test.ts +102 -2
- package/src/mirror-import.ts +182 -22
- package/src/mirror-routes.test.ts +222 -3
- package/src/mirror-routes.ts +228 -86
- package/src/routing.ts +22 -6
- package/src/server.ts +62 -1
- package/src/transcription/install-whisper-cpp.test.ts +228 -0
- package/src/transcription/install-whisper-cpp.ts +219 -0
- package/src/transcription/install-whisper-exec.test.ts +244 -0
- package/src/transcription/install-whisper-exec.ts +237 -0
- package/src/transcription/models.test.ts +87 -0
- package/src/transcription/models.ts +191 -0
- package/src/transcription/providers/whisper-cpp.test.ts +218 -0
- package/src/transcription/providers/whisper-cpp.ts +241 -0
- package/src/transcription/resolve-binary.test.ts +131 -0
- package/src/transcription/resolve-binary.ts +111 -0
- package/src/transcription/select.ts +26 -3
- package/web/ui/dist/assets/index-CD4kPSY9.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-NvwxfZcu.js +0 -61
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model catalog.
|
|
3
|
+
*
|
|
4
|
+
* These tests care about two things a catalog gets wrong quietly: URLs that
|
|
5
|
+
* don't match the filename we save to (so a re-run re-downloads forever), and
|
|
6
|
+
* a default-picker that hands a 1.5 GB model to a 1 GB box.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_MODEL_ID,
|
|
12
|
+
findModel,
|
|
13
|
+
pickDefaultModel,
|
|
14
|
+
TRANSCRIPTION_MODELS,
|
|
15
|
+
} from "./models.ts";
|
|
16
|
+
|
|
17
|
+
describe("catalog integrity", () => {
|
|
18
|
+
test("ids are unique", () => {
|
|
19
|
+
const ids = TRANSCRIPTION_MODELS.map((m) => m.id);
|
|
20
|
+
expect(ids.length).toBe(new Set(ids).size);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("every URL's basename matches the filename we write", () => {
|
|
24
|
+
// Drift here means the downloader saves to a name the resolver never
|
|
25
|
+
// looks for, so every boot re-downloads and nothing ever becomes ready.
|
|
26
|
+
for (const m of TRANSCRIPTION_MODELS) {
|
|
27
|
+
expect(m.url.split("/").pop()).toBe(m.filename);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("Parakeet models come from ggml-org, Whisper from ggerganov", () => {
|
|
32
|
+
// Load-bearing: handy-computer's GGUFs are NOT loadable by whisper.cpp's
|
|
33
|
+
// parakeet-cli (verified — "failed to load Parakeet model"). Pointing a
|
|
34
|
+
// Parakeet entry at handy-computer would produce a model that downloads
|
|
35
|
+
// fine and then fails at every transcription.
|
|
36
|
+
for (const m of TRANSCRIPTION_MODELS) {
|
|
37
|
+
if (m.engine === "parakeet") expect(m.url).toContain("ggml-org/parakeet-GGUF");
|
|
38
|
+
else expect(m.url).toContain("ggerganov/whisper.cpp");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("catalog is ordered smallest-first", () => {
|
|
43
|
+
const sizes = TRANSCRIPTION_MODELS.map((m) => m.sizeMb);
|
|
44
|
+
expect([...sizes].sort((a, b) => a - b)).toEqual(sizes);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("the default exists and is a Parakeet model", () => {
|
|
48
|
+
const d = findModel(DEFAULT_MODEL_ID);
|
|
49
|
+
expect(d).toBeDefined();
|
|
50
|
+
expect(d!.engine).toBe("parakeet");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("covers the small and mid size classes an operator asks for", () => {
|
|
54
|
+
const sizes = TRANSCRIPTION_MODELS.map((m) => m.sizeMb);
|
|
55
|
+
expect(sizes.some((s) => s < 150)).toBe(true); // ~100 MB class
|
|
56
|
+
expect(sizes.some((s) => s >= 300 && s <= 700)).toBe(true); // ~400–700 MB class
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("pickDefaultModel", () => {
|
|
61
|
+
test("a comfortable box gets the recommended Parakeet", () => {
|
|
62
|
+
expect(pickDefaultModel(16384).id).toBe(DEFAULT_MODEL_ID);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a small box steps DOWN rather than swapping itself to death", () => {
|
|
66
|
+
const picked = pickDefaultModel(1024);
|
|
67
|
+
expect(picked.minRamMb).toBeLessThanOrEqual(1024);
|
|
68
|
+
expect(picked.sizeMb).toBeLessThan(200);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("never returns undefined, even on an absurdly small box", () => {
|
|
72
|
+
const picked = pickDefaultModel(64);
|
|
73
|
+
expect(picked).toBeDefined();
|
|
74
|
+
expect(picked.id).toBe(TRANSCRIPTION_MODELS[0]!.id);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a mid box gets something that fits its RAM floor", () => {
|
|
78
|
+
const picked = pickDefaultModel(2048);
|
|
79
|
+
expect(picked.minRamMb).toBeLessThanOrEqual(2048);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("findModel", () => {
|
|
84
|
+
test("unknown id → undefined, not a throw", () => {
|
|
85
|
+
expect(findModel("nope")).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local-transcription model catalog (whisper.cpp era).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this replaced the transcribe.cpp plan
|
|
5
|
+
*
|
|
6
|
+
* The 2026-07-03 ratification adopted transcribe.cpp as the local provider.
|
|
7
|
+
* That decision assumed a `transcribe-cli` binary would ship. It never did:
|
|
8
|
+
* v0.1.3 (2026-07-12, latest as of this writing) still publishes only
|
|
9
|
+
* `libtranscribe.{dylib,so}` + `libggml*` + `contract.json` in its release
|
|
10
|
+
* tarballs — the CLI is build-from-source — and its N-API binding crashes on
|
|
11
|
+
* Bun, so the in-process route is closed too. Four weeks and three releases
|
|
12
|
+
* after the ratification, `parachute-vault transcription install` still could
|
|
13
|
+
* not produce a runnable setup.
|
|
14
|
+
*
|
|
15
|
+
* whisper.cpp ships what transcribe.cpp doesn't: **prebuilt CLIs on both
|
|
16
|
+
* platforms**. `brew install whisper-cpp` (bottled for arm64 macOS) installs
|
|
17
|
+
* BOTH `whisper-cli` and `parakeet-cli`, and the Linux release tarballs carry
|
|
18
|
+
* the same two binaries. Models are prebuilt too — `ggml-org/parakeet-GGUF`
|
|
19
|
+
* and `ggerganov/whisper.cpp` — so nothing has to be converted, and no Python
|
|
20
|
+
* is involved anywhere.
|
|
21
|
+
*
|
|
22
|
+
* Note that handy-computer's own GGUFs (68 repos, 16 model families) are NOT
|
|
23
|
+
* format-compatible with whisper.cpp's `parakeet-cli` — verified: loading
|
|
24
|
+
* `parakeet-tdt-0.6b-v3-Q4_K_M.gguf` fails with "failed to load Parakeet
|
|
25
|
+
* model". They are two ecosystems, and this catalog lives in whisper.cpp's.
|
|
26
|
+
* If transcribe.cpp ships a CLI later, its 16-family catalog becomes a
|
|
27
|
+
* genuinely attractive swap — the provider seam is where that would land.
|
|
28
|
+
*
|
|
29
|
+
* ## Why Parakeet is the default
|
|
30
|
+
*
|
|
31
|
+
* On the Open ASR Leaderboard, Parakeet TDT 0.6b v3 beats Whisper large-v3 on
|
|
32
|
+
* average WER (6.32% vs 7.44%) at roughly a third of the parameters, and runs
|
|
33
|
+
* substantially faster. The property that matters most for voice memos:
|
|
34
|
+
* **Parakeet does not hallucinate during silence**, which is Whisper's
|
|
35
|
+
* best-known failure mode and precisely what a recording with pauses triggers.
|
|
36
|
+
*
|
|
37
|
+
* The trade-off is language coverage — Parakeet v3 handles 25 European
|
|
38
|
+
* languages, Whisper 99 — which is why the Whisper models stay in the catalog
|
|
39
|
+
* rather than being dropped. A vault whose audio isn't in Parakeet's range
|
|
40
|
+
* picks a Whisper model and everything else works the same.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Which whisper.cpp CLI runs a given model. The two binaries take slightly
|
|
45
|
+
* different flags and read different model formats, so this is not cosmetic —
|
|
46
|
+
* it selects the argv builder and the readiness probe.
|
|
47
|
+
*/
|
|
48
|
+
export type TranscriptionEngine = "parakeet" | "whisper";
|
|
49
|
+
|
|
50
|
+
/** A model an operator can pick, with everything needed to fetch and run it. */
|
|
51
|
+
export interface TranscriptionModel {
|
|
52
|
+
/** Stable id — what `TRANSCRIPTION_MODEL` is set to. */
|
|
53
|
+
id: string;
|
|
54
|
+
/** Short label for the admin UI. */
|
|
55
|
+
label: string;
|
|
56
|
+
/** Which CLI runs it. */
|
|
57
|
+
engine: TranscriptionEngine;
|
|
58
|
+
/** Download URL for the ggml `.bin`. */
|
|
59
|
+
url: string;
|
|
60
|
+
/** On-disk filename under `<root>/transcription/models/`. */
|
|
61
|
+
filename: string;
|
|
62
|
+
/** Approximate download size, MB. Real measured values, not estimates. */
|
|
63
|
+
sizeMb: number;
|
|
64
|
+
/**
|
|
65
|
+
* Rough floor of usable system RAM, MB. Used to pick a sane default on a
|
|
66
|
+
* given box and to warn when an operator picks something their machine will
|
|
67
|
+
* struggle with — NOT a hard refusal; an operator may know better.
|
|
68
|
+
*/
|
|
69
|
+
minRamMb: number;
|
|
70
|
+
/** One line for the picker — what this trades away. */
|
|
71
|
+
note: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The catalog, smallest first.
|
|
76
|
+
*
|
|
77
|
+
* Sizes are measured from the HuggingFace API, not estimated. The Parakeet
|
|
78
|
+
* entries come from `ggml-org/parakeet-GGUF` (whisper.cpp's own conversions,
|
|
79
|
+
* which is what makes them loadable by `parakeet-cli`); the Whisper entries
|
|
80
|
+
* from `ggerganov/whisper.cpp`, the canonical source `download-ggml-model.sh`
|
|
81
|
+
* uses.
|
|
82
|
+
*/
|
|
83
|
+
export const TRANSCRIPTION_MODELS: readonly TranscriptionModel[] = [
|
|
84
|
+
{
|
|
85
|
+
id: "whisper-tiny.en",
|
|
86
|
+
label: "Whisper Tiny (English)",
|
|
87
|
+
engine: "whisper",
|
|
88
|
+
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin",
|
|
89
|
+
filename: "ggml-tiny.en.bin",
|
|
90
|
+
sizeMb: 74,
|
|
91
|
+
minRamMb: 1024,
|
|
92
|
+
note: "Smallest option. Noticeably less accurate — for very constrained boxes.",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "whisper-base.en",
|
|
96
|
+
label: "Whisper Base (English)",
|
|
97
|
+
engine: "whisper",
|
|
98
|
+
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
|
|
99
|
+
filename: "ggml-base.en.bin",
|
|
100
|
+
sizeMb: 141,
|
|
101
|
+
minRamMb: 2048,
|
|
102
|
+
note: "Small and quick. English only.",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "parakeet-tdt-0.6b-v3-q4",
|
|
106
|
+
label: "Parakeet TDT 0.6b v3 (q4)",
|
|
107
|
+
engine: "parakeet",
|
|
108
|
+
url: "https://huggingface.co/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-q4_0.bin",
|
|
109
|
+
filename: "ggml-parakeet-tdt-0.6b-v3-q4_0.bin",
|
|
110
|
+
sizeMb: 339,
|
|
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,
|
|
116
|
+
note: "Parakeet at the smallest quantization. 25 European languages.",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: "parakeet-tdt-0.6b-v3",
|
|
120
|
+
label: "Parakeet TDT 0.6b v3 (recommended)",
|
|
121
|
+
engine: "parakeet",
|
|
122
|
+
url: "https://huggingface.co/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-q4_k.bin",
|
|
123
|
+
filename: "ggml-parakeet-tdt-0.6b-v3-q4_k.bin",
|
|
124
|
+
sizeMb: 396,
|
|
125
|
+
minRamMb: 3072,
|
|
126
|
+
note: "Best accuracy-per-byte, and doesn't hallucinate on silence. 25 European languages.",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: "whisper-small.en",
|
|
130
|
+
label: "Whisper Small (English)",
|
|
131
|
+
engine: "whisper",
|
|
132
|
+
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin",
|
|
133
|
+
filename: "ggml-small.en.bin",
|
|
134
|
+
sizeMb: 465,
|
|
135
|
+
minRamMb: 4096,
|
|
136
|
+
note: "More accurate Whisper. English only.",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "parakeet-tdt-0.6b-v3-q8",
|
|
140
|
+
label: "Parakeet TDT 0.6b v3 (q8)",
|
|
141
|
+
engine: "parakeet",
|
|
142
|
+
url: "https://huggingface.co/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-q8_0.bin",
|
|
143
|
+
filename: "ggml-parakeet-tdt-0.6b-v3-q8_0.bin",
|
|
144
|
+
sizeMb: 638,
|
|
145
|
+
minRamMb: 6144,
|
|
146
|
+
note: "Parakeet at higher precision. Marginal gain over q4_k for most audio.",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "whisper-large-v3-turbo",
|
|
150
|
+
label: "Whisper Large v3 Turbo (multilingual)",
|
|
151
|
+
engine: "whisper",
|
|
152
|
+
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin",
|
|
153
|
+
filename: "ggml-large-v3-turbo.bin",
|
|
154
|
+
sizeMb: 1549,
|
|
155
|
+
minRamMb: 8192,
|
|
156
|
+
note: "All 99 Whisper languages. Pick this when Parakeet's 25 aren't enough.",
|
|
157
|
+
},
|
|
158
|
+
] as const;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The default model when nothing is configured.
|
|
162
|
+
*
|
|
163
|
+
* Parakeet q4_k rather than the smallest option: at 396 MB it is a reasonable
|
|
164
|
+
* download on any machine that can host a vault, and choosing accuracy by
|
|
165
|
+
* default is the right call for transcripts an operator will read months
|
|
166
|
+
* later. `pickDefaultModel` steps down on low-RAM boxes.
|
|
167
|
+
*/
|
|
168
|
+
export const DEFAULT_MODEL_ID = "parakeet-tdt-0.6b-v3";
|
|
169
|
+
|
|
170
|
+
/** Look a model up by id. `undefined` for an unknown id. */
|
|
171
|
+
export function findModel(id: string): TranscriptionModel | undefined {
|
|
172
|
+
return TRANSCRIPTION_MODELS.find((m) => m.id === id);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Pick a sensible default for a machine with `totalRamMb`.
|
|
177
|
+
*
|
|
178
|
+
* Walks DOWN from the default to the largest model the box comfortably fits,
|
|
179
|
+
* so a small VPS gets something that runs rather than something that swaps.
|
|
180
|
+
* Never returns undefined — the smallest entry is the floor, and a box too
|
|
181
|
+
* small even for that has bigger problems than model choice.
|
|
182
|
+
*/
|
|
183
|
+
export function pickDefaultModel(totalRamMb: number): TranscriptionModel {
|
|
184
|
+
const preferred = findModel(DEFAULT_MODEL_ID);
|
|
185
|
+
if (preferred && totalRamMb >= preferred.minRamMb) return preferred;
|
|
186
|
+
// Largest model that fits, else the smallest we have.
|
|
187
|
+
const fits = TRANSCRIPTION_MODELS.filter((m) => totalRamMb >= m.minRamMb);
|
|
188
|
+
return fits.length > 0
|
|
189
|
+
? fits.reduce((a, b) => (b.sizeMb > a.sizeMb ? b : a))
|
|
190
|
+
: TRANSCRIPTION_MODELS[0]!;
|
|
191
|
+
}
|