@drakulavich/kesha-voice-kit 1.31.0 → 2.0.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.
@@ -2,22 +2,34 @@ import { dirname, join, resolve, sep } from "path";
2
2
  import { errorMessage } from "./error-utils";
3
3
  import { homedir, tmpdir } from "os";
4
4
  import { existsSync, mkdirSync, chmodSync, accessSync, constants, rmSync } from "fs";
5
- import { getDescribe, getEngineBinPath, protocolEnv, spawnEngineProcess } from "./engine";
5
+ import { getDescribe, getEngineBinPath } from "./engine";
6
6
  import { engineFunctionalHealth, probeExecutable, readExecutableVersion } from "./engine-health";
7
- import { engineTarget, engineTargetEntries, isDarwinArm64, targetKey } from "./engine-targets";
7
+ import {
8
+ engineTarget,
9
+ engineTargetEntries,
10
+ isDarwinArm64,
11
+ parseSha256Sums,
12
+ SIDECARS,
13
+ type SidecarSpec,
14
+ targetKey,
15
+ } from "./engine-targets";
8
16
  import { validateArgv } from "./engine/describe";
9
- import { engineFailure, KeshaError, readEvents, type StderrOutcome } from "./engine/events";
17
+ import { engineFailure, KeshaError, offProtocolLine } from "./engine/events";
18
+ import { runEngineProcess } from "./engine/spawn";
10
19
  import { acquireInstallLock } from "./install-lock";
11
20
  import { log } from "./log";
12
- import { engineVersion } from "./package-info";
21
+ import { engineVersion, injectedEnginePins } from "./package-info";
13
22
  import { keshaCacheDir } from "./paths";
14
23
  import { createLiveStatus, streamResponseToFile } from "./progress";
15
- import { registerProcessTree } from "./process-tree";
24
+ import { interruptedRun } from "./process-tree";
25
+ import { DEFAULT_VOICE_ID } from "./voice-routing";
16
26
  import {
27
+ getVersionMarkerPath,
17
28
  readInstalledEngineVersion,
18
29
  writeInstalledEngineVersion,
19
30
  } from "./engine-version-marker";
20
31
 
