@drakulavich/kesha-voice-kit 1.24.7 → 1.26.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.
package/README.md CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  ## Quick Start
26
26
 
27
- Runtime: **[Bun](https://bun.sh)** >= 1.3.0 · Platforms: macOS arm64, Linux x64. Windows x64 is currently blocked at install ([#216](https://github.com/drakulavich/kesha-voice-kit/issues/216)).
27
+ Runtime: **[Bun](https://bun.sh)** >= 1.3.0 · Platforms: macOS arm64, Linux x64, Windows x64. Linux and Windows run the ONNX engine — everything except microphone capture (`kesha record`), macOS system voices, speaker diarization, and text language detection, which need Apple frameworks.
28
28
 
29
29
  ```bash
30
30
  # 1. Install Bun (skip if you have it) — Linux & macOS:
@@ -57,6 +57,7 @@ kesha --json --timestamps audio.ogg # JSON with timestamped segments
57
57
  kesha --toon audio.ogg # compact LLM-friendly TOON
58
58
  kesha status # show installed backend info
59
59
  kesha status --disk # + recursive cache disk usage
60
+ kesha status --json # machine-readable, for scripts
60
61
  ```
61
62
 
62
63
  Multiple files get `head`-style headers; stdout is the transcript, stderr is errors — pipe-friendly:
@@ -28,7 +28,7 @@ _kesha_completion() {
28
28
  record) opts="--help -h --out --max-seconds --debug" ;;
29
29
  say) opts="--help -h --voice --lang --out --rate --list-voices --ssml --format --bitrate --sample-rate --no-expand-abbrev --verbose --debug" ;;
30
30
  stats) opts="--help -h --format" ;;
31
- status) opts="--help -h --disk" ;;
31
+ status) opts="--help -h --disk --json" ;;
32
32
  support-bundle) opts="--help -h --output --include-logs" ;;
33
33
  *) opts="--help -h --version -v --json --toon --timestamps --speakers --include-errors --verbose --format --lang --debug --vad --no-vad --quiet --no-color" ;;
34
34
  esac
@@ -86,6 +86,7 @@ complete -c kesha -n '__fish_seen_subcommand_from stats' -l format -r -d 'Export
86
86
  complete -c kesha -n '__fish_seen_subcommand_from status' -l help -d 'Show help'
87
87
  complete -c kesha -n '__fish_seen_subcommand_from status' -s h -d 'Show help'
88
88
  complete -c kesha -n '__fish_seen_subcommand_from status' -l disk -d 'Include recursive cache disk usage'
89
+ complete -c kesha -n '__fish_seen_subcommand_from status' -l json -d 'Output status as JSON'
89
90
  complete -c kesha -n '__fish_seen_subcommand_from support-bundle' -l help -d 'Show help'
90
91
  complete -c kesha -n '__fish_seen_subcommand_from support-bundle' -s h -d 'Show help'
91
92
  complete -c kesha -n '__fish_seen_subcommand_from support-bundle' -l output -r -d 'Write archive to this .tar.gz path'
@@ -121,7 +121,8 @@ _kesha() {
121
121
  status)
122
122
  _arguments '--help[Show help]' \
123
123
  '-h[Show help]' \
124
- '--disk[Include recursive cache disk usage]'
124
+ '--disk[Include recursive cache disk usage]' \
125
+ '--json[Output status as JSON]'
125
126
  ;;
126
127
  support-bundle)
127
128
  _arguments '--help[Show help]' \
@@ -60,27 +60,29 @@ The product promise is not "one model for every audio problem." The promise is a
60
60
 
61
61
  | Capability | macOS arm64 | macOS x64 | Linux x64 | Windows x64 | Nix path |
62
62
  |---|---|---|---|---|---|
