@drakulavich/kesha-voice-kit 1.26.0 → 1.27.0-alpha.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/README.md CHANGED
@@ -36,7 +36,8 @@ curl -fsSL https://bun.sh/install | bash # or: brew install oven-sh/bun/b
36
36
  bun add -g @drakulavich/kesha-voice-kit
37
37
  kesha --version # confirms `kesha` resolved on PATH
38
38
  kesha install --plan # preview exact download/disk sizes first — downloads nothing
39
- kesha install # ~2.5 GB (engine + speech-to-text models), explicit — never automatic.
39
+ kesha install # ~2.5 GB on Linux/Windows; ~0.6 GB on Apple Silicon, whose CoreML
40
+ # engine uses a different, smaller model set. Explicit — never automatic.
40
41
  # No progress bar during the model step; can take several minutes.
41
42
  # Prefer a guided wizard? `kesha init` walks through the same choices interactively.
42
43
 
@@ -21,7 +21,7 @@ _kesha_completion() {
21
21
  completions) opts="--help -h" ;;
22
22
  doctor) opts="--help -h --json --redact" ;;
23
23
  init) opts="--help -h --coreml --onnx --no-cache --plan --yes --tts --vad --diarize" ;;
24
- install) opts="--help -h --coreml --onnx --no-cache --plan --tts --vad --diarize" ;;
24
+ install) opts="--help -h --coreml --onnx --no-cache --plan --tts --vad --diarize --engine-version" ;;
25
25
  logs) opts="--help -h --json" ;;
26
26
  manpage) opts="--help -h" ;;
27
27
  mcp) opts="--help -h" ;;
@@ -54,6 +54,7 @@ complete -c kesha -n '__fish_seen_subcommand_from install' -l plan -d 'Show down
54
54
  complete -c kesha -n '__fish_seen_subcommand_from install' -l tts -d 'Also install TTS models (English only; add codes for more, e.g. --tts en ru (preview sizes: kesha install --plan))'
55
55
  complete -c kesha -n '__fish_seen_subcommand_from install' -l vad -d 'Also install Silero VAD (~2.3MB) for long-audio preprocessing'
56
56
  complete -c kesha -n '__fish_seen_subcommand_from install' -l diarize -d 'Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)'
57
+ complete -c kesha -n '__fish_seen_subcommand_from install' -l engine-version -r -d 'Install this exact engine release instead of the pinned one (expert; the next install returns to the pin)'
57
58
  complete -c kesha -n '__fish_seen_subcommand_from logs' -l help -d 'Show help'
58
59
  complete -c kesha -n '__fish_seen_subcommand_from logs' -s h -d 'Show help'
59
60
  complete -c kesha -n '__fish_seen_subcommand_from logs' -l json -d 'Output status as JSON'
@@ -75,7 +75,8 @@ _kesha() {
75
75
  '--plan[Show download, disk, and warm-up plan without changing local state]' \
76
76
  '--tts[Also install TTS models (English only; add codes for more, e.g. --tts en ru (preview sizes: kesha install --plan))]' \
77
77
  '--vad[Also install Silero VAD (~2.3MB) for long-audio preprocessing]' \
78
- '--diarize[Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)]'
78
+ '--diarize[Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)]' \
79
+ '--engine-version=[Install this exact engine release instead of the pinned one (expert; the next install returns to the pin)]:engine version:'
79
80
  ;;
80
81
  logs)
81
82
  _arguments '--help[Show help]' \
package/man/kesha.1 CHANGED
@@ -141,6 +141,9 @@ Also install Silero VAD (~2.3MB) for long\-audio preprocessing
141
141
  .TP
142
142
  .B \-\-diarize