32
+ export { SIDECARS } from "./engine-targets";
21
33
  export {
22
34
  getVersionMarkerPath,
23
35
  readInstalledEngineVersion,
@@ -26,6 +38,120 @@ export {
26
38
 
27
39
  const GITHUB_REPO = "drakulavich/kesha-voice-kit";
28
40
 
41
+ interface ExpectedSha256 {
42
+ sha256: string;
43
+ source: string;
44
+ }
45
+
46
+ /** `package.json#kesha.engine`, injected at publish by `engine-pin.ts`: what every downloaded asset of that Engine hashes to and weighs. */
47
+ export interface AssetPins {
48
+ version: string;
49
+ sha256: Readonly<Record<string, string>>;
50
+ size: Readonly<Record<string, number>>;
51
+ }
52
+
53
+ /** Without `--engine-version` nothing but the maintainer can make an unverifiable pinned release verifiable. */
54
+ function unverifiableFix(version: string): string {
55
+ return version === engineVersion
56
+ ? `report it at https://github.com/${GITHUB_REPO}/issues: the pinned engine release v${version} must publish its checksums.`
57
+ : `pick a release that ships SHA256SUMS (https://github.com/${GITHUB_REPO}/releases), or run ` +
58
+ `\`kesha install\` without --engine-version to install the pinned v${engineVersion}.`;
59
+ }
60
+
61
+ async function fetchSha256Sums(version: string): Promise<Map<string, string>> {
62
+ const url = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/SHA256SUMS`;
63
+ let res: Response;
64
+ try {
65
+ res = await fetch(url, { redirect: "follow" });
66
+ } 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
+ );
70
+ }
71
+ if (!res.ok) {
72
+ throw new Error(
73
+ `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)}`,
75
+ );
76
+ }
77
+ return parseSha256Sums(await res.text());
78
+ }
79
+
80
+ /**
81
+ * The pinned Engine's assets against the pins, never the network; any other release, and every release
82
+ * in a source checkout (which carries no pin), against that release's own SHA256SUMS.
83
+ */
84
+ export function releaseChecksums(
85
+ version: string,
86
+ pins: AssetPins | undefined,
87
+ ): (assetName: string) => Promise<ExpectedSha256> {
88
+ let sums: Promise<Map<string, string>> | undefined;
89
+ return async (assetName) => {
90
+ if (version === pins?.version) {
91
+ const pinned = pins.sha256[assetName];
92
+ 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
+ return { sha256: pinned, source: "its pinned SHA-256" };
94
+ }
95
+ sums ??= fetchSha256Sums(version);
96
+ const sha256 = (await sums).get(assetName);
97
+ if (!sha256) {
98
+ throw new Error(
99
+ `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)}`,
101
+ );
102
+ }
103
+ return { sha256, source: `the SHA-256 in the SHA256SUMS of release v${version}` };
104
+ };
105
+ }
106
+
107
+ /** Tests that serve a stand-in engine replace these, the way they replace `fetch`. */
108
+ export const engineChecksums = {
109
+ pins: injectedEnginePins,
110
+ forRelease: (version: string) => releaseChecksums(version, engineChecksums.pins),
111
+ };
112
+
113
+ async function sha256OfFile(path: string): Promise<string> {
114
+ const hasher = new Bun.CryptoHasher("sha256");
115
+ for await (const chunk of Bun.file(path).stream()) hasher.update(chunk);
116
+ return hasher.digest("hex");
117
+ }
118
+
119
+ /** Deletes a download that does not hash to `expected` and returns the refusal; null when it matches. */
120
+ async function rejectMismatchedDownload(
121
+ path: string,
122
+ what: string,
123
+ version: string,
124
+ expected: ExpectedSha256,
125
+ ): Promise<string | null> {
126
+ const actual = await sha256OfFile(path);
127
+ if (actual === expected.sha256) return null;
128
+ rmSync(path, { force: true });
129
+ return (
130
+ `${what} from release v${version} does not match ${expected.source}: ` +
131
+ `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.`
134
+ );
135
+ }
136
+
137
+ /**
138
+ * 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.
140
+ */
141
+ async function cachedAssetMatchesPin(path: string, assetName: string, what: string, version: string): Promise<boolean> {
142
+ // KESHA_ENGINE_BIN names the user's own build, which no release pin describes.
143
+ if (version !== engineChecksums.pins?.version || process.env.KESHA_ENGINE_BIN || !existsSync(path)) return true;
144
+ const expected = await engineChecksums.forRelease(version)(assetName);
145
+ const actual = await sha256OfFile(path);
146
+ 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
+ );
151
+ rmSync(path, { force: true });
152
+ return false;
153
+ }
154
+
29
155
  export function getEngineBinaryName(
30
156
  platform: string = process.platform,
31
157
  arch: string = process.arch,
@@ -55,44 +181,6 @@ export function assertPlatformCanInstall(
55
181
  }
56
182
  }
57
183
 
58
- /** Sidecar spec — centralises AVSpeech (#141) and future sidecars so each is one entry. */
59
- interface SidecarSpec {
60
- /** Written next to the engine binary; Rust probes this exact name. */
61
- fileBasename: string;
62
- /** Release asset name — may differ from fileBasename (e.g. `say-avspeech-darwin-arm64` vs `say-avspeech`). */
63
- assetName: string;
64
- displayName: string;
65
- availableHint: string;
66
- unavailableHint: string;
67
- }
68
-
69
- export const SIDECARS: SidecarSpec[] = [
70
- {
71
- fileBasename: "say-avspeech",
72
- assetName: "say-avspeech-darwin-arm64",
73
- displayName: "AVSpeech sidecar",
74
- availableHint: "macOS voices available",
75
- unavailableHint: "macos-* voices unavailable",
76
- },
77
- // Kokoro TTS (#207) and speaker diarization (#199) no longer ship as Swift
78
- // sidecars — both run in-engine via the native `fluidaudio-rs` binding. Only
79
- // the AVSpeech and text-lang sidecars remain.
80
- {
81
- // Runtime resolver looks for plain `kesha-textlang` next to the engine
82
- // (see `rust/src/text_lang.rs::helper_path`), not the platform-suffixed
83
- // release-asset name. Mismatch is intentional: the asset name needs the
84
- // suffix for GitHub-release uniqueness; the sidecar lookup wants the
85
- // unsuffixed binary so the same Rust code path works on the build-time
86
- // OUT_DIR baked fallback.
87
- fileBasename: "kesha-textlang",
88
- assetName: "kesha-textlang-darwin-arm64",
89
- displayName: "Text-lang sidecar",
90
- availableHint: "detect-text-lang fast path",
91
- unavailableHint:
92
- "detect-text-lang will fail until next `kesha install` (no swift -e fallback)",
93
- },
94
- ];
95
-
96
184
  const RETIRED_SIDECAR_FILENAMES = [
97
185
  // Historical installed filenames.
98
186
  "kesha-kokoro",
@@ -194,12 +282,22 @@ async function downloadSidecar(
194
282
  spec: SidecarSpec,
195
283
  binPath: string,
196
284
  version: string,
285
+ checksums: (assetName: string) => Promise<ExpectedSha256>,
197
286
  ): Promise<void> {
198
287
  if (!isDarwinArm64()) return;
199
288
 
200
289
  const sidecarPath = join(dirname(binPath), spec.fileBasename);
201
290
  const url = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${spec.assetName}`;
