@drakulavich/kesha-voice-kit 2.0.0 → 2.2.0-beta.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.
@@ -128,7 +128,8 @@ export class KeshaError extends Error {
128
128
  const transcript = this.stderr?.trim();
129
129
  if (!transcript) return renderError(this);
130
130
  const line = renderError(this).split("\n")[0] ?? "";
131
- return transcript.split("\n").some((l) => l === line) ? transcript : `${renderError(this)}\n${transcript}`;
131
+ if (!transcript.split("\n").some((l) => l === line)) return `${renderError(this)}\n${transcript}`;
132
+ return this.hint && !transcript.includes(` hint: ${this.hint}`) ? `${transcript}\n hint: ${this.hint}` : transcript;
132
133
  }
133
134
  }
134
135
 
@@ -19,6 +19,7 @@ import { runEngineProcess } from "./engine/spawn";
19
19
  import { acquireInstallLock } from "./install-lock";
20
20
  import { log } from "./log";
21
21
  import { engineVersion, injectedEnginePins } from "./package-info";
22
+ import { PATH_ERRNOS } from "./path-errnos";
22
23
  import { keshaCacheDir } from "./paths";
23
24
  import { createLiveStatus, streamResponseToFile } from "./progress";
24
25
  import { interruptedRun } from "./process-tree";
@@ -37,6 +38,8 @@ export {
37
38
  } from "./engine-version-marker";
38
39
 
39
40
  const GITHUB_REPO = "drakulavich/kesha-voice-kit";
41
+ const NETWORK_HINT = "check your network connection and try again.";
42
+ const MISMATCH_HINT = `re-run \`kesha install\`; if the mismatch persists, report it at https://github.com/${GITHUB_REPO}/issues.`;
40
43
 