143
143
  Also install the Sortformer streaming\-diarization model (~245MB, darwin\-arm64 only — #199)
144
+ .TP
145
+ .B \-\-engine\-version
146
+ Install this exact engine release instead of the pinned one (expert; the next install returns to the pin)
144
147
  .SS logs
145
148
  .TP
146
149
  .B \-\-json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.26.0",
3
+ "version": "1.27.0-alpha.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": {
@@ -25,7 +25,8 @@
25
25
  "SKILL.md",
26
26
  "LICENSE",
27
27
  "NOTICES.md",
28
- "README.md"
28
+ "README.md",
29
+ "!src/__tests__"
29
30
  ],
30
31
  "openclaw": {
31
32
  "extensions": [
package/src/cli/init.ts CHANGED
@@ -3,7 +3,13 @@ import { confirm, multiselect, isCancel, cancel } from "@clack/prompts";
3
3
  import { renderInstallPlan } from "../install-plan";
4
4
  import { log } from "../log";
5
5
  import { getEngineCapabilities } from "../engine";
6
- import { performInstall, resolveBackendFlag, resolveNoCacheFlag, TTS_LANG_FALLBACK } from "./install";
6
+ import {
7
+ performInstall,
8
+ resolveBackendFlag,
9
+ resolveNoCacheFlag,
10
+ TTS_LANG_FALLBACK,
11
+ unavailableBackendError,
12
+ } from "./install";
7
13
  import type { SharedInstallArgs } from "./types";
8
14
 
9
15
  const TTS_LANG_LABELS: Record<string, string> = {
@@ -171,7 +177,14 @@ export async function promptInitSelection(
171
177
  };
172
178
  }
173
179
 
174
- async function printPlan(selection: InitSelection): Promise<void> {
180
+ /** Mirrors the guard in `performInstall`: a preview must not describe an install this platform rejects (#684). */
181
+ async function printPlan(selection: InitSelection): Promise<boolean> {
182
+ const backendError = unavailableBackendError(selection.backend);
183
+ if (backendError) {
184
+ log.error(backendError);
185
+ process.exitCode = 2;
186
+ return false;
187
+ }
175
188
  log.info(
176
189
  await renderInstallPlan({
177
190
  noCache: selection.noCache,
@@ -181,6 +194,7 @@ async function printPlan(selection: InitSelection): Promise<void> {
181
194
  diarize: selection.diarize,
182
195
  }),
183
196
  );
197
+ return true;
184
198
  }
185
199
 
186
200
  async function runNonInteractive(selection: InitSelection): Promise<void> {
@@ -190,7 +204,7 @@ async function runNonInteractive(selection: InitSelection): Promise<void> {
190
204
  log.warn("--diarize is currently darwin-arm64 only; omitting it from non-interactive examples.");
191
205
  }
192
206
  log.info(renderInitOverview(canDiarize));
193
- await printPlan(printableSelection);
207
+ if (!(await printPlan(printableSelection))) return;
194
208
  log.info("Run one of these commands from an interactive terminal:");
195
209
  for (const command of initSuggestionCommands(printableSelection, canDiarize)) {
196
210
  log.info(` ${command.join(" ")}`);
@@ -260,13 +274,7 @@ export const initCommand = defineCommand({
260
274
  if (selection.diarize && !installSelection.diarize) {
261
275
  log.warn("--diarize is currently darwin-arm64 only; omitting it from the --yes install.");
262
276
  }
263
- await performInstall(
264
- installSelection.noCache,
265
- installSelection.backend,
266
- installSelection.ttsLangs,
267
- installSelection.vad,
268
- installSelection.diarize,
269
- );
277
+ await performInstall(installSelection);
270
278
  return;
271
279
  }
272
280
 
@@ -286,7 +294,7 @@ export const initCommand = defineCommand({
286
294
  noCache,
287
295
  );
288
296
  log.info("");
289
- await printPlan(prompted);
297
+ if (!(await printPlan(prompted))) return;
290
298
  const confirmed = await promptConfirm(
291
299
  `Run \`${initInstallArgs(prompted).join(" ")}\` now?`,
292
300
  true,
@@ -295,13 +303,7 @@ export const initCommand = defineCommand({
295
303
  log.info(`Skipped install. Run later: ${initInstallArgs(prompted).join(" ")}`);
296
304
  return;
297
305
  }
298
- await performInstall(
299
- prompted.noCache,
300
- prompted.backend,
301
- prompted.ttsLangs,
302
- prompted.vad,
303
- prompted.diarize,
304
- );
306
+ await performInstall(prompted);
305
307
  // #523: end on something runnable, not on the install log.
306
308
  log.info("");
307
309
  log.success("Kesha is ready. Try:");
@@ -1,18 +1,22 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { errorMessage } from "../error-utils";
3
- import { downloadEngine } from "../engine-install";
3
+ import { installEngine } from "../engine-install";
4
4
  import { engineTarget } from "../engine-targets";
5
5
  import { getEngineBinPath, getEngineCapabilities } from "../engine";
6
6
  import { renderInstallPlan } from "../install-plan";
7
7
  import { maybeAskForStar } from "../star";
8
8
  import { log } from "../log";
9
9
  import { packageVersion } from "../package-info";
10
+ import { isSemver } from "../semver.mjs";
10
11
  import { createDiagnosticLogSession, type DiagnosticLogSession } from "../diagnostic-log";
11
12
  import type { SharedInstallArgs } from "./types";
12
13
 
13
14
  export interface InstallCommandArgs extends SharedInstallArgs {
14
15
  /** Positional args after `install` — candidate TTS language codes. */
15
16
  _?: string[];
17
+ /** `init` deliberately has no such flag: an override is an expert action, not a guided one. */
18
+ "engine-version"?: string;
19
+ engineVersion?: string;
16
20
  }
17
21
 
18
22
  /**
@@ -57,6 +61,23 @@ export function resolveTtsLangs(input: TtsArgInput, supported: string[] | undefi
57
61
  return langs;
58
62
  }
59
63
 
64
+ /**
65
+ * Validates `--engine-version` before any network call. Returns undefined when the flag is
66
+ * absent, so the Pinned Engine version stays the default; throws on anything the version
67
+ * drift gate would not accept either — one SemVer 2.0 definition across the repo.
68
+ */
69
+ export function resolveEngineVersionFlag(raw: unknown): string | undefined {
70
+ if (raw === undefined || raw === null || raw === false) return undefined;
71
+ const value = String(raw).trim();
72
+ if (isSemver(value)) return value;
73
+ const hint = /^v\d/.test(value)
74
+ ? ' Drop the leading "v" — that belongs to the release tag, not the version.'
75
+ : "";
76
+ throw new Error(
77
+ `--engine-version needs an exact SemVer 2.0 version like 1.24.8 or 1.24.8-alpha.1, got "${value}".${hint}`,
78
+ );
79
+ }
80
+
60
81
  export function resolveNoCacheFlag(
61
82
  args: Pick<InstallCommandArgs, "no-cache" | "noCache" | "no_cache">,
62
83
  rawArgs: string[] = [],
@@ -120,16 +141,42 @@ function finishInstallDiagnostic(
120
141
  }
121
142
  }
122
143
 
123
- export async function performInstall(
124
- noCache: boolean,
125
- backend: string | undefined,
126
- ttsLangs: string[],
127
- vad = false,
128
- diarize = false,
129
- plan = false,
130
- ) {
144
+ /** Null when the requested backend is installable here. `KESHA_ENGINE_BIN` opts out — the user supplied their own engine. */
145
+ export function unavailableBackendError(backend: string | undefined): string | null {
146
+ const platformBackend = defaultBackendForPlatform();
147
+ if (!backend || process.env.KESHA_ENGINE_BIN || !platformBackend) return null;
148
+ if (backend === platformBackend) return null;
149
+ return (
150
+ `Requested backend "${backend}" is not available on this platform; ` +
151
+ `the release engine uses "${platformBackend}".`
152
+ );
153
+ }
154
+
155
+ export interface PerformInstallOptions {
156
+ noCache: boolean;
157
+ backend?: string;
158
+ ttsLangs: string[];
159
+ vad?: boolean;
160
+ diarize?: boolean;
161
+ plan?: boolean;
162
+ /** Engine release to install instead of the pin, for this invocation only. */
163
+ engineVersion?: string;
164
+ }
165
+
166
+ export async function performInstall(options: PerformInstallOptions) {
167
+ const { noCache, backend, ttsLangs, vad = false, diarize = false, plan = false, engineVersion } =
168
+ options;
169
+ // A plan for an unavailable backend previews what its own printed command rejects (#684).
170
+ const backendError = unavailableBackendError(backend);
131
171
  if (plan) {
132
- log.info(await renderInstallPlan({ noCache, backend, ttsLangs, vad, diarize }));
172
+ if (backendError) {
173
+ log.error(backendError);
174
+ process.exitCode = 2;
175
+ return;
176
+ }
177
+ log.info(
178
+ await renderInstallPlan({ noCache, backend, ttsLangs, vad, diarize, engineVersion }),
179
+ );
133
180
  return;
134
181
  }
135
182
 
@@ -145,6 +192,7 @@ export async function performInstall(
145
192
  tts: ttsLangs.length > 0,
146
193
  vad,
147
194
  diarize,
195
+ engineVersionOverride: engineVersion !== undefined,
148
196
  });
149
197
 
150
198
  if (diarize && !(process.platform === "darwin" && process.arch === "arm64")) {
@@ -154,15 +202,11 @@ export async function performInstall(
154
202
  "(see https://github.com/drakulavich/kesha-voice-kit/issues/199).",
155
203
  );
156
204
  }
157
- const platformBackend = defaultBackendForPlatform();
158
- if (backend && !process.env.KESHA_ENGINE_BIN && platformBackend && backend !== platformBackend) {
205
+ if (backendError) {
159
206
  errorKind = "validation_failed";
160
- throw new Error(
161
- `Requested backend "${backend}" is not available on this platform; ` +
162
- `the release engine uses "${platformBackend}".`,
163
- );
207
+ throw new Error(backendError);
164
208
  }
165
- await downloadEngine(noCache, backend, { ttsLangs, vad, diarize });
209
+ await installEngine({ noCache, backend, ttsLangs, vad, diarize, version: engineVersion });
166
210
  await maybeAskForStar(getEngineBinPath(), packageVersion, log);
167
211
  finishInstallDiagnostic(diagnosticLog, startedAt, "success");
168
212
  } catch (err: unknown) {
@@ -214,8 +258,20 @@ export const installCommand = defineCommand({
214
258
  description: "Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)",
215
259
  default: false,
216
260
  },
261
+ "engine-version": {
262
+ type: "string",
263
+ description:
264
+ "Install this exact engine release instead of the pinned one (expert; the next install returns to the pin)",
265
+ },
217
266
  },
218
267
  async run({ args, rawArgs }: { args: InstallCommandArgs; rawArgs: string[] }) {
268
+ let engineVersion: string | undefined;
269
+ try {
270
+ engineVersion = resolveEngineVersionFlag(args["engine-version"] ?? args.engineVersion);
271
+ } catch (err) {
272
+ log.error(errorMessage(err));
273
+ process.exit(2);
274
+ }
219
275
  const backend = resolveBackendFlag(args.coreml, args.onnx);
220
276
  const positionals = (args._ ?? []).map(String);
221
277
  const caps = await getEngineCapabilities();
@@ -227,13 +283,14 @@ export const installCommand = defineCommand({
227
283
  log.error(errorMessage(err));
228
284
  process.exit(1);
229
285
  }
230
- await performInstall(
231
- resolveNoCacheFlag(args, rawArgs),
286
+ await performInstall({
287
+ noCache: resolveNoCacheFlag(args, rawArgs),
232
288
  backend,
233
289
  ttsLangs,
234
- args.vad,
235
- args.diarize,
236
- args.plan,
237
- );
290
+ vad: args.vad,
291
+ diarize: args.diarize,
292
+ plan: args.plan,
293
+ engineVersion,
294
+ });
238
295
  },
239
296
  });
package/src/doctor.ts CHANGED
@@ -10,12 +10,13 @@ import {
10
10
  } from "./engine";
11
11
  import { readInstalledEngineVersion } from "./engine-version-marker";
12
12
  import { keshaCacheDir } from "./paths";
13
- import { packageName, packageVersion } from "./package-info";
13
+ import { engineVersion, packageName, packageVersion } from "./package-info";
14
14
  import { getStatsStatus, type StatsStatus } from "./stats";
15
15
  import {
16
16
  fluidKokoroCacheInfo,
17
17
  type FluidKokoroCacheInfo,
18
18
  } from "./fluid-kokoro-cache";
19
+ import { fluidAsrCacheInfo, isCoremlBackend } from "./fluid-asr-cache";
19
20
  import { diagnosticHomeDir, dirSizeBytes } from "./diagnostic-paths";
20
21
  import {
21
22
  getDiagnosticLogStatus,
@@ -30,6 +31,7 @@ const KNOWN_ENV_KEYS = [
30
31
  "KESHA_STATS_DB",
31
32
  "KESHA_DEBUG",
32
33
  "KESHA_DEBUG_FD",
34
+ "KESHA_KOKORO_COMPUTE_UNITS",
33
35
  ] as const;
34
36
 
35
37
  interface DoctorOptions {
@@ -53,6 +55,21 @@ interface OptionalComponent extends PathSummary {
53
55
 
54
56
  type DoctorDiagnosticLogStatus = DiagnosticLogStatus & { error?: string };
55
57
 
58
+ /**
59
+ * How the Recorded Engine version relates to the Pinned Engine version. `differs-from-pin`
60
+ * is a supported state (`kesha install --engine-version`), not a fault — `unrecorded` is
61
+ * separate so a missing marker is never reported as a difference.
62
+ */
63
+ export type EngineVersionState = "matches-pin" | "differs-from-pin" | "unrecorded";
64
+
65
+ export function engineVersionState(
66
+ versionMarker: string | null,
67
+ pinnedVersion: string,
68
+ ): EngineVersionState {
69
+ if (versionMarker === null) return "unrecorded";
70
+ return versionMarker === pinnedVersion ? "matches-pin" : "differs-from-pin";
71
+ }
72
+
56
73
  export interface DoctorReport {
57
74
  generatedAt: string;
58
75
  redacted: boolean;
@@ -69,6 +86,8 @@ export interface DoctorReport {
69
86
  path: string;
70
87
  installed: boolean;
71
88
  versionMarker: string | null;
89
+ pinnedVersion: string;
90
+ versionState: EngineVersionState;
72
91
  capabilities: EngineCapabilities | null;
73
92
  probeError: string | null;
74
93
  };
@@ -188,10 +207,14 @@ async function collectEngine(redact: boolean): Promise<DoctorReport["engine"]> {
188
207
  }
189
208
  }
190
209
 
210
+ const versionMarker = readInstalledEngineVersion(binPath);
211
+
191
212
  return {
192
213
  path: redactPath(binPath, redact),
193
214
  installed,
194
- versionMarker: readInstalledEngineVersion(binPath),
215
+ versionMarker,
216
+ pinnedVersion: engineVersion,
217
+ versionState: engineVersionState(versionMarker, engineVersion),
195
218
  capabilities,
196
219
  probeError: redactString("probeError", probeError, redact),
197
220
  };
@@ -200,18 +223,33 @@ async function collectEngine(redact: boolean): Promise<DoctorReport["engine"]> {
200
223
  function collectCache(
201
224
  redact: boolean,
202
225
  fluidKokoro: FluidKokoroCacheInfo,
226
+ backend?: string,
203
227
  ): DoctorReport["cache"] {
204
228
  const cache = keshaCacheDir();
205
229
  const binPath = getEngineBinPath();
206
230
  const engineDir = dirname(dirname(binPath));
231
+ const coreml = isCoremlBackend(backend);
207
232
  const components: CacheComponent[] = [
208
233
  { label: "Engine", ...pathSummary(engineDir) },
209
- { label: "ASR (Parakeet)", ...pathSummary(join(cache, "models/parakeet-tdt-v3")) },
234
+ // A CoreML engine never populates the ONNX dir; pointing at it would render a
235
+ // healthy darwin install as "ASR missing" (#684).
236
+ ...(coreml
237
+ ? []
238
+ : [{ label: "ASR (Parakeet)", ...pathSummary(join(cache, "models/parakeet-tdt-v3")) }]),
210
239
  { label: "Language ID", ...pathSummary(join(cache, "models/lang-id-ecapa")) },
211
240
  { label: "VAD (Silero)", ...pathSummary(join(cache, "models/silero-vad")) },
212
241
  { label: "TTS (Kokoro)", ...pathSummary(join(cache, "models/kokoro-82m")) },
213
242
  { label: "TTS (Vosk)", ...pathSummary(join(cache, "models/vosk-ru")) },
214
243
  ];
244
+ if (coreml) {
245
+ const fluidAsr = fluidAsrCacheInfo();
246
+ components.push({
247
+ label: "ASR (Parakeet, FluidAudio) (external)",
248
+ path: fluidAsr.path,
249
+ exists: fluidAsr.exists,
250
+ sizeBytes: fluidAsr.sizeBytes,
251
+ });
252
+ }
215
253
  if (fluidKokoro.supported) {
216
254
  components.push({
217
255
  label: "FluidAudio Kokoro cache (external)",
@@ -346,6 +384,7 @@ export async function collectDoctorReport(
346
384
  ): Promise<DoctorReport> {
347
385
  const redact = options.redact === true;
348
386
  const fluidKokoro = fluidKokoroCacheInfo();
387
+ const engine = await collectEngine(redact);
349
388
  return {
350
389
  generatedAt: new Date().toISOString(),
351
390
  redacted: redact,
@@ -355,8 +394,8 @@ export async function collectDoctorReport(
355
394
  platform: process.platform,
356
395
  arch: process.arch,
357
396
  },
358
- engine: await collectEngine(redact),
359
- cache: collectCache(redact, fluidKokoro),
397
+ engine,
398
+ cache: collectCache(redact, fluidKokoro, engine.capabilities?.backend),
360
399
  optionalComponents: collectOptionalComponents(redact, fluidKokoro),
361
400
  stats: collectStats(redact),
362
401
  diagnosticLogs: collectDiagnosticLogs(redact),
@@ -368,6 +407,22 @@ function formatInstalled(installed: boolean): string {
368
407
  return installed ? "installed" : "missing";
369
408
  }
370
409
 
410
+ function formatVersionState(engine: DoctorReport["engine"]): string {
411
+ switch (engine.versionState) {
412
+ case "differs-from-pin":
413
+ return (
414
+ `installed v${engine.versionMarker} differs from the pinned v${engine.pinnedVersion} — ` +
415
+ "supported; `kesha install` without --engine-version restores the pin"
416
+ );
417
+ case "unrecorded":
418
+ return engine.installed
419
+ ? "no recorded version beside the binary; `kesha install` records one"
420
+ : "no engine installed";
421
+ default:
422
+ return "matches the pin";
423
+ }
424
+ }
425
+
371
426
  function formatCapabilities(engine: DoctorReport["engine"]): string {
372
427
  if (!engine.capabilities) return engine.probeError ?? "not available";
373
428
  const { backend, protocolVersion, features } = engine.capabilities;
@@ -437,6 +492,8 @@ export function formatDoctorReport(report: DoctorReport): string {
437
492
  "Engine:",
438
493
  ` Binary: ${report.engine.path} (${formatInstalled(report.engine.installed)})`,
439
494
  ` Version marker: ${report.engine.versionMarker ?? "missing"}`,
495
+ ` Pinned version: ${report.engine.pinnedVersion}`,
496
+ ` Version state: ${formatVersionState(report.engine)}`,
440
497
  ` Capabilities: ${formatCapabilities(report.engine)}`,
441
498
  "",
442
499
  ...formatCacheSection(report.cache),
@@ -192,12 +192,12 @@ function darwinTrustBinary(path: string, displayName: string): void {
192
192
  async function downloadSidecar(
193
193
  spec: SidecarSpec,
194
194
  binPath: string,
195
- engineVersion: string,
195
+ version: string,
196
196
  ): Promise<void> {
197
197
  if (!isDarwinArm64()) return;
198
198
 
199
199
  const sidecarPath = join(dirname(binPath), spec.fileBasename);
200
- const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/${spec.assetName}`;
200
+ const url = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${spec.assetName}`;
201
201
 
202
202
  let res: Response;
203
203
  try {
@@ -211,7 +211,7 @@ async function downloadSidecar(
211
211
 
212
212
  if (!res.ok) {
213
213
  log.warn(
214
- `${spec.displayName} not in release v${engineVersion} (HTTP ${res.status}); ${spec.unavailableHint}.`,
214
+ `${spec.displayName} not in release v${version} (HTTP ${res.status}); ${spec.unavailableHint}.`,
215
215
  );
216
216
  return;
217
217
  }
@@ -328,21 +328,22 @@ export interface InstallOptions {
328
328
  }
329
329
 
330
330
  /**
331
- * Cache-valid path: the binary at binPath already matches engineVersion.
331
+ * Cache-valid path: the binary at binPath already matches the requested version.
332
332
  * Re-trusts it and tops up any sidecars the cached install is missing.
333
333
  */
334
334
  async function refreshCachedEngine(
335
335
  binPath: string,
336
336
  canWriteEngineDir: boolean,
337
337
  noCache: boolean,
338
+ version: string,
338
339
  ): Promise<void> {
339
340
  const engineDir = dirname(binPath);
340
341
  if (noCache && !canWriteEngineDir) {
341
342
  log.info(
342
- `Engine binary at v${engineVersion} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
343
+ `Engine binary at v${version} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
343
344
  );
344
345
  } else {
345
- log.success(`Engine binary already installed (v${engineVersion}).`);
346
+ log.success(`Engine binary already installed (v${version}).`);
346
347
  }
347
348
  // Re-trust on cache hit: a user who upgraded to Sequoia after install would still have
348
349
  // com.apple.provenance attached; idempotent (~10ms no-op if already correct).
@@ -355,7 +356,7 @@ async function refreshCachedEngine(
355
356
  const missing = SIDECARS.filter(
356
357
  (s) => !existsSync(join(engineDir, s.fileBasename)),
357
358
  );
358
- await Promise.all(missing.map((s) => downloadSidecar(s, binPath, engineVersion)));
359
+ await Promise.all(missing.map((s) => downloadSidecar(s, binPath, version)));
359
360
  // Also re-trust already-present sidecars for the Sequoia upgrade scenario.
360
361
  for (const s of SIDECARS) {
361
362
  const p = join(engineDir, s.fileBasename);
@@ -428,21 +429,22 @@ export async function waitUntilSpawnable(
428
429
  async function fetchEngineBinary(
429
430
  binPath: string,
430
431
  installedVersion: string | null,
432
+ version: string,
431
433
  ): Promise<void> {
432
434
  // Log why we're downloading — helps diagnose surprising re-downloads.
433
- if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
435
+ if (existsSync(binPath) && installedVersion && installedVersion !== version) {
434
436
  log.progress(
435
- `Upgrading engine v${installedVersion} → v${engineVersion}...`,
437
+ `Replacing engine v${installedVersion} → v${version}...`,
436
438
  );
437
439
  }
438
440
  const binaryName = getEngineBinaryName();
439
- const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/${binaryName}`;
441
+ const url = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${binaryName}`;
440
442
 
441
443
  mkdirSync(dirname(binPath), { recursive: true });
442
444
 
443
445
  // Overlap sidecar fetches with the engine fetch (~15-30s saved on cold install).
444
446
  const sidecarPromises = SIDECARS.map((s) =>
445
- downloadSidecar(s, binPath, engineVersion),
447
+ downloadSidecar(s, binPath, version),
446
448
  );
447
449
  // If the engine fetch throws, silence in-flight sidecar rejections so unhandledRejection doesn't obscure the engine error.
448
450
  const muteSidecarRejections = () =>
@@ -460,8 +462,9 @@ async function fetchEngineBinary(
460
462
 
461
463
  if (!res.ok) {
462
464
  muteSidecarRejections();
465
+ // Names the tag: nothing falls back to the pin, so the caller must see which release 404ed.
463
466
  throw new Error(
464
- `Failed to download engine binary (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
467
+ `Failed to download engine binary from release v${version} (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
465
468
  );
466
469
  }
467
470
 
@@ -470,8 +473,8 @@ async function fetchEngineBinary(
470
473
  darwinTrustBinary(binPath, "kesha-engine binary");
471
474
  // Marker last: writing it first sends the retry down the cacheValid branch, which never waits.
472
475
  await waitUntilSpawnable(binPath);
473
- writeInstalledEngineVersion(binPath, engineVersion);
474
- log.success(`Engine binary downloaded (v${engineVersion}).`);
476
+ writeInstalledEngineVersion(binPath, version);
477
+ log.success(`Engine binary downloaded (v${version}).`);
475
478
  await Promise.all(sidecarPromises);
476
479
  }
477
480
 
@@ -556,27 +559,51 @@ function runEngineModelInstall(
556
559
  }
557
560
  }
558
561
 
559
- export async function downloadEngine(
560
- noCache = false,
561
- backend?: string,
562
- options: InstallOptions = {},
563
- ): Promise<string> {
562
+ export interface EngineInstallRequest extends InstallOptions {
563
+ noCache?: boolean;
564
+ backend?: string;
565
+ /** Engine release to install; defaults to the Pinned Engine version and applies to this call only. */
566
+ version?: string;
567
+ }
568
+
569
+ /**
570
+ * Installs one Engine release plus its models.
571
+ *
572
+ * `version` is the single input for the release URL, the cache-validity comparison, the
573
+ * sidecar downloads and the recorded `.version` marker — reading the pin at any one of
574
+ * them would install the requested Engine and then replace it on the next cache check.
575
+ */
576
+ export async function installEngine(request: EngineInstallRequest = {}): Promise<string> {
577
+ const { noCache = false, backend, version = engineVersion, ...options } = request;
564
578
  const binPath = getEngineBinPath();
565
579
  const installedVersion = readInstalledEngineVersion(binPath);
566
580
  const engineDir = dirname(binPath);
567
581
 
582
+ if (version !== engineVersion) {
583
+ log.info(
584
+ `Installing engine v${version} instead of the pinned v${engineVersion}; ` +
585
+ "a later `kesha install` without --engine-version restores the pin.",
586
+ );
587
+ }
588
+
568
589
  // Read-only engine dir = Nix-store install; skip download/sidecar writes to avoid EROFS errors.
569
590
  const canWriteEngineDir = checkEngineWritable(engineDir);
570
591
 
571
- const versionMatches =
572
- existsSync(binPath) && installedVersion === engineVersion;
592
+ const versionMatches = existsSync(binPath) && installedVersion === version;
573
593
  // On read-only fs, --no-cache can't re-download; treat as cache-valid and forward flag to model install.
574
594
  const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
575
595
 
576
596
  if (cacheValid) {
577
- await refreshCachedEngine(binPath, canWriteEngineDir, noCache);
597
+ await refreshCachedEngine(binPath, canWriteEngineDir, noCache, version);
578
598
  } else {
579
- await fetchEngineBinary(binPath, installedVersion);
599
+ if (!canWriteEngineDir) {
600
+ throw new Error(
601
+ `Cannot install engine v${version}: ${engineDir} is not writable ` +
602
+ `(installed: ${installedVersion ? `v${installedVersion}` : "no recorded version"}).\n` +
603
+ " Fix: point KESHA_ENGINE_BIN at a writable path, or install into a writable prefix.",
604
+ );
605
+ }
606
+ await fetchEngineBinary(binPath, installedVersion, version);
580
607
  }
581
608
 
582
609
  if (backend || options.diarize) {
@@ -599,6 +626,22 @@ export async function downloadEngine(
599
626
  cleanupRetiredSidecars(engineDir);
600
627
  }
601
628
 
602
- log.success("Backend installed successfully.");
629
+ log.success(`Backend installed successfully (engine v${version}).`);
603
630
  return binPath;
604
631
  }
632
+
633
+ /** Installs the Pinned Engine version. Public API (`downloadModel`); the override is CLI-only. */
634
+ export async function downloadEngine(
635
+ noCache = false,
636
+ backend?: string,
637
+ options: InstallOptions = {},
638
+ ): Promise<string> {
639
+ // Field-by-field, not a spread: a caller's stray `version` must not reach installEngine.
640
+ return installEngine({
641
+ noCache,
642
+ backend,
643
+ ttsLangs: options.ttsLangs,
644
+ vad: options.vad,
645
+ diarize: options.diarize,
646
+ });
647
+ }
@@ -0,0 +1,91 @@
1
+ import { existsSync } from "fs";
2
+ import { join } from "path";
3
+ import { diagnosticHomeDir, dirSizeBytes } from "./diagnostic-paths";
4
+ import { isDarwinArm64 } from "./fluid-kokoro-cache";
5
+ import { engineTarget } from "./engine-targets";
6
+
7
+ export const FLUID_ASR_CACHE_NOTE =
8
+ "required for speech-to-text; fetched by the backend during warm-up, outside Kesha's model cache";
9
+
10
+ /**
11
+ * Which ASR layout applies. The reported backend is authoritative; when the capabilities
12
+ * probe yields nothing, fall back to the platform's shipped backend rather than to "not
13
+ * CoreML", which would point darwin at an ONNX directory it never populates (#684).
14
+ */
15
+ export function isCoremlBackend(
16
+ backend?: string,
17
+ platform?: string,
18
+ arch?: string,
19
+ ): boolean {
20
+ return (backend ?? engineTarget(platform, arch)?.backend) === "coreml";
21
+ }
22
+
23
+ export interface FluidAsrCacheInfo {
24
+ supported: boolean;
25
+ path: string;
26
+ exists: boolean;
27
+ sizeBytes: number;
28
+ }
29
+
30
+ /**
31
+ * FluidAudio loads ASR from `<ApplicationSupport>/FluidAudio/Models/<repo folder>`,
32
+ * and that folder name has the `-coreml` suffix stripped — the `…-v3-coreml` sibling
33
+ * that also appears on disk is not what it reads (#684).
34
+ */
35
+ export function fluidAsrCachePath(homeDir = diagnosticHomeDir()): string {
36
+ return join(
37
+ homeDir,
38
+ "Library",
39
+ "Application Support",
40
+ "FluidAudio",
41
+ "Models",
42
+ "parakeet-tdt-0.6b-v3",
43
+ );
44
+ }
45
+
46
+ /**
47
+ * What FluidAudio's own `modelsExist` requires. The encoder is pinned to int8 because
48
+ * the bridge calls `downloadAndLoad(to:)` with its default `useInt8Encoder: true` —
49
+ * accepting `EncoderInt4.mlmodelc` would pass preflight and then let FluidAudio fetch
50
+ * the int8 encoder on first transcribe. Keep in step with `models.rs::FLUID_ASR_REQUIRED`.
51
+ */
52
+ export const FLUID_ASR_REQUIRED = [
53
+ "Preprocessor.mlmodelc",
54
+ "Encoder.mlmodelc",
55
+ "Decoder.mlmodelc",
56
+ "JointDecisionv3.mlmodelc",
57
+ "parakeet_vocab.json",
58
+ ];
59
+
60
+ /**
61
+ * Complete enough to transcribe. Mirrors `models.rs::fluidaudio_asr_ready` — a bare
62
+ * directory check would call an interrupted fetch healthy, and the engine would then
63
+ * download the remainder on first transcribe (#684).
64
+ *
65
+ * Existence-only on purpose: FluidAudio's own `modelsExist` is `fileExists` over the same
66
+ * names, and its download skips whatever that predicate accepts. Requiring more here (that
67
+ * each `.mlmodelc` is a directory, that `coremldata.bin` is inside) would report not-ready
68
+ * for a bundle FluidAudio considers present, so `kesha install` would fetch nothing and the
69
+ * user could never clear the error. Match the loader; do not out-strict it.
70
+ */
71
+ export function fluidAsrCacheReady(path: string): boolean {
72
+ return FLUID_ASR_REQUIRED.every((f) => existsSync(join(path, f)));
73
+ }
74
+
75
+ export function fluidAsrCacheInfo(
76
+ options: {
77
+ platform?: typeof process.platform;
78
+ arch?: typeof process.arch;
79
+ homeDir?: string;
80
+ } = {},
81
+ ): FluidAsrCacheInfo {
82
+ const supported = isDarwinArm64(options.platform, options.arch);
83
+ const path = fluidAsrCachePath(options.homeDir);
84
+
85
+ return {
86
+ supported,
87
+ path,
88
+ exists: supported && fluidAsrCacheReady(path),
89
+ sizeBytes: supported ? dirSizeBytes(path) : 0,
90
+ };
91
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, statSync } from "fs";
2
2
  import { humanBytes } from "./format";
3
3
  import { dirname, join } from "path";
4
- import { getEngineBinPath } from "./engine";
4
+ import { getEngineBinPath, getEngineCapabilities } from "./engine";
5
5
  import { SIDECARS } from "./engine-install";
6
6
  import { engineTarget } from "./engine-targets";
7
7
  import { readInstalledEngineVersion } from "./engine-version-marker";
@@ -13,6 +13,12 @@ import {
13
13
  isDarwinArm64,
14
14
  } from "./fluid-kokoro-cache";
15
15
  import modelPlan from "../model-plan.json" with { type: "json" };
16
+ import {
17
+ FLUID_ASR_CACHE_NOTE,
18
+ fluidAsrCachePath,
19
+ fluidAsrCacheReady,
20
+ isCoremlBackend,
21
+ } from "./fluid-asr-cache";
16
22
 
17
23
  export interface InstallPlanOptions {
18
24
  noCache?: boolean;
@@ -21,6 +27,8 @@ export interface InstallPlanOptions {
21
27
  ttsLangs?: string[];
22
28
  vad?: boolean;
23
29
  diarize?: boolean;
30
+ /** `--engine-version`; the plan must preview the release the real install would fetch. */
31
+ engineVersion?: string;
24
32
  }
25
33
 
26
34
  interface PlanFile {
@@ -46,6 +54,8 @@ interface PlanComponent {
46
54
  cached: boolean;
47
55
  refresh: boolean;
48
56
  note?: string;
57
+ /** Fetched by a backend into its own cache — excluded from the Kesha-managed totals. */
58
+ external?: boolean;
49
59
  }
50
60
 
51
61
  interface PlanWarmup {
@@ -135,14 +145,59 @@ function bundleComponent(input: {
135
145
  };
136
146
  }
137
147
 
138
- function buildEngineComponent(binPath: string, noCache: boolean): PlanComponent {
148
+ /**
149
+ * A CoreML engine cannot read the ONNX weights, so quoting their 2.43 GB would
150
+ * promise a download that never happens. FluidAudio fetches its own bundle during
151
+ * the install warm-up instead — still a cost, so it is named rather than dropped (#684).
152
+ *
153
+ * Measured from `parakeet-tdt-0.6b-v3`, the directory the current FluidAudio actually
154
+ * loads. A `…-v3-coreml` sibling may also exist from earlier versions; counting it here
155
+ * would overstate the plan against what `status --disk` then reports. An estimate, not a
156
+ * manifest sum — rendered as approximate.
157
+ */
158
+ const FLUID_ASR_BUNDLE_BYTES = 473 * 1024 * 1024;
159
+
160
+ /**
161
+ * An explicit `--coreml` / `--onnx` wins. Otherwise ask the installed engine, so a custom
162
+ * `KESHA_ENGINE_BIN` is planned for what it actually is rather than what the host usually
163
+ * ships (#684). The probe is optional: `--plan` must still work before any engine exists,
164
+ * and `isCoremlBackend` falls back to the platform when it yields nothing.
165
+ */
166
+ async function planBackendIsCoreml(options: InstallPlanOptions): Promise<boolean> {
167
+ if (options.backend === "coreml") return true;
168
+ if (options.backend === "onnx") return false;
169
+ const probed = await getEngineCapabilities().catch(() => null);
170
+ return isCoremlBackend(probed?.backend);
171
+ }
172
+
173
+ function fluidAsrComponent(): PlanComponent {
174
+ return {
175
+ name: "ASR Parakeet TDT v3 (CoreML)",
176
+ source: "FluidAudio cache",
177
+ sizeBytes: FLUID_ASR_BUNDLE_BYTES,
178
+ cached: fluidAsrCacheReady(fluidAsrCachePath()),
179
+ // `--no-cache` is a Kesha-cache flag; FluidAudio's bundle is not re-fetched by it.
180
+ refresh: false,
181
+ external: true,
182
+ note: FLUID_ASR_CACHE_NOTE,
183
+ };
184
+ }
185
+
186
+ function engineIsCached(binPath: string, version: string): boolean {
187
+ return existsSync(binPath) && readInstalledEngineVersion(binPath) === version;
188
+ }
189
+
190
+ function buildEngineComponent(
191
+ binPath: string,
192
+ noCache: boolean,
193
+ version: string,
194
+ ): PlanComponent {
139
195
  const engineAsset = engineAssetForPlatform();
140
196
  if (engineAsset) {
141
- const engineCached =
142
- existsSync(binPath) && readInstalledEngineVersion(binPath) === engineVersion;
197
+ const engineCached = engineIsCached(binPath, version);
143
198
  return {
144
199
  name: `Engine ${engineAsset.assetName}`,
145
- source: `GitHub release v${engineVersion}`,
200
+ source: `GitHub release v${version}`,
146
201
  sizeBytes: engineAsset.sizeBytes,
147
202
  cached: engineCached,
148
203
  refresh: noCache,
@@ -158,13 +213,19 @@ function buildEngineComponent(binPath: string, noCache: boolean): PlanComponent
158
213
  };
159
214
  }
160
215
 
161
- function buildSidecarComponents(engineDir: string, noCache: boolean): PlanComponent[] {
216
+ function buildSidecarComponents(
217
+ engineDir: string,
218
+ noCache: boolean,
219
+ version: string,
220
+ engineCached: boolean,
221
+ ): PlanComponent[] {
162
222
  if (!isDarwinArm64()) return [];
163
223
  return DARWIN_SIDECARS.map((sidecar) => ({
164
224
  name: `Sidecar ${sidecar.assetName}`,
165
- source: `GitHub release v${engineVersion}`,
225
+ source: `GitHub release v${version}`,
166
226
  sizeBytes: sidecar.sizeBytes,
167
- cached: existsSync(join(engineDir, sidecar.fileBasename)),
227
+ // A cold engine fetch re-downloads every sidecar; only a cache hit tops up the missing ones.
228
+ cached: engineCached && existsSync(join(engineDir, sidecar.fileBasename)),
168
229
  refresh: noCache,
169
230
  }));
170
231
  }
@@ -245,6 +306,8 @@ function assembleComponents(input: {
245
306
  engineDir: string;
246
307
  noCache: boolean;
247
308
  options: InstallPlanOptions;
309
+ coreml: boolean;
310
+ version: string;
248
311
  tts: ReturnType<typeof buildTtsComponents>;
249
312
  }): PlanComponent[] {
250
313
  const { cacheRoot, noCache, options } = input;
@@ -252,9 +315,16 @@ function assembleComponents(input: {
252
315
  bundleComponent({ cacheRoot, name, source: "model cache", files, refresh: noCache, note });
253
316
 
254
317
  const components: PlanComponent[] = [
255
- buildEngineComponent(input.binPath, noCache),
256
- ...buildSidecarComponents(input.engineDir, noCache),
257
- modelBundle("ASR Parakeet TDT v3", ASR_FILES, "required for speech-to-text"),
318
+ buildEngineComponent(input.binPath, noCache, input.version),
319
+ ...buildSidecarComponents(
320
+ input.engineDir,
321
+ noCache,
322
+ input.version,
323
+ engineIsCached(input.binPath, input.version),
324
+ ),
325
+ input.coreml
326
+ ? fluidAsrComponent()
327
+ : modelBundle("ASR Parakeet TDT v3", ASR_FILES, "required for speech-to-text"),
258
328
  modelBundle(
259
329
  "Audio language ID ECAPA",
260
330
  LANG_ID_FILES,
@@ -279,12 +349,18 @@ function assembleComponents(input: {
279
349
  return components;
280
350
  }
281
351
 
282
- function renderHeader(cacheRoot: string, binPath: string, backend: string | undefined): string[] {
352
+ function renderHeader(
353
+ cacheRoot: string,
354
+ binPath: string,
355
+ backend: string | undefined,
356
+ version: string,
357
+ ): string[] {
358
+ const pinNote = version === engineVersion ? "" : ` (overrides the pinned v${engineVersion})`;
283
359
  return [
284
360
  "Kesha install plan",
285
361
  "",
286
362
  `Package: @drakulavich/kesha-voice-kit ${packageVersion}`,
287
- `Engine release: v${engineVersion}`,
363
+ `Engine release: v${version}${pinNote}`,
288
364
  `Platform: ${process.platform} ${process.arch}`,
289
365
  `Cache: ${cacheRoot}`,
290
366
  `Engine binary: ${binPath}`,
@@ -298,7 +374,9 @@ function renderComponentLines(components: PlanComponent[]): string[] {
298
374
  const status = (c: PlanComponent) => (c.refresh ? "refresh" : c.cached ? "cached" : "needed");
299
375
  const lines: string[] = [];
300
376
  for (const c of components) {
301
- lines.push(` - ${c.name}: ${humanBytes(c.sizeBytes)} (${c.sizeBytes} bytes, ${status(c)}, ${c.source})`);
377
+ const size = c.external ? `~${humanBytes(c.sizeBytes)}` : humanBytes(c.sizeBytes);
378
+ const detail = c.external ? "approximate" : `${c.sizeBytes} bytes`;
379
+ lines.push(` - ${c.name}: ${size} (${detail}, ${status(c)}, ${c.source})`);
302
380
  if (c.note) lines.push(` ${c.note}`);
303
381
  }
304
382
  return lines;
@@ -310,14 +388,25 @@ function renderWarmupLines(warmups: PlanWarmup[]): string[] {
310
388
  }
311
389
 
312
390
  function renderTotalLines(components: PlanComponent[]): string[] {
313
- const coldBytes = components.reduce((sum, c) => sum + c.sizeBytes, 0);
314
- const expectedNetworkBytes = components.reduce((sum, c) => (c.cached && !c.refresh ? sum : sum + c.sizeBytes), 0);
315
- return [
391
+ // External components are fetched by a backend into its own cache, so they cannot
392
+ // sit under a total labelled "Kesha-managed" — they get their own line (#684).
393
+ const managed = components.filter((c) => !c.external);
394
+ const external = components.filter((c) => c.external);
395
+ const coldBytes = managed.reduce((sum, c) => sum + c.sizeBytes, 0);
396
+ const expectedNetworkBytes = managed.reduce((sum, c) => (c.cached && !c.refresh ? sum : sum + c.sizeBytes), 0);
397
+ const lines = [
316
398
  "",
317
399
  "Totals:",
318
400
  ` Cold-cache Kesha-managed download: ${humanBytes(coldBytes)}`,
319
401
  ` Expected Kesha-managed network for this run: ${humanBytes(expectedNetworkBytes)}`,
320
402
  ];
403
+ const externalPending = external.reduce((sum, c) => (c.cached ? sum : sum + c.sizeBytes), 0);
404
+ if (externalPending > 0) {
405
+ lines.push(
406
+ ` Additionally fetched by the backend into its own cache: ~${humanBytes(externalPending)}`,
407
+ );
408
+ }
409
+ return lines;
321
410
  }
322
411
 
323
412
  function renderBehaviorLines(ttsLangs: string[], wantsAnyKokoro: boolean): string[] {
@@ -344,6 +433,7 @@ export function buildInstallCommand(options: InstallPlanOptions, ttsLangs: strin
344
433
  return [
345
434
  "kesha",
346
435
  "install",
436
+ ...(options.engineVersion ? ["--engine-version", options.engineVersion] : []),
347
437
  options.noCache ? "--no-cache" : "",
348
438
  options.backend === "coreml" ? "--coreml" : "",
349
439
  options.backend === "onnx" ? "--onnx" : "",
@@ -379,11 +469,23 @@ export async function renderInstallPlan(options: InstallPlanOptions = {}): Promi
379
469
  const noCache = options.noCache === true;
380
470
  const ttsLangs = options.ttsLangs ?? [];
381
471
 
472
+ const version = options.engineVersion ?? engineVersion;
473
+
382
474
  const tts = buildTtsComponents(cacheRoot, ttsLangs, noCache);
383
- const components = assembleComponents({ cacheRoot, binPath, engineDir, noCache, options, tts });
475
+ const coreml = await planBackendIsCoreml(options);
476
+ const components = assembleComponents({
477
+ cacheRoot,
478
+ binPath,
479
+ engineDir,
480
+ noCache,
481
+ options,
482
+ coreml,
483
+ version,
484
+ tts,
485
+ });
384
486
 
385
487
  const lines = [
386
- ...renderHeader(cacheRoot, binPath, options.backend),
488
+ ...renderHeader(cacheRoot, binPath, options.backend, version),
387
489
  ...renderComponentLines(components),
388
490
  ...renderFooter({
389
491
  components,
@@ -0,0 +1,16 @@
1
+ // The module stays .mjs because the npm-publish and homebrew workflows run their scripts
2
+ // under node, which cannot import a .ts module.
3
+ export type SemVer = {
4
+ major: number;
5
+ minor: number;
6
+ patch: number;
7
+ prerelease: string[];
8
+ build: string[];
9
+ };
10
+
11
+ export function tryParseSemver(raw: unknown): SemVer | null;
12
+ export function parseSemver(raw: unknown, label: string): SemVer;
13
+ export function isSemver(raw: unknown): boolean;
14
+ export function isStableVersion(raw: unknown): boolean;
15
+ export function cmp(a: SemVer, b: SemVer): number;
16
+ export function fmt(v: SemVer): string;
package/src/semver.mjs ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * SemVer 2.0 — the one place in this repo that knows the grammar.
3
+ *
4
+ * `kesha install --engine-version`, the version drift gate, the alpha derivation, the npm
5
+ * dist-tag resolver, the package.json writer and the Linux package names all decide "is this
6
+ * a version" or "which is newer". Separate copies disagree at the edges: `derive-alpha-version`
7
+ * asserts its output sorts above the pin that `check-versions` later validates, so one parser
8
+ * accepting what another refuses is a release that passes derivation and fails the gate
9
+ * (#685, #738).
10
+ *
11
+ * Stays `.mjs`: npm-publish.yml, release-npm-publish.yml and homebrew-tap.yml run their
12
+ * scripts under node, which cannot import a `.ts` module. Types live in `semver.d.mts`.
13
+ *
14
+ * The release *tag* grammar is deliberately not here — `.github/scripts/release-tags.mjs`
15
+ * keeps it as a POSIX ERE string so bash `=~` and JavaScript consume identical text.
16
+ */
17
+
18
+ /** The pattern published with the SemVer 2.0 spec; a looser one accepts `1.0.0-01` and `01.2.3`. */
19
+ const SEMVER_2_0 =
20
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
21
+
22
+ /** The only place the pattern is applied; everything else here builds on this. */
23
+ export function tryParseSemver(raw) {
24
+ const m = typeof raw === "string" ? raw.match(SEMVER_2_0) : null;
25
+ if (!m) return null;
26
+ return {
27
+ major: Number(m[1]),
28
+ minor: Number(m[2]),
29
+ patch: Number(m[3]),
30
+ prerelease: m[4]?.split(".") ?? [],
31
+ build: m[5]?.split(".") ?? [],
32
+ };
33
+ }
34
+
35
+ export function parseSemver(raw, label) {
36
+ const parsed = tryParseSemver(raw);
37
+ if (!parsed) {
38
+ throw new Error(`${label}: not a valid SemVer 2.0 version (got '${raw}')`);
39
+ }
40
+ return parsed;
41
+ }
42
+
43
+ export function isSemver(raw) {
44
+ return tryParseSemver(raw) !== null;
45
+ }
46
+
47
+ /** A version fit for a release tag or a package filename: no prerelease, no build metadata. */
48
+ export function isStableVersion(raw) {
49
+ const parsed = tryParseSemver(raw);
50
+ return parsed !== null && parsed.prerelease.length === 0 && parsed.build.length === 0;
51
+ }
52
+
53
+ /** SemVer §10: build metadata is ignored when determining precedence. */
54
+ export function cmp(a, b) {
55
+ if (a.major !== b.major) return a.major - b.major;
56
+ if (a.minor !== b.minor) return a.minor - b.minor;
57
+ if (a.patch !== b.patch) return a.patch - b.patch;
58
+
59
+ // SemVer precedence: a stable version outranks its prereleases.
60
+ if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0;
61
+ if (a.prerelease.length === 0) return 1;
62
+ if (b.prerelease.length === 0) return -1;
63
+
64
+ const len = Math.max(a.prerelease.length, b.prerelease.length);
65
+ for (let i = 0; i < len; i++) {
66
+ const ai = a.prerelease[i];
67
+ const bi = b.prerelease[i];
68
+ if (ai === undefined) return -1;
69
+ if (bi === undefined) return 1;
70
+ if (ai === bi) continue;
71
+
72
+ const an = ai.match(/^(0|[1-9]\d*)$/);
73
+ const bn = bi.match(/^(0|[1-9]\d*)$/);
74
+ if (an && bn) return Number(ai) - Number(bi);
75
+ if (an) return -1;
76
+ if (bn) return 1;
77
+ return ai < bi ? -1 : 1;
78
+ }
79
+
80
+ return 0;
81
+ }
82
+
83
+ export function fmt(v) {
84
+ const base = `${v.major}.${v.minor}.${v.patch}`;
85
+ const withPre = v.prerelease.length ? `${base}-${v.prerelease.join(".")}` : base;
86
+ return v.build.length ? `${withPre}+${v.build.join(".")}` : withPre;
87
+ }
package/src/star.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "fs";
2
+ import { tryParseSemver } from "./semver.mjs";
2
3
 
3
4
  /**
4
5
  * Version-bump gate for the "star the repo" prompt in `kesha install`.
@@ -26,12 +27,8 @@ export function writeStarSeen(binPath: string, version: string): void {
26
27
  }
27
28
 
28
29
  function parseMajorMinor(v: string): [number, number] | null {
29
- const parts = v.split(".");
30
- if (parts.length < 2) return null;
31
- const major = Number(parts[0]);
32
- const minor = Number(parts[1]);
33
- if (!Number.isFinite(major) || !Number.isFinite(minor)) return null;
34
- return [major, minor];
30
+ const parsed = tryParseSemver(v);
31
+ return parsed && [parsed.major, parsed.minor];
35
32
  }
36
33
 
37
34
  /**
package/src/status.ts CHANGED
@@ -11,6 +11,7 @@ import { log } from "./log";
11
11
  import { packageVersion } from "./package-info";
12
12
  import { keshaCacheDir } from "./paths";
13
13
  import { fluidKokoroCacheInfo } from "./fluid-kokoro-cache";
14
+ import { fluidAsrCacheInfo, isCoremlBackend } from "./fluid-asr-cache";
14
15
  import { dirSizeBytes } from "./diagnostic-paths";
15
16
  import pc from "picocolors";
16
17
 
@@ -52,6 +53,7 @@ export interface StatusDiskUsage {
52
53
  componentTotalBytes: number;
53
54
  totalBytes: number;
54
55
  fluidKokoro: { path: string; sizeBytes: number } | null;
56
+ fluidAsr: { path: string; sizeBytes: number } | null;
55
57
  }
56
58
 
57
59
  /**
@@ -90,7 +92,7 @@ export async function collectStatus(options: ShowStatusOptions = {}): Promise<St
90
92
  ? null
91
93
  : `Run \`${installHint()}\` to download the engine and models.`,
92
94
  // Absent engine means no disk walk, matching the human path (#647).
93
- disk: installed && options.disk ? collectDiskUsage(path) : null,
95
+ disk: installed && options.disk ? collectDiskUsage(path, capabilities?.backend) : null,
94
96
  };
95
97
  }
96
98
 
@@ -144,10 +146,19 @@ export function renderStatus(report: StatusReport): void {
144
146
  }
145
147
 
146
148
 
147
- function buildDiskComponents(cache: string, engineDir: string): Array<{ label: string; path: string }> {
149
+ function buildDiskComponents(
150
+ cache: string,
151
+ engineDir: string,
152
+ coreml: boolean,
153
+ ): Array<{ label: string; path: string }> {
148
154
  return [
149
155
  { label: "Engine", path: engineDir },
150
- { label: "ASR (Parakeet)", path: join(cache, "models/parakeet-tdt-v3") },
156
+ // A CoreML engine never populates the ONNX dir, and its own bundle lives outside
157
+ // the Kesha cache — so it is reported under external caches, not here, or the rows
158
+ // would sum past a Total that cannot include it (#684).
159
+ ...(coreml
160
+ ? []
161
+ : [{ label: "ASR (Parakeet)", path: join(cache, "models/parakeet-tdt-v3") }]),
151
162
  { label: "Language ID", path: join(cache, "models/lang-id-ecapa") },
152
163
  { label: "VAD (Silero)", path: join(cache, "models/silero-vad") },
153
164
  { label: "TTS (Kokoro)", path: join(cache, "models/kokoro-82m") },
@@ -169,20 +180,28 @@ function logDiskRows(rows: StatusDiskComponent[], total: number, componentTotal:
169
180
  }
170
181
  }
171
182
 
172
- function logFluidKokoroCache(fluidKokoro: StatusDiskUsage["fluidKokoro"]): void {
173
- if (!fluidKokoro) return;
183
+ function logExternalCaches(disk: StatusDiskUsage): void {
184
+ if (!disk.fluidKokoro && !disk.fluidAsr) return;
174
185
  log.info("");
175
186
  log.info(`External caches (not included in Kesha total):`);
176
- log.info(` FluidAudio Kokoro: ${humanBytes(fluidKokoro.sizeBytes)} (${fluidKokoro.path})`);
187
+ if (disk.fluidAsr) {
188
+ log.info(` FluidAudio ASR: ${humanBytes(disk.fluidAsr.sizeBytes)} (${disk.fluidAsr.path})`);
189
+ }
190
+ if (disk.fluidKokoro) {
191
+ log.info(
192
+ ` FluidAudio Kokoro: ${humanBytes(disk.fluidKokoro.sizeBytes)} (${disk.fluidKokoro.path})`,
193
+ );
194
+ }
177
195
  }
178
196
 
179
- function collectDiskUsage(binPath: string): StatusDiskUsage {
197
+ function collectDiskUsage(binPath: string, backend?: string): StatusDiskUsage {
180
198
  const cache = keshaCacheDir();
181
199
  // Two levels up from the binary (`<cache>/engine/bin/`) so future engine-root siblings are counted.
182
200
  const engineDir = join(binPath, "..", "..");
201
+ const coreml = isCoremlBackend(backend);
183
202
 
184
203
  const components: StatusDiskComponent[] = [];
185
- for (const c of buildDiskComponents(cache, engineDir)) {
204
+ for (const c of buildDiskComponents(cache, engineDir, coreml)) {
186
205
  const sizeBytes = dirSizeBytes(c.path);
187
206
  if (sizeBytes > 0) components.push({ label: c.label, sizeBytes });
188
207
  }
@@ -191,6 +210,8 @@ function collectDiskUsage(binPath: string): StatusDiskUsage {
191
210
  const cacheTotal = dirSizeBytes(cache);
192
211
  const engineOutsideCache = engineDir.startsWith(cache) ? 0 : dirSizeBytes(engineDir);
193
212
  const fluidKokoro = fluidKokoroCacheInfo();
213
+ // Only walked when it is the backend in play; otherwise the size is computed and dropped.
214
+ const fluidAsr = coreml ? fluidAsrCacheInfo() : null;
194
215
 
195
216
  return {
196
217
  cachePath: cache,
@@ -201,6 +222,12 @@ function collectDiskUsage(binPath: string): StatusDiskUsage {
201
222
  fluidKokoro.exists && fluidKokoro.sizeBytes > 0
202
223
  ? { path: fluidKokoro.path, sizeBytes: fluidKokoro.sizeBytes }
203
224
  : null,
225
+ // Gate on readiness, not size, so a partial bundle is not shown as a present
226
+ // cache while doctor reports it missing.
227
+ fluidAsr:
228
+ fluidAsr?.exists && fluidAsr.sizeBytes > 0
229
+ ? { path: fluidAsr.path, sizeBytes: fluidAsr.sizeBytes }
230
+ : null,
204
231
  };
205
232
  }
206
233
 
@@ -209,7 +236,7 @@ function showDiskUsage(disk: StatusDiskUsage): void {
209
236
 
210
237
  log.info(`Disk usage (${disk.cachePath}):`);
211
238
  logDiskRows(disk.components, disk.totalBytes, disk.componentTotalBytes);
212
- logFluidKokoroCache(disk.fluidKokoro);
239
+ logExternalCaches(disk);
213
240
  log.info("");
214
241
  log.info(
215
242
  pc.dim(` To reset cache: rm -rf ${disk.cachePath} — next \`kesha install\` re-downloads.`),
@@ -1,29 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { existsSync, readFileSync } from "node:fs";
3
- import { spawnSync } from "node:child_process";
4
- import { join } from "node:path";
5
- import { KNOWN_TS_CODES } from "../error-codes";
6
-
7
- const ENGINE_BIN =
8
- process.env.KESHA_ENGINE_BIN ?? join(import.meta.dir, "../../rust/target/release/kesha-engine");
9
-
10
- const describeOrSkip = existsSync(ENGINE_BIN) ? describe : describe.skip;
11
-
12
- describeOrSkip("error-code drift", () => {
13
- test("engine codes ∪ TS-native codes == codes documented in docs/errors.md", () => {
14
- const res = spawnSync(ENGINE_BIN, ["--error-codes-json"], { encoding: "utf8" });
15
- expect(res.status).toBe(0);
16
- const engineCodes: string[] = JSON.parse(res.stdout).map((e: { code: string }) => e.code);
17
-
18
- const known = new Set<string>([...engineCodes, ...KNOWN_TS_CODES]);
19
-
20
- const doc = readFileSync(join(import.meta.dir, "../../docs/errors.md"), "utf8");
21
- const documented = new Set<string>();
22
- for (const m of doc.matchAll(/`(E_[A-Z0-9_]+)`/g)) documented.add(m[1]);
23
-
24
- // every known code is documented
25
- for (const c of known) expect(documented.has(c)).toBe(true);
26
- // every documented code is known (no stale doc rows)
27
- for (const c of documented) expect(known.has(c)).toBe(true);
28
- });
29
- });
@@ -1,25 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { extractEngineErrorCode, TS_NATIVE_CODES, KNOWN_TS_CODES } from "../error-codes";
3
-
4
- describe("extractEngineErrorCode", () => {
5
- test("extracts the code from a coded engine stderr line", () => {
6
- const stderr = "error [E_MODEL_MISSING]: voice 'ru-vosk-m02' not installed. run: kesha install --tts";
7
- expect(extractEngineErrorCode(stderr)).toBe("E_MODEL_MISSING");
8
- });
9
-
10
- test("extracts even when the message embeds a path or token", () => {
11
- const stderr = "warning: foo\nerror [E_BAD_AUDIO]: decode error in: /Users/alice/secret-token-abc.wav";
12
- expect(extractEngineErrorCode(stderr)).toBe("E_BAD_AUDIO");
13
- });
14
-
15
- test("returns undefined for an uncoded stderr so caller can fall back", () => {
16
- expect(extractEngineErrorCode("Error: something went wrong")).toBeUndefined();
17
- });
18
-
19
- test("TS-native codes are exposed and included in KNOWN_TS_CODES", () => {
20
- expect(TS_NATIVE_CODES.INPUT_NOT_FOUND).toBe("E_INPUT_NOT_FOUND");
21
- expect(TS_NATIVE_CODES.ENGINE_SPAWN).toBe("E_ENGINE_SPAWN");
22
- expect(TS_NATIVE_CODES.INVALID_ARG).toBe("E_INVALID_ARG");
23
- expect(KNOWN_TS_CODES.has("E_INTERNAL")).toBe(true);
24
- });
25
- });
@@ -1,14 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { SayError } from "../synth";
3
-
4
- describe("SayError carries a taxonomy code", () => {
5
- test("pre-check throws carry text codes", () => {
6
- const e = new SayError("text is empty", 2, "", "E_TEXT_EMPTY");
7
- expect(e.code).toBe("E_TEXT_EMPTY");
8
- expect(e.exitCode).toBe(2);
9
- });
10
- test("defaults to E_INTERNAL when unspecified", () => {
11
- const e = new SayError("boom", 4, "");
12
- expect(e.code).toBe("E_INTERNAL");
13
- });
14
- });