@drakulavich/kesha-voice-kit 1.25.0 → 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:
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.25.0",
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": {
@@ -57,7 +57,8 @@
57
57
  "check:workflows": "bun .github/scripts/check-workflows.ts",
58
58
  "check:specs": "bunx @fission-ai/openspec@1.4.1 validate --specs --strict",
59
59
  "check:release-manifest": "bun .github/scripts/release-manifest.mjs --check",
60
- "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"
61
62
  },
62
63
  "keywords": [
63
64
  "asr",
@@ -102,6 +103,7 @@
102
103
  },
103
104
  "overrides": {
104
105
  "hono": "^4.12.25",
105
- "fast-uri": "^3.1.3"
106
+ "fast-uri": "^3.1.5",
107
+ "ip-address": "^10.3.1"
106
108
  }
107
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
@@ -26,8 +26,7 @@ export const statusCommand = defineCommand({
26
26
  async run({ args }: { args: StatusCommandArgs }) {
27
27
  const report = await collectStatus({ disk: args.disk });
28
28
  if (args.json) {
29
- // `log.*` writes to stdout, so the payload must be the only thing printed —
30
- // the setup hint ships inside it instead of on stderr (#647).
29
+ // `log.*` writes to stdout, so the payload must be the only thing printed (#647).
31
30
  console.log(JSON.stringify(report, null, 2));
32
31
  return;
33
32
  }
@@ -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
@@ -373,9 +373,7 @@ export async function getEngineCapabilities(): Promise<EngineCapabilities | null
373
373
  }
374
374
  }
375
375
 
376
- // A non-null return has to mean "the engine described itself" — callers reach
377
- // straight for `.features.join()` and `.backend` (#647). Only the fields those
378
- // callers consume are checked, so a newer engine adding fields still validates.
376
+ // A non-null return must mean "it described itself" — callers reach straight for `.features.join()` (#647).
379
377
  function parseCapabilities(parsed: unknown): EngineCapabilities | null {
380
378
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
381
379
  const { protocolVersion, backend, features } = parsed as Record<string, unknown>;
@@ -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";
@@ -105,16 +106,8 @@ function sumFiles(files: PlanFile[]): number {
105
106
  }
106
107
 
107
108
  function engineAssetForPlatform(): ReleaseAssetSpec | null {
108
- if (process.platform === "darwin" && process.arch === "arm64") {
109
- return { assetName: "kesha-engine-darwin-arm64", sizeBytes: 59_621_264 };
110
- }
111
- if (process.platform === "linux" && process.arch === "x64") {
112
- return { assetName: "kesha-engine-linux-x64", sizeBytes: 62_897_808 };
113
- }
114
- if (process.platform === "win32" && process.arch === "x64") {
115
- return { assetName: "kesha-engine-windows-x64.exe", sizeBytes: 63_126_528 };
116
- }
117
- return null;
109
+ const target = engineTarget();
110
+ return target && { assetName: target.assetName, sizeBytes: target.sizeBytes };
118
111
  }
119
112
 
120
113
  function filesCached(cacheRoot: string, files: PlanFile[]): boolean {
@@ -153,10 +146,6 @@ function buildEngineComponent(binPath: string, noCache: boolean): PlanComponent
153
146
  sizeBytes: engineAsset.sizeBytes,
154
147
  cached: engineCached,
155
148
  refresh: noCache,
156
- note:
157
- process.platform === "win32"
158
- ? "Windows x64 is currently blocked by the install path; see issue #216."
159
- : undefined,
160
149
  };
161
150
  }
162
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();