41
44
  interface ExpectedSha256 {
42
45
  sha256: string;
@@ -51,30 +54,46 @@ export interface AssetPins {
51
54
  }
52
55
 
53
56
  /** Without `--engine-version` nothing but the maintainer can make an unverifiable pinned release verifiable. */
54
- function unverifiableFix(version: string): string {
57
+ function unverifiableFix(version: string, pins: AssetPins | undefined): string {
58
+ if (!pins) {
59
+ return version === engineVersion
60
+ ? `this source checkout installs its own version v${engineVersion}, which may not be released yet: run ` +
61
+ `\`kesha install --engine-version <version>\` with a published release that ships SHA256SUMS (https://github.com/${GITHUB_REPO}/releases).`
62
+ : `pass --engine-version a published release that ships SHA256SUMS (https://github.com/${GITHUB_REPO}/releases).`;
63
+ }
55
64
  return version === engineVersion
56
65
  ? `report it at https://github.com/${GITHUB_REPO}/issues: the pinned engine release v${version} must publish its checksums.`
57
66
  : `pick a release that ships SHA256SUMS (https://github.com/${GITHUB_REPO}/releases), or run ` +
58
67
  `\`kesha install\` without --engine-version to install the pinned v${engineVersion}.`;
59
68
  }
60
69
 
61
- async function fetchSha256Sums(version: string): Promise<Map<string, string>> {
70
+ async function fetchSha256Sums(version: string, pins: AssetPins | undefined): Promise<Map<string, string>> {
62
71
  const url = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/SHA256SUMS`;
63
72
  let res: Response;
64
73
  try {
65
74
  res = await fetch(url, { redirect: "follow" });
66
75
  } catch (e) {
67
- throw new Error(
68
- `Failed to fetch SHA256SUMS for release v${version}: ${errorMessage(e)}\n Fix: Check your network connection and try again`,
69
- );
76
+ throw new KeshaError("E_MODEL_DOWNLOAD", `Failed to fetch SHA256SUMS for release v${version}: ${errorMessage(e)}`, {
77
+ hint: NETWORK_HINT,
78
+ });
70
79
  }
71
80
  if (!res.ok) {
72
- throw new Error(
81
+ throw new KeshaError(
82
+ "E_MODEL_DOWNLOAD",
73
83
  `Cannot verify engine v${version}: release v${version} publishes no SHA256SUMS (HTTP ${res.status}), ` +
74
- `so its binaries cannot be checked before they are installed.\n Fix: ${unverifiableFix(version)}`,
84
+ "so its binaries cannot be checked before they are installed.",
85
+ { hint: unverifiableFix(version, pins) },
75
86
  );
76
87
  }
77
- return parseSha256Sums(await res.text());
88
+ let body: string;
89
+ try {
90
+ body = await res.text();
91
+ } catch (e) {
92
+ throw new KeshaError("E_MODEL_DOWNLOAD", `Failed to read SHA256SUMS for release v${version}: ${errorMessage(e)}`, {
93
+ hint: NETWORK_HINT,
94
+ });
95
+ }
96
+ return parseSha256Sums(body);
78
97
  }
79
98
 
80
99
  /**
@@ -92,12 +111,14 @@ export function releaseChecksums(
92
111
  if (!pinned) throw new Error(`No pinned SHA-256 for ${assetName} of engine v${version}; this CLI build is incomplete.\n Fix: report it at https://github.com/${GITHUB_REPO}/issues.`);
93
112
  return { sha256: pinned, source: "its pinned SHA-256" };
94
113
  }
95
- sums ??= fetchSha256Sums(version);
114
+ sums ??= fetchSha256Sums(version, pins);
96
115
  const sha256 = (await sums).get(assetName);
97
116
  if (!sha256) {
98
- throw new Error(
117
+ throw new KeshaError(
118
+ "E_MODEL_DOWNLOAD",
99
119
  `Cannot verify ${assetName}: the SHA256SUMS of release v${version} does not list it, ` +
100
- `so it cannot be checked before it is installed.\n Fix: ${unverifiableFix(version)}`,
120
+ "so it cannot be checked before it is installed.",
121
+ { hint: unverifiableFix(version, pins) },
101
122
  );
102
123
  }
103
124
  return { sha256, source: `the SHA-256 in the SHA256SUMS of release v${version}` };
@@ -129,25 +150,44 @@ async function rejectMismatchedDownload(
129
150
  return (
130
151
  `${what} from release v${version} does not match ${expected.source}: ` +
131
152
  `expected sha256 ${expected.sha256}, got ${actual}. The download was corrupted or altered, ` +
132
- "so it was deleted instead of installed.\n" +
133
- ` Fix: re-run \`kesha install\`; if the mismatch persists, report it at https://github.com/${GITHUB_REPO}/issues.`
153
+ "so it was deleted instead of installed."
134
154
  );
135
155
  }
136
156
 
137
157
  /**
138
158
  * Whether a cached asset of the pinned release still hashes to its pin; one that does not is
139
- * deleted so the verified download replaces it. `--engine-version` caches are not re-hashed.
159
+ * deleted so the verified download replaces it, or refused when its directory is read-only.
160
+ * `--engine-version` caches are not re-hashed.
140
161
  */
141
- async function cachedAssetMatchesPin(path: string, assetName: string, what: string, version: string): Promise<boolean> {
162
+ async function cachedAssetMatchesPin(
163
+ path: string,
164
+ assetName: string,
165
+ what: string,
166
+ version: string,
167
+ canRepair: boolean,
168
+ ): Promise<boolean> {
142
169
  // KESHA_ENGINE_BIN names the user's own build, which no release pin describes.
143
170
  if (version !== engineChecksums.pins?.version || process.env.KESHA_ENGINE_BIN || !existsSync(path)) return true;
144
171
  const expected = await engineChecksums.forRelease(version)(assetName);
145
- const actual = await sha256OfFile(path);
172
+ let actual: string;
173
+ try {
174
+ actual = await sha256OfFile(path);
175
+ } catch (e) {
176
+ throw canRepair ? e : unreadableInReadOnlyDir(path, e);
177
+ }
146
178
  if (actual === expected.sha256) return true;
147
- log.warn(
148
- `Installed ${what} ${assetName} does not match ${expected.source} (expected sha256 ${expected.sha256}, ` +
149
- `got ${actual}); deleting it and downloading a verified copy.`,
150
- );
179
+ const mismatch =
180
+ `Installed ${what} ${assetName} at ${path} does not match ${expected.source} ` +
181
+ `(expected sha256 ${expected.sha256}, got ${actual})`;
182
+ if (!canRepair) {
183
+ const dir = dirname(path);
184
+ throw new KeshaError("E_CACHE_CORRUPT", `${mismatch}; ${dir} is read-only, so it cannot be replaced.`, {
185
+ hint:
186
+ `make ${dir} writable and re-run \`kesha install\` to replace it with a verified copy, ` +
187
+ "or point KESHA_CACHE_DIR at a writable directory and re-run `kesha install`.",
188
+ });
189
+ }
190
+ log.warn(`${mismatch}; deleting it and downloading a verified copy.`);
151
191
  rmSync(path, { force: true });
152
192
  return false;
153
193
  }
@@ -209,7 +249,7 @@ export function cleanupRetiredSidecars(engineDir: string): string[] {
209
249
  }
210
250
 
211
251
  if (removed.length > 0) {
212
- log.success(`Removed retired sidecars: ${removed.join(", ")}.`);
252
+ log.progress(`Removed retired sidecars: ${removed.join(", ")}.`);
213
253
  }
214
254
 
215
255
  return removed;
@@ -320,12 +360,12 @@ async function downloadSidecar(
320
360
  await streamResponseToFile(res, sidecarPath, spec.displayName);
321
361
  const refusal = await rejectMismatchedDownload(sidecarPath, `${spec.displayName} ${spec.assetName}`, version, expected);
322
362
  if (refusal) {
323
- log.warn(`${refusal}\n ${spec.displayName} not installed; ${spec.unavailableHint}.`);
363
+ log.warn(`${refusal}\n Fix: ${MISMATCH_HINT}\n ${spec.displayName} not installed; ${spec.unavailableHint}.`);
324
364
  return;
325
365
  }
326
366
  chmodSync(sidecarPath, 0o755);
327
367
  darwinTrustBinary(sidecarPath, spec.displayName);
328
- log.success(`${spec.displayName} installed (${spec.availableHint}).`);
368
+ log.progress(`${spec.displayName} installed (${spec.availableHint}).`);
329
369
  } catch (e) {
330
370
  log.warn(
331
371
  `${spec.displayName} install failed (${errorMessage(e)}); ${spec.unavailableHint}.`,
@@ -376,7 +416,7 @@ export async function warmDarwinKokoro(binPath: string, timeoutMs = 180_000): Pr
376
416
  return;
377
417
  }
378
418
 
379
- log.success(
419
+ log.progress(
380
420
  `FluidAudio Kokoro warmed (${Math.round(performance.now() - startedAt)}ms).`,
381
421
  );
382
422
  } catch (e) {
@@ -446,19 +486,18 @@ async function refreshCachedEngine(
446
486
  ): Promise<void> {
447
487
  const engineDir = dirname(binPath);
448
488
  if (noCache && !canWriteEngineDir) {
449
- log.info(
489
+ log.warn(
450
490
  `Engine binary at v${version} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
451
491
  );
452
492
  } else {
453
- log.success(`Engine binary already installed (v${version}).`);
493
+ log.progress(`Engine binary already installed (v${version}).`);
454
494
  }
455
495
  // Re-trust on cache hit: a user who upgraded to Sequoia after install would still have
456
496
  // com.apple.provenance attached; idempotent (~10ms no-op if already correct).
457
497
  if (canWriteEngineDir && existsSync(binPath)) {
458
498
  darwinTrustBinary(binPath, "kesha-engine binary");
459
499
  }
460
- // Top up missing or broken sidecars (pre-#141/#199 cached binaries never had them);
461
- // skip on read-only fs (Nix-store) to avoid confusing "install failed" warnings.
500
+ // Top up missing or broken sidecars (pre-#141/#199 cached binaries never had them).
462
501
  if (canWriteEngineDir) {
463
502
  const checksums = engineChecksums.forRelease(version);
464
503
  await Promise.all(
@@ -468,7 +507,7 @@ async function refreshCachedEngine(
468
507
  // sidecar is SIGKILLed on spawn, and re-downloading it would not lift the block.
469
508
  let pinned = false;
470
509
  try {
471
- pinned = existsSync(path) && (await cachedAssetMatchesPin(path, spec.assetName, spec.displayName, version));
510
+ pinned = existsSync(path) && (await cachedAssetMatchesPin(path, spec.assetName, spec.displayName, version, true));
472
511
  } catch (e) {
473
512
  log.warn(`Could not read ${spec.displayName} at ${path} (${errorMessage(e)}); removing it and downloading it again.`);
474
513
  rmSync(path, { force: true });
@@ -479,6 +518,10 @@ async function refreshCachedEngine(
479
518
  }
480
519
  }),
481
520
  );
521
+ } else {
522
+ for (const spec of SIDECARS) {
523
+ await cachedAssetMatchesPin(join(engineDir, spec.fileBasename), spec.assetName, spec.displayName, version, false);
524
+ }
482
525
  }
483
526
  }
484
527
 
@@ -602,32 +645,35 @@ async function fetchEngineBinary(
602
645
  res = await fetch(url, { redirect: "follow" });
603
646
  } catch (e) {
604
647
  muteSidecarRejections();
605
- throw new Error(
606
- `Failed to fetch engine binary: ${errorMessage(e)}\n Fix: Check your network connection and try again`,
607
- );
648
+ throw new KeshaError("E_MODEL_DOWNLOAD", `Failed to fetch engine binary: ${errorMessage(e)}`, { hint: NETWORK_HINT });
608
649
  }
609
650
 
610
651
  if (!res.ok) {
611
652
  muteSidecarRejections();
612
653
  // Names the tag: nothing falls back to the pin, so the caller must see which release 404ed.
613
- throw new Error(
614
- `Failed to download engine binary from release v${version} (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
615
- );
654
+ throw new KeshaError("E_MODEL_DOWNLOAD", `Failed to download engine binary from release v${version} (HTTP ${res.status})`, {
655
+ hint: `check https://github.com/${GITHUB_REPO}/releases for available versions.`,
656
+ });
616
657
  }
617
658
 
618
- await streamResponseToFile(res, binPath, "kesha-engine binary");
659
+ try {
660
+ await streamResponseToFile(res, binPath, "kesha-engine binary");
661
+ } catch (e) {
662
+ muteSidecarRejections();
663
+ throw e instanceof KeshaError ? e : (engineWriteFailure(binPath, e) ?? e);
664
+ }
619
665
  const refusal = await rejectMismatchedDownload(binPath, `kesha-engine binary ${binaryName}`, version, expected);
620
666
  if (refusal) {
621
667
  muteSidecarRejections();
622
668
  rmSync(getVersionMarkerPath(binPath), { force: true });
623
- throw new Error(refusal);
669
+ throw new KeshaError("E_CACHE_CORRUPT", refusal, { hint: MISMATCH_HINT });
624
670
  }
625
671
  chmodSync(binPath, 0o755);
626
672
  darwinTrustBinary(binPath, "kesha-engine binary");
627
673
  // Marker last: writing it first sends the retry down the cacheValid branch, which never waits.
628
674
  await waitUntilSpawnable(binPath);
629
675
  writeInstalledEngineVersion(binPath, version);
630
- log.success(`Engine binary downloaded (v${version}).`);
676
+ log.progress(`Engine binary downloaded (v${version}).`);
631
677
  await Promise.all(sidecarPromises);
632
678
  }
633
679
 
@@ -807,17 +853,28 @@ async function assertRequestedVersionLanded(binPath: string, version: string): P
807
853
  );
808
854
  }
809
855
 
810
- /** mkdir errnos that mean the configured path is itself wrong; anything else is not a bad argument. */
811
- const ENGINE_DIR_PATH_ERRNOS: Record<string, string> = {
812
- EACCES: "permission denied",
813
- EPERM: "permission denied",
814
- ENOTDIR: "a component of it is a file, not a directory",
815
- // mkdir(recursive) raises this, not ENOTDIR, when the engine directory itself is an existing file.
816
- EEXIST: "it already exists as a file, not a directory",
817
- EROFS: "the filesystem is read-only",
818
- ELOOP: "the path loops through symlinks",
819
- ENAMETOOLONG: "the path is too long",
820
- };
856
+ function engineDirSetting(): { name: string; value: string } | null {
857
+ if (process.env.KESHA_ENGINE_BIN) return { name: "KESHA_ENGINE_BIN", value: process.env.KESHA_ENGINE_BIN };
858
+ if (process.env.KESHA_CACHE_DIR) return { name: "KESHA_CACHE_DIR", value: process.env.KESHA_CACHE_DIR };
859
+ if (process.env.KESHA_HOME) return { name: "KESHA_HOME", value: process.env.KESHA_HOME };
860
+ return null;
861
+ }
862
+
863
+ function unreadableInReadOnlyDir(path: string, e: unknown): unknown {
864
+ const errno = (e as NodeJS.ErrnoException).code ?? "";
865
+ const why = PATH_ERRNOS[errno];
866
+ if (!why) return e;
867
+ const dir = dirname(path);
868
+ return new KeshaError(
869
+ "E_INVALID_ARG",
870
+ `Cannot verify ${path} against its pinned SHA-256: ${why} (${errno}), and ${dir} is read-only, so it cannot be replaced.`,
871
+ {
872
+ hint:
873
+ `make ${path} readable and ${dir} writable, then re-run \`kesha install\`. Or ` +
874
+ engineDirFix(engineDirSetting()?.name, false, dir),
875
+ },
876
+ );
877
+ }
821
878
 
822
879
  /** `KESHA_ENGINE_BIN` names the binary file, so "point it at a writable directory" breaks the next install. */
823
880
  function engineDirFix(setting: string | undefined, notADir: boolean, engineDir: string): string {
@@ -847,15 +904,9 @@ function ensureEngineDirCreatable(binPath: string): void {
847
904
  try {
848
905
  mkdirSync(engineDir, { recursive: true });
849
906
  } catch (e) {
850
- const setting = process.env.KESHA_ENGINE_BIN
851
- ? { name: "KESHA_ENGINE_BIN", value: process.env.KESHA_ENGINE_BIN }
852
- : process.env.KESHA_CACHE_DIR
853
- ? { name: "KESHA_CACHE_DIR", value: process.env.KESHA_CACHE_DIR }
854
- : process.env.KESHA_HOME
855
- ? { name: "KESHA_HOME", value: process.env.KESHA_HOME }
856
- : null;
907
+ const setting = engineDirSetting();
857
908
  const errno = (e as NodeJS.ErrnoException).code ?? "";
858
- const why = ENGINE_DIR_PATH_ERRNOS[errno];
909
+ const why = PATH_ERRNOS[errno];
859
910
  const what = setting
860
911
  ? `${setting.name}="${setting.value}" cannot hold the engine directory ${engineDir}: ${why ?? errorMessage(e)}`
861
912
  : `cannot create the engine directory ${engineDir}: ${why ?? errorMessage(e)}`;
@@ -871,6 +922,28 @@ function ensureEngineDirCreatable(binPath: string): void {
871
922
  }
872
923
  }
873
924
 
925
+ function engineWriteFailure(binPath: string, e: unknown): KeshaError | null {
926
+ const engineDir = dirname(binPath);
927
+ const errno = (e as NodeJS.ErrnoException).code ?? "";
928
+ const setting = engineDirSetting();
929
+ const into = setting ? `${engineDir} (from ${setting.name}="${setting.value}")` : engineDir;
930
+ const why = PATH_ERRNOS[errno];
931
+ if (why) {
932
+ return new KeshaError("E_INVALID_ARG", `Cannot write the engine binary into ${into}: ${why}`, {
933
+ hint: engineDirFix(setting?.name, errno === "ENOTDIR" || errno === "EEXIST", engineDir),
934
+ });
935
+ }
936
+ if (!errno) return null;
937
+ if (errno === "ENOSPC") {
938
+ return new KeshaError("E_INTERNAL", `Cannot write the engine binary into ${into}: no space left on its disk (ENOSPC)`, {
939
+ hint: `the disk is full: free space on the volume that holds ${engineDir}, then re-run \`kesha install\`.`,
940
+ });
941
+ }
942
+ return new KeshaError("E_INTERNAL", `Cannot write the engine binary into ${into}: ${errorMessage(e)}`, {
943
+ hint: `resolve that filesystem error on ${engineDir} and re-run \`kesha install\`; if it persists, file a bug with \`kesha support-bundle\`.`,
944
+ });
945
+ }
946
+
874
947
  export interface EngineInstallRequest extends InstallOptions {
875
948
  noCache?: boolean;
876
949
  backend?: string;
@@ -913,7 +986,7 @@ async function installLockedEngine(
913
986
  const engineDir = dirname(binPath);
914
987
 
915
988
  if (version !== engineVersion) {
916
- log.info(
989
+ log.warn(
917
990
  `Installing engine v${version} instead of the pinned v${engineVersion}; ` +
918
991
  "a later `kesha install` without --engine-version restores the pin.",
919
992
  );
@@ -923,15 +996,14 @@ async function installLockedEngine(
923
996
  const canWriteEngineDir = checkEngineWritable(engineDir);
924
997
 
925
998
  const markerMatches = existsSync(binPath) && installedVersion === version;
926
- // The marker vouches for a file, not for a working binary. Skipped on a read-only engine
927
- // dir: nothing there can be repaired, so a failed probe would only turn a usable Nix
999
+ // The marker vouches for a file, not for a working binary. The probe is skipped on a read-only
1000
+ // engine dir: nothing there can be repaired, so a failed probe would only turn a usable Nix
928
1001
  // install into a hard error.
929
1002
  // Hashed before the health probe, so an altered binary is never run.
930
1003
  const versionMatches =
931
1004
  markerMatches &&
932
- (!canWriteEngineDir ||
933
- ((await cachedAssetMatchesPin(binPath, getEngineBinaryName(), "kesha-engine binary", version)) &&
934
- (await engineWorks(binPath))));
1005
+ (await cachedAssetMatchesPin(binPath, getEngineBinaryName(), "kesha-engine binary", version, canWriteEngineDir)) &&
1006
+ (!canWriteEngineDir || (await engineWorks(binPath)));
935
1007
  // On read-only fs, --no-cache can't re-download; treat as cache-valid and forward flag to model install.
936
1008
  const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
937
1009
 
@@ -1,4 +1,12 @@
1
1
  import { readFileSync, writeFileSync } from "fs";
2
+ import { cmp, parseSemver, tryParseSemver } from "./semver.mjs";
3
+
4
+ export const MIN_ENGINE_VERSION = "1.26.0";
5
+
6
+ export function predatesMinimumEngine(version: string): boolean {
7
+ const parsed = tryParseSemver(version);
8
+ return parsed !== null && cmp(parsed, parseSemver(MIN_ENGINE_VERSION, "MIN_ENGINE_VERSION")) < 0;
9
+ }
2
10
 
3
11
  /**
4
12
  * Version-marker file written next to the engine binary on download (#151).
package/src/engine.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "child_process";
1
2
  import { existsSync, statSync, type Stats } from "fs";
2
3
  import { errorMessage } from "./error-utils";
3
4
  import { join } from "path";
@@ -8,6 +9,7 @@ import { defaultEngineBinPath, keshaCacheDir } from "./paths";
8
9
  import { engineAbortError, interruptedRun } from "./process-tree";
9
10
  import { runEngineProcess, spawnHint } from "./engine/spawn";
10
11
  import { engineFailure, KeshaError, renderError, type ErrorEvent } from "./engine/events";
12
+ import { MIN_ENGINE_VERSION, predatesMinimumEngine, readInstalledEngineVersion } from "./engine-version-marker";
11
13
  import {
12
14
  describeToCapabilities,
13
15
  parseDescribe,
@@ -124,6 +126,16 @@ function failed(run: EngineRun): boolean {
124
126
  return reportedFailure(run) || run.invalid.length > 0;
125
127
  }
126
128
 
129
+ function assertEngineSupported(binPath: string): void {
130
+ const installed = readInstalledEngineVersion(binPath);
131
+ if (installed === null || !predatesMinimumEngine(installed)) return;
132
+ throw new KeshaError(
133
+ "E_ENGINE_PROTOCOL",
134
+ `kesha-engine at ${binPath} is v${installed}; this CLI needs v${MIN_ENGINE_VERSION} or newer`,
135
+ { hint: "run `kesha install` to fetch the engine this CLI expects", versionMismatch: true },
136
+ );
137
+ }
138
+
127
139
  let cachedDescribe: { identity: string; doc: DescribeDocument } | null = null;
128
140
 
129
141
  /** ctime is part of it because utimes cannot set it back after a replacement that restores mtime (#1256, #1258). */
@@ -142,6 +154,7 @@ export async function getDescribe(opts: RunEngineOptions = {}): Promise<Describe
142
154
  hint: spawnHint(),
143
155
  });
144
156
  }
157
+ assertEngineSupported(binPath);
145
158
  if (cachedDescribe?.identity === identity) return cachedDescribe.doc;
146
159
  const run = await runEngine(["describe"], opts);
147
160
  let doc: DescribeDocument | null = null;
@@ -295,10 +308,29 @@ export function buildTranscribeArgs(
295
308
  return args;
296
309
  }
297
310
 
311
+ function hostIsVirtualMac(): boolean {
312
+ return spawnSync("sysctl", ["-n", "kern.hv_vmm_present"], { encoding: "utf8" }).stdout?.trim() === "1";
313
+ }
314
+
315
+ /** CoreML transcription is unreliable without an Apple Neural Engine, which no virtualised Mac has (#1419); synthesis says the same (#742). */
316
+ export function withVirtualMacHint(
317
+ err: KeshaError,
318
+ platform: string = process.platform,
319
+ isVirtualMac: () => boolean = hostIsVirtualMac,
320
+ ): KeshaError {
321
+ if (platform !== "darwin" || err.hint || !err.message.includes("Swift bridge error") || !isVirtualMac()) return err;
322
+ return new KeshaError(err.code, err.message, {
323
+ exitCode: err.exitCode,
324
+ stderr: err.stderr,
325
+ origin: err.origin,
326
+ hint: "this is a virtualised Mac with no Apple Neural Engine, where CoreML transcription is unreliable; transcribe on a physical Mac, or on Linux or Windows",
327
+ });
328
+ }
329
+
298
330
  export async function transcribeEngine(audioPath: string, opts: TranscribeEngineOptions = {}): Promise<string> {
299
331
  const args = await validatedArgs(buildTranscribeArgs(audioPath, opts), { signal: opts.signal });
300
332
  const run = await runEngine(args, { signal: opts.signal, onProgressLine: opts.onProgressLine });
301
- if (failed(run)) throw engineFailure(args[0] ?? "", run, run.exitCode);
333
+ if (failed(run)) throw withVirtualMacHint(engineFailure(args[0] ?? "", run, run.exitCode));
302
334
  return run.stdout;
303
335
  }
304
336
 
@@ -356,7 +388,7 @@ export async function transcribeEngineWithSegments(
356
388
  const args = await validatedArgs(buildTranscribeArgs(audioPath, opts, true), { signal: opts.signal });
357
389
  if (opts.speakers) assertSpeakerModelsInstalled();
358
390
  const run = await runEngine(args, { signal: opts.signal, onProgressLine: opts.onProgressLine });
359
- if (failed(run)) throw engineFailure(args[0] ?? "", run, run.exitCode);
391
+ if (failed(run)) throw withVirtualMacHint(engineFailure(args[0] ?? "", run, run.exitCode));
360
392
  try {
361
393
  return parseTranscriptionOutput(run.stdout);
362
394
  } catch (err: unknown) {
@@ -477,7 +509,7 @@ export async function recordEngine(target: RecordTarget, maxSeconds: number): Pr
477
509
  const signalled = target.live && SIGNALLED_LIVE_EXIT_CODES.has(exitCode);
478
510
  // A clean interrupt delivers the transcript and exits 128+signal saying nothing (rust/src/cli/record.rs:82),
479
511
  // so an error event beside that status is a real failure the signal must not excuse.
480
- if (events.error || events.invalid.length > 0) throw engineFailure("record", events, exitCode);
512
+ if (events.error || events.invalid.length > 0) throw withVirtualMacHint(engineFailure("record", events, exitCode));
481
513
  // A silent non-zero exit named nothing, so it stays the operational 1 it has been since #1167.
482
514
  if (!signalled && exitCode !== 0) throw new Error(`kesha-engine record exited with code ${exitCode}`);
483
515
  }
@@ -518,6 +550,7 @@ export async function detectTextLanguageEngine(
518
550
  ): Promise<LangDetectResult | null> {
519
551
  if (text.trim().length === 0) return null;
520
552
  if (!isEngineInstalled()) return null;
553
+ assertEngineSupported(getEngineBinPath());
521
554
  const run = await runEngine(["detect-text-lang"], { ...opts, stdin: text });
522
555
  if (reportedFailure(run)) {
523
556
  const warning = textLangFailureWarning(run.stderr);
package/src/log.ts CHANGED
@@ -16,6 +16,10 @@ export function setColorEnabled(enabled: boolean): void {
16
16
  colors = enabled ? pc : pc.createColors(false);
17
17
  }
18
18
 
19
+ export function isColorEnabled(): boolean {
20
+ return colors.isColorSupported;
21
+ }
22
+
19
23
  /**
20
24
  * Debug mode (#148): when `KESHA_DEBUG` is truthy OR the caller has flipped
21
25
  * `log.debugEnabled = true` (via `--debug`), `log.debug()` writes structured
package/src/mcp/tools.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { errorMessage } from "../error-utils";
3
+ import { KeshaError } from "../engine/events";
3
4
  import { installHint } from "../install-hint";
4
5
  import { z } from "zod";
5
6
  import { chmodSync, existsSync, readFileSync, statSync } from "fs";
@@ -126,7 +127,8 @@ export function registerTools(server: McpServer): void {
126
127
  const hint = isAbsolute(path)
127
128
  ? ""
128
129
  : ` (relative paths resolve against the MCP server's working directory, ${process.cwd()} — pass an absolute path instead)`;
129
- return { isError: true, content: [{ type: "text" as const, text: `File not found: ${path}${hint}` }] };
130
+ const err = new KeshaError("E_INPUT_NOT_FOUND", `File not found: ${path}${hint}`);
131
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
130
132
  }
131
133
  try {
132
134
  assertAudioFileArgument(path);
@@ -0,0 +1,11 @@
1
+ /** Errnos that mean the path itself is wrong for writing, so the fix is a different path; anything else is not a bad argument. */
2
+ export const PATH_ERRNOS: Record<string, string> = {
3
+ EACCES: "permission denied",
4
+ EPERM: "permission denied",
5
+ ENOTDIR: "a component of it is a file, not a directory",
6
+ // mkdir(recursive) raises this, not ENOTDIR, when the directory itself is an existing file.
7
+ EEXIST: "it already exists as a file, not a directory",
8
+ EROFS: "the filesystem is read-only",
9
+ ELOOP: "the path loops through symlinks",
10
+ ENAMETOOLONG: "the path is too long",
11
+ };
package/src/progress.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { copyFileSync, readdirSync, renameSync, rmSync, statSync } from "fs";
2
2
  import { basename, dirname, join } from "path";
3
3
  import { errorMessage } from "./error-utils";
4
+ import { KeshaError } from "./engine/events";
4
5
  import { log } from "./log";
5
6
 
6
7
  const BAR_WIDTH = 20;
@@ -172,11 +173,15 @@ export async function applyBackpressure(
172
173
  */
173
174
  const STALE_STAGING_MS = 24 * 60 * 60 * 1000;
174
175
 
176
+ export function stagingPrefix(destPath: string): string {
177
+ return `${destPath}.part.`;
178
+ }
179
+
175
180
  /** A Ctrl-C kills the process before any cleanup runs, so last run's staging file is swept by the next one (#770). */
176
181
  function sweepStagingFiles(destPath: string): void {
177
182
  if (process.platform === "win32") return;
178
183
  const dir = dirname(destPath);
179
- const prefix = `${basename(destPath)}.part.`;
184
+ const prefix = basename(stagingPrefix(destPath));
180
185
  const cutoffMs = Date.now() - STALE_STAGING_MS;
181
186
 
182
187
  let names: string[];
@@ -225,32 +230,44 @@ export async function streamResponseToFile(
225
230
  destPath: string,
226
231
  label: string,
227
232
  ): Promise<number> {
228
- if (!res.body) {
229
- throw new Error(
230
- `Download failed: empty response for ${label}\n Fix: Try again — the server may be temporarily unavailable`,
231
- );
232
- }
233
+ const emptyResponse = () =>
234
+ new KeshaError("E_MODEL_DOWNLOAD", `Download failed: empty response for ${label}`, {
235
+ hint: "try again; the server may be temporarily unavailable.",
236
+ });
237
+ if (!res.body) throw emptyResponse();
233
238
 
234
239
  const totalBytes = Number(res.headers.get("content-length") || 0);
235
240
  const progress = createProgressBar(label, totalBytes);
236
241
 
237
242
  sweepStagingFiles(destPath);
238
- const stagingPath = `${destPath}.part.${process.pid}.${stagingSeq++}`;
239
- const writer = Bun.file(stagingPath).writer();
243
+ const stagingPath = `${stagingPrefix(destPath)}${process.pid}.${stagingSeq++}`;
244
+ const reader = res.body.getReader();
240
245
  let bytes = 0;
241
246
  try {
247
+ const writer = Bun.file(stagingPath).writer();
242
248
  try {
243
- for await (const chunk of res.body) {
244
- await applyBackpressure(writer, writer.write(chunk));
245
- bytes += chunk.length;
246
- progress.update(chunk.length);
249
+ for (;;) {
250
+ let next: Awaited<ReturnType<typeof reader.read>>;
251
+ try {
252
+ next = await reader.read();
253
+ } catch (err) {
254
+ throw new KeshaError("E_MODEL_DOWNLOAD", `Download of ${label} broke off after ${bytes} bytes: ${errorMessage(err)}`, {
255
+ hint: "check your network connection and try again.",
256
+ });
257
+ }
258
+ if (next.done) break;
259
+ await applyBackpressure(writer, writer.write(next.value));
260
+ bytes += next.value.length;
261
+ progress.update(next.value.length);
247
262
  }
248
263
  } finally {
249
264
  // Must await: an open write handle makes the freshly-downloaded engine unspawnable — EBUSY on Windows, ETXTBSY on Linux.
250
265
  await writer.end();
251
266
  }
267
+ if (bytes === 0) throw emptyResponse();
252
268
  publishStaging(stagingPath, destPath);
253
269
  } catch (err) {
270
+ reader.cancel().catch(() => {});
254
271
  rmSync(stagingPath, { force: true });
255
272
  throw err;
256
273
  }
@@ -272,7 +289,7 @@ export function createProgressBar(label: string, totalBytes: number): {
272
289
  return {
273
290
  update() {},
274
291
  finish() {
275
- log.success(`Downloaded ${label} ✓`);
292
+ log.progress(`Downloaded ${label} ✓`);
276
293
  },
277
294
  };
278
295
  }