whisper-api 0.1.0

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.
@@ -0,0 +1,1125 @@
1
+ // src/cli/index.ts
2
+ import fs9 from "fs";
3
+ import path7 from "path";
4
+ import dotenv from "dotenv";
5
+ import { Command } from "commander";
6
+ import { intro, outro, select, multiselect, isCancel, cancel, note } from "@clack/prompts";
7
+
8
+ // src/config/index.ts
9
+ import os from "os";
10
+ import path from "path";
11
+ import fs from "fs";
12
+ import fsp from "fs/promises";
13
+ var DEFAULT_CONFIG = {
14
+ engine: "auto",
15
+ defaultModel: "base.en",
16
+ host: "0.0.0.0",
17
+ port: 8080,
18
+ maxUploadBytes: 25 * 1024 * 1024,
19
+ rateLimit: { max: 120, timeWindow: "1 minute" }
20
+ };
21
+ function homeDir() {
22
+ return process.env.WHISPER_API_HOME || path.join(os.homedir(), ".whisper-api");
23
+ }
24
+ var paths = {
25
+ home: homeDir,
26
+ config: () => path.join(homeDir(), "config.json"),
27
+ keys: () => path.join(homeDir(), "keys.json"),
28
+ /** GGML model files for whisper.cpp. */
29
+ models: () => path.join(homeDir(), "models"),
30
+ /** transformers.js / ONNX model cache. */
31
+ cache: () => path.join(homeDir(), "cache"),
32
+ /** Built/downloaded whisper.cpp binaries. */
33
+ bin: () => path.join(homeDir(), "bin"),
34
+ /** Scratch space for uploads and converted audio. */
35
+ tmp: () => path.join(homeDir(), "tmp")
36
+ };
37
+ function ensureDirs() {
38
+ for (const p of [homeDir(), paths.models(), paths.cache(), paths.bin(), paths.tmp()]) {
39
+ fs.mkdirSync(p, { recursive: true });
40
+ }
41
+ }
42
+ async function loadConfig() {
43
+ try {
44
+ const raw = await fsp.readFile(paths.config(), "utf8");
45
+ const parsed = JSON.parse(raw);
46
+ return {
47
+ ...DEFAULT_CONFIG,
48
+ ...parsed,
49
+ rateLimit: { ...DEFAULT_CONFIG.rateLimit, ...parsed.rateLimit ?? {} }
50
+ };
51
+ } catch {
52
+ return { ...DEFAULT_CONFIG };
53
+ }
54
+ }
55
+ async function saveConfig(cfg) {
56
+ ensureDirs();
57
+ await fsp.writeFile(paths.config(), JSON.stringify(cfg, null, 2) + "\n", "utf8");
58
+ }
59
+ function applyEnvOverrides(cfg) {
60
+ const out = { ...cfg, rateLimit: { ...cfg.rateLimit } };
61
+ if (process.env.WHISPER_API_PORT) out.port = Number(process.env.WHISPER_API_PORT);
62
+ if (process.env.WHISPER_API_HOST) out.host = process.env.WHISPER_API_HOST;
63
+ if (process.env.WHISPER_API_ENGINE) out.engine = process.env.WHISPER_API_ENGINE;
64
+ if (process.env.WHISPER_API_MODEL) out.defaultModel = process.env.WHISPER_API_MODEL;
65
+ if (process.env.WHISPER_API_MAX_UPLOAD_MB) {
66
+ out.maxUploadBytes = Number(process.env.WHISPER_API_MAX_UPLOAD_MB) * 1024 * 1024;
67
+ }
68
+ if (process.env.WHISPER_API_RATE_MAX) out.rateLimit.max = Number(process.env.WHISPER_API_RATE_MAX);
69
+ return out;
70
+ }
71
+
72
+ // src/models/registry.ts
73
+ import fs2 from "fs";
74
+ import path2 from "path";
75
+ var HF_GGML_BASE = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main";
76
+ function ggml(name) {
77
+ return `ggml-${name}.bin`;
78
+ }
79
+ var MODELS = [
80
+ { name: "tiny.en", ggmlFile: ggml("tiny.en"), onnxRepo: "Xenova/whisper-tiny.en", sizeMB: 75, englishOnly: true, description: "Fastest, English-only" },
81
+ { name: "tiny", ggmlFile: ggml("tiny"), onnxRepo: "Xenova/whisper-tiny", sizeMB: 75, englishOnly: false, description: "Fastest, multilingual" },
82
+ { name: "base.en", ggmlFile: ggml("base.en"), onnxRepo: "Xenova/whisper-base.en", sizeMB: 142, englishOnly: true, description: "Good speed/quality, English-only" },
83
+ { name: "base", ggmlFile: ggml("base"), onnxRepo: "Xenova/whisper-base", sizeMB: 142, englishOnly: false, description: "Good speed/quality, multilingual" },
84
+ { name: "small.en", ggmlFile: ggml("small.en"), onnxRepo: "Xenova/whisper-small.en", sizeMB: 466, englishOnly: true, description: "Higher quality, English-only" },
85
+ { name: "small", ggmlFile: ggml("small"), onnxRepo: "Xenova/whisper-small", sizeMB: 466, englishOnly: false, description: "Higher quality, multilingual" },
86
+ { name: "medium.en", ggmlFile: ggml("medium.en"), onnxRepo: "Xenova/whisper-medium.en", sizeMB: 1500, englishOnly: true, description: "High quality, English-only" },
87
+ { name: "medium", ggmlFile: ggml("medium"), onnxRepo: "Xenova/whisper-medium", sizeMB: 1500, englishOnly: false, description: "High quality, multilingual" },
88
+ { name: "large-v3-turbo", ggmlFile: ggml("large-v3-turbo"), onnxRepo: "onnx-community/whisper-large-v3-turbo", sizeMB: 1600, englishOnly: false, description: "Near large-v3 quality, much faster" },
89
+ { name: "large-v3", ggmlFile: ggml("large-v3"), onnxRepo: "onnx-community/whisper-large-v3", sizeMB: 3100, englishOnly: false, description: "Best quality, multilingual" }
90
+ ];
91
+ var OPENAI_ALIASES = /* @__PURE__ */ new Set(["whisper-1", "whisper-large-v3", "gpt-4o-transcribe", "gpt-4o-mini-transcribe"]);
92
+ function isAlias(model) {
93
+ return OPENAI_ALIASES.has(model);
94
+ }
95
+ function findModel(name) {
96
+ return MODELS.find((m) => m.name === name);
97
+ }
98
+ function resolveModel(requested, defaultModel) {
99
+ if (requested && !isAlias(requested)) {
100
+ const found = findModel(requested);
101
+ if (found) return found;
102
+ }
103
+ return findModel(defaultModel) ?? MODELS.find((m) => m.name === "base.en");
104
+ }
105
+ function ggmlPath(model) {
106
+ return path2.join(paths.models(), model.ggmlFile);
107
+ }
108
+ function ggmlUrl(model) {
109
+ return `${HF_GGML_BASE}/${model.ggmlFile}`;
110
+ }
111
+ function isGgmlInstalled(model) {
112
+ return fs2.existsSync(ggmlPath(model));
113
+ }
114
+ async function downloadGgml(model, onProgress) {
115
+ const dest = ggmlPath(model);
116
+ if (fs2.existsSync(dest)) return dest;
117
+ fs2.mkdirSync(paths.models(), { recursive: true });
118
+ const url = ggmlUrl(model);
119
+ const res = await fetch(url, { redirect: "follow" });
120
+ if (!res.ok || !res.body) {
121
+ throw new Error(`Failed to download ${model.name} (${res.status} ${res.statusText}) from ${url}`);
122
+ }
123
+ const total = Number(res.headers.get("content-length") || 0);
124
+ const tmp = dest + ".part";
125
+ const out = fs2.createWriteStream(tmp);
126
+ let received = 0;
127
+ const reader = res.body.getReader();
128
+ try {
129
+ for (; ; ) {
130
+ const { done, value } = await reader.read();
131
+ if (done) break;
132
+ if (value) {
133
+ received += value.length;
134
+ if (!out.write(value)) {
135
+ await new Promise((resolve) => out.once("drain", resolve));
136
+ }
137
+ onProgress?.(received, total);
138
+ }
139
+ }
140
+ } finally {
141
+ out.end();
142
+ await new Promise((resolve, reject) => {
143
+ out.on("finish", () => resolve());
144
+ out.on("error", reject);
145
+ });
146
+ }
147
+ fs2.renameSync(tmp, dest);
148
+ return dest;
149
+ }
150
+
151
+ // src/keys/store.ts
152
+ import crypto from "crypto";
153
+ import fs3 from "fs";
154
+ import fsp2 from "fs/promises";
155
+ var KEY_PREFIX = "sk-wapi-";
156
+ function generateRawKey() {
157
+ return KEY_PREFIX + crypto.randomBytes(32).toString("base64url");
158
+ }
159
+ function hashKey(raw) {
160
+ return crypto.createHash("sha256").update(raw).digest("hex");
161
+ }
162
+ function newId() {
163
+ return "key_" + crypto.randomBytes(10).toString("hex");
164
+ }
165
+ async function load() {
166
+ try {
167
+ const raw = await fsp2.readFile(paths.keys(), "utf8");
168
+ const parsed = JSON.parse(raw);
169
+ return Array.isArray(parsed) ? parsed : [];
170
+ } catch {
171
+ return [];
172
+ }
173
+ }
174
+ async function persist(list) {
175
+ ensureDirs();
176
+ await fsp2.writeFile(paths.keys(), JSON.stringify(list, null, 2) + "\n", { encoding: "utf8", mode: 384 });
177
+ try {
178
+ fs3.chmodSync(paths.keys(), 384);
179
+ } catch {
180
+ }
181
+ }
182
+ async function createKey(name) {
183
+ const raw = generateRawKey();
184
+ const record = {
185
+ id: newId(),
186
+ name: name || "default",
187
+ prefix: raw.slice(0, KEY_PREFIX.length + 6),
188
+ hash: hashKey(raw),
189
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
190
+ lastUsedAt: null,
191
+ revoked: false
192
+ };
193
+ const list = await load();
194
+ list.push(record);
195
+ await persist(list);
196
+ return { raw, record };
197
+ }
198
+ async function listKeys() {
199
+ return load();
200
+ }
201
+ async function countActiveKeys() {
202
+ return (await load()).filter((k) => !k.revoked).length;
203
+ }
204
+ async function revokeKey(idOrPrefix) {
205
+ const list = await load();
206
+ let changed = false;
207
+ for (const rec of list) {
208
+ if (!rec.revoked && (rec.id === idOrPrefix || rec.prefix === idOrPrefix)) {
209
+ rec.revoked = true;
210
+ changed = true;
211
+ }
212
+ }
213
+ if (changed) await persist(list);
214
+ return changed;
215
+ }
216
+ async function verifyKey(raw) {
217
+ if (!raw || !raw.startsWith(KEY_PREFIX)) return null;
218
+ const candidate = Buffer.from(hashKey(raw), "hex");
219
+ const list = await load();
220
+ let match = null;
221
+ for (const rec of list) {
222
+ if (rec.revoked) continue;
223
+ const stored = Buffer.from(rec.hash, "hex");
224
+ if (stored.length === candidate.length && crypto.timingSafeEqual(stored, candidate)) {
225
+ match = rec;
226
+ break;
227
+ }
228
+ }
229
+ if (match) {
230
+ match.lastUsedAt = (/* @__PURE__ */ new Date()).toISOString();
231
+ await persist(list);
232
+ }
233
+ return match;
234
+ }
235
+
236
+ // src/audio.ts
237
+ import { spawn } from "child_process";
238
+ import fs4 from "fs";
239
+ import path3 from "path";
240
+ import crypto2 from "crypto";
241
+ import ffmpegStatic from "ffmpeg-static";
242
+ var SAMPLE_RATE = 16e3;
243
+ function ffmpegBin() {
244
+ return process.env.FFMPEG_PATH || ffmpegStatic || "ffmpeg";
245
+ }
246
+ function run(args) {
247
+ return new Promise((resolve, reject) => {
248
+ const proc = spawn(ffmpegBin(), args, { stdio: ["ignore", "pipe", "pipe"] });
249
+ const out = [];
250
+ const err = [];
251
+ proc.stdout.on("data", (d) => out.push(d));
252
+ proc.stderr.on("data", (d) => err.push(d));
253
+ proc.on("error", reject);
254
+ proc.on("close", (code) => {
255
+ if (code === 0) resolve(Buffer.concat(out));
256
+ else reject(new Error(`ffmpeg exited with code ${code}: ${Buffer.concat(err).toString("utf8").slice(-800)}`));
257
+ });
258
+ });
259
+ }
260
+ async function toWav16k(inputPath) {
261
+ ensureDirs();
262
+ const outPath = path3.join(paths.tmp(), `wav-${crypto2.randomBytes(8).toString("hex")}.wav`);
263
+ await run(["-nostdin", "-i", inputPath, "-ar", String(SAMPLE_RATE), "-ac", "1", "-c:a", "pcm_s16le", "-f", "wav", outPath, "-y"]);
264
+ return outPath;
265
+ }
266
+ async function toFloat32(inputPath) {
267
+ const raw = await run(["-nostdin", "-i", inputPath, "-ar", String(SAMPLE_RATE), "-ac", "1", "-f", "f32le", "-"]);
268
+ const aligned = new Uint8Array(raw.byteLength);
269
+ aligned.set(raw);
270
+ const samples = new Float32Array(aligned.buffer, 0, Math.floor(aligned.byteLength / 4));
271
+ return { samples, sampleRate: SAMPLE_RATE, duration: samples.length / SAMPLE_RATE };
272
+ }
273
+ function cleanup(file) {
274
+ if (!file) return;
275
+ try {
276
+ fs4.rmSync(file, { force: true });
277
+ } catch {
278
+ }
279
+ }
280
+
281
+ // src/engine/onnx.ts
282
+ var ISO_TO_NAME = {
283
+ en: "english",
284
+ es: "spanish",
285
+ fr: "french",
286
+ de: "german",
287
+ it: "italian",
288
+ pt: "portuguese",
289
+ nl: "dutch",
290
+ ru: "russian",
291
+ zh: "chinese",
292
+ ja: "japanese",
293
+ ko: "korean",
294
+ ar: "arabic",
295
+ hi: "hindi",
296
+ tr: "turkish",
297
+ pl: "polish",
298
+ uk: "ukrainian",
299
+ sv: "swedish",
300
+ cs: "czech",
301
+ da: "danish",
302
+ fi: "finnish"
303
+ };
304
+ var OnnxEngine = class _OnnxEngine {
305
+ kind = "onnx";
306
+ model;
307
+ device;
308
+ pipe = null;
309
+ constructor(model) {
310
+ this.model = model;
311
+ this.device = process.env.WHISPER_API_ONNX_DEVICE || "cpu";
312
+ }
313
+ describe() {
314
+ return `onnx (${this.device})`;
315
+ }
316
+ forModel(model) {
317
+ return new _OnnxEngine(model);
318
+ }
319
+ async getPipe(onProgress) {
320
+ if (this.pipe) return this.pipe;
321
+ const { pipeline: pipeline2, env } = await import("@huggingface/transformers");
322
+ env.cacheDir = paths.cache();
323
+ env.allowLocalModels = true;
324
+ const dtype = process.env.WHISPER_API_ONNX_DTYPE || (this.model.sizeMB > 1e3 ? "q8" : "fp32");
325
+ const pipe = await pipeline2("automatic-speech-recognition", this.model.onnxRepo, {
326
+ device: this.device,
327
+ dtype,
328
+ progress_callback: onProgress ? (p) => {
329
+ if (p.status === "progress" && typeof p.loaded === "number" && typeof p.total === "number") {
330
+ onProgress(p.loaded, p.total);
331
+ }
332
+ } : void 0
333
+ });
334
+ this.pipe = pipe;
335
+ return pipe;
336
+ }
337
+ async ensureModel(onProgress) {
338
+ await this.getPipe(onProgress);
339
+ }
340
+ async transcribe(inputPath, opts) {
341
+ const pipe = await this.getPipe();
342
+ const { samples, duration } = await toFloat32(inputPath);
343
+ const runOpts = {
344
+ return_timestamps: true,
345
+ chunk_length_s: 30,
346
+ stride_length_s: 5
347
+ };
348
+ if (!this.model.englishOnly) {
349
+ runOpts["task"] = opts.translate ? "translate" : "transcribe";
350
+ if (opts.language) runOpts["language"] = ISO_TO_NAME[opts.language] ?? opts.language;
351
+ }
352
+ if (typeof opts.temperature === "number") runOpts["temperature"] = opts.temperature;
353
+ const out = await pipe(samples, runOpts);
354
+ const segments = (out.chunks ?? []).map((c, i) => ({
355
+ id: i,
356
+ start: c.timestamp[0] ?? 0,
357
+ end: c.timestamp[1] ?? duration,
358
+ text: c.text.trim()
359
+ }));
360
+ return {
361
+ text: (out.text ?? "").trim(),
362
+ language: opts.language,
363
+ duration,
364
+ segments
365
+ };
366
+ }
367
+ };
368
+
369
+ // src/engine/whispercpp.ts
370
+ import { spawn as spawn2 } from "child_process";
371
+ import fs5 from "fs";
372
+ import fsp3 from "fs/promises";
373
+ import os2 from "os";
374
+ import path4 from "path";
375
+ import crypto3 from "crypto";
376
+ var WhisperCppEngine = class _WhisperCppEngine {
377
+ kind = "whispercpp";
378
+ model;
379
+ bin;
380
+ gpu;
381
+ constructor(model, bin, gpu) {
382
+ this.model = model;
383
+ this.bin = bin;
384
+ this.gpu = gpu;
385
+ }
386
+ describe() {
387
+ return `whisper.cpp (${this.gpu ?? "cpu"})`;
388
+ }
389
+ forModel(model) {
390
+ return new _WhisperCppEngine(model, this.bin, this.gpu);
391
+ }
392
+ async ensureModel(onProgress) {
393
+ await downloadGgml(this.model, onProgress);
394
+ }
395
+ async transcribe(inputPath, opts) {
396
+ await this.ensureModel();
397
+ const wav = await toWav16k(inputPath);
398
+ const outPrefix = path4.join(paths.tmp(), `wcpp-${crypto3.randomBytes(8).toString("hex")}`);
399
+ const jsonPath = outPrefix + ".json";
400
+ const args = [
401
+ "-m",
402
+ ggmlPath(this.model),
403
+ "-f",
404
+ wav,
405
+ "-oj",
406
+ "-of",
407
+ outPrefix,
408
+ "-np",
409
+ "-t",
410
+ String(Math.max(1, os2.cpus().length))
411
+ ];
412
+ if (this.model.englishOnly) {
413
+ args.push("-l", "en");
414
+ } else {
415
+ args.push("-l", opts.language ?? "auto");
416
+ }
417
+ if (opts.translate) args.push("--translate");
418
+ if (typeof opts.temperature === "number") args.push("-tp", String(opts.temperature));
419
+ if (opts.prompt) args.push("--prompt", opts.prompt);
420
+ try {
421
+ await this.run(args);
422
+ const raw = await fsp3.readFile(jsonPath, "utf8");
423
+ return this.parse(JSON.parse(raw), opts);
424
+ } finally {
425
+ cleanup(wav);
426
+ cleanup(jsonPath);
427
+ }
428
+ }
429
+ run(args) {
430
+ return new Promise((resolve, reject) => {
431
+ const proc = spawn2(this.bin, args, { stdio: ["ignore", "ignore", "pipe"] });
432
+ const err = [];
433
+ proc.stderr.on("data", (d) => err.push(d));
434
+ proc.on("error", reject);
435
+ proc.on(
436
+ "close",
437
+ (code) => code === 0 ? resolve() : reject(new Error(`whisper-cli exited with code ${code}: ${Buffer.concat(err).toString("utf8").slice(-800)}`))
438
+ );
439
+ });
440
+ }
441
+ parse(data, opts) {
442
+ const items = data.transcription ?? [];
443
+ const segments = items.map((it, i) => ({
444
+ id: i,
445
+ start: (it.offsets?.from ?? 0) / 1e3,
446
+ end: (it.offsets?.to ?? 0) / 1e3,
447
+ text: (it.text ?? "").trim()
448
+ }));
449
+ const last = items[items.length - 1];
450
+ return {
451
+ text: items.map((it) => it.text ?? "").join("").trim(),
452
+ language: data.result?.language ?? data.params?.language ?? opts.language,
453
+ duration: last?.offsets?.to ? last.offsets.to / 1e3 : void 0,
454
+ segments
455
+ };
456
+ }
457
+ };
458
+
459
+ // src/engine/probe.ts
460
+ import { spawn as spawn3, spawnSync } from "child_process";
461
+ import fs6 from "fs";
462
+ import os3 from "os";
463
+ import path5 from "path";
464
+ var WHISPER_CPP_REPO = "https://github.com/ggml-org/whisper.cpp";
465
+ function which(cmd) {
466
+ const finder = process.platform === "win32" ? "where" : "which";
467
+ const r = spawnSync(finder, [cmd], { stdio: "ignore" });
468
+ return r.status === 0;
469
+ }
470
+ function hasBuildTools() {
471
+ const compiler = which("cc") || which("clang") || which("gcc") || which("c++");
472
+ return which("git") && which("cmake") && compiler;
473
+ }
474
+ function detectGpu() {
475
+ if (which("nvidia-smi")) return "cuda";
476
+ if (process.platform === "darwin" && os3.arch() === "arm64") return "metal";
477
+ return null;
478
+ }
479
+ function binCandidates() {
480
+ const exe = process.platform === "win32" ? ".exe" : "";
481
+ const names = [`whisper-cli${exe}`, `main${exe}`];
482
+ const out = [];
483
+ if (process.env.WHISPER_CPP_BIN) out.push(process.env.WHISPER_CPP_BIN);
484
+ for (const n of names) out.push(path5.join(paths.bin(), n));
485
+ return out;
486
+ }
487
+ function locateWhisperBinary() {
488
+ for (const c of binCandidates()) {
489
+ if (fs6.existsSync(c)) return c;
490
+ }
491
+ if (which("whisper-cli")) return "whisper-cli";
492
+ return null;
493
+ }
494
+ function step(cmd, args, cwd, onLog) {
495
+ return new Promise((resolve, reject) => {
496
+ onLog?.(`$ ${cmd} ${args.join(" ")}`);
497
+ const proc = spawn3(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
498
+ const relay = (d) => onLog?.(d.toString("utf8").trimEnd());
499
+ proc.stdout.on("data", relay);
500
+ proc.stderr.on("data", relay);
501
+ proc.on("error", reject);
502
+ proc.on(
503
+ "close",
504
+ (code) => code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`))
505
+ );
506
+ });
507
+ }
508
+ async function buildWhisperCpp(onLog) {
509
+ if (!hasBuildTools()) {
510
+ throw new Error("Build tools missing (need git, cmake and a C/C++ compiler).");
511
+ }
512
+ ensureDirs();
513
+ const srcDir = path5.join(paths.cache(), "whisper.cpp-src");
514
+ const buildDir = path5.join(srcDir, "build");
515
+ const gpu = detectGpu();
516
+ if (!fs6.existsSync(path5.join(srcDir, "CMakeLists.txt"))) {
517
+ fs6.rmSync(srcDir, { recursive: true, force: true });
518
+ await step("git", ["clone", "--depth", "1", WHISPER_CPP_REPO, srcDir], paths.cache(), onLog);
519
+ }
520
+ const cmakeArgs = [
521
+ "-S",
522
+ srcDir,
523
+ "-B",
524
+ buildDir,
525
+ "-DCMAKE_BUILD_TYPE=Release",
526
+ "-DWHISPER_BUILD_TESTS=OFF",
527
+ "-DWHISPER_BUILD_SERVER=OFF",
528
+ "-DWHISPER_BUILD_EXAMPLES=ON"
529
+ ];
530
+ if (gpu === "cuda") cmakeArgs.push("-DGGML_CUDA=ON");
531
+ await step("cmake", cmakeArgs, srcDir, onLog);
532
+ await step("cmake", ["--build", buildDir, "-j", String(Math.max(1, os3.cpus().length)), "--config", "Release"], srcDir, onLog);
533
+ const exe = process.platform === "win32" ? ".exe" : "";
534
+ const built = [
535
+ path5.join(buildDir, "bin", `whisper-cli${exe}`),
536
+ path5.join(buildDir, "bin", "Release", `whisper-cli${exe}`)
537
+ ].find((p) => fs6.existsSync(p));
538
+ if (!built) throw new Error("Build finished but whisper-cli binary was not found.");
539
+ const dest = path5.join(paths.bin(), `whisper-cli${exe}`);
540
+ fs6.copyFileSync(built, dest);
541
+ fs6.chmodSync(dest, 493);
542
+ onLog?.(`Installed whisper.cpp \u2192 ${dest}`);
543
+ return dest;
544
+ }
545
+
546
+ // src/engine/detect.ts
547
+ async function createEngine(opts) {
548
+ const { engine, model, model: m } = opts;
549
+ const allowBuild = opts.allowBuild ?? process.env.WHISPER_API_AUTOBUILD === "1";
550
+ if (engine === "onnx") {
551
+ return { engine: new OnnxEngine(m), reason: "engine=onnx (portable, no compiler)" };
552
+ }
553
+ if (engine === "whispercpp") {
554
+ const bin = locateWhisperBinary() ?? await tryBuild(opts.onLog);
555
+ if (!bin) {
556
+ throw new Error(
557
+ "engine=whispercpp requested but no binary is available and it could not be built. Install build tools (git, cmake, a C/C++ compiler) and run `whisper-api build-engine`, set WHISPER_CPP_BIN, or use --engine onnx."
558
+ );
559
+ }
560
+ return { engine: new WhisperCppEngine(model, bin, detectGpu()), reason: "engine=whispercpp" };
561
+ }
562
+ const existing = locateWhisperBinary();
563
+ if (existing) {
564
+ return { engine: new WhisperCppEngine(model, existing, detectGpu()), reason: "auto \u2192 whisper.cpp (binary found)" };
565
+ }
566
+ if (allowBuild && hasBuildTools()) {
567
+ const built = await tryBuild(opts.onLog);
568
+ if (built) {
569
+ return { engine: new WhisperCppEngine(model, built, detectGpu()), reason: "auto \u2192 whisper.cpp (built from source)" };
570
+ }
571
+ }
572
+ return {
573
+ engine: new OnnxEngine(m),
574
+ reason: hasBuildTools() ? "auto \u2192 onnx (no whisper.cpp binary; run `whisper-api build-engine` for native speed)" : "auto \u2192 onnx (no whisper.cpp binary and no build toolchain)"
575
+ };
576
+ }
577
+ async function tryBuild(onLog) {
578
+ try {
579
+ return await buildWhisperCpp(onLog);
580
+ } catch (err) {
581
+ onLog?.(`whisper.cpp build failed: ${err.message}`);
582
+ return null;
583
+ }
584
+ }
585
+
586
+ // src/server/app.ts
587
+ import { fileURLToPath } from "url";
588
+ import fs8 from "fs";
589
+ import Fastify from "fastify";
590
+ import multipart from "@fastify/multipart";
591
+ import rateLimit from "@fastify/rate-limit";
592
+ import fastifyStatic from "@fastify/static";
593
+
594
+ // src/server/formats.ts
595
+ var RESPONSE_FORMATS = ["json", "verbose_json", "text", "srt", "vtt"];
596
+ function isResponseFormat(v) {
597
+ return RESPONSE_FORMATS.includes(v);
598
+ }
599
+ function pad(n, width) {
600
+ return Math.floor(n).toString().padStart(width, "0");
601
+ }
602
+ function timestamp(seconds, sep) {
603
+ const ms = Math.round((seconds - Math.floor(seconds)) * 1e3);
604
+ const s = Math.floor(seconds) % 60;
605
+ const m = Math.floor(seconds / 60) % 60;
606
+ const h = Math.floor(seconds / 3600);
607
+ return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)}${sep}${pad(ms, 3)}`;
608
+ }
609
+ function toSrt(r) {
610
+ return r.segments.map((seg, i) => `${i + 1}
611
+ ${timestamp(seg.start, ",")} --> ${timestamp(seg.end, ",")}
612
+ ${seg.text}
613
+ `).join("\n") + "\n";
614
+ }
615
+ function toVtt(r) {
616
+ const cues = r.segments.map((seg) => `${timestamp(seg.start, ".")} --> ${timestamp(seg.end, ".")}
617
+ ${seg.text}`).join("\n\n");
618
+ return `WEBVTT
619
+
620
+ ${cues}
621
+ `;
622
+ }
623
+ function serialize(result, format) {
624
+ switch (format) {
625
+ case "text":
626
+ return { contentType: "text/plain; charset=utf-8", body: result.text + "\n" };
627
+ case "srt":
628
+ return { contentType: "text/plain; charset=utf-8", body: toSrt(result) };
629
+ case "vtt":
630
+ return { contentType: "text/vtt; charset=utf-8", body: toVtt(result) };
631
+ case "verbose_json":
632
+ return {
633
+ contentType: "application/json; charset=utf-8",
634
+ body: JSON.stringify({
635
+ task: "transcribe",
636
+ language: result.language ?? "unknown",
637
+ duration: result.duration ?? 0,
638
+ text: result.text,
639
+ segments: result.segments.map((seg) => ({
640
+ id: seg.id,
641
+ seek: 0,
642
+ start: seg.start,
643
+ end: seg.end,
644
+ text: seg.text,
645
+ tokens: [],
646
+ temperature: 0,
647
+ avg_logprob: 0,
648
+ compression_ratio: 0,
649
+ no_speech_prob: 0
650
+ }))
651
+ })
652
+ };
653
+ case "json":
654
+ default:
655
+ return { contentType: "application/json; charset=utf-8", body: JSON.stringify({ text: result.text }) };
656
+ }
657
+ }
658
+ function errorBody(message, type = "invalid_request_error", code = null) {
659
+ return { error: { message, type, param: null, code } };
660
+ }
661
+
662
+ // src/server/auth.ts
663
+ var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/health", "/", "/favicon.ico"]);
664
+ function isPublic(url) {
665
+ const pathname = url.split("?")[0] ?? url;
666
+ return PUBLIC_PATHS.has(pathname) || !pathname.startsWith("/v1/") && !pathname.startsWith("/v1");
667
+ }
668
+ function extractBearer(header) {
669
+ if (!header) return null;
670
+ const m = /^Bearer\s+(.+)$/i.exec(header.trim());
671
+ return m ? m[1].trim() : null;
672
+ }
673
+ async function authHook(req, reply) {
674
+ if (isPublic(req.url)) return;
675
+ const token = extractBearer(req.headers.authorization);
676
+ if (!token) {
677
+ await reply.code(401).send(errorBody("Missing bearer token. Pass `Authorization: Bearer <key>`.", "invalid_request_error", "missing_api_key"));
678
+ return;
679
+ }
680
+ const record = await verifyKey(token);
681
+ if (!record) {
682
+ await reply.code(401).send(errorBody("Invalid or revoked API key.", "invalid_request_error", "invalid_api_key"));
683
+ return;
684
+ }
685
+ req.apiKey = record;
686
+ }
687
+
688
+ // src/server/routes/transcriptions.ts
689
+ import fs7 from "fs";
690
+ import path6 from "path";
691
+ import crypto4 from "crypto";
692
+ import { pipeline } from "stream/promises";
693
+ async function parseMultipart(req) {
694
+ ensureDirs();
695
+ const result = { fields: {} };
696
+ for await (const part of req.parts()) {
697
+ if (part.type === "file") {
698
+ if (part.fieldname === "file") {
699
+ const ext = path6.extname(part.filename || "") || ".bin";
700
+ const dest = path6.join(paths.tmp(), `up-${crypto4.randomBytes(8).toString("hex")}${ext}`);
701
+ await pipeline(part.file, fs7.createWriteStream(dest));
702
+ result.filePath = dest;
703
+ result.filename = part.filename;
704
+ if (part.file.truncated) {
705
+ cleanup(dest);
706
+ throw Object.assign(new Error("Uploaded file exceeds the configured size limit."), { statusCode: 413 });
707
+ }
708
+ } else {
709
+ part.file.resume();
710
+ }
711
+ } else {
712
+ result.fields[part.fieldname] = String(part.value);
713
+ }
714
+ }
715
+ return result;
716
+ }
717
+ function registerTranscriptions(app, ctx) {
718
+ const handler = (translate) => async (req, reply) => {
719
+ let parsed;
720
+ try {
721
+ parsed = await parseMultipart(req);
722
+ if (!parsed.filePath) {
723
+ return reply.code(400).send(errorBody("Missing required `file` field.", "invalid_request_error", "missing_file"));
724
+ }
725
+ const fmtRaw = parsed.fields["response_format"] || "json";
726
+ if (!isResponseFormat(fmtRaw)) {
727
+ return reply.code(400).send(errorBody(`Unsupported response_format '${fmtRaw}'.`, "invalid_request_error"));
728
+ }
729
+ const format = fmtRaw;
730
+ const model = resolveModel(parsed.fields["model"], ctx.config.defaultModel);
731
+ const temperature = parsed.fields["temperature"] !== void 0 ? Number(parsed.fields["temperature"]) : void 0;
732
+ const task = parsed.fields["task"];
733
+ const engine = await ctx.getEngine(model.name);
734
+ const result = await engine.transcribe(parsed.filePath, {
735
+ language: parsed.fields["language"] || void 0,
736
+ translate: translate || task === "translate",
737
+ temperature: typeof temperature === "number" && !Number.isNaN(temperature) ? temperature : void 0,
738
+ prompt: parsed.fields["prompt"] || void 0
739
+ });
740
+ const { contentType, body } = serialize(result, format);
741
+ return reply.header("content-type", contentType).send(body);
742
+ } catch (err) {
743
+ const e = err;
744
+ const status = e.statusCode ?? 500;
745
+ req.log.error({ err: e }, "transcription failed");
746
+ return reply.code(status).send(errorBody(e.message || "Transcription failed.", status === 413 ? "invalid_request_error" : "server_error"));
747
+ } finally {
748
+ cleanup(parsed?.filePath);
749
+ }
750
+ };
751
+ const routeOpts = {
752
+ config: { rateLimit: { max: ctx.config.rateLimit.max, timeWindow: ctx.config.rateLimit.timeWindow } }
753
+ };
754
+ app.post("/v1/audio/transcriptions", routeOpts, handler(false));
755
+ app.post("/v1/audio/translations", routeOpts, handler(true));
756
+ }
757
+
758
+ // src/server/routes/models.ts
759
+ function registerModels(app, ctx) {
760
+ const created = 17e8;
761
+ const entry = (id) => ({ id, object: "model", created, owned_by: "whisper-api" });
762
+ app.get("/v1/models", async () => ({
763
+ object: "list",
764
+ data: [entry("whisper-1"), ...MODELS.map((m) => entry(m.name))]
765
+ }));
766
+ app.get("/v1/models/:id", async (req, reply) => {
767
+ const id = req.params.id;
768
+ if (id === "whisper-1" || MODELS.some((m) => m.name === id)) {
769
+ return entry(id);
770
+ }
771
+ return reply.code(404).send({ error: { message: `Model '${id}' not found.`, type: "invalid_request_error" } });
772
+ });
773
+ app.get("/v1/models/active", async () => entry(ctx.defaultEngine.model.name));
774
+ }
775
+
776
+ // src/server/routes/health.ts
777
+ function registerHealth(app, ctx) {
778
+ app.get("/health", async () => ({
779
+ status: "ok",
780
+ engine: ctx.engineLabel,
781
+ model: ctx.defaultEngine.model.name,
782
+ activeKeys: await countActiveKeys(),
783
+ uptime: Math.round(process.uptime()),
784
+ version: ctx.version
785
+ }));
786
+ }
787
+
788
+ // src/server/app.ts
789
+ function findWebDir() {
790
+ for (const rel of ["../web", "../../web", "../../../web"]) {
791
+ const dir = fileURLToPath(new URL(rel, import.meta.url));
792
+ if (fs8.existsSync(dir)) return dir;
793
+ }
794
+ return null;
795
+ }
796
+ async function buildServer(ctx) {
797
+ const app = Fastify({
798
+ logger: process.env.WHISPER_API_LOG === "silent" ? false : { level: process.env.WHISPER_API_LOG || "info" },
799
+ bodyLimit: ctx.config.maxUploadBytes + 1024 * 1024
800
+ });
801
+ await app.register(multipart, {
802
+ limits: { fileSize: ctx.config.maxUploadBytes, files: 1, fields: 25 }
803
+ });
804
+ await app.register(rateLimit, {
805
+ global: false,
806
+ keyGenerator: (req) => extractBearer(req.headers.authorization) ?? req.ip,
807
+ errorResponseBuilder: () => errorBody("Rate limit exceeded. Slow down or request a higher limit.", "rate_limit_error", "rate_limit_exceeded")
808
+ });
809
+ app.addHook("onRequest", authHook);
810
+ app.setErrorHandler((err, req, reply) => {
811
+ const status = err.statusCode && err.statusCode >= 400 ? err.statusCode : 500;
812
+ req.log.error({ err }, "request error");
813
+ reply.code(status).send(errorBody(err.message || "Internal server error.", status >= 500 ? "server_error" : "invalid_request_error"));
814
+ });
815
+ registerHealth(app, ctx);
816
+ registerModels(app, ctx);
817
+ registerTranscriptions(app, ctx);
818
+ const webDir = findWebDir();
819
+ if (webDir) {
820
+ await app.register(fastifyStatic, { root: webDir, prefix: "/", index: ["index.html"] });
821
+ } else {
822
+ app.get("/", async () => ({ name: "whisper-api", status: "ok", docs: "/health" }));
823
+ }
824
+ return app;
825
+ }
826
+ async function startServer(ctx) {
827
+ const app = await buildServer(ctx);
828
+ await app.listen({ host: ctx.config.host, port: ctx.config.port });
829
+ const shown = ctx.config.host === "0.0.0.0" ? "localhost" : ctx.config.host;
830
+ return { app, url: `http://${shown}:${ctx.config.port}` };
831
+ }
832
+
833
+ // src/version.ts
834
+ var VERSION = "0.1.0";
835
+
836
+ // src/cli/ui.ts
837
+ import cliProgress from "cli-progress";
838
+ import pc from "picocolors";
839
+ function mb(bytes) {
840
+ return Math.round(bytes / (1024 * 1024));
841
+ }
842
+ function modelProgress(label) {
843
+ const bar = new cliProgress.SingleBar(
844
+ { clearOnComplete: false, hideCursor: true, format: ` ${label} [{bar}] {percentage}% | {info}` },
845
+ cliProgress.Presets.shades_classic
846
+ );
847
+ let started = false;
848
+ return {
849
+ onProgress(received, total) {
850
+ if (!started) {
851
+ bar.start(total || 1, 0, { info: "" });
852
+ started = true;
853
+ }
854
+ if (total) bar.setTotal(total);
855
+ bar.update(received, { info: total ? `${mb(received)}/${mb(total)} MB` : `${mb(received)} MB` });
856
+ },
857
+ done() {
858
+ if (started) {
859
+ bar.update(bar.getTotal());
860
+ bar.stop();
861
+ } else {
862
+ console.log(` ${label} ${pc.green("\u2713")} ${pc.dim("(already present)")}`);
863
+ }
864
+ }
865
+ };
866
+ }
867
+ function printAccessExample(baseUrl, key) {
868
+ const k = key ?? "sk-wapi-\u2026";
869
+ const note2 = key ? "" : pc.dim(" (generate one with: whisper-api key generate)\n");
870
+ console.log();
871
+ console.log(pc.bold(" Connect a third-party app to this endpoint:"));
872
+ console.log();
873
+ if (note2) console.log(note2.trimEnd());
874
+ console.log(pc.dim(" # curl"));
875
+ console.log(` curl ${baseUrl}/v1/audio/transcriptions \\`);
876
+ console.log(` -H ${pc.cyan(`"Authorization: Bearer ${k}"`)} \\`);
877
+ console.log(` -F file=@audio.m4a -F model=whisper-1`);
878
+ console.log();
879
+ console.log(pc.dim(" # Python \u2014 official OpenAI SDK, just repoint base_url"));
880
+ console.log(` from openai import OpenAI`);
881
+ console.log(` client = OpenAI(base_url=${pc.cyan(`"${baseUrl}/v1"`)}, api_key=${pc.cyan(`"${k}"`)})`);
882
+ console.log(` print(client.audio.transcriptions.create(`);
883
+ console.log(` model="whisper-1", file=open("audio.m4a", "rb")).text)`);
884
+ console.log();
885
+ console.log(pc.dim(" # Node \u2014 official OpenAI SDK"));
886
+ console.log(` import OpenAI from "openai";`);
887
+ console.log(` const client = new OpenAI({ baseURL: ${pc.cyan(`"${baseUrl}/v1"`)}, apiKey: ${pc.cyan(`"${k}"`)} });`);
888
+ console.log();
889
+ console.log(pc.dim(` # Any OpenAI-compatible app (Open WebUI, n8n, Raycast, LibreChat\u2026):`));
890
+ console.log(` Base URL ${pc.cyan(`${baseUrl}/v1`)}`);
891
+ console.log(` API key ${pc.cyan(k)}`);
892
+ console.log();
893
+ }
894
+ function printNewKey(raw, name, id) {
895
+ console.log();
896
+ console.log(pc.green(" \u2714 New API key \u2014 store it now, it is not recoverable:"));
897
+ console.log();
898
+ console.log(` ${pc.bold(pc.cyan(raw))}`);
899
+ console.log();
900
+ console.log(pc.dim(` id ${id} name ${name}`));
901
+ console.log();
902
+ }
903
+
904
+ // src/cli/index.ts
905
+ dotenv.config({ quiet: true });
906
+ dotenv.config({ path: path7.join(homeDir(), ".env"), quiet: true });
907
+ async function provision(engineChoice, model, label) {
908
+ const ui = modelProgress(label.padEnd(16));
909
+ try {
910
+ if (engineChoice === "onnx") {
911
+ await new OnnxEngine(model).ensureModel(ui.onProgress);
912
+ } else {
913
+ await downloadGgml(model, ui.onProgress);
914
+ }
915
+ } finally {
916
+ ui.done();
917
+ }
918
+ }
919
+ async function runInit() {
920
+ ensureDirs();
921
+ const existing = await loadConfig();
922
+ intro(pc.bold(" whisper-api setup "));
923
+ const engine = await select({
924
+ message: "Transcription engine",
925
+ initialValue: existing.engine,
926
+ options: [
927
+ { value: "auto", label: "auto", hint: "whisper.cpp if available, else portable ONNX" },
928
+ { value: "whispercpp", label: "whisper.cpp", hint: "fastest, GPU; needs build tools or a prebuilt binary" },
929
+ { value: "onnx", label: "onnx", hint: "pure-JS, no compiler, runs anywhere" }
930
+ ]
931
+ });
932
+ if (isCancel(engine)) return cancel("Setup cancelled.");
933
+ const chosen = await multiselect({
934
+ message: "Models to download (space to toggle)",
935
+ required: true,
936
+ initialValues: [existing.defaultModel],
937
+ options: MODELS.map((m) => ({ value: m.name, label: `${m.name} ${pc.dim(`(${m.sizeMB} MB)`)}`, hint: m.description }))
938
+ });
939
+ if (isCancel(chosen)) return cancel("Setup cancelled.");
940
+ const selected = chosen;
941
+ let defaultModel = selected[0];
942
+ if (selected.length > 1) {
943
+ const d = await select({
944
+ message: "Default model (served for the `whisper-1` alias)",
945
+ initialValue: selected.includes(existing.defaultModel) ? existing.defaultModel : selected[0],
946
+ options: selected.map((n) => ({ value: n, label: n }))
947
+ });
948
+ if (isCancel(d)) return cancel("Setup cancelled.");
949
+ defaultModel = d;
950
+ }
951
+ const config = { ...DEFAULT_CONFIG, ...existing, engine, defaultModel };
952
+ await saveConfig(config);
953
+ const { raw, record } = await createKey("default");
954
+ note(`${pc.cyan(raw)}
955
+ ${pc.dim(`id ${record.id}`)}`, "API key \u2014 copy now, shown once");
956
+ console.log();
957
+ console.log(pc.bold(` Downloading ${selected.length} model(s) for ${engine === "onnx" ? "ONNX" : "whisper.cpp"}\u2026`));
958
+ for (const name of selected) {
959
+ await provision(config.engine, findModel(name), name);
960
+ }
961
+ outro(pc.green("Setup complete."));
962
+ console.log(` Config: ${paths.config()}`);
963
+ console.log(` Start: ${pc.bold("whisper-api start")}`);
964
+ printAccessExample(`http://localhost:${config.port}`, raw);
965
+ }
966
+ async function runStart(opts) {
967
+ ensureDirs();
968
+ const config = applyEnvOverrides(await loadConfig());
969
+ if (opts.port) config.port = Number(opts.port);
970
+ if (opts.host) config.host = opts.host;
971
+ if (opts.engine) config.engine = opts.engine;
972
+ const model = resolveModel(opts.model || config.defaultModel, config.defaultModel);
973
+ const allowBuild = config.engine === "whispercpp" || process.env.WHISPER_API_AUTOBUILD === "1";
974
+ console.log(pc.dim(` Selecting engine (${config.engine})\u2026`));
975
+ const selection = await createEngine({
976
+ engine: config.engine,
977
+ model,
978
+ allowBuild,
979
+ onLog: (l) => console.log(pc.dim(" " + l))
980
+ });
981
+ console.log(pc.dim(` ${selection.reason}`));
982
+ const ui = modelProgress(`model ${model.name}`.padEnd(16));
983
+ try {
984
+ await selection.engine.ensureModel(ui.onProgress);
985
+ } finally {
986
+ ui.done();
987
+ }
988
+ const cache = /* @__PURE__ */ new Map([[model.name, selection.engine]]);
989
+ const getEngine = async (name) => {
990
+ const cached = cache.get(name);
991
+ if (cached) return cached;
992
+ const info = findModel(name) ?? model;
993
+ const eng = selection.engine.forModel(info);
994
+ cache.set(name, eng);
995
+ return eng;
996
+ };
997
+ const ctx = {
998
+ config,
999
+ defaultEngine: selection.engine,
1000
+ engineLabel: selection.engine.describe(),
1001
+ version: VERSION,
1002
+ getEngine
1003
+ };
1004
+ const { url } = await startServer(ctx);
1005
+ console.log();
1006
+ console.log(
1007
+ ` ${pc.green("\u25B6")} ${pc.bold("whisper-api")} on ${pc.cyan(url)} ${pc.dim("\xB7")} engine ${pc.bold(
1008
+ selection.engine.describe()
1009
+ )} ${pc.dim("\xB7")} model ${pc.bold(model.name)}`
1010
+ );
1011
+ if (await countActiveKeys() === 0) {
1012
+ console.log(` ${pc.yellow("!")} No API keys yet \u2014 run ${pc.bold("whisper-api key generate")}`);
1013
+ }
1014
+ printAccessExample(url);
1015
+ }
1016
+ async function runModelsList() {
1017
+ const config = await loadConfig();
1018
+ console.log(pc.bold(" Models ") + pc.dim("(\u2713 = GGML present locally)"));
1019
+ for (const m of MODELS) {
1020
+ const mark = isGgmlInstalled(m) ? pc.green("\u2713") : pc.dim("\xB7");
1021
+ const def = m.name === config.defaultModel ? pc.cyan(" (default)") : "";
1022
+ console.log(` ${mark} ${m.name.padEnd(16)} ${String(m.sizeMB).padStart(5)} MB ${pc.dim(m.description)}${def}`);
1023
+ }
1024
+ }
1025
+ async function runModelsPull(name) {
1026
+ const model = findModel(name);
1027
+ if (!model) {
1028
+ console.error(pc.red(`Unknown model '${name}'. Run \`whisper-api models list\`.`));
1029
+ process.exitCode = 1;
1030
+ return;
1031
+ }
1032
+ const config = await loadConfig();
1033
+ await provision(config.engine, model, name);
1034
+ console.log(pc.green(` \u2713 ${name} ready`));
1035
+ }
1036
+ async function runModelsRm(name) {
1037
+ const model = findModel(name);
1038
+ if (!model) {
1039
+ console.error(pc.red(`Unknown model '${name}'.`));
1040
+ process.exitCode = 1;
1041
+ return;
1042
+ }
1043
+ const p = ggmlPath(model);
1044
+ if (fs9.existsSync(p)) {
1045
+ fs9.rmSync(p);
1046
+ console.log(pc.green(` \u2713 removed ${model.ggmlFile}`));
1047
+ } else {
1048
+ console.log(pc.dim(` nothing to remove for ${name} (ONNX weights live under ${paths.cache()})`));
1049
+ }
1050
+ }
1051
+ async function runKeyGenerate(opts) {
1052
+ ensureDirs();
1053
+ const { raw, record } = await createKey(opts.name || "default");
1054
+ printNewKey(raw, record.name, record.id);
1055
+ const config = await loadConfig();
1056
+ printAccessExample(`http://localhost:${config.port}`, raw);
1057
+ }
1058
+ async function runKeyList() {
1059
+ const keys = await listKeys();
1060
+ if (!keys.length) {
1061
+ console.log(pc.dim(" No keys yet. Create one: whisper-api key generate"));
1062
+ return;
1063
+ }
1064
+ for (const k of keys) {
1065
+ const state = k.revoked ? pc.red("revoked") : pc.green("active");
1066
+ console.log(
1067
+ ` ${k.prefix}\u2026 ${state} ${pc.dim(k.id)} name=${k.name} created=${k.createdAt.slice(0, 10)} lastUsed=${k.lastUsedAt ? k.lastUsedAt.slice(0, 10) : "never"}`
1068
+ );
1069
+ }
1070
+ }
1071
+ async function runKeyRevoke(idOrPrefix) {
1072
+ const ok = await revokeKey(idOrPrefix);
1073
+ console.log(ok ? pc.green(` \u2713 revoked ${idOrPrefix}`) : pc.yellow(` no active key matched ${idOrPrefix}`));
1074
+ }
1075
+ async function runStatus() {
1076
+ const config = applyEnvOverrides(await loadConfig());
1077
+ const installed = MODELS.filter(isGgmlInstalled).map((m) => m.name);
1078
+ const bin = locateWhisperBinary();
1079
+ console.log(pc.bold(" whisper-api status"));
1080
+ console.log(` home ${homeDir()}`);
1081
+ console.log(` engine ${config.engine}`);
1082
+ console.log(` default ${config.defaultModel}`);
1083
+ console.log(` listen ${config.host}:${config.port}`);
1084
+ console.log(` rate limit ${config.rateLimit.max} / ${config.rateLimit.timeWindow} per key`);
1085
+ console.log(` ggml models ${installed.length ? installed.join(", ") : pc.dim("(none downloaded)")}`);
1086
+ console.log(` api keys ${await countActiveKeys()} active`);
1087
+ console.log(` whisper.cpp ${bin ? pc.green(bin) : pc.dim("not built (run: whisper-api build-engine)")}`);
1088
+ }
1089
+ async function runBuildEngine() {
1090
+ console.log(pc.bold(" Building whisper.cpp from source\u2026"));
1091
+ try {
1092
+ const bin = await buildWhisperCpp((l) => console.log(pc.dim(" " + l)));
1093
+ console.log(pc.green(` \u2713 built: ${bin}`));
1094
+ } catch (e) {
1095
+ console.error(pc.red(` build failed: ${e.message}`));
1096
+ process.exitCode = 1;
1097
+ }
1098
+ }
1099
+ function buildProgram() {
1100
+ const program2 = new Command();
1101
+ program2.name("whisper-api").description("Self-hostable, OpenAI-compatible Whisper speech-to-text API server.").version(VERSION, "-v, --version").showHelpAfterError();
1102
+ program2.command("init").description("Interactive setup: choose engine, download models, create an API key").action(runInit);
1103
+ program2.command("start").description("Start the API server").option("-p, --port <port>", "port to listen on").option("--host <host>", "host to bind").option("-m, --model <name>", "default model (overrides config)").option("-e, --engine <engine>", "auto | whispercpp | onnx").action(runStart);
1104
+ const models = program2.command("models").description("Manage local transcription models");
1105
+ models.command("list").description("List available and installed models").action(runModelsList);
1106
+ models.command("pull <name>").description("Download a model (e.g. base.en, large-v3)").action(runModelsPull);
1107
+ models.command("rm <name>").description("Remove a downloaded GGML model").action(runModelsRm);
1108
+ const key = program2.command("key").description("Manage API access keys");
1109
+ key.command("generate").description("Generate a new API key").option("-n, --name <name>", "label for the key").action(runKeyGenerate);
1110
+ key.command("list").description("List API keys").action(runKeyList);
1111
+ key.command("revoke <idOrPrefix>").description("Revoke a key by id or prefix").action(runKeyRevoke);
1112
+ program2.command("status").description("Show configuration and local state").action(runStatus);
1113
+ program2.command("build-engine").description("Build whisper.cpp from source for native speed").action(runBuildEngine);
1114
+ return program2;
1115
+ }
1116
+ var program = buildProgram();
1117
+ if (process.argv.slice(2).length === 0) {
1118
+ program.outputHelp();
1119
+ process.exit(0);
1120
+ }
1121
+ program.parseAsync(process.argv).catch((err) => {
1122
+ console.error(pc.red(err.message));
1123
+ process.exit(1);
1124
+ });
1125
+ //# sourceMappingURL=whisper-api.js.map