@drakulavich/kesha-voice-kit 1.24.0 → 1.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.24.0",
3
+ "version": "1.24.1",
4
4
  "description": "Give your tools a voice — speech to text and back, 25 languages, up to ~19× faster than Whisper. On your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,6 +52,30 @@ function isFalsey(v: string): boolean {
52
52
  const USER_FORCED_NO_COLOR =
53
53
  process.env.NO_COLOR !== undefined && !isFalsey(process.env.NO_COLOR);
54
54
 
55
+ /**
56
+ * Strip a boolean global flag out of rawArgs so citty never sees it. Matches
57
+ * any of `names` bare (sets the flag) and the `--name=<value>` form for the
58
+ * long names (`<falsey>` explicitly turns the flag back off).
59
+ */
60
+ function stripBooleanFlag(
61
+ rawArgs: string[],
62
+ names: string[],
63
+ ): { value: boolean; rawArgs: string[] } {
64
+ const valuedPrefixes = names.filter((n) => n.startsWith("--")).map((n) => `${n}=`);
65
+ let value = false;
66
+ const cleaned: string[] = [];
67
+ for (const arg of rawArgs) {
68
+ if (names.includes(arg)) {
69
+ value = true;
70
+ } else if (valuedPrefixes.some((prefix) => arg.startsWith(prefix))) {
71
+ value = !isFalsey(arg.slice(arg.indexOf("=") + 1));
72
+ } else {
73
+ cleaned.push(arg);
74
+ }
75
+ }
76
+ return { value, rawArgs: cleaned };
77
+ }
78
+
55
79
  /**
56
80
  * Decide whether ANSI colors should be disabled (#531) and return rawArgs with
57
81
  * any `--no-color[=value]` token stripped so citty never sees it.
@@ -65,19 +89,9 @@ export function resolveColorMode(
65
89
  rawArgs: string[],
66
90
  env: { CI?: string } = process.env as { CI?: string },
67
91
  ): { disableColor: boolean; rawArgs: string[] } {
68
- let flag = false;
69
- const cleaned: string[] = [];
70
- for (const arg of rawArgs) {
71
- if (arg === "--no-color") {
72
- flag = true;
73
- } else if (arg.startsWith("--no-color=")) {
74
- flag = !isFalsey(arg.slice("--no-color=".length));
75
- } else {
76
- cleaned.push(arg);
77
- }
78
- }
92
+ const { value, rawArgs: cleaned } = stripBooleanFlag(rawArgs, ["--no-color"]);
79
93
  const ci = env.CI !== undefined && !isFalsey(env.CI);
80
- return { disableColor: flag || ci, rawArgs: cleaned };
94
+ return { disableColor: value || ci, rawArgs: cleaned };
81
95
  }
82
96
 
83
97
  /**
@@ -87,18 +101,8 @@ export function resolveColorMode(
87
101
  * subcommand that doesn't declare it never sees the flag.
88
102
  */
89
103
  export function resolveQuietMode(rawArgs: string[]): { quiet: boolean; rawArgs: string[] } {
90
- let flag = false;
91
- const cleaned: string[] = [];
92
- for (const arg of rawArgs) {
93
- if (arg === "--quiet" || arg === "-q") {
94
- flag = true;
95
- } else if (arg.startsWith("--quiet=")) {
96
- flag = !isFalsey(arg.slice("--quiet=".length));
97
- } else {
98
- cleaned.push(arg);
99
- }
100
- }
101
- return { quiet: flag, rawArgs: cleaned };
104
+ const { value, rawArgs: cleaned } = stripBooleanFlag(rawArgs, ["--quiet", "-q"]);
105
+ return { quiet: value, rawArgs: cleaned };
102
106
  }
103
107
 
104
108
  export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
@@ -1,4 +1,5 @@
1
1
  import { defineCommand } from "citty";
2
+ import { errorMessage } from "../error-utils";
2
3
  import { downloadEngine } from "../engine-install";
3
4
  import { getEngineBinPath, getEngineCapabilities } from "../engine";
4
5
  import { renderInstallPlan } from "../install-plan";
@@ -111,7 +112,7 @@ function finishInstallDiagnostic(
111
112
  });
112
113
  diagnosticLog.finish(status);
113
114
  } catch (err) {
114
- log.debug(`install diagnostic log finish dropped: ${err instanceof Error ? err.message : String(err)}`);
115
+ log.debug(`install diagnostic log finish dropped: ${errorMessage(err)}`);
115
116
  }
116
117
  }
117
118
 
