@drakulavich/kesha-voice-kit 1.26.0 → 1.27.0-alpha.2

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
 
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: kesha-voice-kit
3
- description: Local multilingual voice toolkit — speech-to-text (STT), text-to-speech (TTS), and language detection. Runs entirely offline on Apple Silicon, Linux, and Windows. No API keys, no cloud. NVIDIA Parakeet TDT for STT across 25 European languages, Kokoro-82M + Vosk-TTS for TTS, plus macOS AVSpeechSynthesizer for ~180 system voices with zero install.
3
+ description: Local multilingual voice toolkit — speech-to-text (STT), text-to-speech (TTS), speaker diarization, and language detection, over a CLI or an MCP server. Runs entirely offline on Apple Silicon, Linux, and Windows. No API keys, no cloud. NVIDIA Parakeet TDT for STT across 25 European languages, Kokoro-82M + Vosk-TTS for TTS in 9 languages, plus macOS AVSpeechSynthesizer for ~180 system voices with zero install.
4
4
  emoji: 🎙️
5
5
 
6
6
  requires:
@@ -17,7 +17,7 @@ install:
17
17
 
18
18
  Local voice toolkit: transcribe voice messages to text, synthesize speech, detect language of audio or text. Fully offline after `kesha install`. No API keys, no per-minute billing.
19
19
 
20
- **Trigger keywords for when to use this skill:** voice message, voice memo, voice note, .ogg, .opus, .wav, .mp3, audio file, transcribe, transcription, speech-to-text, STT, text-to-speech, TTS, synthesize speech, say, telegram voice note, whatsapp voice note, ogg-opus, opus, multilingual voice, multilingual ASR, language detection, offline voice, privacy, Apple Silicon, CoreML.
20
+ **Trigger keywords for when to use this skill:** voice message, voice memo, voice note, .ogg, .opus, .wav, .mp3, audio file, transcribe, transcription, speech-to-text, STT, text-to-speech, TTS, synthesize speech, say, telegram voice note, whatsapp voice note, ogg-opus, opus, multilingual voice, multilingual ASR, language detection, speaker diarization, who said what, meeting transcript, MCP server, offline voice, privacy, Apple Silicon, CoreML.
21
21
 
22
22
  ## When to use
23
23
 
@@ -107,6 +107,16 @@ When invoking Kesha manually from an OpenClaw flow, write OGG/Opus into an OpenC
107
107
 
108
108
  Do not configure OpenClaw Telegram TTS as `kesha say "<text>" > reply.wav`; that creates a WAV file and will not render as a native Telegram voice note.
109
109
 
110
+ ## MCP server
111
+
112
+ `kesha mcp` serves the same capabilities over Model Context Protocol (stdio), exposing `transcribe_audio`, `synthesize_speech`, `list_voices`, and `list_languages` to any MCP client:
113
+
114
+ ```json
115
+ { "mcpServers": { "kesha": { "command": "kesha", "args": ["mcp"] } } }
116
+ ```
117
+
118
+ Models are never auto-downloaded — tools fail with a `kesha install` / `kesha install --tts` hint when missing. Full client setup (Claude Code, Claude Desktop, Codex, Cursor): [docs/mcp.md](docs/mcp.md).
119
+
110
120
  ## STT: transcribe audio
111
121
 
112
122
  ```bash
@@ -207,8 +217,9 @@ bun add -g @drakulavich/kesha-voice-kit # global CLI install (always fi
207
217
  kesha init
208
218
 
209
219
  # For agents and CI: explicit, scriptable install commands
210
- kesha install # engine only (~350 MB)
211
- kesha install --tts # + Kokoro + Vosk-TTS RU (~990 MB more)
220
+ kesha install # engine only — ~0.6 GB on Apple Silicon, ~2.5 GB on Linux/Windows
221
+ kesha install --plan # preview exact download/disk sizes first — downloads nothing
222
+ kesha install --tts # + English Kokoro; add langs additively: --tts en ru es
212
223
  kesha install --tts --vad # + Silero VAD (long-audio chunking)
213
224
  kesha install --tts --vad --diarize # + speaker diarization (darwin-arm64 only)
214
225
  ```
@@ -223,7 +234,7 @@ No system deps — English G2P is embedded (`misaki-rs`); Russian G2P is bundled
223
234
 
224
235
  **Speech-to-text (25):** Bulgarian, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hungarian, Italian, Latvian, Lithuanian, Maltese, Polish, Portuguese, Romanian, Russian, Slovak, Slovenian, Spanish, Swedish, Ukrainian.
225
236
 
226
- **Text-to-speech:** English (Kokoro-82M; FluidAudio CoreML on darwin-arm64, ONNX elsewhere), Russian (Vosk-TTS, 5 baked-in speakers — default `ru-vosk-m02`), plus any macOS system voice via `--voice macos-*`.
237
+ **Text-to-speech (9):** English (Kokoro-82M; FluidAudio CoreML on darwin-arm64, ONNX elsewhere), Russian (Vosk-TTS, 5 baked-in speakers — default `ru-vosk-m02`), Spanish, French, Italian, Portuguese (Kokoro — CharsiuG2P on ONNX builds, FluidAudio G2P on darwin-arm64), plus Hindi, Japanese, Chinese (Kokoro, darwin-arm64 only). `hi`/`ja` accept romanized Latin input only; native Devanagari and kana/kanji are rejected with `E_SCRIPT_UNSUPPORTED` — see [#492](https://github.com/drakulavich/kesha-voice-kit/issues/492). Any macOS system voice is also available via `--voice macos-*`.
227
238
 
228
239
  ## Performance
229
240
 
@@ -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.2",
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),