202
291
 
292
+ let expected: ExpectedSha256;
293
+ try {
294
+ expected = await checksums(spec.assetName);
295
+ } catch (e) {
296
+ rmSync(sidecarPath, { force: true });
297
+ log.warn(`Could not verify ${spec.displayName} for v${version} (${errorMessage(e)}); ${spec.unavailableHint}.`);
298
+ return;
299
+ }
300
+
203
301
  let res: Response;
204
302
  try {
205
303
  res = await fetch(url, { redirect: "follow" });
@@ -220,6 +318,11 @@ async function downloadSidecar(
220
318
  // Catch stream/chmod failures so a sidecar error can't poison the engine install.
221
319
  try {
222
320
  await streamResponseToFile(res, sidecarPath, spec.displayName);
321
+ const refusal = await rejectMismatchedDownload(sidecarPath, `${spec.displayName} ${spec.assetName}`, version, expected);
322
+ if (refusal) {
323
+ log.warn(`${refusal}\n ${spec.displayName} not installed; ${spec.unavailableHint}.`);
324
+ return;
325
+ }
223
326
  chmodSync(sidecarPath, 0o755);
224
327
  darwinTrustBinary(sidecarPath, spec.displayName);
225
328
  log.success(`${spec.displayName} installed (${spec.availableHint}).`);
@@ -230,7 +333,8 @@ async function downloadSidecar(
230
333
  }
231
334
  }
232
335
 
233
- async function warmDarwinKokoro(binPath: string): Promise<void> {
336
+ /** `timeoutMs` exists for tests; install always uses the default. */
337
+ export async function warmDarwinKokoro(binPath: string, timeoutMs = 180_000): Promise<void> {
234
338
  if (!isDarwinArm64()) return;
235
339
  // Kokoro now runs in-engine (FluidAudio CoreML, system_kokoro) — warm it by
236
340
  // exercising the engine's own `say`, not a sidecar. The first synthesis
@@ -238,30 +342,28 @@ async function warmDarwinKokoro(binPath: string): Promise<void> {
238
342
  if (!existsSync(binPath)) return;
239
343
 
240
344
  const outPath = join(tmpdir(), `kesha-kokoro-warmup-${process.pid}.wav`);
345
+ let args: string[];
346
+ try {
347
+ const validated = validateArgv(["say", "--voice", DEFAULT_VOICE_ID, "--out", outPath, "Kesha warmup."], await getDescribe());
348
+ for (const warning of validated.warnings) log.warn(warning);
349
+ args = validated.argv;
350
+ } catch (e) {
351
+ log.warn(`FluidAudio Kokoro warmup skipped (${errorMessage(e)}); first \`kesha say en-*\` may still be slow.`);
352
+ return;
353
+ }
241
354
  log.progress("Warming FluidAudio Kokoro CoreML cache...");
242
355
 
243
356
  const startedAt = performance.now();
244
- const proc = spawnEngineProcess(
245
- binPath,
246
- ["say", "--voice", "en-am_michael", "--out", outPath, "Kesha warmup."],
247
- ["ignore", "pipe", "pipe"],
248
- protocolEnv(),
249
- );
250
- const tree = registerProcessTree(proc);
251
-
252
- let timedOut = false;
253
- const timer = setTimeout(() => {
254
- timedOut = true;
255
- proc.kill();
256
- }, 180_000);
257
-
258
357
  try {
259
- const [events, exitCode] = await Promise.all([
260
- readEvents(proc.stderr as ReadableStream<Uint8Array>),
261
- proc.exited,
262
- ]);
358
+ const { exitCode, aborted, events } = await runEngineProcess(binPath, args, {
359
+ stdio: ["ignore", "ignore", "pipe"],
360
+ signal: AbortSignal.timeout(timeoutMs),
361
+ });
263
362
 
264
- if (timedOut) {
363
+ // A Ctrl+C that lands while the deadline is stopping the engine is still the user's interrupt.
364
+ const interrupted = interruptedRun(exitCode);
365
+ if (interrupted) throw interrupted;
366
+ if (aborted) {
265
367
  log.warn("FluidAudio Kokoro warmup timed out; first `kesha say en-*` may still be slow.");
266
368
  return;
267
369
  }
@@ -278,13 +380,13 @@ async function warmDarwinKokoro(binPath: string): Promise<void> {
278
380
  `FluidAudio Kokoro warmed (${Math.round(performance.now() - startedAt)}ms).`,
279
381
  );
280
382
  } catch (e) {
383
+ // A launch failure was never the warmup's to swallow, and an interrupt is the user's.
384
+ if (e instanceof KeshaError && (e.code === "E_INTERRUPTED" || e.code === "E_ENGINE_SPAWN")) throw e;
281
385
  log.warn(
282
386
  `FluidAudio Kokoro warmup failed (${errorMessage(e)}); ` +
283
387
  "first `kesha say en-*` may still be slow.",
284
388
  );
285
389
  } finally {
286
- clearTimeout(timer);
287
- tree.dispose();
288
390
  try {
289
391
  rmSync(outPath, { force: true });
290
392
  } catch {
@@ -358,14 +460,22 @@ async function refreshCachedEngine(
358
460
  // Top up missing or broken sidecars (pre-#141/#199 cached binaries never had them);
359
461
  // skip on read-only fs (Nix-store) to avoid confusing "install failed" warnings.
360
462
  if (canWriteEngineDir) {
463
+ const checksums = engineChecksums.forRelease(version);
361
464
  await Promise.all(
362
465
  SIDECARS.map(async (spec) => {
363
466
  const path = join(engineDir, spec.fileBasename);
364
467
  // Re-trust before probing, for the Sequoia upgrade scenario: a provenance-blocked
365
468
  // sidecar is SIGKILLed on spawn, and re-downloading it would not lift the block.
366
- if (existsSync(path)) darwinTrustBinary(path, spec.displayName);
469
+ let pinned = false;
470
+ try {
471
+ pinned = existsSync(path) && (await cachedAssetMatchesPin(path, spec.assetName, spec.displayName, version));
472
+ } catch (e) {
473
+ log.warn(`Could not read ${spec.displayName} at ${path} (${errorMessage(e)}); removing it and downloading it again.`);
474
+ rmSync(path, { force: true });
475
+ }
476
+ if (pinned) darwinTrustBinary(path, spec.displayName);
367
477
  if (await sidecarNeedsDownload(spec, engineDir)) {
368
- await downloadSidecar(spec, binPath, version);
478
+ await downloadSidecar(spec, binPath, version, checksums);
369
479
  }
370
480
  }),
371
481
  );
@@ -399,9 +509,10 @@ export async function waitUntilSpawnable(binPath: string, deadlineMs = 60_000):
399
509
  lastError = health.status === "unusable" ? health.detail : "the binary is gone";
400
510
  // Only a lock is worth waiting out — a missing or corrupt binary never becomes spawnable.
401
511
  if (!isTransientSpawnLock(lastError)) {
402
- throw new Error(
403
- `Downloaded the engine to ${binPath} but it could not be started: ${lastError}\n` +
404
- ` Fix: delete ${dirname(binPath)} and re-run \`kesha install\`.`,
512
+ throw new KeshaError(
513
+ "E_ENGINE_SPAWN",
514
+ `Downloaded the engine to ${binPath} but it could not be started: ${lastError}`,
515
+ { hint: `delete ${dirname(binPath)} and re-run \`kesha install\`.` },
405
516
  );
406
517
  }
407
518
  if (Date.now() + delay > deadline) break;
@@ -413,11 +524,15 @@ export async function waitUntilSpawnable(binPath: string, deadlineMs = 60_000):
413
524
  delay = Math.min(delay * 2, 2_000);
414
525
  }
415
526
 
416
- throw new Error(
527
+ throw new KeshaError(
528
+ "E_ENGINE_SPAWN",
417
529
  `Downloaded the engine to ${binPath} but it is still locked after ` +
418
- `${Math.round(deadlineMs / 1000)}s: ${lastError}\n` +
419
- ` Fix: a security scanner is likely holding the file. Re-run \`kesha install\`, ` +
420
- `or exclude ${dirname(binPath)} from real-time scanning.`,
530
+ `${Math.round(deadlineMs / 1000)}s: ${lastError}`,
531
+ {
532
+ hint:
533
+ "a security scanner is likely holding the file. Re-run `kesha install`, " +
534
+ `or exclude ${dirname(binPath)} from real-time scanning.`,
535
+ },
421
536
  );
422
537
  }
423
538
 
@@ -466,13 +581,22 @@ async function fetchEngineBinary(
466
581
  mkdirSync(dirname(binPath), { recursive: true });
467
582
 
468
583
  // Overlap sidecar fetches with the engine fetch (~15-30s saved on cold install).
584
+ const checksums = engineChecksums.forRelease(version);
469
585
  const sidecarPromises = SIDECARS.map((s) =>
470
- downloadSidecar(s, binPath, version),
586
+ downloadSidecar(s, binPath, version, checksums),
471
587
  );
472
588
  // If the engine fetch throws, silence in-flight sidecar rejections so unhandledRejection doesn't obscure the engine error.
473
589
  const muteSidecarRejections = () =>
474
590
  sidecarPromises.forEach((p) => p.catch(() => {}));
475
591
 
592
+ let expected: ExpectedSha256;
593
+ try {
594
+ expected = await checksums(binaryName);
595
+ } catch (e) {
596
+ muteSidecarRejections();
597
+ throw e;
598
+ }
599
+
476
600
  let res: Response;
477
601
  try {
478
602
  res = await fetch(url, { redirect: "follow" });
@@ -492,6 +616,12 @@ async function fetchEngineBinary(
492
616
  }
493
617
 
494
618
  await streamResponseToFile(res, binPath, "kesha-engine binary");
619
+ const refusal = await rejectMismatchedDownload(binPath, `kesha-engine binary ${binaryName}`, version, expected);
620
+ if (refusal) {
621
+ muteSidecarRejections();
622
+ rmSync(getVersionMarkerPath(binPath), { force: true });
623
+ throw new Error(refusal);
624
+ }
495
625
  chmodSync(binPath, 0o755);
496
626
  darwinTrustBinary(binPath, "kesha-engine binary");
497
627
  // Marker last: writing it first sends the retry down the cacheValid branch, which never waits.
@@ -520,7 +650,7 @@ function checkEngineWritable(engineDir: string): boolean {
520
650
  /**
521
651
  * The install path's pre-spawn gate: checks the requested backend against the describe
522
652
  * document, then validates the built engine argv through it. `--diarize` on a build that
523
- * lacks `system_diarize` (the Nix build, which omits it on purpose — docs/nix-install.md)
653
+ * lacks `system_diarize` (the Nix build, which omits it on purpose — docs/distribution.md#nix)
524
654
  * gets the npm-release remedy instead of `validateArgv`'s generic "needs" message.
525
655
  *
526
656
  * A bare `install` (no backend, no flags) skips `getDescribe()` entirely: a read-only
@@ -558,7 +688,7 @@ async function validateInstallRequest(
558
688
  throw new KeshaError(
559
689
  "E_INVALID_ARG",
560
690
  "The installed engine was built without the system_diarize feature, so --diarize is unavailable " +
561
- "(the Nix build in docs/nix-install.md is one such case).",
691
+ "(the Nix build in docs/distribution.md is one such case).",
562
692
  {
563
693
  hint: "install via the npm release with `bun add -g @drakulavich/kesha-voice-kit`, which ships the diarize-enabled engine on darwin-arm64",
564
694
  },
@@ -571,15 +701,13 @@ async function validateInstallRequest(
571
701
  /** Runs `kesha-engine install` to download/verify models. */
572
702
  async function runEngineModelInstall(binPath: string, installArgs: string[]): Promise<void> {
573
703
  log.progress("Installing models...");
574
- const proc = spawnEngineProcess(binPath, installArgs, ["inherit", "inherit", "pipe"], protocolEnv());
575
- const tree = registerProcessTree(proc);
576
704
  // The byte percentage repaints one row; `GET`/`OK`/`retrying` are discrete steps that must stay in the log.
577
705
  const status = createLiveStatus();
578
- let events: StderrOutcome;
579
- let exitCode: number;
706
+ let run: Awaited<ReturnType<typeof runEngineProcess>>;
580
707
  try {
581
- [events, exitCode] = await Promise.all([
582
- readEvents(proc.stderr as ReadableStream<Uint8Array>, {
708
+ run = await runEngineProcess(binPath, installArgs, {
709
+ stdio: ["inherit", "inherit", "pipe"],
710
+ sinks: {
583
711
  onProgress: (line, event) => {
584
712
  if (event.pct !== undefined) {
585
713
  status.update(line);
@@ -592,14 +720,17 @@ async function runEngineModelInstall(binPath: string, installArgs: string[]): Pr
592
720
  status.clear();
593
721
  log.warn(line);
594
722
  },
595
- }),
596
- proc.exited,
597
- ]);
723
+ },
724
+ });
598
725
  } finally {
599
726
  status.clear();
600
- tree.dispose();
601
727
  }
602
-
728
+ const { exitCode, events } = run;
729
+ // #1301: engine 1.26.0 lets FluidAudio log a download retry it recovers from on raw fd 2; a clean exit outranks that line.
730
+ if (exitCode === 0 && events.error === null) {
731
+ for (const line of events.invalid) log.warn(offProtocolLine("install", line));
732
+ return;
733
+ }
603
734
  if (events.error || events.invalid.length > 0) throw engineFailure("install", events, exitCode);
604
735
  if (exitCode !== 0) {
605
736
  // Nothing coded and nothing off-protocol: the engine failed without saying why, so neither do we.
@@ -795,8 +926,12 @@ async function installLockedEngine(
795
926
  // The marker vouches for a file, not for a working binary. Skipped on a read-only engine
796
927
  // dir: nothing there can be repaired, so a failed probe would only turn a usable Nix
797
928
  // install into a hard error.
929
+ // Hashed before the health probe, so an altered binary is never run.
798
930
  const versionMatches =
799
- markerMatches && (!canWriteEngineDir || (await engineWorks(binPath)));
931
+ markerMatches &&
932
+ (!canWriteEngineDir ||
933
+ ((await cachedAssetMatchesPin(binPath, getEngineBinaryName(), "kesha-engine binary", version)) &&
934
+ (await engineWorks(binPath))));
800
935
  // On read-only fs, --no-cache can't re-download; treat as cache-valid and forward flag to model install.
801
936
  const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
802
937
 
@@ -835,19 +970,3 @@ async function installLockedEngine(
835
970
  log.success(`Backend installed successfully (engine v${version}).`);
836
971
  return binPath;
837
972
  }
838
-
839
- /** Installs the Pinned Engine version. Public API (`downloadModel`); the override is CLI-only. */
840
- export async function downloadEngine(
841
- noCache = false,
842
- backend?: string,
843
- options: InstallOptions = {},
844
- ): Promise<string> {
845
- // Field-by-field, not a spread: a caller's stray `version` must not reach installEngine.
846
- return installEngine({
847
- noCache,
848
- backend,
849
- ttsLangs: options.ttsLangs,
850
- vad: options.vad,
851
- diarize: options.diarize,
852
- });
853
- }
@@ -9,28 +9,76 @@ export interface EngineTarget {
9
9
  /** GitHub release asset name — suffixed for uniqueness across platforms. */
10
10
  assetName: string;
11
11
  backend: "coreml" | "onnx";
12
- /** Published asset size, for `kesha install --plan`. Verified by `check:engine-targets`. */
13
- sizeBytes: number;
14
12
  }
15
13
 
16
14
  const ENGINE_TARGETS: Record<string, EngineTarget> = {
17
15
  "darwin-arm64": {
18
16
  assetName: "kesha-engine-darwin-arm64",
19
17
  backend: "coreml",
20
- sizeBytes: 64_713_088,
21
18
  },
22
19
  "linux-x64": {
23
20
  assetName: "kesha-engine-linux-x64",
24
21
  backend: "onnx",
25
- sizeBytes: 66_588_184,
26
22
  },
27
23
  "win32-x64": {
28
24
  assetName: "kesha-engine-windows-x64.exe",
29
25
  backend: "onnx",
30
- sizeBytes: 65_587_712,
31
26
  },
32
27
  };
33
28
 
29
+ /** Sidecar spec — centralises AVSpeech (#141) and future sidecars so each is one entry. */
30
+ export interface SidecarSpec {
31
+ /** Written next to the engine binary; Rust probes this exact name. */
32
+ fileBasename: string;
33
+ /** Release asset name — may differ from fileBasename (e.g. `say-avspeech-darwin-arm64` vs `say-avspeech`). */
34
+ assetName: string;
35
+ displayName: string;
36
+ availableHint: string;
37
+ unavailableHint: string;
38
+ }
39
+
40
+ export const SIDECARS: SidecarSpec[] = [
41
+ {
42
+ fileBasename: "say-avspeech",
43
+ assetName: "say-avspeech-darwin-arm64",
44
+ displayName: "AVSpeech sidecar",
45
+ availableHint: "macOS voices available",
46
+ unavailableHint: "macos-* voices unavailable",
47
+ },
48
+ // Kokoro TTS (#207) and speaker diarization (#199) no longer ship as Swift
49
+ // sidecars — both run in-engine via the native `fluidaudio-rs` binding. Only
50
+ // the AVSpeech and text-lang sidecars remain.
51
+ {
52
+ // Runtime resolver looks for plain `kesha-textlang` next to the engine
53
+ // (see `rust/src/text_lang.rs::helper_path`), not the platform-suffixed
54
+ // release-asset name. Mismatch is intentional: the asset name needs the
55
+ // suffix for GitHub-release uniqueness; the sidecar lookup wants the
56
+ // unsuffixed binary so the same Rust code path works on the build-time
57
+ // OUT_DIR baked fallback.
58
+ fileBasename: "kesha-textlang",
59
+ assetName: "kesha-textlang-darwin-arm64",
60
+ displayName: "Text-lang sidecar",
61
+ availableHint: "detect-text-lang fast path",
62
+ unavailableHint:
63
+ "detect-text-lang will fail until next `kesha install` (no swift -e fallback)",
64
+ },
65
+ ];
66
+
67
+ /** Every asset `kesha install` downloads, and so every asset a published CLI's Engine pin covers. */
68
+ export function downloadedAssetNames(): string[] {
69
+ return [...Object.values(ENGINE_TARGETS).map((t) => t.assetName), ...SIDECARS.map((s) => s.assetName)];
70
+ }
71
+
72
+ /** Asset name to SHA-256 from a release's `sha256sum`-format SHA256SUMS, whose names carry a `./` prefix. */
73
+ export function parseSha256Sums(text: string): Map<string, string> {
74
+ const sums = new Map<string, string>();
75
+ for (const line of text.split(/\r?\n/)) {
76
+ const m = /^([0-9a-f]{64}) [ *]?(?:\.\/)?(\S.*)$/.exec(line.trim());
77
+ if (m) sums.set(m[2]!, m[1]!);
78
+ }
79
+ return sums;
80
+ }
81
+
34
82
  /**
35
83
  * The one target whose engine links FluidAudio, so the only one with ANE Kokoro assets,
36
84
  * Swift sidecars and diarization. Kept beside the target table because that is what makes