@@ -161,7 +162,7 @@ export async function performInstall(
161
162
  await maybeAskForStar(getEngineBinPath(), packageVersion, log);
162
163
  finishInstallDiagnostic(diagnosticLog, startedAt, "success");
163
164
  } catch (err: unknown) {
164
- const message = err instanceof Error ? err.message : String(err);
165
+ const message = errorMessage(err);
165
166
  finishInstallDiagnostic(diagnosticLog, startedAt, "failed", errorKind);
166
167
  log.error(message);
167
168
  process.exit(1);
@@ -219,7 +220,7 @@ export const installCommand = defineCommand({
219
220
  try {
220
221
  ttsLangs = resolveTtsLangs({ tts: args.tts === true, positionals }, supported);
221
222
  } catch (err) {
222
- log.error(err instanceof Error ? err.message : String(err));
223
+ log.error(errorMessage(err));
223
224
  process.exit(1);
224
225
  }
225
226
  await performInstall(
package/src/cli/main.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { defineCommand } from "citty";
2
+ import { errorMessage } from "../error-utils";
2
3
  import { existsSync } from "fs";
3
4
  import { detect } from "tinyld";
4
5
  import { preflightTranscribeWithSegments, transcribeWithSegments } from "../transcribe";
@@ -435,7 +436,7 @@ export const mainCommand = defineCommand({
435
436
  } catch (err: unknown) {
436
437
  progress?.stop();
437
438
  hasError = true;
438
- const stderrText = err instanceof Error ? err.message : String(err);
439
+ const stderrText = errorMessage(err);
439
440
  const code = extractEngineErrorCode(stderrText) ?? ENGINE_CODES.TRANSCRIBE_FAILED;
440
441
  stats.recordError("transcribe", err, code);
441
442
  diagnosticLog.event("engine.exit", {
package/src/cli/say.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { defineCommand } from "citty";
2
+ import { errorMessage } from "../error-utils";
2
3
  import { detectTextLanguageEngine, getEngineBinPath } from "../engine";
3
4
  import { log } from "../log";
4
5
  import { say, SayError, type SayFormat } from "../synth";
@@ -289,7 +290,7 @@ export const sayCommand = defineCommand({
289
290
  log.error(err.stderr.trim() || err.message);
290
291
  process.exit(err.exitCode);
291
292
  }
292
- const message = err instanceof Error ? err.message : String(err);
293
+ const message = errorMessage(err);
293
294
  log.error(message);
294
295
  process.exit(4);
295
296
  }
@@ -1,4 +1,5 @@
1
1
  import { defineCommand } from "citty";
2
+ import { errorMessage } from "../error-utils";
2
3
  import { createSupportBundle } from "../support-bundle";
3
4
  import { log } from "../log";
4
5
 
@@ -33,7 +34,7 @@ export const supportBundleCommand = defineCommand({
33
34
  log.info(`Entries: ${bundle.entries.length}`);
34
35
  log.info(`Size: ${bundle.sizeBytes} bytes`);
35
36
  } catch (err) {
36
- log.error(err instanceof Error ? err.message : String(err));
37
+ log.error(errorMessage(err));
37
38
  process.exit(1);
38
39
  }
39
40
  },
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from "./error-utils";
1
2
  import {
2
3
  closeSync,
3
4
  existsSync,
@@ -232,7 +233,7 @@ export function createDiagnosticLogSession(): DiagnosticLogSession {
232
233
  appendDiagnosticLogLine(line, status);
233
234
  return true;
234
235
  } catch (err) {
235
- log.debug(`diagnostic log event dropped: ${err instanceof Error ? err.message : String(err)}`);
236
+ log.debug(`diagnostic log event dropped: ${errorMessage(err)}`);
236
237
  return false;
237
238
  }
238
239
  },
package/src/doctor.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { existsSync, statSync } from "fs";
2
+ import { errorMessage } from "./error-utils";
3
+ import { humanBytes } from "./format";
2
4
  import { dirname, join, sep } from "path";
3
5
  import {
4
6
  getEngineBinPath,
@@ -82,18 +84,6 @@ export interface DoctorReport {
82
84
  env: Record<string, string | null>;
83
85
  }
84
86
 
85
- function humanBytes(bytes: number): string {
86
- if (bytes < 1024) return `${bytes} B`;
87
- const units = ["KB", "MB", "GB", "TB"];
88
- let n = bytes / 1024;
89
- let i = 0;
90
- while (n >= 1024 && i < units.length - 1) {
91
- n /= 1024;
92
- i++;
93
- }
94
- return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
95
- }
96
-
97
87
  function pathSummary(path: string): PathSummary {
98
88
  return {
99
89
  path,
@@ -194,7 +184,7 @@ async function collectEngine(redact: boolean): Promise<DoctorReport["engine"]> {
194
184
  capabilities = await getEngineCapabilities();
195
185
  if (!capabilities) probeError = "capabilities probe returned no data";
196
186
  } catch (err) {
197
- probeError = err instanceof Error ? err.message : String(err);
187
+ probeError = errorMessage(err);
198
188
  }
199
189
  }
200
190
 
@@ -305,7 +295,7 @@ function collectStats(redact: boolean): DoctorReport["stats"] {
305
295
  : status;
306
296
  } catch (err) {
307
297
  return {
308
- error: redactString("statsError", err instanceof Error ? err.message : String(err), redact) ?? "unknown",
298
+ error: redactString("statsError", errorMessage(err), redact) ?? "unknown",
309
299
  };
310
300
  }
311
301
  }
@@ -337,7 +327,7 @@ function collectDiagnosticLogs(redact: boolean): DoctorReport["diagnosticLogs"]
337
327
  maxBytes: 10 * 1024 * 1024,
338
328
  retain: 5,
339
329
  error:
340
- redactString("diagnosticLogsError", err instanceof Error ? err.message : String(err), redact) ??
330
+ redactString("diagnosticLogsError", errorMessage(err), redact) ??
341
331
  "unknown",
342
332
  };
343
333
  }
@@ -1,4 +1,5 @@
1
1
  import { dirname, join } from "path";
2
+ import { errorMessage } from "./error-utils";
2
3
  import { tmpdir } from "os";
3
4
  import { existsSync, mkdirSync, chmodSync, accessSync, constants, rmSync } from "fs";
4
5
  import {
@@ -6,6 +7,7 @@ import {
6
7
  getEngineCapabilities,
7
8
  TRANSCRIBE_DIARIZE_FEATURE,
8
9
  } from "./engine";
10
+ import { isDarwinArm64 } from "./fluid-kokoro-cache";
9
11
  import { log } from "./log";
10
12
  import { engineVersion } from "./package-info";
11
13
  import { streamResponseToFile } from "./progress";
@@ -61,7 +63,7 @@ interface SidecarSpec {
61
63
  unavailableHint: string;
62
64
  }
63
65
 
64
- const SIDECARS: SidecarSpec[] = [
66
+ export const SIDECARS: SidecarSpec[] = [
65
67
  {
66
68
  fileBasename: "say-avspeech",
67
69
  assetName: "say-avspeech-darwin-arm64",
@@ -110,7 +112,7 @@ export function cleanupRetiredSidecars(engineDir: string): string[] {
110
112
  removed.push(filename);
111
113
  } catch (e) {
112
114
  log.warn(
113
- `Could not remove retired sidecar ${filename} (${e instanceof Error ? e.message : e}); continuing.`,
115
+ `Could not remove retired sidecar ${filename} (${errorMessage(e)}); continuing.`,
114
116
  );
115
117
  }
116
118
  }
@@ -163,7 +165,7 @@ function darwinTrustBinary(path: string, displayName: string): void {
163
165
  }
164
166
  } catch (e) {
165
167
  log.debug(
166
- `codesign on ${displayName} threw: ${e instanceof Error ? e.message : e}`,
168
+ `codesign on ${displayName} threw: ${errorMessage(e)}`,
167
169
  );
168
170
  }
169
171
  try {
@@ -184,7 +186,7 @@ function darwinTrustBinary(path: string, displayName: string): void {
184
186
  }
185
187
  } catch (e) {
186
188
  log.debug(
187
- `xattr -d on ${displayName} threw: ${e instanceof Error ? e.message : e}`,
189
+ `xattr -d on ${displayName} threw: ${errorMessage(e)}`,
188
190
  );
189
191
  }
190
192
  if (!codesignOk && !xattrOk) {
@@ -212,7 +214,7 @@ async function downloadSidecar(
212
214
  binPath: string,
213
215
  engineVersion: string,
214
216
  ): Promise<void> {
215
- if (process.platform !== "darwin" || process.arch !== "arm64") return;
217
+ if (!isDarwinArm64()) return;
216
218
 
217
219
  const sidecarPath = join(dirname(binPath), spec.fileBasename);
218
220
  const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/${spec.assetName}`;
@@ -222,7 +224,7 @@ async function downloadSidecar(
222
224
  res = await fetch(url, { redirect: "follow" });
223
225
  } catch (e) {
224
226
  log.warn(
225
- `Could not fetch ${spec.displayName} (${e instanceof Error ? e.message : e}); ${spec.unavailableHint}.`,
227
+ `Could not fetch ${spec.displayName} (${errorMessage(e)}); ${spec.unavailableHint}.`,
226
228
  );
227
229
  return;
228
230
  }
@@ -248,13 +250,13 @@ async function downloadSidecar(
248
250
  log.success(`${spec.displayName} installed (${spec.availableHint}).`);
249
251
  } catch (e) {
250
252
  log.warn(
251
- `${spec.displayName} install failed (${e instanceof Error ? e.message : e}); ${spec.unavailableHint}.`,
253
+ `${spec.displayName} install failed (${errorMessage(e)}); ${spec.unavailableHint}.`,
252
254
  );
253
255
  }
254
256
  }
255
257
 
256
258
  async function warmDarwinKokoro(binPath: string): Promise<void> {
257
- if (process.platform !== "darwin" || process.arch !== "arm64") return;
259
+ if (!isDarwinArm64()) return;
258
260
  // Kokoro now runs in-engine (FluidAudio CoreML, system_kokoro) — warm it by
259
261
  // exercising the engine's own `say`, not a sidecar. The first synthesis
260
262
  // compiles/fetches FluidAudio's CoreML Kokoro cache.
@@ -312,7 +314,7 @@ async function warmDarwinKokoro(binPath: string): Promise<void> {
312
314
  );
313
315
  } catch (e) {
314
316
  log.warn(
315
- `FluidAudio Kokoro warmup failed (${e instanceof Error ? e.message : e}); ` +
317
+ `FluidAudio Kokoro warmup failed (${errorMessage(e)}); ` +
316
318
  "first `kesha say en-*` may still be slow.",
317
319
  );
318
320
  } finally {
@@ -351,6 +353,120 @@ export interface InstallOptions {
351
353
  diarize?: boolean;
352
354
  }
353
355
 
356
+ /**
357
+ * Cache-valid path: the binary at binPath already matches engineVersion.
358
+ * Re-trusts it and tops up any sidecars the cached install is missing.
359
+ */
360
+ async function refreshCachedEngine(
361
+ binPath: string,
362
+ canWriteEngineDir: boolean,
363
+ noCache: boolean,
364
+ ): Promise<void> {
365
+ const engineDir = dirname(binPath);
366
+ if (noCache && !canWriteEngineDir) {
367
+ log.info(
368
+ `Engine binary at v${engineVersion} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
369
+ );
370
+ } else {
371
+ log.success(`Engine binary already installed (v${engineVersion}).`);
372
+ }
373
+ // Re-run the macOS trust step against the cached binary too. Reason:
374
+ // a user who upgraded to macOS 15+ Sequoia AFTER installing kesha
375
+ // would have a v<X> binary on disk with `com.apple.provenance` still
376
+ // attached, and `kesha install` would normally bail at "already
377
+ // installed" without healing the SIGKILL. Idempotent — re-signing
378
+ // an already-correctly-signed binary is a ~10 ms no-op; `xattr -d`
379
+ // on an absent attr is handled (exit 1 + "No such xattr" treated
380
+ // as success in `darwinTrustBinary`). Skipped on read-only
381
+ // filesystems (Nix-store installs) — no write access anyway.
382
+ if (canWriteEngineDir && existsSync(binPath)) {
383
+ darwinTrustBinary(binPath, "kesha-engine binary");
384
+ }
385
+ // Top up any sidecars missing from this cached install. Pre-#141 / pre-#199
386
+ // engines never shipped them, so a cache-valid binary may still need
387
+ // fetching. Run independent fetches concurrently — same shape as the
388
+ // cold path in fetchEngineBinary.
389
+ //
390
+ // Skip the top-up entirely when the engine sits in a read-only
391
+ // filesystem (e.g., a Nix-store install). Those installs stage the
392
+ // supported sidecars at build time; writing more is impossible, and
393
+ // attempting it would emit a confusing "install failed" warning for
394
+ // a feature the Nix README explicitly documents as unsupported
395
+ // (diarize on Nix needs network access at build time, which the
396
+ // sandbox forbids).
397
+ if (canWriteEngineDir) {
398
+ const missing = SIDECARS.filter(
399
+ (s) => !existsSync(join(engineDir, s.fileBasename)),
400
+ );
401
+ await Promise.all(missing.map((s) => downloadSidecar(s, binPath, engineVersion)));
402
+ // The cache-valid branch also re-trusts any sidecars that already
403
+ // exist alongside the engine — same upgrade-to-Sequoia scenario as
404
+ // above. The `missing.map` above only handles NEW sidecars; this
405
+ // loop handles ALREADY-PRESENT ones.
406
+ for (const s of SIDECARS) {
407
+ const p = join(engineDir, s.fileBasename);
408
+ if (existsSync(p)) darwinTrustBinary(p, s.displayName);
409
+ }
410
+ }
411
+ }
412
+
413
+ /** Cold path: download the engine binary (and sidecars, concurrently). */
414
+ async function fetchEngineBinary(
415
+ binPath: string,
416
+ installedVersion: string | null,
417
+ ): Promise<void> {
418
+ // Log why we're downloading — helps diagnose surprising re-downloads.
419
+ if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
420
+ log.progress(
421
+ `Upgrading engine v${installedVersion} → v${engineVersion}...`,
422
+ );
423
+ }
424
+ const binaryName = getEngineBinaryName();
425
+ const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/${binaryName}`;
426
+
427
+ mkdirSync(dirname(binPath), { recursive: true });
428
+
429
+ // Kick off all sidecar fetches concurrently with the engine fetch. They
430
+ // target independent github.com release assets, so overlapping the HTTP
431
+ // round-trips saves ~15-30s on a cold install. Each sidecar is
432
+ // best-effort (404 on older engines, warn + continue) so a failure
433
+ // doesn't cascade into the engine path.
434
+ const sidecarPromises = SIDECARS.map((s) =>
435
+ downloadSidecar(s, binPath, engineVersion),
436
+ );
437
+ // Defense-in-depth: if the engine fetch throws below, attach no-op
438
+ // rejection handlers so we don't surface unhandledRejection errors
439
+ // from sidecar paths whose internal try/catch ever drifts. Logs from
440
+ // sidecars whose own work is still in flight will print when they
441
+ // complete; the engine error is what the user needs to see now.
442
+ const muteSidecarRejections = () =>
443
+ sidecarPromises.forEach((p) => p.catch(() => {}));
444
+
445
+ let res: Response;
446
+ try {
447
+ res = await fetch(url, { redirect: "follow" });
448
+ } catch (e) {
449
+ muteSidecarRejections();
450
+ throw new Error(
451
+ `Failed to fetch engine binary: ${errorMessage(e)}\n Fix: Check your network connection and try again`,
452
+ );
453
+ }
454
+
455
+ if (!res.ok) {
456
+ muteSidecarRejections();
457
+ throw new Error(
458
+ `Failed to download engine binary (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
459
+ );
460
+ }
461
+
462
+ await streamResponseToFile(res, binPath, "kesha-engine binary");
463
+ chmodSync(binPath, 0o755);
464
+ darwinTrustBinary(binPath, "kesha-engine binary");
465
+ writeInstalledEngineVersion(binPath, engineVersion);
466
+ log.success(`Engine binary downloaded (v${engineVersion}).`);
467
+ await Promise.all(sidecarPromises);
468
+ }
469
+
354
470
  export async function downloadEngine(
355
471
  noCache = false,
356
472
  backend?: string,
@@ -383,102 +499,9 @@ export async function downloadEngine(
383
499
  const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
384
500
 
385
501
  if (cacheValid) {
386
- if (noCache && !canWriteEngineDir) {
387
- log.info(
388
- `Engine binary at v${engineVersion} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
389
- );
390
- } else {
391
- log.success(`Engine binary already installed (v${engineVersion}).`);
392
- }
393
- // Re-run the macOS trust step against the cached binary too. Reason:
394
- // a user who upgraded to macOS 15+ Sequoia AFTER installing kesha
395
- // would have a v<X> binary on disk with `com.apple.provenance` still
396
- // attached, and `kesha install` would normally bail at "already
397
- // installed" without healing the SIGKILL. Idempotent — re-signing
398
- // an already-correctly-signed binary is a ~10 ms no-op; `xattr -d`
399
- // on an absent attr is handled (exit 1 + "No such xattr" treated
400
- // as success in `darwinTrustBinary`). Skipped on read-only
401
- // filesystems (Nix-store installs) — no write access anyway.
402
- if (canWriteEngineDir && existsSync(binPath)) {
403
- darwinTrustBinary(binPath, "kesha-engine binary");
404
- }
405
- // Top up any sidecars missing from this cached install. Pre-#141 / pre-#199
406
- // engines never shipped them, so a cache-valid binary may still need
407
- // fetching. Run independent fetches concurrently — same shape as the
408
- // cold path below.
409
- //
410
- // Skip the top-up entirely when the engine sits in a read-only
411
- // filesystem (e.g., a Nix-store install). Those installs stage the
412
- // supported sidecars at build time; writing more is impossible, and
413
- // attempting it would emit a confusing "install failed" warning for
414
- // a feature the Nix README explicitly documents as unsupported
415
- // (diarize on Nix needs network access at build time, which the
416
- // sandbox forbids).
417
- if (canWriteEngineDir) {
418
- const missing = SIDECARS.filter(
419
- (s) => !existsSync(join(engineDir, s.fileBasename)),
420
- );
421
- await Promise.all(missing.map((s) => downloadSidecar(s, binPath, engineVersion)));
422
- // The cache-valid branch also re-trusts any sidecars that already
423
- // exist alongside the engine — same upgrade-to-Sequoia scenario as
424
- // above. The `missing.map` above only handles NEW sidecars; this
425
- // loop handles ALREADY-PRESENT ones.
426
- for (const s of SIDECARS) {
427
- const p = join(engineDir, s.fileBasename);
428
- if (existsSync(p)) darwinTrustBinary(p, s.displayName);
429
- }
430
- }
502
+ await refreshCachedEngine(binPath, canWriteEngineDir, noCache);
431
503
  } else {
432
- // Log why we're downloading — helps diagnose surprising re-downloads.
433
- if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
434
- log.progress(
435
- `Upgrading engine v${installedVersion} → v${engineVersion}...`,
436
- );
437
- }
438
- const binaryName = getEngineBinaryName();
439
- const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/${binaryName}`;
440
-
441
- mkdirSync(dirname(binPath), { recursive: true });
442
-
443
- // Kick off all sidecar fetches concurrently with the engine fetch. They
444
- // target independent github.com release assets, so overlapping the HTTP
445
- // round-trips saves ~15-30s on a cold install. Each sidecar is
446
- // best-effort (404 on older engines, warn + continue) so a failure
447
- // doesn't cascade into the engine path.
448
- const sidecarPromises = SIDECARS.map((s) =>
449
- downloadSidecar(s, binPath, engineVersion),
450
- );
451
- // Defense-in-depth: if the engine fetch throws below, attach no-op
452
- // rejection handlers so we don't surface unhandledRejection errors
453
- // from sidecar paths whose internal try/catch ever drifts. Logs from
454
- // sidecars whose own work is still in flight will print when they
455
- // complete; the engine error is what the user needs to see now.
456
- const muteSidecarRejections = () =>
457
- sidecarPromises.forEach((p) => p.catch(() => {}));
458
-
459
- let res: Response;
460
- try {
461
- res = await fetch(url, { redirect: "follow" });
462
- } catch (e) {
463
- muteSidecarRejections();
464
- throw new Error(
465
- `Failed to fetch engine binary: ${e instanceof Error ? e.message : e}\n Fix: Check your network connection and try again`,
466
- );
467
- }
468
-
469
- if (!res.ok) {
470
- muteSidecarRejections();
471
- throw new Error(
472
- `Failed to download engine binary (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
473
- );
474
- }
475
-
476
- await streamResponseToFile(res, binPath, "kesha-engine binary");
477
- chmodSync(binPath, 0o755);
478
- darwinTrustBinary(binPath, "kesha-engine binary");
479
- writeInstalledEngineVersion(binPath, engineVersion);
480
- log.success(`Engine binary downloaded (v${engineVersion}).`);
481
- await Promise.all(sidecarPromises);
504
+ await fetchEngineBinary(binPath, installedVersion);
482
505
  }
483
506
 
484
507
  if (backend) {
package/src/engine.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { existsSync, statSync } from "fs";
2
+ import { errorMessage } from "./error-utils";
2
3
  import { join } from "path";
3
4
  import { installHint } from "./install-hint";
4
5
  import { log } from "./log";
@@ -280,7 +281,7 @@ export async function transcribeEngineWithSegments(
280
281
  try {
281
282
  return parseTranscriptionOutput(stdout);
282
283
  } catch (err: unknown) {
283
- const message = err instanceof Error ? err.message : String(err);
284
+ const message = errorMessage(err);
284
285
  throw new Error(`${message}: ${stdout}`);
285
286
  }
286
287
  }
@@ -0,0 +1,4 @@
1
+ /** Human-readable message from an unknown catch value. */
2
+ export function errorMessage(err: unknown): string {
3
+ return err instanceof Error ? err.message : String(err);
4
+ }
package/src/format.ts CHANGED
@@ -60,3 +60,16 @@ export function formatJsonOutput(
60
60
  errors === undefined ? results : { results, errors };
61
61
  return JSON.stringify(payload, null, 2) + "\n";
62
62
  }
63
+
64
+ /** Render a byte count as a human-readable size (e.g. "1.5 MB"). */
65
+ export function humanBytes(bytes: number): string {
66
+ if (bytes < 1024) return `${bytes} B`;
67
+ const units = ["KB", "MB", "GB", "TB"];
68
+ let n = bytes / 1024;
69
+ let i = 0;
70
+ while (n >= 1024 && i < units.length - 1) {
71
+ n /= 1024;
72
+ i++;
73
+ }
74
+ return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
75
+ }
@@ -1,6 +1,8 @@
1
1
  import { existsSync, statSync } from "fs";
2
+ import { humanBytes } from "./format";
2
3
  import { dirname, join } from "path";
3
4
  import { getEngineBinPath } from "./engine";
5
+ import { SIDECARS } from "./engine-install";
4
6
  import { readInstalledEngineVersion } from "./engine-version-marker";
5
7
  import { keshaCacheDir } from "./paths";
6
8
  import { engineVersion, packageVersion } from "./package-info";
@@ -119,29 +121,30 @@ const DIARIZE_FILES: PlanFile[] = [
119
121
  },
120
122
  ];
121
123
 
122
- // Kokoro (#207) and diarization (#199) are in-engine now (native fluidaudio-rs),
123
- // so only AVSpeech and text-lang ship as separate Swift-sidecar assets.
124
- const DARWIN_SIDECARS: ReleaseAssetSpec[] = [
125
- { assetName: "say-avspeech-darwin-arm64", sizeBytes: 63_056 },
126
- { assetName: "kesha-textlang-darwin-arm64", sizeBytes: 57_648 },
127
- ];
124
+ // The sidecar list (and the asset-name → installed-filename mapping) lives in
125
+ // SIDECARS in engine-install.ts; only the release-asset sizes are pinned here,
126
+ // like the model tables above.
127
+ const SIDECAR_ASSET_SIZES: Record<string, number> = {
128
+ "say-avspeech-darwin-arm64": 63_056,
129
+ "kesha-textlang-darwin-arm64": 57_648,
130
+ };
131
+
132
+ const DARWIN_SIDECARS = SIDECARS.map((s) => {
133
+ const sizeBytes = SIDECAR_ASSET_SIZES[s.assetName];
134
+ if (sizeBytes === undefined) {
135
+ // Fail fast at module load: a sidecar added to SIDECARS without a size
136
+ // entry here would otherwise silently render as "0 B" in the plan.
137
+ throw new Error(
138
+ `install-plan: missing release-asset size for sidecar "${s.assetName}"; add it to SIDECAR_ASSET_SIZES`,
139
+ );
140
+ }
141
+ return { assetName: s.assetName, fileBasename: s.fileBasename, sizeBytes };
142
+ });
128
143
 
129
144
  function sumFiles(files: PlanFile[]): number {
130
145
  return files.reduce((sum, file) => sum + file.sizeBytes, 0);
131
146
  }
132
147
 
133
- function humanBytes(bytes: number): string {
134
- if (bytes < 1024) return `${bytes} B`;
135
- const units = ["KB", "MB", "GB", "TB"];
136
- let n = bytes / 1024;
137
- let i = 0;
138
- while (n >= 1024 && i < units.length - 1) {
139
- n /= 1024;
140
- i++;
141
- }
142
- return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
143
- }
144
-
145
148
  function engineAssetForPlatform(): ReleaseAssetSpec | null {
146
149
  if (process.platform === "darwin" && process.arch === "arm64") {
147
150
  return { assetName: "kesha-engine-darwin-arm64", sizeBytes: 59_621_264 };
@@ -155,12 +158,6 @@ function engineAssetForPlatform(): ReleaseAssetSpec | null {
155
158
  return null;
156
159
  }
157
160
 
158
- function sidecarFilename(assetName: string): string {
159
- if (assetName === "say-avspeech-darwin-arm64") return "say-avspeech";
160
- if (assetName === "kesha-textlang-darwin-arm64") return "kesha-textlang";
161
- return assetName;
162
- }
163
-
164
161
  function filesCached(cacheRoot: string, files: PlanFile[]): boolean {
165
162
  return files.every((file) => {
166
163
  const path = join(cacheRoot, file.relPath);
@@ -226,7 +223,7 @@ export async function renderInstallPlan(options: InstallPlanOptions = {}): Promi
226
223
  name: `Sidecar ${sidecar.assetName}`,
227
224
  source: `GitHub release v${engineVersion}`,
228
225
  sizeBytes: sidecar.sizeBytes,
229
- cached: existsSync(join(engineDir, sidecarFilename(sidecar.assetName))),
226
+ cached: existsSync(join(engineDir, sidecar.fileBasename)),
230
227
  refresh: noCache,
231
228
  });
232
229
  }
package/src/mcp/tools.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { errorMessage } from "../error-utils";
2
3
  import { z } from "zod";
3
4
  import { chmodSync, existsSync, readFileSync, statSync } from "fs";
4
5
  import { basename, join } from "path";
@@ -78,7 +79,7 @@ export function registerTools(server: McpServer): void {
78
79
  structuredContent: { uri, path: outPath, format: fmt, voice: resolvedVoice, bytes },
79
80
  };
80
81
  } catch (err) {
81
- return { isError: true, content: [{ type: "text" as const, text: toToolError(err) }] };
82
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
82
83
  }
83
84
  },
84
85
  );
@@ -126,7 +127,7 @@ export function registerTools(server: McpServer): void {
126
127
  structuredContent: { text, segments: [] },
127
128
  };
128
129
  } catch (err) {
129
- return { isError: true, content: [{ type: "text" as const, text: toToolError(err) }] };
130
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
130
131
  }
131
132
  },
132
133
  );
@@ -159,7 +160,7 @@ export function registerTools(server: McpServer): void {
159
160
  structuredContent: { voices },
160
161
  };
161
162
  } catch (err) {
162
- return { isError: true, content: [{ type: "text" as const, text: `list_voices failed: ${toToolError(err)}` }] };
163
+ return { isError: true, content: [{ type: "text" as const, text: `list_voices failed: ${errorMessage(err)}` }] };
163
164
  }
164
165
  },
165
166
  );
@@ -189,12 +190,8 @@ export function registerTools(server: McpServer): void {
189
190
  structuredContent: { languages },
190
191
  };
191
192
  } catch (err) {
192
- return { isError: true, content: [{ type: "text" as const, text: `list_languages failed: ${toToolError(err)}` }] };
193
+ return { isError: true, content: [{ type: "text" as const, text: `list_languages failed: ${errorMessage(err)}` }] };
193
194
  }
194
195
  },
195
196
  );
196
197
  }
197
-
198
- function toToolError(err: unknown): string {
199
- return err instanceof Error ? err.message : String(err);
200
- }
package/src/stats.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { errorMessage } from "./error-utils";
3
+ import { humanBytes } from "./format";
2
4
  import { existsSync, mkdirSync, statSync } from "fs";
3
5
  import { homedir } from "os";
4
6
  import { dirname, extname, join } from "path";
@@ -148,7 +150,7 @@ function warnStatsWriteFailed(err: unknown): void {
148
150
  log.warn("warning: failed to write Kesha Stats; continuing without stats for this event");
149
151
  warnedStatsWriteFailed = true;
150
152
  }
151
- const message = err instanceof Error ? err.message : String(err);
153
+ const message = errorMessage(err);
152
154
  log.debug(`stats write failed: ${message}`);
153
155
  }
154
156
 
@@ -170,23 +172,33 @@ export function createStatsRecorder(command: StatsCommandName): StatsRecorder {
170
172
  }
171
173
  }
172
174
 
173
- export function enableStats(): void {
175
+ /** Open the stats DB (creating it if needed), run fn, always close. */
176
+ function withStatsDb<T>(fn: (db: Database) => T): T {
174
177
  const db = openStatsDatabase(resolveStatsDbPath());
175
178
  try {
176
- setSetting(db, "enabled", "1");
177
- applyStatsRetention(db);
179
+ return fn(db);
178
180
  } finally {
179
181
  db.close();
180
182
  }
181
183
  }
182
184
 
185
+ /** Like withStatsDb, but returns fallback() without creating the DB when none exists. */
186
+ function withExistingStatsDb<T>(fallback: () => T, fn: (db: Database) => T): T {
187
+ if (!existsSync(resolveStatsDbPath())) return fallback();
188
+ return withStatsDb(fn);
189
+ }
190
+
191
+ export function enableStats(): void {
192
+ withStatsDb((db) => {
193
+ setSetting(db, "enabled", "1");
194
+ applyStatsRetention(db);
195
+ });
196
+ }
197
+
183
198
  export function disableStats(): void {
184
- const db = openStatsDatabase(resolveStatsDbPath());
185
- try {
199
+ withStatsDb((db) => {
186
200
  setSetting(db, "enabled", "0");
187
- } finally {
188
- db.close();
189
- }
201
+ });
190
202
  }
191
203
 
192
204
  export interface StatsStatus {
@@ -199,27 +211,22 @@ export interface StatsStatus {
199
211
 
200
212
  export function getStatsStatus(): StatsStatus {
201
213
  const dbPath = resolveStatsDbPath();
202
- if (!existsSync(dbPath)) {
203
- return {
214
+ return withExistingStatsDb<StatsStatus>(
215
+ () => ({
204
216
  enabled: false,
205
217
  dbPath,
206
218
  runCount: 0,
207
219
  exists: false,
208
220
  retentionDays: defaultRetentionDays(),
209
- };
210
- }
211
- const db = openStatsDatabase(dbPath);
212
- try {
213
- return {
221
+ }),
222
+ (db) => ({
214
223
  enabled: getStatsEnabled(db),
215
224
  dbPath,
216
225
  runCount: Number((db.query("select count(*) as n from runs").get() as { n: number }).n),
217
226
  exists: true,
218
227
  retentionDays: getStatsRetentionDays(db),
219
- };
220
- } finally {
221
- db.close();
222
- }
228
+ }),
229
+ );
223
230
  }
224
231
 
225
232
  export interface StatsResetResult {
@@ -230,31 +237,27 @@ export interface StatsResetResult {
230
237
  }
231
238
 
232
239
  export function resetStats(): StatsResetResult {
233
- const dbPath = resolveStatsDbPath();
234
- if (!existsSync(dbPath)) {
235
- return { runs: 0, artifacts: 0, stageTimings: 0, errors: 0 };
236
- }
237
- const db = openStatsDatabase(dbPath);
238
- try {
239
- let artifacts = 0;
240
- let stageTimings = 0;
241
- let errors = 0;
242
- let runs = 0;
243
- db.exec("begin");
244
- try {
245
- artifacts = runChanges(db, "delete from artifacts");
246
- stageTimings = runChanges(db, "delete from stage_timings");
247
- errors = runChanges(db, "delete from errors");
248
- runs = runChanges(db, "delete from runs");
249
- db.exec("commit");
250
- } catch (err) {
251
- db.exec("rollback");
252
- throw err;
253
- }
254
- return { runs, artifacts, stageTimings, errors };
255
- } finally {
256
- db.close();
257
- }
240
+ return withExistingStatsDb(
241
+ () => ({ runs: 0, artifacts: 0, stageTimings: 0, errors: 0 }),
242
+ (db) => {
243
+ let artifacts = 0;
244
+ let stageTimings = 0;
245
+ let errors = 0;
246
+ let runs = 0;
247
+ db.exec("begin");
248
+ try {
249
+ artifacts = runChanges(db, "delete from artifacts");
250
+ stageTimings = runChanges(db, "delete from stage_timings");
251
+ errors = runChanges(db, "delete from errors");
252
+ runs = runChanges(db, "delete from runs");
253
+ db.exec("commit");
254
+ } catch (err) {
255
+ db.exec("rollback");
256
+ throw err;
257
+ }
258
+ return { runs, artifacts, stageTimings, errors };
259
+ },
260
+ );
258
261
  }
259
262
 
260
263
  export interface StatsVacuumResult {
@@ -273,13 +276,10 @@ export function vacuumStats(): StatsVacuumResult {
273
276
  afterBytes: 0,
274
277
  };
275
278
  }
276
- const db = openStatsDatabase(dbPath);
277
- try {
279
+ withStatsDb((db) => {
278
280
  db.exec("pragma wal_checkpoint(TRUNCATE)");
279
281
  db.exec("vacuum");
280
- } finally {
281
- db.close();
282
- }
282
+ });
283
283
  return {
284
284
  dbPath,
285
285
  beforeBytes,
@@ -291,13 +291,10 @@ export function setStatsRetentionDays(days: number | null): void {
291
291
  if (days !== null && (!Number.isInteger(days) || days < 1)) {
292
292
  throw new Error("Stats retention must be a positive whole number of days, or 'off'");
293
293
  }
294
- const db = openStatsDatabase(resolveStatsDbPath());
295
- try {
294
+ withStatsDb((db) => {
296
295
  setSetting(db, "retention_days", days === null ? "off" : String(days));
297
296
  applyStatsRetention(db);
298
- } finally {
299
- db.close();
300
- }
297
+ });
301
298
  }
302
299
 
303
300
  export function exportStats(format: StatsExportFormat): string {
@@ -379,11 +376,8 @@ interface StatsRunTimingRow {
379
376
  }
380
377
 
381
378
  export function getWeekSummary(now = new Date()): StatsWeekSummary {
382
- const dbPath = resolveStatsDbPath();
383
- if (!existsSync(dbPath)) return emptyWeekSummary();
384
379
  const since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
385
- const db = openStatsDatabase(dbPath);
386
- try {
380
+ return withExistingStatsDb(emptyWeekSummary, (db) => {
387
381
  const stageRows = db.query(
388
382
  `select
389
383
  run_id as runId,
@@ -432,9 +426,7 @@ export function getWeekSummary(now = new Date()): StatsWeekSummary {
432
426
  hasInputDurationData: inputArtifacts.some((row) => typeof row.durationMs === "number"),
433
427
  slowestRuns: summarizeSlowestRuns(runRows, stageRows),
434
428
  };
435
- } finally {
436
- db.close();
437
- }
429
+ });
438
430
  }
439
431
 
440
432
  export interface StatsRunExportRow {
@@ -491,11 +483,10 @@ export interface StatsErrorRow {
491
483
  }
492
484
 
493
485
  export function getRecentErrors(limit = 20): StatsErrorRow[] {
494
- const dbPath = resolveStatsDbPath();
495
- if (!existsSync(dbPath)) return [];
496
- const db = openStatsDatabase(dbPath);
497
- try {
498
- const rows = db.query(
486
+ return withExistingStatsDb(
487
+ () => [],
488
+ (db) => {
489
+ const rows = db.query(
499
490
  `select
500
491
  e.occurred_at as occurredAt,
501
492
  r.command as command,
@@ -507,11 +498,10 @@ export function getRecentErrors(limit = 20): StatsErrorRow[] {
507
498
  left join runs r on r.id = e.run_id
508
499
  order by e.occurred_at desc
509
500
  limit ?`,
510
- ).all(limit) as StatsErrorRow[];
511
- return rows;
512
- } finally {
513
- db.close();
514
- }
501
+ ).all(limit) as StatsErrorRow[];
502
+ return rows;
503
+ },
504
+ );
515
505
  }
516
506
 
517
507
  export function renderWeekSummary(summary: StatsWeekSummary): string {
@@ -779,15 +769,11 @@ function insertRun(db: Database, command: StatsCommandName): number {
779
769
  }
780
770
 
781
771
  function readStatsExport(): StatsExportData {
782
- const dbPath = resolveStatsDbPath();
783
- if (!existsSync(dbPath)) {
784
- return emptyStatsExport(defaultRetentionDays());
785
- }
786
-
787
- const db = openStatsDatabase(dbPath);
788
- try {
789
- applyStatsRetention(db);
790
- return {
772
+ return withExistingStatsDb(
773
+ () => emptyStatsExport(defaultRetentionDays()),
774
+ (db) => {
775
+ applyStatsRetention(db);
776
+ return {
791
777
  schemaVersion: SCHEMA_VERSION,
792
778
  exportedAt: new Date().toISOString(),
793
779
  retentionDays: getStatsRetentionDays(db),
@@ -839,11 +825,10 @@ function readStatsExport(): StatsExportData {
839
825
  from errors e
840
826
  left join runs r on r.id = e.run_id
841
827
  order by e.occurred_at asc, e.id asc`,
842
- ).all() as StatsErrorRow[],
843
- };
844
- } finally {
845
- db.close();
846
- }
828
+ ).all() as StatsErrorRow[],
829
+ };
830
+ },
831
+ );
847
832
  }
848
833
 
849
834
  function emptyStatsExport(retentionDays: number | null): StatsExportData {
@@ -1200,18 +1185,6 @@ function orderBuckets(buckets: Map<string, number>, order: string[]): StatsCount
1200
1185
  .map((label) => ({ label, count: buckets.get(label) ?? 0 }));
1201
1186
  }
1202
1187
 
1203
- function humanBytes(bytes: number): string {
1204
- if (bytes < 1024) return `${bytes} B`;
1205
- const units = ["KB", "MB", "GB", "TB"];
1206
- let n = bytes / 1024;
1207
- let i = 0;
1208
- while (n >= 1024 && i < units.length - 1) {
1209
- n /= 1024;
1210
- i++;
1211
- }
1212
- return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
1213
- }
1214
-
1215
1188
  function humanDuration(ms: number): string {
1216
1189
  const seconds = Math.round(ms / 1000);
1217
1190
  if (seconds < 1) return `${ms}ms`;