@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 +1 -1
- package/docs/product-positioning.md +17 -15
- package/package.json +5 -3
- package/src/cli/init.ts +52 -53
- package/src/cli/install.ts +6 -4
- package/src/cli/status.ts +1 -2
- package/src/engine-install.ts +78 -26
- package/src/engine-targets.ts +48 -0
- package/src/engine.ts +1 -3
- package/src/install-plan.ts +3 -14
- package/src/paths.ts +4 -2
- package/src/progress.ts +24 -2
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
|
|
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 |
|
|
64
|
-
| Audio language detection | Supported | Not shipped | Supported |
|
|
65
|
-
| VAD | Supported with `kesha install --vad` | Not shipped | Supported with `kesha install --vad` |
|
|
66
|
-
| TTS: English Kokoro | Supported, FluidAudio/CoreML in release builds | Not shipped | Supported, ONNX path |
|
|
67
|
-
| TTS: Russian Vosk-TTS | Supported | Not shipped | Supported |
|
|
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 |
|
|
72
|
-
| Hermes Agent integration | Supported through command providers | Not shipped | Supported through command providers |
|
|
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`
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
install
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
});
|
package/src/cli/install.ts
CHANGED
|
@@ -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(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
}
|
package/src/engine-install.ts
CHANGED
|
@@ -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(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
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
|
-
*
|
|
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: "
|
|
493
|
-
stderr: "
|
|
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
|
-
|
|
503
|
-
|
|
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
|
|
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>;
|
package/src/install-plan.ts
CHANGED
|
@@ -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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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();
|