@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 +2 -1
- package/completions/kesha.bash +1 -1
- package/completions/kesha.fish +1 -0
- package/completions/kesha.zsh +2 -1
- package/docs/product-positioning.md +17 -15
- package/man/kesha.1 +3 -0
- package/model-plan.json +43 -0
- package/package.json +8 -5
- package/src/cli/init.ts +52 -53
- package/src/cli/install.ts +6 -4
- package/src/cli/status.ts +14 -2
- package/src/engine-install.ts +78 -26
- package/src/engine-targets.ts +48 -0
- package/src/engine.ts +12 -1
- package/src/install-plan.ts +28 -78
- package/src/paths.ts +4 -2
- package/src/progress.ts +24 -2
- package/src/status.ts +110 -47
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:
|
|
@@ -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:
|
package/completions/kesha.bash
CHANGED
|
@@ -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
|
package/completions/kesha.fish
CHANGED
|
@@ -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'
|
package/completions/kesha.zsh
CHANGED
|
@@ -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 |
|
|
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/man/kesha.1
CHANGED
package/model-plan.json
ADDED
|
@@ -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.
|
|
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": "^
|
|
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.
|
|
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 {
|
|
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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
-
import {
|
|
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
|
|
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
|
});
|
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
|
@@ -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)
|
|
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
|
+
}
|
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";
|
|
@@ -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
|
-
//
|
|
49
|
-
//
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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();
|
package/src/status.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { readdirSync, statSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
|
-
import {
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
60
|
-
const
|
|
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 ?
|
|
120
|
+
log.info(formatStatusLine("Binary", installed ? path : null, installed));
|
|
65
121
|
|
|
66
122
|
if (installed) {
|
|
67
|
-
|
|
123
|
+
logEngineCapabilities(capabilities);
|
|
68
124
|
}
|
|
69
125
|
log.info("");
|
|
70
126
|
|
|
71
|
-
log.info(formatStatusLine("Runtime", `Bun ${
|
|
72
|
-
log.info(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 (
|
|
88
|
-
log.warn(
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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(
|
|
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
|
|