63
- | STT | Supported, CoreML path | Not shipped | Supported, ONNX CPU path | Blocked at install (#216) | `aarch64-darwin`, `x86_64-linux` |
64
- | Audio language detection | Supported | Not shipped | Supported | Blocked at install (#216) | Supported where the flake builds |
65
- | VAD | Supported with `kesha install --vad` | Not shipped | Supported with `kesha install --vad` | Blocked at install (#216) | Supported where the engine path includes VAD assets |
66
- | TTS: English Kokoro | Supported, FluidAudio/CoreML in release builds | Not shipped | Supported, ONNX path | Blocked at install (#216) | Supported except where noted in `docs/nix-install.md` |
67
- | TTS: Russian Vosk-TTS | Supported | Not shipped | Supported | Blocked at install (#216) | Supported except where noted in `docs/nix-install.md` |
63
+ | STT | Supported, CoreML path | Not shipped | Supported, ONNX CPU path | Supported, ONNX CPU path | `aarch64-darwin`, `x86_64-linux` |
64
+ | Audio language detection | Supported | Not shipped | Supported | Supported | Supported where the flake builds |
65
+ | VAD | Supported with `kesha install --vad` | Not shipped | Supported with `kesha install --vad` | Supported with `kesha install --vad` | Supported where the engine path includes VAD assets |
66
+ | TTS: English Kokoro | Supported, FluidAudio/CoreML in release builds | Not shipped | Supported, ONNX path | Supported, ONNX path | Supported except where noted in `docs/nix-install.md` |
67
+ | TTS: Russian Vosk-TTS | Supported | Not shipped | Supported | Supported | Supported except where noted in `docs/nix-install.md` |
68
68
  | macOS system voices | Supported | Not shipped | Not applicable | Not applicable | Supported on `aarch64-darwin` |
69
69
  | Speaker diarization | Preview, darwin-arm64 only | Not supported | Not supported | Not supported | Not wired into the Nix build yet |
70
70
  | Raycast extension | Supported | Not shipped | Not applicable | Not applicable | Not applicable |
71
- | OpenClaw integration | Supported through CLI route | Not shipped | Supported through CLI route | Blocked at install (#216) | Use the installed `kesha` command from the chosen path |
72
- | Hermes Agent integration | Supported through command providers | Not shipped | Supported through command providers | Blocked at install (#216) | Use the installed `kesha` command from the chosen path |
71
+ | OpenClaw integration | Supported through CLI route | Not shipped | Supported through CLI route | Supported through CLI route | Use the installed `kesha` command from the chosen path |
72
+ | Hermes Agent integration | Supported through command providers | Not shipped | Supported through command providers | Supported through command providers | Use the installed `kesha` command from the chosen path |
73
73
 
74
74
  `macOS x64` means Intel Macs. Kesha does not currently publish a `darwin-x64` engine binary, so Intel Macs are intentionally marked as not shipped rather than implied to use the ONNX fallback.
75
75
 
76
- `Windows x64` shows "Blocked at install" because the CLI's install step
77
- refuses to run on Windows, not because no binary exists — `build-engine.yml`
78
- builds and publishes `kesha-engine-windows-x64.exe` with every release. The
79
- Vosk-TTS native dependency trips MSVC at link time, so the CLI blocks the
80
- install client-side rather than shipping a broken engine. See
81
- [issue #216](https://github.com/drakulavich/kesha-voice-kit/issues/216) for
82
- the underlying failure and the v1.4.x workaround; the toolkit is unusable on
83
- Windows until that lands.
76
+ `Windows x64` runs the same ONNX engine as Linux. The MSVC link failure behind
77
+ [issue #216](https://github.com/drakulavich/kesha-voice-kit/issues/216) was fixed by
78
+ vendoring the Vosk-TTS runtime under `rust/vendor/vosk-tts/`, and the CLI's install
79
+ refusal — which outlived that fix by about twenty minor versions — is gone. CI now runs
80
+ a cold `kesha install` on `windows-latest`, synthesises with both the Kokoro and Vosk
81
+ engines, and transcribes the result back, so the platform's row reflects a tested path
82
+ rather than a published binary nobody had run.
83
+
84
+ Microphone capture is the one Linux/Windows gap that is not about Apple frameworks:
85
+ `kesha record` is macOS-only, so pass an existing audio file instead.
84
86
 
85
87
  ## When to choose something else
86
88
 
package/man/kesha.1 CHANGED
@@ -200,6 +200,9 @@ Export format: json | csv
200
200
  .TP
201
201
  .B \-\-disk
202
202
  Include recursive cache disk usage
203
+ .TP
204
+ .B \-\-json
205
+ Output status as JSON
203
206
  .SS support-bundle
204
207
  .TP
205
208
  .B \-\-output
@@ -0,0 +1,43 @@
1
+ {
2
+ "asr": [
3
+ { "relPath": "models/parakeet-tdt-v3/encoder-model.onnx", "sizeBytes": 41770866 },
4
+ { "relPath": "models/parakeet-tdt-v3/encoder-model.onnx.data", "sizeBytes": 2435420160 },
5
+ { "relPath": "models/parakeet-tdt-v3/decoder_joint-model.onnx", "sizeBytes": 72520893 },
6
+ { "relPath": "models/parakeet-tdt-v3/nemo128.onnx", "sizeBytes": 139764 },
7
+ { "relPath": "models/parakeet-tdt-v3/vocab.txt", "sizeBytes": 93939 }
8
+ ],
9
+ "langId": [
10
+ { "relPath": "models/lang-id-ecapa/lang-id-ecapa.onnx", "sizeBytes": 759814 },
11
+ { "relPath": "models/lang-id-ecapa/lang-id-ecapa.onnx.data", "sizeBytes": 85327872 },
12
+ { "relPath": "models/lang-id-ecapa/labels.json", "sizeBytes": 646 }
13
+ ],
14
+ "vad": [
15
+ { "relPath": "models/silero-vad/silero_vad.onnx", "sizeBytes": 2327524 }
16
+ ],
17
+ "g2pCharsiu": [
18
+ { "relPath": "models/g2p/byt5-tiny/encoder_model.onnx", "sizeBytes": 12478704 },
19
+ { "relPath": "models/g2p/byt5-tiny/decoder_model.onnx", "sizeBytes": 11983268 },
20
+ { "relPath": "models/g2p/byt5-tiny/decoder_with_past_model.onnx", "sizeBytes": 5427260 }
21
+ ],
22
+ "kokoroGraph": { "relPath": "models/kokoro-82m/model.onnx", "sizeBytes": 325532387 },
23
+ "kokoroVoices": {
24
+ "en": { "relPath": "models/kokoro-82m/voices/am_michael.bin", "sizeBytes": 522240 },
25
+ "es": { "relPath": "models/kokoro-82m/voices/em_alex.bin", "sizeBytes": 522240 },
26
+ "fr": { "relPath": "models/kokoro-82m/voices/ff_siwis.bin", "sizeBytes": 522240 },
27
+ "it": { "relPath": "models/kokoro-82m/voices/im_nicola.bin", "sizeBytes": 522240 },
28
+ "pt": { "relPath": "models/kokoro-82m/voices/pm_alex.bin", "sizeBytes": 522240 }
29
+ },
30
+ "voskRu": [
31
+ { "relPath": "models/vosk-ru/model.onnx", "sizeBytes": 179314533 },
32
+ { "relPath": "models/vosk-ru/dictionary", "sizeBytes": 101431118 },
33
+ { "relPath": "models/vosk-ru/config.json", "sizeBytes": 1518 },
34
+ { "relPath": "models/vosk-ru/bert/model.onnx", "sizeBytes": 654361598 },
35
+ { "relPath": "models/vosk-ru/bert/vocab.txt", "sizeBytes": 1780720 }
36
+ ],
37
+ "diarize": [
38
+ { "relPath": "models/diarize/SortformerNvidiaLow_v2.mlpackage/Manifest.json", "sizeBytes": 617 },
39
+ { "relPath": "models/diarize/SortformerNvidiaLow_v2.mlpackage/Data/com.apple.CoreML/model.mlmodel", "sizeBytes": 7080357 },
40
+ { "relPath": "models/diarize/SortformerNvidiaLow_v2.mlpackage/Data/com.apple.CoreML/weights/0-weight.bin", "sizeBytes": 5930400 },
41
+ { "relPath": "models/diarize/SortformerNvidiaLow_v2.mlpackage/Data/com.apple.CoreML/weights/1-weight.bin", "sizeBytes": 232161600 }
42
+ ]
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.24.7",
3
+ "version": "1.26.0",
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": {
@@ -15,6 +15,7 @@
15
15
  "completions/",
16
16
  "man/",
17
17
  "src/",
18
+ "model-plan.json",
18
19
  "package.json",
19
20
  "tsconfig.json",
20
21
  "openclaw.plugin.json",
@@ -43,7 +44,7 @@
43
44
  "scripts": {
44
45
  "test": "bun test --timeout 30000",
45
46
  "test:watch": "bun test --timeout 30000 --watch",
46
- "test:unit": "bun test tests/unit/",
47
+ "test:unit": "bun test --timeout 30000 tests/unit/",
47
48
  "test:cli-fast": "bun test --timeout 30000 tests/unit/ tests/integration/cli-contracts.test.ts tests/integration/e2e-cli.test.ts tests/integration/say.test.ts",
48
49
  "test:integration": "bun test --timeout 30000 tests/integration/",
49
50
  "coverage:ts": "bun run test:cli-fast --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage/ts",
@@ -56,7 +57,8 @@
56
57
  "check:workflows": "bun .github/scripts/check-workflows.ts",
57
58
  "check:specs": "bunx @fission-ai/openspec@1.4.1 validate --specs --strict",
58
59
  "check:release-manifest": "bun .github/scripts/release-manifest.mjs --check",
59
- "check:versions": "bun .github/scripts/check-versions.ts"
60
+ "check:versions": "bun .github/scripts/check-versions.ts",
61
+ "check:engine-targets": "bun .github/scripts/check-engine-targets.ts"
60
62
  },
61
63
  "keywords": [
62
64
  "asr",
@@ -92,7 +94,7 @@
92
94
  "dependencies": {
93
95
  "@clack/prompts": "^1.5.0",
94
96
  "@modelcontextprotocol/sdk": "^1.29.0",
95
- "@toon-format/toon": "^2.1.0",
97
+ "@toon-format/toon": "^4.1.0",
96
98
  "citty": "^0.2.2",
97
99
  "fastest-levenshtein": "^1.0.16",
98
100
  "picocolors": "^1.1.1",
@@ -101,6 +103,7 @@
101
103
  },
102
104
  "overrides": {
103
105
  "hono": "^4.12.25",
104
- "fast-uri": "^3.1.3"
106
+ "fast-uri": "^3.1.5",
107
+ "ip-address": "^10.3.1"
105
108
  }
106
109
  }
package/src/cli/init.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  import { defineCommand } from "citty";
2
- import { createInterface } from "node:readline/promises";
3
- import { stdin as input, stdout as output } from "node:process";
4
- import { multiselect, isCancel, cancel } from "@clack/prompts";
2
+ import { confirm, multiselect, isCancel, cancel } from "@clack/prompts";
5
3
  import { renderInstallPlan } from "../install-plan";
6
4
  import { log } from "../log";
7
5
  import { getEngineCapabilities } from "../engine";
@@ -23,6 +21,18 @@ const TTS_LANG_LABELS: Record<string, string> = {
23
21
  /** Signature of the interactive TTS-language picker (injectable for tests). */
24
22
  export type TtsLangPrompt = (preselect: string[]) => Promise<string[]>;
25
23
 
24
+ /** Signature of the interactive yes/no prompt (injectable for tests). */
25
+ export type ConfirmPrompt = (message: string, initialValue: boolean) => Promise<boolean>;
26
+
27
+ /** Ctrl-C at any prompt ends init without downloading, rather than falling through with a default. */
28
+ function exitIfCancelled<T>(answer: T | symbol): T {
29
+ if (isCancel(answer)) {
30
+ cancel("Init cancelled.");
31
+ process.exit(0);
32
+ }
33
+ return answer as T;
34
+ }
35
+
26
36
  async function promptTtsLangs(preselect: string[]): Promise<string[]> {
27
37
  const caps = await getEngineCapabilities();
28
38
  const supported = caps?.tts?.languages.map((l) => l.code) ?? TTS_LANG_FALLBACK;
@@ -33,11 +43,12 @@ async function promptTtsLangs(preselect: string[]): Promise<string[]> {
33
43
  initialValues: preselect.filter((l) => supported.includes(l)),
34
44
  required: false,
35
45
  });
36
- if (isCancel(selected)) {
37
- cancel("Init cancelled.");
38
- process.exit(0);
39
- }
40
- return selected as string[];
46
+ return exitIfCancelled(selected);
47
+ }
48
+
49
+ /** #677: keep every prompt on clack — its close() unpipes stdin, deadlocking any readline sharing it. */
50
+ async function promptConfirm(message: string, initialValue: boolean): Promise<boolean> {
51
+ return exitIfCancelled(await confirm({ message, initialValue }));
41
52
  }
42
53
 
43
54
  export interface InitCommandArgs extends SharedInstallArgs {
@@ -52,10 +63,6 @@ export interface InitSelection {
52
63
  diarize: boolean;
53
64
  }
54
65
 
55
- interface PromptApi {
56
- question(prompt: string): Promise<string>;
57
- }
58
-
59
66
  export function canInstallDiarizeOnPlatform(
60
67
  platform = process.platform,
61
68
  arch = process.arch,
@@ -140,17 +147,17 @@ export function renderInitOverview(canDiarize = canInstallDiarizeOnPlatform()):
140
147
 
141
148
  export async function promptInitSelection(
142
149
  args: InitCommandArgs,
143
- prompt: PromptApi,
150
+ prompt: ConfirmPrompt,
144
151
  backend = resolveBackendFlag(args.coreml, args.onnx),
145
152
  canDiarize = canInstallDiarizeOnPlatform(),
146
153
  noCache = resolveNoCacheFlag(args),
147
154
  promptTts: TtsLangPrompt = promptTtsLangs,
148
155
  ): Promise<InitSelection> {
149
156
  const ttsLangs = await promptTts(args.tts ? ["en"] : []);
150
- const vad = await askYesNo(prompt, "Install VAD for long or silence-heavy audio?", args.vad);
157
+ const vad = await prompt("Install VAD for long or silence-heavy audio?", args.vad);
151
158
  let diarize = false;
152
159
  if (canDiarize) {
153
- diarize = await askYesNo(prompt, "Install speaker diarization for `--speakers`?", args.diarize);
160
+ diarize = await prompt("Install speaker diarization for `--speakers`?", args.diarize);
154
161
  } else if (args.diarize) {
155
162
  log.warn("--diarize is currently darwin-arm64 only; omitting it from the interactive install.");
156
163
  }
@@ -164,17 +171,6 @@ export async function promptInitSelection(
164
171
  };
165
172
  }
166
173
 
167
- async function askYesNo(prompt: PromptApi, message: string, defaultValue: boolean): Promise<boolean> {
168
- const suffix = defaultValue ? "Y/n" : "y/N";
169
- for (;;) {
170
- const answer = (await prompt.question(`${message} [${suffix}] `)).trim().toLowerCase();
171
- if (answer === "") return defaultValue;
172
- if (answer === "y" || answer === "yes") return true;
173
- if (answer === "n" || answer === "no") return false;
174
- log.warn("Please answer yes or no.");
175
- }
176
- }
177
-
178
174
  async function printPlan(selection: InitSelection): Promise<void> {
179
175
  log.info(
180
176
  await renderInstallPlan({
@@ -282,33 +278,36 @@ export const initCommand = defineCommand({
282
278
  }
283
279
 
284
280
  log.info(renderInitOverview());
285
- const rl = createInterface({ input, output });
286
- try {
287
- const prompted = await promptInitSelection(args, rl, backend, canInstallDiarizeOnPlatform(), noCache);
288
- log.info("");
289
- await printPlan(prompted);
290
- const confirmed = await askYesNo(rl, `Run \`${initInstallArgs(prompted).join(" ")}\` now?`, true);
291
- if (!confirmed) {
292
- log.info(`Skipped install. Run later: ${initInstallArgs(prompted).join(" ")}`);
293
- return;
294
- }
295
- await performInstall(
296
- prompted.noCache,
297
- prompted.backend,
298
- prompted.ttsLangs,
299
- prompted.vad,
300
- prompted.diarize,
301
- );
302
- // Next-steps hint (#523): leave the user with something runnable rather
303
- // than ending on the install log.
304
- log.info("");
305
- log.success("Kesha is ready. Try:");
306
- log.info(" kesha path/to/audio.ogg Transcribe a file.");
307
- if (prompted.ttsLangs.length > 0) {
308
- log.info(' kesha say "hello" Speak text (TTS).');
309
- }
310
- } finally {
311
- rl.close();
281
+ const prompted = await promptInitSelection(
282
+ args,
283
+ promptConfirm,
284
+ backend,
285
+ canInstallDiarizeOnPlatform(),
286
+ noCache,
287
+ );
288
+ log.info("");
289
+ await printPlan(prompted);
290
+ const confirmed = await promptConfirm(
291
+ `Run \`${initInstallArgs(prompted).join(" ")}\` now?`,
292
+ true,
293
+ );
294
+ if (!confirmed) {
295
+ log.info(`Skipped install. Run later: ${initInstallArgs(prompted).join(" ")}`);
296
+ return;
297
+ }
298
+ await performInstall(
299
+ prompted.noCache,
300
+ prompted.backend,
301
+ prompted.ttsLangs,
302
+ prompted.vad,
303
+ prompted.diarize,
304
+ );
305
+ // #523: end on something runnable, not on the install log.
306
+ log.info("");
307
+ log.success("Kesha is ready. Try:");
308
+ log.info(" kesha path/to/audio.ogg Transcribe a file.");
309
+ if (prompted.ttsLangs.length > 0) {
310
+ log.info(' kesha say "hello" Speak text (TTS).');
312
311
  }
313
312
  },
314
313
  });
@@ -1,6 +1,7 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { errorMessage } from "../error-utils";
3
3
  import { downloadEngine } from "../engine-install";
4
+ import { engineTarget } from "../engine-targets";
4
5
  import { getEngineBinPath, getEngineCapabilities } from "../engine";
5
6
  import { renderInstallPlan } from "../install-plan";
6
7
  import { maybeAskForStar } from "../star";
@@ -90,10 +91,11 @@ export function resolveBackendFlag(coreml: boolean, onnx: boolean): string | und
90
91
  return selection.backend;
91
92
  }
92
93
 
93
- function defaultBackendForPlatform(): string | undefined {
94
- if (process.platform === "darwin" && process.arch === "arm64") return "coreml";
95
- if (process.platform === "linux" && process.arch === "x64") return "onnx";
96
- return undefined;
94
+ export function defaultBackendForPlatform(
95
+ platform: string = process.platform,
96
+ arch: string = process.arch,
97
+ ): string | undefined {
98
+ return engineTarget(platform, arch)?.backend;
97
99
  }
98
100
 
99
101
  type InstallDiagnosticErrorKind = "validation_failed" | "install_failed";
package/src/cli/status.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { defineCommand } from "citty";
2
- import { showStatus } from "../status";
2
+ import { collectStatus, renderStatus } from "../status";
3
3
 
4
4
  interface StatusCommandArgs {
5
5
  disk: boolean;
6
+ json: boolean;
6
7
  }
7
8
 
8
9
  export const statusCommand = defineCommand({
@@ -16,8 +17,19 @@ export const statusCommand = defineCommand({
16
17
  description: "Include recursive cache disk usage",
17
18
  default: false,
18
19
  },
20
+ json: {
21
+ type: "boolean",
22
+ description: "Output status as JSON",
23
+ default: false,
24
+ },
19
25
  },
20
26
  async run({ args }: { args: StatusCommandArgs }) {
21
- await showStatus({ disk: args.disk });
27
+ const report = await collectStatus({ disk: args.disk });
28
+ if (args.json) {
29
+ // `log.*` writes to stdout, so the payload must be the only thing printed (#647).
30
+ console.log(JSON.stringify(report, null, 2));
31
+ return;
32
+ }
33
+ renderStatus(report);
22
34
  },
23
35
  });
@@ -8,6 +8,7 @@ import {
8
8
  TRANSCRIBE_DIARIZE_FEATURE,
9
9
  type EngineCapabilities,
10
10
  } from "./engine";
11
+ import { engineTarget } from "./engine-targets";
11
12
  import { isDarwinArm64 } from "./fluid-kokoro-cache";
12
13
  import { log } from "./log";
13
14
  import { engineVersion } from "./package-info";
@@ -25,22 +26,13 @@ export {
25
26
 
26
27
  const GITHUB_REPO = "drakulavich/kesha-voice-kit";
27
28
 
28
- function getEngineBinaryName(): string {
29
- const platform = process.platform;
30
- const arch = process.arch;
31
-
32
- if (platform === "darwin" && arch === "arm64") return "kesha-engine-darwin-arm64";
33
- if (platform === "linux" && arch === "x64") return "kesha-engine-linux-x64";
34
- if (platform === "win32" && arch === "x64") {
35
- throw new Error(
36
- "Windows x64 is temporarily unsupported in v1.5.0 — the Vosk-TTS engine has " +
37
- "native deps that trip MSVC at link time. Tracked at " +
38
- "https://github.com/drakulavich/kesha-voice-kit/issues/216. " +
39
- "Use v1.4.x as a workaround until the fix lands.",
40
- );
41
- }
42
-
43
- throw new Error(`Unsupported platform: ${platform} ${arch}`);
29
+ export function getEngineBinaryName(
30
+ platform: string = process.platform,
31
+ arch: string = process.arch,
32
+ ): string {
33
+ const target = engineTarget(platform, arch);
34
+ if (!target) throw new Error(`Unsupported platform: ${platform} ${arch}`);
35
+ return target.assetName;
44
36
  }
45
37
 
46
38
  /** Sidecar spec — centralises AVSpeech (#141) and future sidecars so each is one entry. */
@@ -372,6 +364,66 @@ async function refreshCachedEngine(
372
364
  }
373
365
  }
374
366
 
367
+ /** EACCES/EPERM are deliberately absent: those are policy (noexec, ACL, AppLocker) and never clear. */
368
+ export function isTransientSpawnLock(message: string): boolean {
369
+ return /\b(EBUSY|ETXTBSY)\b/.test(message) || /sharing violation/i.test(message);
370
+ }
371
+
372
+ /**
373
+ * Blocks until the freshly-downloaded engine can actually be spawned.
374
+ *
375
+ * A security scanner (Windows Defender is the one this was found on) holds a lock on a newly
376
+ * written 60 MB PE while it scans, so the first spawn fails with EBUSY 15 ms after the download
377
+ * reported success (#216). The lock is transient, so probe until it clears.
378
+ */
379
+ export async function waitUntilSpawnable(
380
+ binPath: string,
381
+ deadlineMs = 60_000,
382
+ probeTimeoutMs = 5_000,
383
+ ): Promise<void> {
384
+ const deadline = Date.now() + deadlineMs;
385
+ let lastError = "";
386
+ let warned = false;
387
+ let delay = 100;
388
+
389
+ for (;;) {
390
+ try {
391
+ // Only a throw means locked; the timeout is for a scanner that detains rather than refuses.
392
+ const proc = Bun.spawn([binPath, "--version"], { stdout: "ignore", stderr: "ignore" });
393
+ const timer = setTimeout(() => proc.kill(), probeTimeoutMs);
394
+ try {
395
+ await proc.exited;
396
+ } finally {
397
+ clearTimeout(timer);
398
+ }
399
+ return;
400
+ } catch (e) {
401
+ lastError = errorMessage(e);
402
+ // Only a lock is worth waiting out — a missing or corrupt binary never becomes spawnable.
403
+ if (!isTransientSpawnLock(lastError)) {
404
+ throw new Error(
405
+ `Downloaded the engine to ${binPath} but it could not be started: ${lastError}\n` +
406
+ ` Fix: delete ${dirname(binPath)} and re-run \`kesha install\`.`,
407
+ );
408
+ }
409
+ if (Date.now() + delay > deadline) break;
410
+ if (!warned) {
411
+ warned = true;
412
+ log.progress("Waiting for the engine binary to be released by the system...");
413
+ }
414
+ await Bun.sleep(delay);
415
+ delay = Math.min(delay * 2, 2_000);
416
+ }
417
+ }
418
+
419
+ throw new Error(
420
+ `Downloaded the engine to ${binPath} but it is still locked after ` +
421
+ `${Math.round(deadlineMs / 1000)}s: ${lastError}\n` +
422
+ ` Fix: a security scanner is likely holding the file. Re-run \`kesha install\`, ` +
423
+ `or exclude ${dirname(binPath)} from real-time scanning.`,
424
+ );
425
+ }
426
+
375
427
  /** Cold path: download the engine binary (and sidecars, concurrently). */
376
428
  async function fetchEngineBinary(
377
429
  binPath: string,
@@ -416,6 +468,8 @@ async function fetchEngineBinary(
416
468
  await streamResponseToFile(res, binPath, "kesha-engine binary");
417
469
  chmodSync(binPath, 0o755);
418
470
  darwinTrustBinary(binPath, "kesha-engine binary");
471
+ // Marker last: writing it first sends the retry down the cacheValid branch, which never waits.
472
+ await waitUntilSpawnable(binPath);
419
473
  writeInstalledEngineVersion(binPath, engineVersion);
420
474
  log.success(`Engine binary downloaded (v${engineVersion}).`);
421
475
  await Promise.all(sidecarPromises);
@@ -474,7 +528,7 @@ function validateDiarize(caps: EngineCapabilities | null): void {
474
528
 
475
529
  /**
476
530
  * Runs `kesha-engine install` to download/verify models.
477
- * Streams stderr to the process and throws on non-zero exit.
531
+ * Inherits stdio so per-file progress reaches the user live, and throws on non-zero exit.
478
532
  */
479
533
  function runEngineModelInstall(
480
534
  binPath: string,
@@ -488,19 +542,17 @@ function runEngineModelInstall(
488
542
  vad: options.vad,
489
543
  diarize: options.diarize,
490
544
  });
545
+ // #680: piping buffers the child until exit, so multi-GB downloads looked hung.
491
546
  const proc = Bun.spawnSync([binPath, ...installArgs], {
492
- stdout: "pipe",
493
- stderr: "pipe",
547
+ stdout: "inherit",
548
+ stderr: "inherit",
494
549
  });
495
550
 
496
- const stderr = proc.stderr.toString();
497
- if (stderr) {
498
- process.stderr.write(stderr);
499
- }
500
-
501
551
  if (proc.exitCode !== 0) {
502
- const detail = stderr.trim();
503
- throw new Error(detail ? `Failed to install models: ${detail}` : "Failed to install models");
552
+ throw new Error(
553
+ `Failed to install models: kesha-engine install exited with code ${proc.exitCode}. ` +
554
+ "See the engine output above for the failing file.",
555
+ );
504
556
  }
505
557
  }
506
558
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The one table of what each shipped platform gets.
3
+ *
4
+ * Kept import-free so any module can read it — `paths.ts` in particular must stay
5
+ * dependency-light. Adding a platform is one row here; #216 had to touch four
6
+ * separate switch chains, and nothing would have caught a missed one.
7
+ */
8
+ export interface EngineTarget {
9
+ /** GitHub release asset name — suffixed for uniqueness across platforms. */
10
+ assetName: string;
11
+ backend: "coreml" | "onnx";
12
+ /** Published asset size, for `kesha install --plan`. Verified by `check:engine-targets`. */
13
+ sizeBytes: number;
14
+ }
15
+
16
+ const ENGINE_TARGETS: Record<string, EngineTarget> = {
17
+ "darwin-arm64": {
18
+ assetName: "kesha-engine-darwin-arm64",
19
+ backend: "coreml",
20
+ sizeBytes: 62_172_768,
21
+ },
22
+ "linux-x64": {
23
+ assetName: "kesha-engine-linux-x64",
24
+ backend: "onnx",
25
+ sizeBytes: 63_188_728,
26
+ },
27
+ "win32-x64": {
28
+ assetName: "kesha-engine-windows-x64.exe",
29
+ backend: "onnx",
30
+ sizeBytes: 63_447_040,
31
+ },
32
+ };
33
+
34
+ /** Returns null for a platform with no published engine — callers choose how loudly to fail. */
35
+ export function engineTarget(
36
+ platform: string = process.platform,
37
+ arch: string = process.arch,
38
+ ): EngineTarget | null {
39
+ return ENGINE_TARGETS[`${platform}-${arch}`] ?? null;
40
+ }
41
+
42
+ export function engineTargetEntries(): Array<{ platform: string; arch: string; target: EngineTarget }> {
43
+ return Object.entries(ENGINE_TARGETS).map(([key, target]) => {
44
+ // Split once from the left: an arch token may itself contain a hyphen, a platform never does.
45
+ const sep = key.indexOf("-");
46
+ return { platform: key.slice(0, sep), arch: key.slice(sep + 1), target };
47
+ });
48
+ }
package/src/engine.ts CHANGED
@@ -364,10 +364,21 @@ export async function getEngineCapabilities(): Promise<EngineCapabilities | null
364
364
  const { stdout, exitCode } = await runEngine(["--capabilities-json"]);
365
365
  if (exitCode !== 0) return null;
366
366
  try {
367
- const capabilities = JSON.parse(stdout) as EngineCapabilities;
367
+ const capabilities = parseCapabilities(JSON.parse(stdout));
368
+ if (!capabilities) return null;
368
369
  cachedEngineCapabilities = { binPath, mtime, capabilities };
369
370
  return capabilities;
370
371
  } catch {
371
372
  return null;
372
373
  }
373
374
  }
375
+
376
+ // A non-null return must mean "it described itself" — callers reach straight for `.features.join()` (#647).
377
+ function parseCapabilities(parsed: unknown): EngineCapabilities | null {
378
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
379
+ const { protocolVersion, backend, features } = parsed as Record<string, unknown>;
380
+ if (typeof protocolVersion !== "number") return null;
381
+ if (typeof backend !== "string") return null;
382
+ if (!Array.isArray(features) || features.some((f) => typeof f !== "string")) return null;
383
+ return parsed as EngineCapabilities;
384
+ }
@@ -3,6 +3,7 @@ import { humanBytes } from "./format";
3
3
  import { dirname, join } from "path";
4
4
  import { getEngineBinPath } from "./engine";
5
5
  import { SIDECARS } from "./engine-install";
6
+ import { engineTarget } from "./engine-targets";
6
7
  import { readInstalledEngineVersion } from "./engine-version-marker";
7
8
  import { keshaCacheDir } from "./paths";
8
9
  import { engineVersion, packageVersion } from "./package-info";
@@ -11,6 +12,7 @@ import {
11
12
  fluidKokoroCachePath,
12
13
  isDarwinArm64,
13
14
  } from "./fluid-kokoro-cache";
15
+ import modelPlan from "../model-plan.json" with { type: "json" };
14
16
 
15
17
  export interface InstallPlanOptions {
16
18
  noCache?: boolean;
@@ -26,6 +28,17 @@ interface PlanFile {
26
28
  sizeBytes: number;
27
29
  }
28
30
 
31
+ interface ModelPlan {
32
+ asr: PlanFile[];
33
+ langId: PlanFile[];
34
+ vad: PlanFile[];
35
+ g2pCharsiu: PlanFile[];
36
+ kokoroGraph: PlanFile;
37
+ kokoroVoices: Record<string, PlanFile>;
38
+ voskRu: PlanFile[];
39
+ diarize: PlanFile[];
40
+ }
41
+
29
42
  interface PlanComponent {
30
43
  name: string;
31
44
  source: string;
@@ -45,46 +58,19 @@ interface ReleaseAssetSpec {
45
58
  sizeBytes: number;
46
59
  }
47
60
 
48
- // Mirrors the pinned runtime manifests so `kesha install --plan` works before
49
- // the engine exists. Keep in sync with rust/src/models.rs and release assets.
50
- const ASR_FILES: PlanFile[] = [
51
- { relPath: "models/parakeet-tdt-v3/encoder-model.onnx", sizeBytes: 41_770_866 },
52
- { relPath: "models/parakeet-tdt-v3/encoder-model.onnx.data", sizeBytes: 2_435_420_160 },
53
- { relPath: "models/parakeet-tdt-v3/decoder_joint-model.onnx", sizeBytes: 72_520_893 },
54
- { relPath: "models/parakeet-tdt-v3/nemo128.onnx", sizeBytes: 139_764 },
55
- { relPath: "models/parakeet-tdt-v3/vocab.txt", sizeBytes: 93_939 },
56
- ];
57
-
58
- const LANG_ID_FILES: PlanFile[] = [
59
- { relPath: "models/lang-id-ecapa/lang-id-ecapa.onnx", sizeBytes: 759_814 },
60
- { relPath: "models/lang-id-ecapa/lang-id-ecapa.onnx.data", sizeBytes: 85_327_872 },
61
- { relPath: "models/lang-id-ecapa/labels.json", sizeBytes: 646 },
62
- ];
63
-
64
- const VAD_FILES: PlanFile[] = [
65
- { relPath: "models/silero-vad/silero_vad.onnx", sizeBytes: 2_327_524 },
66
- ];
67
-
68
- // klebster CharsiuG2P byt5-tiny ONNX export (CC-BY 4.0 — see NOTICES.md). Multilingual G2P for es/fr/it/pt (#212).
69
- const G2P_CHARSIU_FILES: PlanFile[] = [
70
- { relPath: "models/g2p/byt5-tiny/encoder_model.onnx", sizeBytes: 12_478_704 },
71
- { relPath: "models/g2p/byt5-tiny/decoder_model.onnx", sizeBytes: 11_983_268 },
72
- { relPath: "models/g2p/byt5-tiny/decoder_with_past_model.onnx", sizeBytes: 5_427_260 },
73
- ];
74
-
75
- // Kokoro ONNX graph (shared by all Kokoro languages on non-darwin builds).
76
- const KOKORO_GRAPH_FILE: PlanFile = { relPath: "models/kokoro-82m/model.onnx", sizeBytes: 325_532_387 };
77
-
78
- // Multilingual voice packs for es/fr/it/pt (#212).
79
- // em_alex (es, male), ff_siwis (fr, female — only French voice in Kokoro v1.0),
80
- // im_nicola (it, male), pm_alex (pt, male).
81
- const KOKORO_VOICE_FILES: Record<string, PlanFile> = {
82
- en: { relPath: "models/kokoro-82m/voices/am_michael.bin", sizeBytes: 522_240 },
83
- es: { relPath: "models/kokoro-82m/voices/em_alex.bin", sizeBytes: 522_240 },
84
- fr: { relPath: "models/kokoro-82m/voices/ff_siwis.bin", sizeBytes: 522_240 },
85
- it: { relPath: "models/kokoro-82m/voices/im_nicola.bin", sizeBytes: 522_240 },
86
- pt: { relPath: "models/kokoro-82m/voices/pm_alex.bin", sizeBytes: 522_240 },
87
- };
61
+ // Shared plan metadata keeps `kesha install --plan` usable before the engine
62
+ // exists. Rust tests guard these cache paths against its pinned manifests.
63
+ const plan: ModelPlan = modelPlan;
64
+ const {
65
+ asr: ASR_FILES,
66
+ langId: LANG_ID_FILES,
67
+ vad: VAD_FILES,
68
+ g2pCharsiu: G2P_CHARSIU_FILES,
69
+ kokoroGraph: KOKORO_GRAPH_FILE,
70
+ kokoroVoices: KOKORO_VOICE_FILES,
71
+ voskRu: VOSK_RU_FILES,
72
+ diarize: DIARIZE_FILES,
73
+ } = plan;
88
74
 
89
75
  function kokoroPlanFiles(langs: string[]): PlanFile[] {
90
76
  const files: PlanFile[] = [KOKORO_GRAPH_FILE];
@@ -95,30 +81,6 @@ function kokoroPlanFiles(langs: string[]): PlanFile[] {
95
81
  return files;
96
82
  }
97
83
 
98
- const VOSK_RU_FILES: PlanFile[] = [
99
- { relPath: "models/vosk-ru/model.onnx", sizeBytes: 179_314_533 },
100
- { relPath: "models/vosk-ru/dictionary", sizeBytes: 101_431_118 },
101
- { relPath: "models/vosk-ru/config.json", sizeBytes: 1_518 },
102
- { relPath: "models/vosk-ru/bert/model.onnx", sizeBytes: 654_361_598 },
103
- { relPath: "models/vosk-ru/bert/vocab.txt", sizeBytes: 1_780_720 },
104
- ];
105
-
106
- const DIARIZE_FILES: PlanFile[] = [
107
- { relPath: "models/diarize/SortformerNvidiaLow_v2.mlpackage/Manifest.json", sizeBytes: 617 },
108
- {
109
- relPath: "models/diarize/SortformerNvidiaLow_v2.mlpackage/Data/com.apple.CoreML/model.mlmodel",
110
- sizeBytes: 7_080_357,
111
- },
112
- {
113
- relPath: "models/diarize/SortformerNvidiaLow_v2.mlpackage/Data/com.apple.CoreML/weights/0-weight.bin",
114
- sizeBytes: 5_930_400,
115
- },
116
- {
117
- relPath: "models/diarize/SortformerNvidiaLow_v2.mlpackage/Data/com.apple.CoreML/weights/1-weight.bin",
118
- sizeBytes: 232_161_600,
119
- },
120
- ];
121
-
122
84
  // The sidecar list (and the asset-name → installed-filename mapping) lives in
123
85
  // SIDECARS in engine-install.ts; only the release-asset sizes are pinned here,
124
86
  // like the model tables above.
@@ -144,16 +106,8 @@ function sumFiles(files: PlanFile[]): number {
144
106
  }
145
107
 
146
108
  function engineAssetForPlatform(): ReleaseAssetSpec | null {
147
- if (process.platform === "darwin" && process.arch === "arm64") {
148
- return { assetName: "kesha-engine-darwin-arm64", sizeBytes: 59_621_264 };
149
- }
150
- if (process.platform === "linux" && process.arch === "x64") {
151
- return { assetName: "kesha-engine-linux-x64", sizeBytes: 62_897_808 };
152
- }
153
- if (process.platform === "win32" && process.arch === "x64") {
154
- return { assetName: "kesha-engine-windows-x64.exe", sizeBytes: 63_126_528 };
155
- }
156
- return null;
109
+ const target = engineTarget();
110
+ return target && { assetName: target.assetName, sizeBytes: target.sizeBytes };
157
111
  }
158
112
 
159
113
  function filesCached(cacheRoot: string, files: PlanFile[]): boolean {
@@ -192,10 +146,6 @@ function buildEngineComponent(binPath: string, noCache: boolean): PlanComponent
192
146
  sizeBytes: engineAsset.sizeBytes,
193
147
  cached: engineCached,
194
148
  refresh: noCache,
195
- note:
196
- process.platform === "win32"
197
- ? "Windows x64 is currently blocked by the install path; see issue #216."
198
- : undefined,
199
149
  };
200
150
  }
201
151
  return {
package/src/paths.ts CHANGED
@@ -5,6 +5,8 @@ export function keshaCacheDir(): string {
5
5
  return process.env.KESHA_CACHE_DIR ?? join(homedir(), ".cache", "kesha");
6
6
  }
7
7
 
8
- export function defaultEngineBinPath(): string {
9
- return join(keshaCacheDir(), "engine", "bin", "kesha-engine");
8
+ /** The `.exe` suffix is a platform fact, not a per-target one: an extensionless PE is not reliably spawnable. */
9
+ export function defaultEngineBinPath(platform = process.platform): string {
10
+ const basename = platform === "win32" ? "kesha-engine.exe" : "kesha-engine";
11
+ return join(keshaCacheDir(), "engine", "bin", basename);
10
12
  }
package/src/progress.ts CHANGED
@@ -105,6 +105,27 @@ export function createPercentProgress(
105
105
  };
106
106
  }
107
107
 
108
+ type SinkWrite = ReturnType<Bun.FileSink["write"]>;
109
+
110
+ /**
111
+ * Waits for the sink whenever a write did not complete inline.
112
+ *
113
+ * Bun's FileSink reports a pending write as a Promise and backpressure as the negative
114
+ * sentinel `-(bytes + 1)`; discarding either buffers the whole download in memory instead
115
+ * of throttling to disk speed, which for the engine is ~63 MB and for the Vosk voice ~937 MB
116
+ * (#669). Exported for the test — the sentinel is not reproducible on demand.
117
+ */
118
+ export async function applyBackpressure(
119
+ writer: Pick<Bun.FileSink, "flush">,
120
+ written: SinkWrite,
121
+ ): Promise<void> {
122
+ if (typeof written !== "number") {
123
+ await written;
124
+ return;
125
+ }
126
+ if (written < 0) await writer.flush();
127
+ }
128
+
108
129
  export async function streamResponseToFile(
109
130
  res: Response,
110
131
  destPath: string,
@@ -123,12 +144,13 @@ export async function streamResponseToFile(
123
144
  let bytes = 0;
124
145
  try {
125
146
  for await (const chunk of res.body) {
126
- writer.write(chunk);
147
+ await applyBackpressure(writer, writer.write(chunk));
127
148
  bytes += chunk.length;
128
149
  progress.update(chunk.length);
129
150
  }
130
151
  } finally {
131
- writer.end();
152
+ // Must await: an open write handle makes the freshly-downloaded engine unspawnable — EBUSY on Windows, ETXTBSY on Linux.
153
+ await writer.end();
132
154
  }
133
155
 
134
156
  progress.finish();
package/src/status.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import { readdirSync, statSync } from "fs";
2
2
  import { join } from "path";
3
- import { isEngineInstalled, getEngineBinPath, getEngineCapabilities } from "./engine";
3
+ import {
4
+ isEngineInstalled,
5
+ getEngineBinPath,
6
+ getEngineCapabilities,
7
+ type EngineCapabilities,
8
+ } from "./engine";
4
9
  import { installHint } from "./install-hint";
5
10
  import { log } from "./log";
11
+ import { packageVersion } from "./package-info";
6
12
  import { keshaCacheDir } from "./paths";
7
13
  import { fluidKokoroCacheInfo } from "./fluid-kokoro-cache";
8
14
  import { dirSizeBytes } from "./diagnostic-paths";
@@ -35,8 +41,60 @@ export interface ShowStatusOptions {
35
41
  disk?: boolean;
36
42
  }
37
43
 
38
- async function logEngineCapabilities(): Promise<void> {
39
- const caps = await getEngineCapabilities().catch(() => null);
44
+ export interface StatusDiskComponent {
45
+ label: string;
46
+ sizeBytes: number;
47
+ }
48
+
49
+ export interface StatusDiskUsage {
50
+ cachePath: string;
51
+ components: StatusDiskComponent[];
52
+ componentTotalBytes: number;
53
+ totalBytes: number;
54
+ fluidKokoro: { path: string; sizeBytes: number } | null;
55
+ }
56
+
57
+ /**
58
+ * Machine-readable `kesha status` payload (#647). Contract: every key is always
59
+ * present — absent values are null (or `[]` for voices), never omitted, so a
60
+ * consumer never distinguishes a missing key from a null value. `engine.installed`
61
+ * reports only that a binary exists; usability additionally requires
62
+ * `engine.capabilities` to be non-null.
63
+ */
64
+ export interface StatusReport {
65
+ cliVersion: string;
66
+ engine: {
67
+ installed: boolean;
68
+ path: string;
69
+ capabilities: EngineCapabilities | null;
70
+ };
71
+ voices: string[];
72
+ runtime: { bun: string; platform: string; arch: string };
73
+ modelMirror: string | null;
74
+ hint: string | null;
75
+ disk: StatusDiskUsage | null;
76
+ }
77
+
78
+ export async function collectStatus(options: ShowStatusOptions = {}): Promise<StatusReport> {
79
+ const path = getEngineBinPath();
80
+ const installed = isEngineInstalled();
81
+ const capabilities = installed ? await getEngineCapabilities().catch(() => null) : null;
82
+
83
+ return {
84
+ cliVersion: packageVersion,
85
+ engine: { installed, path, capabilities },
86
+ voices: installed ? listInstalledVoices() : [],
87
+ runtime: { bun: Bun.version, platform: process.platform, arch: process.arch },
88
+ modelMirror: activeModelMirror(),
89
+ hint: installed
90
+ ? null
91
+ : `Run \`${installHint()}\` to download the engine and models.`,
92
+ // Absent engine means no disk walk, matching the human path (#647).
93
+ disk: installed && options.disk ? collectDiskUsage(path) : null,
94
+ };
95
+ }
96
+
97
+ function logEngineCapabilities(caps: EngineCapabilities | null): void {
40
98
  if (caps) {
41
99
  log.info(formatStatusLine("Backend", caps.backend, true));
42
100
  log.info(formatStatusLine("Protocol", `v${caps.protocolVersion}`, true));
@@ -46,8 +104,7 @@ async function logEngineCapabilities(): Promise<void> {
46
104
  }
47
105
  }
48
106
 
49
- function logInstalledVoices(): void {
50
- const voices = listInstalledVoices();
107
+ function logInstalledVoices(voices: string[]): void {
51
108
  if (voices.length === 0) return;
52
109
  log.info("TTS voices:");
53
110
  for (const v of voices) {
@@ -56,40 +113,37 @@ function logInstalledVoices(): void {
56
113
  log.info("");
57
114
  }
58
115
 
59
- export async function showStatus(options: ShowStatusOptions = {}): Promise<void> {
60
- const binPath = getEngineBinPath();
61
- const installed = isEngineInstalled();
116
+ export function renderStatus(report: StatusReport): void {
117
+ const { installed, path, capabilities } = report.engine;
62
118
 
63
119
  log.info("Engine:");
64
- log.info(formatStatusLine("Binary", installed ? binPath : null, installed));
120
+ log.info(formatStatusLine("Binary", installed ? path : null, installed));
65
121
 
66
122
  if (installed) {
67
- await logEngineCapabilities();
123
+ logEngineCapabilities(capabilities);
68
124
  }
69
125
  log.info("");
70
126
 
71
- log.info(formatStatusLine("Runtime", `Bun ${Bun.version}`, true));
72
- log.info(formatStatusLine("Platform", `${process.platform} ${process.arch}`, true));
73
- const mirror = activeModelMirror();
74
- if (mirror) {
75
- log.info(formatStatusLine("Mirror", mirror, true));
127
+ log.info(formatStatusLine("Runtime", `Bun ${report.runtime.bun}`, true));
128
+ log.info(
129
+ formatStatusLine("Platform", `${report.runtime.platform} ${report.runtime.arch}`, true),
130
+ );
131
+ if (report.modelMirror) {
132
+ log.info(formatStatusLine("Mirror", report.modelMirror, true));
76
133
  }
77
134
  log.info("");
78
135
 
79
136
  if (installed) {
80
- logInstalledVoices();
81
-
82
- if (options.disk) {
83
- showDiskUsage(binPath);
84
- }
137
+ logInstalledVoices(report.voices);
138
+ if (report.disk) showDiskUsage(report.disk);
85
139
  }
86
140
 
87
- if (!installed) {
88
- log.warn(`Run \`${installHint()}\` to download the engine and models.`);
89
- return;
141
+ if (report.hint) {
142
+ log.warn(report.hint);
90
143
  }
91
144
  }
92
145
 
146
+
93
147
  function buildDiskComponents(cache: string, engineDir: string): Array<{ label: string; path: string }> {
94
148
  return [
95
149
  { label: "Engine", path: engineDir },
@@ -101,11 +155,11 @@ function buildDiskComponents(cache: string, engineDir: string): Array<{ label: s
101
155
  ];
102
156
  }
103
157
 
104
- function logDiskRows(rows: Array<{ label: string; size: number }>, total: number, componentTotal: number): void {
158
+ function logDiskRows(rows: StatusDiskComponent[], total: number, componentTotal: number): void {
105
159
  const labelWidth = Math.max(...rows.map((r) => r.label.length), "Total".length);
106
160
  for (const r of rows) {
107
161
  const pad = " ".repeat(labelWidth - r.label.length + 2);
108
- log.info(` ${r.label}:${pad}${humanBytes(r.size)}`);
162
+ log.info(` ${r.label}:${pad}${humanBytes(r.sizeBytes)}`);
109
163
  }
110
164
  const totalPad = " ".repeat(labelWidth - "Total".length + 2);
111
165
  log.info(` ${pc.bold("Total")}:${totalPad}${pc.bold(humanBytes(total))}`);
@@ -115,42 +169,51 @@ function logDiskRows(rows: Array<{ label: string; size: number }>, total: number
115
169
  }
116
170
  }
117
171
 
118
- function logFluidKokoroCache(): void {
119
- const fluidKokoro = fluidKokoroCacheInfo();
120
- if (!fluidKokoro.exists || fluidKokoro.sizeBytes <= 0) return;
172
+ function logFluidKokoroCache(fluidKokoro: StatusDiskUsage["fluidKokoro"]): void {
173
+ if (!fluidKokoro) return;
121
174
  log.info("");
122
175
  log.info(`External caches (not included in Kesha total):`);
123
176
  log.info(` FluidAudio Kokoro: ${humanBytes(fluidKokoro.sizeBytes)} (${fluidKokoro.path})`);
124
177
  }
125
178
 
126
- function showDiskUsage(binPath: string): void {
179
+ function collectDiskUsage(binPath: string): StatusDiskUsage {
127
180
  const cache = keshaCacheDir();
128
181
  // Two levels up from the binary (`<cache>/engine/bin/`) so future engine-root siblings are counted.
129
182
  const engineDir = join(binPath, "..", "..");
130
183
 
131
- const components = buildDiskComponents(cache, engineDir);
132
-
133
- const rows: Array<{ label: string; size: number }> = [];
134
- for (const c of components) {
135
- const size = dirSizeBytes(c.path);
136
- if (size > 0) rows.push({ label: c.label, size });
184
+ const components: StatusDiskComponent[] = [];
185
+ for (const c of buildDiskComponents(cache, engineDir)) {
186
+ const sizeBytes = dirSizeBytes(c.path);
187
+ if (sizeBytes > 0) components.push({ label: c.label, sizeBytes });
137
188
  }
138
189
 
139
- if (rows.length === 0) return;
140
-
141
190
  // Sum cache root + engine dir separately so `KESHA_ENGINE_BIN` overrides outside the cache are still counted.
142
- const componentTotal = rows.reduce((n, r) => n + r.size, 0);
143
191
  const cacheTotal = dirSizeBytes(cache);
144
- const engineOutsideCache = engineDir.startsWith(cache)
145
- ? 0
146
- : dirSizeBytes(engineDir);
147
- const total = cacheTotal + engineOutsideCache;
148
-
149
- log.info(`Disk usage (${cache}):`);
150
- logDiskRows(rows, total, componentTotal);
151
- logFluidKokoroCache();
192
+ const engineOutsideCache = engineDir.startsWith(cache) ? 0 : dirSizeBytes(engineDir);
193
+ const fluidKokoro = fluidKokoroCacheInfo();
194
+
195
+ return {
196
+ cachePath: cache,
197
+ components,
198
+ componentTotalBytes: components.reduce((n, c) => n + c.sizeBytes, 0),
199
+ totalBytes: cacheTotal + engineOutsideCache,
200
+ fluidKokoro:
201
+ fluidKokoro.exists && fluidKokoro.sizeBytes > 0
202
+ ? { path: fluidKokoro.path, sizeBytes: fluidKokoro.sizeBytes }
203
+ : null,
204
+ };
205
+ }
206
+
207
+ function showDiskUsage(disk: StatusDiskUsage): void {
208
+ if (disk.components.length === 0) return;
209
+
210
+ log.info(`Disk usage (${disk.cachePath}):`);
211
+ logDiskRows(disk.components, disk.totalBytes, disk.componentTotalBytes);
212
+ logFluidKokoroCache(disk.fluidKokoro);
152
213
  log.info("");
153
- log.info(pc.dim(` To reset cache: rm -rf ${cache} — next \`kesha install\` re-downloads.`));
214
+ log.info(
215
+ pc.dim(` To reset cache: rm -rf ${disk.cachePath} — next \`kesha install\` re-downloads.`),
216
+ );
154
217
  log.info("");
155
218
  }
156
219