@drakulavich/kesha-voice-kit 1.31.0 → 2.1.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 +4 -4
- package/SKILL.md +1 -1
- package/docs/product-positioning.md +3 -3
- package/model-plan.json +14 -14
- package/package.json +21 -7
- package/src/cache-layout.ts +78 -8
- package/src/cli/init.ts +10 -11
- package/src/cli/install.ts +7 -5
- package/src/cli/main.ts +2 -14
- package/src/cli/support-bundle.ts +2 -1
- package/src/doctor.ts +3 -9
- package/src/engine/events.ts +6 -1
- package/src/engine/spawn.ts +114 -0
- package/src/engine-health.ts +25 -56
- package/src/engine-install.ts +333 -142
- package/src/engine-targets.ts +53 -5
- package/src/engine.ts +79 -121
- package/src/install-plan.ts +20 -41
- package/src/kokoro-ane.ts +1 -1
- package/src/language-routing.ts +14 -0
- package/src/lib.ts +95 -66
- package/src/mcp/tools.ts +5 -11
- package/src/package-info.ts +28 -2
- package/src/path-errnos.ts +11 -0
- package/src/process-tree.ts +13 -11
- package/src/progress.ts +30 -13
- package/src/semver.mjs +7 -10
- package/src/status.ts +5 -16
- package/src/support-bundle.ts +72 -8
- package/src/synth.ts +38 -86
- package/src/transcribe.ts +9 -4
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
- **Small Rust engine** — single ~65MB binary, no ffmpeg, no Python, no native Node addons
|
|
20
20
|
|
|
21
21
|
<p align="center">
|
|
22
|
-
<img src="https://github.com/drakulavich/kesha-voice-kit/raw/main/demo.
|
|
22
|
+
<img src="https://github.com/drakulavich/kesha-voice-kit/raw/main/docs/assets/demo.webp" alt="kesha demo: an English voice message transcribed, a Russian one as JSON with automatic language detection, then text-to-speech into a wav" width="800">
|
|
23
23
|
</p>
|
|
24
24
|
|
|
25
25
|
## Quick Start
|
|
@@ -138,10 +138,10 @@ Full per-file breakdown (Russian + English): [BENCHMARK.md](BENCHMARK.md). The C
|
|
|
138
138
|
|
|
139
139
|
All of these install the Bun CLI wrapper; engine + models still download explicitly via `kesha install`. (Nix is the exception — it currently builds only the engine from source; see below.)
|
|
140
140
|
|
|
141
|
-
- **Homebrew** — `brew install drakulavich/tap/kesha-voice-kit` · [docs/
|
|
142
|
-
- **Linux packages** (`.deb`/`.rpm`, x64) — published on
|
|
141
|
+
- **Homebrew** — `brew install drakulavich/tap/kesha-voice-kit` · [docs/distribution.md](docs/distribution.md#homebrew)
|
|
142
|
+
- **Linux packages** (`.deb`/`.rpm`, x64) — published on stable releases, see [docs/distribution.md](docs/distribution.md#linux-packages)
|
|
143
143
|
- **Docker** (GHCR image) — [docs/docker.md](docs/docker.md)
|
|
144
|
-
- **Nix** (`aarch64-darwin` / `x86_64-linux`) — builds the engine from source (`nix build github:drakulavich/kesha-voice-kit#kesha-engine`). The full `kesha` CLI via `nix run` / `nix profile install` is **not yet available** — it needs a maintainer with Nix to populate a build hash ([#946](https://github.com/drakulavich/kesha-voice-kit/issues/946)). · [docs/
|
|
144
|
+
- **Nix** (`aarch64-darwin` / `x86_64-linux`) — builds the engine from source (`nix build github:drakulavich/kesha-voice-kit#kesha-engine`). The full `kesha` CLI via `nix run` / `nix profile install` is **not yet available** — it needs a maintainer with Nix to populate a build hash ([#946](https://github.com/drakulavich/kesha-voice-kit/issues/946)). · [docs/distribution.md](docs/distribution.md#nix)
|
|
145
145
|
- **Shell completions + manpage** — `kesha completions bash|zsh|fish` and `kesha manpage` print the packaged files to install wherever your shell expects them.
|
|
146
146
|
|
|
147
147
|
## Integrations
|
package/SKILL.md
CHANGED
|
@@ -236,7 +236,7 @@ No system deps — English G2P is embedded (`misaki-rs`); Russian G2P is bundled
|
|
|
236
236
|
|
|
237
237
|
**Speech-to-text (25):** Bulgarian, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hungarian, Italian, Latvian, Lithuanian, Maltese, Polish, Portuguese, Romanian, Russian, Slovak, Slovenian, Spanish, Swedish, Ukrainian.
|
|
238
238
|
|
|
239
|
-
**Text-to-speech (9):** English (Kokoro-82M; FluidAudio CoreML on darwin-arm64, ONNX elsewhere), Russian (Vosk-TTS, 5 baked-in speakers — default `ru-vosk-m02`), Spanish, French, Italian, Portuguese (Kokoro — CharsiuG2P on ONNX builds, FluidAudio G2P on darwin-arm64), plus Hindi, Japanese, Chinese (Kokoro, darwin-arm64 only). `hi`/`ja`
|
|
239
|
+
**Text-to-speech (9):** English (Kokoro-82M; FluidAudio CoreML on darwin-arm64, ONNX elsewhere), Russian (Vosk-TTS, 5 baked-in speakers — default `ru-vosk-m02`), Spanish, French, Italian, Portuguese (Kokoro — CharsiuG2P on ONNX builds, FluidAudio G2P on darwin-arm64), plus Hindi, Japanese, Chinese (Kokoro, darwin-arm64 only). The `hi`/`ja` Kokoro voices read romanized Latin input only; on macOS, text that is mostly Devanagari or kana/kanji with no `--voice` routes to an installed AVSpeech voice (Lekha for `hi-IN`, Otoya for `ja-JP`), while an explicit `--voice hi-*`/`ja-*` refuses them with `E_SCRIPT_UNSUPPORTED`; non-macOS builds have no `hi`/`ja` voices, so the text is refused by the default English voice (`E_SCRIPT_UNSUPPORTED`) and `--voice hi-*`/`ja-*` is `E_VOICE_UNKNOWN` — see [#492](https://github.com/drakulavich/kesha-voice-kit/issues/492). Any macOS system voice is also available via `--voice macos-*`.
|
|
240
240
|
|
|
241
241
|
## Performance
|
|
242
242
|
|
|
@@ -27,7 +27,7 @@ The product promise is not "one model for every audio problem." The promise is a
|
|
|
27
27
|
| Integrate with OpenClaw as a local voice model | Stable integration surface, user-configured route | `docs/openclaw.md` |
|
|
28
28
|
| Integrate with Hermes Agent as local STT/TTS commands | Stable CLI surface, user-configured route | `docs/hermes.md` |
|
|
29
29
|
| Use Raycast actions for selected-file transcription or clipboard speech | Beta, macOS only | `raycast/` extension |
|
|
30
|
-
| Install through Nix instead of Bun/npm | Beta, selected systems | `docs/
|
|
30
|
+
| Install through Nix instead of Bun/npm | Beta, selected systems | `docs/distribution.md#nix` |
|
|
31
31
|
|
|
32
32
|
## Non-goals
|
|
33
33
|
|
|
@@ -63,8 +63,8 @@ The product promise is not "one model for every audio problem." The promise is a
|
|
|
63
63
|
| STT | Supported, CoreML path | Not shipped | Supported, ONNX CPU path | Supported, ONNX CPU path | `aarch64-darwin`, `x86_64-linux` |
|
|
64
64
|
| Audio language detection | Supported | Not shipped | Supported | Supported | Supported where the flake builds |
|
|
65
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: Kokoro (`en` `es` `fr` `it` `pt`) | Supported, FluidAudio/CoreML in release builds | Not shipped | Supported, ONNX path | Supported, ONNX path | Supported except where noted in `docs/
|
|
67
|
-
| TTS: Russian Vosk-TTS | Supported | Not shipped | Supported | Supported | Supported except where noted in `docs/
|
|
66
|
+
| TTS: Kokoro (`en` `es` `fr` `it` `pt`) | Supported, FluidAudio/CoreML in release builds | Not shipped | Supported, ONNX path | Supported, ONNX path | Supported except where noted in `docs/distribution.md#nix` |
|
|
67
|
+
| TTS: Russian Vosk-TTS | Supported | Not shipped | Supported | Supported | Supported except where noted in `docs/distribution.md#nix` |
|
|
68
68
|
| TTS: Kokoro (`hi` `ja` `zh`) | Supported, darwin-arm64 only | Not shipped | Not supported | Not supported | `aarch64-darwin` only |
|
|
69
69
|
| macOS system voices | Supported | Not shipped | Not applicable | Not applicable | Supported on `aarch64-darwin` |
|
|
70
70
|
| Speaker diarization | Preview, darwin-arm64 only | Not supported | Not supported | Not supported | Not wired into the Nix build yet |
|
package/model-plan.json
CHANGED
|
@@ -62,14 +62,14 @@
|
|
|
62
62
|
{ "relPath": "KokoroNoise_v2.mlmodelc/weights/weight.bin", "sizeBytes": 4580160 },
|
|
63
63
|
{ "relPath": "KokoroVocoder.mlmodelc/analytics/coremldata.bin", "sizeBytes": 243 },
|
|
64
64
|
{ "relPath": "KokoroVocoder.mlmodelc/coremldata.bin", "sizeBytes": 626 },
|
|
65
|
-
{ "relPath": "KokoroVocoder.mlmodelc/metadata.json", "sizeBytes":
|
|
66
|
-
{ "relPath": "KokoroVocoder.mlmodelc/model.mil", "sizeBytes":
|
|
65
|
+
{ "relPath": "KokoroVocoder.mlmodelc/metadata.json", "sizeBytes": 4331 },
|
|
66
|
+
{ "relPath": "KokoroVocoder.mlmodelc/model.mil", "sizeBytes": 309000 },
|
|
67
67
|
{ "relPath": "KokoroVocoder.mlmodelc/weights/weight.bin", "sizeBytes": 48889920 },
|
|
68
|
-
{ "relPath": "
|
|
69
|
-
{ "relPath": "
|
|
70
|
-
{ "relPath": "
|
|
71
|
-
{ "relPath": "
|
|
72
|
-
{ "relPath": "
|
|
68
|
+
{ "relPath": "KokoroTail_v2.mlmodelc/analytics/coremldata.bin", "sizeBytes": 243 },
|
|
69
|
+
{ "relPath": "KokoroTail_v2.mlmodelc/coremldata.bin", "sizeBytes": 392 },
|
|
70
|
+
{ "relPath": "KokoroTail_v2.mlmodelc/metadata.json", "sizeBytes": 1872 },
|
|
71
|
+
{ "relPath": "KokoroTail_v2.mlmodelc/model.mil", "sizeBytes": 7014 },
|
|
72
|
+
{ "relPath": "KokoroTail_v2.mlmodelc/weights/weight.bin", "sizeBytes": 81088 },
|
|
73
73
|
{ "relPath": "vocab.json", "sizeBytes": 1416 },
|
|
74
74
|
{ "relPath": "af_heart.bin", "sizeBytes": 522240 }
|
|
75
75
|
],
|
|
@@ -143,14 +143,14 @@
|
|
|
143
143
|
{ "relPath": "KokoroNoise_v2.mlmodelc/weights/weight.bin", "sizeBytes": 4580160 },
|
|
144
144
|
{ "relPath": "KokoroVocoder.mlmodelc/analytics/coremldata.bin", "sizeBytes": 243 },
|
|
145
145
|
{ "relPath": "KokoroVocoder.mlmodelc/coremldata.bin", "sizeBytes": 627 },
|
|
146
|
-
{ "relPath": "KokoroVocoder.mlmodelc/metadata.json", "sizeBytes":
|
|
147
|
-
{ "relPath": "KokoroVocoder.mlmodelc/model.mil", "sizeBytes":
|
|
146
|
+
{ "relPath": "KokoroVocoder.mlmodelc/metadata.json", "sizeBytes": 4331 },
|
|
147
|
+
{ "relPath": "KokoroVocoder.mlmodelc/model.mil", "sizeBytes": 308997 },
|
|
148
148
|
{ "relPath": "KokoroVocoder.mlmodelc/weights/weight.bin", "sizeBytes": 48889920 },
|
|
149
|
-
{ "relPath": "
|
|
150
|
-
{ "relPath": "
|
|
151
|
-
{ "relPath": "
|
|
152
|
-
{ "relPath": "
|
|
153
|
-
{ "relPath": "
|
|
149
|
+
{ "relPath": "KokoroTail_v2.mlmodelc/analytics/coremldata.bin", "sizeBytes": 243 },
|
|
150
|
+
{ "relPath": "KokoroTail_v2.mlmodelc/coremldata.bin", "sizeBytes": 392 },
|
|
151
|
+
{ "relPath": "KokoroTail_v2.mlmodelc/metadata.json", "sizeBytes": 1872 },
|
|
152
|
+
{ "relPath": "KokoroTail_v2.mlmodelc/model.mil", "sizeBytes": 7014 },
|
|
153
|
+
{ "relPath": "KokoroTail_v2.mlmodelc/weights/weight.bin", "sizeBytes": 81088 },
|
|
154
154
|
{ "relPath": "vocab.json", "sizeBytes": 1796 },
|
|
155
155
|
{ "relPath": "voices/zf_001.bin", "sizeBytes": 522240 },
|
|
156
156
|
{ "relPath": "voices/zm_050.bin", "sizeBytes": 522240 },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.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": {
|
|
@@ -39,9 +39,6 @@
|
|
|
39
39
|
"openclawVersion": "2026.5.27"
|
|
40
40
|
}
|
|
41
41
|
},
|
|
42
|
-
"keshaEngine": {
|
|
43
|
-
"version": "1.26.0"
|
|
44
|
-
},
|
|
45
42
|
"scripts": {
|
|
46
43
|
"test": "bun test --timeout 30000",
|
|
47
44
|
"test:watch": "bun test --timeout 30000 --watch",
|
|
@@ -61,7 +58,7 @@
|
|
|
61
58
|
"check:release-manifest": "bun .github/scripts/release-manifest.mjs --check",
|
|
62
59
|
"check:versions": "bun .github/scripts/check-versions.ts",
|
|
63
60
|
"check:engine-targets": "bun .github/scripts/check-engine-targets.ts",
|
|
64
|
-
"
|
|
61
|
+
"check:file-sizes": "bun .github/scripts/check-file-sizes.ts"
|
|
65
62
|
},
|
|
66
63
|
"keywords": [
|
|
67
64
|
"asr",
|
|
@@ -89,8 +86,6 @@
|
|
|
89
86
|
"bun": ">=1.3.0"
|
|
90
87
|
},
|
|
91
88
|
"devDependencies": {
|
|
92
|
-
"@hughescr/stryker-bun-runner": "^1.3.8",
|
|
93
|
-
"@stryker-mutator/core": "^9.6.1",
|
|
94
89
|
"@types/bun": "latest",
|
|
95
90
|
"fast-xml-parser": "^5.8.0",
|
|
96
91
|
"typescript": "^7.0.2",
|
|
@@ -111,5 +106,24 @@
|
|
|
111
106
|
"fast-uri": "^3.1.5",
|
|
112
107
|
"ip-address": "^10.3.1",
|
|
113
108
|
"smol-toml": "1.7.2"
|
|
109
|
+
},
|
|
110
|
+
"kesha": {
|
|
111
|
+
"engine": {
|
|
112
|
+
"version": "2.1.0",
|
|
113
|
+
"sha256": {
|
|
114
|
+
"kesha-engine-darwin-arm64": "51446285f272a3d5060aa2dd21d2d16a5c31682a8554f29c944fe1af27677008",
|
|
115
|
+
"kesha-engine-linux-x64": "8728b467ca31640739dcbe18e53eadd27e795033075f6442fc14b729e0014a62",
|
|
116
|
+
"kesha-engine-windows-x64.exe": "82515821a6b424ff9c75462721f7723891705a2f1edcba0a1eb5c1704cfadbec",
|
|
117
|
+
"say-avspeech-darwin-arm64": "a3aa5b75ddff68a48f1158e838310b37cac72ef86841d9cfcdf1ee233cb7451a",
|
|
118
|
+
"kesha-textlang-darwin-arm64": "e45e2822248b14dd72aa79e2def8fa6b9c07c97423f11c841508769225d72da4"
|
|
119
|
+
},
|
|
120
|
+
"size": {
|
|
121
|
+
"kesha-engine-darwin-arm64": 73639664,
|
|
122
|
+
"kesha-engine-linux-x64": 67394344,
|
|
123
|
+
"kesha-engine-windows-x64.exe": 66586112,
|
|
124
|
+
"say-avspeech-darwin-arm64": 64208,
|
|
125
|
+
"kesha-textlang-darwin-arm64": 57648
|
|
126
|
+
}
|
|
127
|
+
}
|
|
114
128
|
}
|
|
115
129
|
}
|
package/src/cache-layout.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, realpathSync, statSync } from "fs";
|
|
2
|
+
import { dirname, join, resolve, sep } from "path";
|
|
3
|
+
import { dirSizeBytes } from "./diagnostic-paths";
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
/** The sidecar executables the engine spawns from its own directory. */
|
|
6
|
+
const SIDECAR_FILES = ["say-avspeech", "kesha-textlang"] as const;
|
|
7
|
+
|
|
8
|
+
export interface CacheComponentSize {
|
|
4
9
|
label: string;
|
|
5
10
|
path: string;
|
|
11
|
+
exists: boolean;
|
|
12
|
+
sizeBytes: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface EngineFootprint {
|
|
16
|
+
path: string;
|
|
17
|
+
members: string[];
|
|
18
|
+
sizeOf: (path: string) => number;
|
|
6
19
|
}
|
|
7
20
|
|
|
8
21
|
/**
|
|
@@ -18,6 +31,56 @@ export function isInsideDir(child: string, parent: string): boolean {
|
|
|
18
31
|
return resolvedChild === resolvedParent || resolvedChild.startsWith(`${resolvedParent}${sep}`);
|
|
19
32
|
}
|
|
20
33
|
|
|
34
|
+
/**
|
|
35
|
+
* What the Engine row owns (#1313). A managed install owns all of `<cache>/engine`. An
|
|
36
|
+
* overridden binary (`KESHA_ENGINE_BIN`, a Nix store path) shares its directory with software
|
|
37
|
+
* Kesha does not own, so only the binary and the sidecars beside it are counted — its
|
|
38
|
+
* grandparent may be `/usr/local`.
|
|
39
|
+
*/
|
|
40
|
+
function engineFootprint(binPath: string, cacheRoot: string): EngineFootprint {
|
|
41
|
+
const managedRoot = join(cacheRoot, "engine");
|
|
42
|
+
if (resolve(dirname(binPath)) === resolve(managedRoot, "bin")) {
|
|
43
|
+
return { path: managedRoot, members: [managedRoot], sizeOf: dirSizeBytes };
|
|
44
|
+
}
|
|
45
|
+
const binDir = dirname(binPath);
|
|
46
|
+
return {
|
|
47
|
+
path: binPath,
|
|
48
|
+
members: [binPath, ...SIDECAR_FILES.map((f) => join(binDir, f))],
|
|
49
|
+
sizeOf: regularFileBytes,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A sidecar name that is (or links to) a directory is not ours to walk. */
|
|
54
|
+
function regularFileBytes(path: string): number {
|
|
55
|
+
try {
|
|
56
|
+
const st = statSync(path);
|
|
57
|
+
return st.isFile() ? st.size : 0;
|
|
58
|
+
} catch {
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function footprintBytes(engine: EngineFootprint, members: string[]): number {
|
|
64
|
+
return members.reduce((n, p) => n + engine.sizeOf(p), 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The cache plus whatever engine bytes live outside it, each counted once (#790). */
|
|
68
|
+
export function cacheTotalBytes(cacheRoot: string, binPath: string): number {
|
|
69
|
+
const engine = engineFootprint(binPath, cacheRoot);
|
|
70
|
+
const realCache = existingPath(cacheRoot);
|
|
71
|
+
const outside = engine.members.filter((m) => !isInsideDir(existingPath(m), realCache));
|
|
72
|
+
return dirSizeBytes(cacheRoot) + footprintBytes(engine, outside);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Compared by target, so an outside symlink into the cache is not counted twice. */
|
|
76
|
+
function existingPath(path: string): string {
|
|
77
|
+
try {
|
|
78
|
+
return realpathSync(path);
|
|
79
|
+
} catch {
|
|
80
|
+
return resolve(path);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
21
84
|
/**
|
|
22
85
|
* The Kesha-managed directories both `kesha status --disk` and `kesha doctor` report, in one
|
|
23
86
|
* place so a new model dir cannot be added to only one of them. The two ASR rows are mutually
|
|
@@ -26,13 +89,19 @@ export function isInsideDir(child: string, parent: string): boolean {
|
|
|
26
89
|
* Whatever stayed in FluidAudio's own trees is outside this cache and is reported separately
|
|
27
90
|
* by `fluidExternalRoots`.
|
|
28
91
|
*/
|
|
29
|
-
export function
|
|
92
|
+
export function cacheComponents(
|
|
30
93
|
cacheRoot: string,
|
|
31
|
-
|
|
94
|
+
binPath: string,
|
|
32
95
|
coreml: boolean,
|
|
33
|
-
):
|
|
34
|
-
|
|
35
|
-
|
|
96
|
+
): CacheComponentSize[] {
|
|
97
|
+
const engine = engineFootprint(binPath, cacheRoot);
|
|
98
|
+
const engineRow = {
|
|
99
|
+
label: "Engine",
|
|
100
|
+
path: engine.path,
|
|
101
|
+
exists: existsSync(engine.path),
|
|
102
|
+
sizeBytes: footprintBytes(engine, engine.members),
|
|
103
|
+
};
|
|
104
|
+
const modelRows = [
|
|
36
105
|
...(coreml
|
|
37
106
|
? [{ label: "FluidAudio (in cache)", path: join(cacheRoot, "fluidaudio") }]
|
|
38
107
|
: [{ label: "ASR (Parakeet)", path: join(cacheRoot, "models/parakeet-tdt-v3") }]),
|
|
@@ -40,5 +109,6 @@ export function cacheComponentPaths(
|
|
|
40
109
|
{ label: "VAD (Silero)", path: join(cacheRoot, "models/silero-vad") },
|
|
41
110
|
{ label: "TTS (Kokoro)", path: join(cacheRoot, "models/kokoro-82m") },
|
|
42
111
|
{ label: "TTS (Vosk)", path: join(cacheRoot, "models/vosk-ru") },
|
|
43
|
-
];
|
|
112
|
+
].map((row) => ({ ...row, exists: existsSync(row.path), sizeBytes: dirSizeBytes(row.path) }));
|
|
113
|
+
return [engineRow, ...modelRows];
|
|
44
114
|
}
|
package/src/cli/init.ts
CHANGED
|
@@ -176,14 +176,7 @@ export async function promptInitSelection(
|
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
|
|
180
|
-
async function printPlan(selection: InitSelection): Promise<boolean> {
|
|
181
|
-
const backendError = unavailableBackendRefusal(selection.backend);
|
|
182
|
-
if (backendError) {
|
|
183
|
-
log.error(errorMessage(backendError));
|
|
184
|
-
process.exitCode = exitCodeFor(backendError);
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
179
|
+
async function printPlan(selection: InitSelection): Promise<void> {
|
|
187
180
|
log.info(
|
|
188
181
|
await renderInstallPlan({
|
|
189
182
|
noCache: selection.noCache,
|
|
@@ -193,7 +186,6 @@ async function printPlan(selection: InitSelection): Promise<boolean> {
|
|
|
193
186
|
diarize: selection.diarize,
|
|
194
187
|
}),
|
|
195
188
|
);
|
|
196
|
-
return true;
|
|
197
189
|
}
|
|
198
190
|
|
|
199
191
|
async function runNonInteractive(selection: InitSelection): Promise<void> {
|
|
@@ -203,7 +195,7 @@ async function runNonInteractive(selection: InitSelection): Promise<void> {
|
|
|
203
195
|
log.warn("--diarize is currently darwin-arm64 only; omitting it from non-interactive examples.");
|
|
204
196
|
}
|
|
205
197
|
log.info(renderInitOverview(canDiarize));
|
|
206
|
-
|
|
198
|
+
await printPlan(printableSelection);
|
|
207
199
|
log.info("Run one of these commands from an interactive terminal:");
|
|
208
200
|
for (const command of initSuggestionCommands(printableSelection, canDiarize)) {
|
|
209
201
|
log.info(` ${command.join(" ")}`);
|
|
@@ -262,6 +254,13 @@ export const initCommand = defineCommand({
|
|
|
262
254
|
const noCache = resolveNoCacheFlag(args, rawArgs);
|
|
263
255
|
const selection = resolveInitSelection(args, backend, noCache);
|
|
264
256
|
|
|
257
|
+
const backendError = args.yes && !args.plan ? null : unavailableBackendRefusal(backend);
|
|
258
|
+
if (backendError) {
|
|
259
|
+
log.error(errorMessage(backendError));
|
|
260
|
+
process.exitCode = exitCodeFor(backendError);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
265
264
|
if (args.plan) {
|
|
266
265
|
log.info(renderInitOverview());
|
|
267
266
|
await printPlan(selection);
|
|
@@ -293,7 +292,7 @@ export const initCommand = defineCommand({
|
|
|
293
292
|
noCache,
|
|
294
293
|
);
|
|
295
294
|
log.info("");
|
|
296
|
-
|
|
295
|
+
await printPlan(prompted);
|
|
297
296
|
const confirmed = await promptConfirm(
|
|
298
297
|
`Run \`${initInstallArgs(prompted).join(" ")}\` now?`,
|
|
299
298
|
true,
|
package/src/cli/install.ts
CHANGED
|
@@ -86,10 +86,12 @@ export function resolveEngineVersionFlag(raw: unknown): string | undefined {
|
|
|
86
86
|
const value = String(raw).trim();
|
|
87
87
|
if (isSemver(value)) return value;
|
|
88
88
|
const hint = /^v\d/.test(value)
|
|
89
|
-
? '
|
|
90
|
-
:
|
|
91
|
-
throw new
|
|
92
|
-
|
|
89
|
+
? 'drop the leading "v" — that belongs to the release tag, not the version.'
|
|
90
|
+
: undefined;
|
|
91
|
+
throw new KeshaError(
|
|
92
|
+
"E_INVALID_ARG",
|
|
93
|
+
`--engine-version needs an exact SemVer 2.0 version like 1.24.8 or 1.24.8-alpha.1, got "${value}".`,
|
|
94
|
+
hint === undefined ? {} : { hint },
|
|
93
95
|
);
|
|
94
96
|
}
|
|
95
97
|
|
|
@@ -244,7 +246,7 @@ export async function performInstall(options: PerformInstallOptions) {
|
|
|
244
246
|
throw backendError;
|
|
245
247
|
}
|
|
246
248
|
await installEngine({ noCache, backend, ttsLangs, vad, diarize, version: engineVersion });
|
|
247
|
-
await maybeAskForStar(getEngineBinPath(), packageVersion, log);
|
|
249
|
+
if (!log.quietEnabled) await maybeAskForStar(getEngineBinPath(), packageVersion, { info: log.progress, warn: log.warn });
|
|
248
250
|
finishInstallDiagnostic(diagnosticLog, startedAt, "success");
|
|
249
251
|
} catch (err: unknown) {
|
|
250
252
|
const signalExitCode = getPendingSignalExitCode();
|
package/src/cli/main.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
|
-
import { detectAll } from "tinyld";
|
|
5
4
|
import { isDirectoryPath, transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
|
|
6
5
|
|
|
7
6
|
export { isDirectoryPath };
|
|
@@ -26,7 +25,7 @@ import { runCommandSession, type CommandSession } from "./command-session";
|
|
|
26
25
|
import { USAGE_MESSAGE } from "./dispatch";
|
|
27
26
|
import type { CliContext } from "./context";
|
|
28
27
|
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
29
|
-
import { routeLanguage } from "../language-routing";
|
|
28
|
+
import { detectTextLanguageFallback, routeLanguage } from "../language-routing";
|
|
30
29
|
import { renderInvalidArg } from "./options";
|
|
31
30
|
|
|
32
31
|
interface MainCommandArgs {
|
|
@@ -47,18 +46,7 @@ interface MainCommandArgs {
|
|
|
47
46
|
"no-color": boolean;
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
* The CLI-side `tinyld` fallback, tagged `source: "tinyld"` so a consumer can
|
|
52
|
-
* tell it apart from an Engine detection instead of reading the placeholder
|
|
53
|
-
* `confidence: 0` it used to carry as a genuine zero (#941). The score is
|
|
54
|
-
* tinyld's own and is not on the Engine's scale.
|
|
55
|
-
*/
|
|
56
|
-
export function detectTextLanguageFallback(text: string): TextLangDetectResult | undefined {
|
|
57
|
-
if (!text) return undefined;
|
|
58
|
-
const [best] = detectAll(text);
|
|
59
|
-
if (!best) return undefined;
|
|
60
|
-
return { code: best.lang, confidence: best.accuracy, source: "tinyld" };
|
|
61
|
-
}
|
|
49
|
+
export { detectTextLanguageFallback };
|
|
62
50
|
|
|
63
51
|
export function detectLanguage(text: string): string {
|
|
64
52
|
return detectTextLanguageFallback(text)?.code ?? "";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
|
+
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
3
4
|
import { createSupportBundle } from "../support-bundle";
|
|
4
5
|
import { log } from "../log";
|
|
5
6
|
|
|
@@ -35,7 +36,7 @@ export const supportBundleCommand = defineCommand({
|
|
|
35
36
|
log.status(`Size: ${bundle.sizeBytes} bytes`);
|
|
36
37
|
} catch (err) {
|
|
37
38
|
log.error(errorMessage(err));
|
|
38
|
-
process.exit(1);
|
|
39
|
+
process.exit(err instanceof KeshaError ? exitCodeFor(err) : 1);
|
|
39
40
|
}
|
|
40
41
|
},
|
|
41
42
|
});
|
package/src/doctor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
-
import {
|
|
2
|
+
import { cacheComponents, cacheTotalBytes } from "./cache-layout";
|
|
3
3
|
import { errorMessage } from "./error-utils";
|
|
4
4
|
import { humanBytes } from "./format";
|
|
5
5
|
import { dirname, join } from "path";
|
|
@@ -270,14 +270,8 @@ function collectCache(
|
|
|
270
270
|
): DoctorReport["cache"] {
|
|
271
271
|
const cache = keshaCacheDir();
|
|
272
272
|
const binPath = getEngineBinPath();
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
cache,
|
|
276
|
-
engineDir,
|
|
277
|
-
isCoremlBackend(backend),
|
|
278
|
-
).map((component) => ({ label: component.label, ...pathSummary(component.path) }));
|
|
279
|
-
const engineOutsideCache = isInsideDir(engineDir, cache) ? 0 : dirSizeBytes(engineDir);
|
|
280
|
-
const totalBytes = dirSizeBytes(cache) + engineOutsideCache;
|
|
273
|
+
const components: CacheComponent[] = cacheComponents(cache, binPath, isCoremlBackend(backend));
|
|
274
|
+
const totalBytes = cacheTotalBytes(cache, binPath);
|
|
281
275
|
const externalRoots = fluidExternalRoots({ homeDir, cacheRoot: cache });
|
|
282
276
|
const externalTotalBytes = fluidExternalTotalBytes(externalRoots);
|
|
283
277
|
|
package/src/engine/events.ts
CHANGED
|
@@ -233,6 +233,11 @@ function quoteInvalidLine(line: string): string {
|
|
|
233
233
|
return chars.length <= MAX_QUOTED_LINE_CHARS ? line : `${chars.slice(0, MAX_QUOTED_LINE_CHARS).join("")}…`;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/** One wording for an off-protocol line, whether it fails the run or only warns. */
|
|
237
|
+
export function offProtocolLine(command: string, line: string): string {
|
|
238
|
+
return `kesha-engine ${command} wrote a line that is not a protocol event: "${quoteInvalidLine(line)}"`;
|
|
239
|
+
}
|
|
240
|
+
|
|
236
241
|
/** The KeshaError for a run that wrote a non-event line, reported an error event, or exited non-zero in silence; `stderr` is the transcript unless the caller substitutes one. */
|
|
237
242
|
export function engineFailure(command: string, outcome: StderrOutcome, exitCode: number | undefined, stderr = outcome.stderr.trim()): KeshaError {
|
|
238
243
|
const extra = { exitCode, stderr, origin: "engine" as const };
|
|
@@ -240,7 +245,7 @@ export function engineFailure(command: string, outcome: StderrOutcome, exitCode:
|
|
|
240
245
|
const rest = stderr.split("\n").filter((line) => !outcome.invalid.includes(line)).join("\n").trim();
|
|
241
246
|
return new KeshaError(
|
|
242
247
|
"E_INTERNAL",
|
|
243
|
-
|
|
248
|
+
offProtocolLine(command, outcome.invalid[0]!),
|
|
244
249
|
{ ...extra, stderr: rest },
|
|
245
250
|
);
|
|
246
251
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { errorMessage } from "../error-utils";
|
|
2
|
+
import { log } from "../log";
|
|
3
|
+
import { abortOnSignal, engineAbortError, pendingInterruption, registerProcessTree } from "../process-tree";
|
|
4
|
+
import { resolveStatePaths } from "../state-paths";
|
|
5
|
+
import { PROTOCOL_VERSION } from "./describe";
|
|
6
|
+
import { KeshaError, readEvents, type EventSinks, type StderrOutcome } from "./events";
|
|
7
|
+
|
|
8
|
+
type SpawnStdioEntry = "inherit" | "pipe" | "ignore";
|
|
9
|
+
type SpawnStdio = [SpawnStdioEntry, SpawnStdioEntry, SpawnStdioEntry];
|
|
10
|
+
type EngineProcess = ReturnType<typeof Bun.spawn>;
|
|
11
|
+
|
|
12
|
+
/** The env for a spawn whose stderr is parsed as protocol 4 events. */
|
|
13
|
+
function protocolEnv(): Record<string, string | undefined> {
|
|
14
|
+
return { ...process.env, KESHA_PROTOCOL: String(PROTOCOL_VERSION) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function spawnHint(): string {
|
|
18
|
+
return process.env.KESHA_ENGINE_BIN
|
|
19
|
+
? "KESHA_ENGINE_BIN points at it; fix the path or unset it and run `kesha install`"
|
|
20
|
+
: "run `kesha install`";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The engine reads only `KESHA_CACHE_DIR`; it gets the root the CLI resolved whenever that differs from the raw value. */
|
|
24
|
+
function withResolvedCacheDir(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
|
25
|
+
const cache = resolveStatePaths(env).cacheDir;
|
|
26
|
+
const raw = env.KESHA_CACHE_DIR;
|
|
27
|
+
if (cache.source === "default") {
|
|
28
|
+
if (raw === undefined) return env;
|
|
29
|
+
const { KESHA_CACHE_DIR: _empty, ...rest } = env;
|
|
30
|
+
return rest;
|
|
31
|
+
}
|
|
32
|
+
return raw === cache.path ? env : { ...env, KESHA_CACHE_DIR: cache.path };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `Bun.spawn` throws synchronously on ENOENT/EACCES; every launch failure becomes `E_ENGINE_SPAWN`. */
|
|
36
|
+
function spawnEngineProcess(
|
|
37
|
+
binPath: string,
|
|
38
|
+
args: string[],
|
|
39
|
+
stdio: SpawnStdio,
|
|
40
|
+
env: Record<string, string | undefined> = process.env,
|
|
41
|
+
): EngineProcess {
|
|
42
|
+
const interrupted = pendingInterruption();
|
|
43
|
+
if (interrupted) throw interrupted;
|
|
44
|
+
try {
|
|
45
|
+
// `env` is passed explicitly: Bun snapshots process.env at startup otherwise (#874).
|
|
46
|
+
return Bun.spawn([binPath, ...args], { detached: true, stdio, env: withResolvedCacheDir(env) });
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new KeshaError("E_ENGINE_SPAWN", `failed to launch kesha-engine at ${binPath}: ${errorMessage(err)}`, {
|
|
49
|
+
hint: spawnHint(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** An engine that refuses the run before reading closes the pipe; that EPIPE is its answer, reported by the exit status and stderr. */
|
|
55
|
+
async function writeEngineStdin(proc: EngineProcess, text: string): Promise<void> {
|
|
56
|
+
const sink = proc.stdin as Bun.FileSink;
|
|
57
|
+
try {
|
|
58
|
+
sink.write(text);
|
|
59
|
+
await sink.end();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
log.debug(`stdin write failed: ${errorMessage(err)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface EngineProcessOptions<T> {
|
|
66
|
+
/** `[stdin, stdout, stderr]`. A piped stderr is read as protocol 4 events and the engine is told so through its env. */
|
|
67
|
+
stdio: SpawnStdio;
|
|
68
|
+
/** Written to a piped stdin, which is then closed. */
|
|
69
|
+
stdin?: string;
|
|
70
|
+
/** Consumes a piped stdout; `proc` lets a relay stop the engine once nobody reads it. */
|
|
71
|
+
readStdout?: (stream: ReadableStream<Uint8Array>, proc: EngineProcess) => Promise<T>;
|
|
72
|
+
sinks?: EventSinks;
|
|
73
|
+
/** Aborting terminates the process tree, SIGKILL after a grace; an already-aborted signal is `E_INTERRUPTED` before any spawn. */
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface EngineProcessRun<T> {
|
|
78
|
+
exitCode: number;
|
|
79
|
+
signalCode: string | null;
|
|
80
|
+
/** The signal fired while the engine ran: a caller's cancel, or a deadline it set with `AbortSignal.timeout`. */
|
|
81
|
+
aborted: boolean;
|
|
82
|
+
stdout: T;
|
|
83
|
+
events: StderrOutcome;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Spawns the engine (or a sidecar) as its own process tree and waits for it; the tree is unregistered however the run ends. */
|
|
87
|
+
export async function runEngineProcess<T = undefined>(
|
|
88
|
+
binPath: string,
|
|
89
|
+
args: string[],
|
|
90
|
+
opts: EngineProcessOptions<T>,
|
|
91
|
+
): Promise<EngineProcessRun<T>> {
|
|
92
|
+
if (opts.signal?.aborted) throw engineAbortError();
|
|
93
|
+
const startedAt = performance.now();
|
|
94
|
+
log.debug(`spawn ${binPath} ${args.join(" ")}`);
|
|
95
|
+
const eventsOnStderr = opts.stdio[2] === "pipe";
|
|
96
|
+
const proc = spawnEngineProcess(binPath, args, opts.stdio, eventsOnStderr ? protocolEnv() : process.env);
|
|
97
|
+
const tree = registerProcessTree(proc);
|
|
98
|
+
const cancel = abortOnSignal(tree, opts.signal);
|
|
99
|
+
try {
|
|
100
|
+
const [stdout, events, exitCode] = await Promise.all([
|
|
101
|
+
opts.readStdout?.(proc.stdout as ReadableStream<Uint8Array>, proc),
|
|
102
|
+
eventsOnStderr
|
|
103
|
+
? readEvents(proc.stderr as ReadableStream<Uint8Array>, opts.sinks)
|
|
104
|
+
: { stderr: "", error: null, invalid: [] },
|
|
105
|
+
proc.exited,
|
|
106
|
+
opts.stdin === undefined ? undefined : writeEngineStdin(proc, opts.stdin),
|
|
107
|
+
]);
|
|
108
|
+
log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms args=${JSON.stringify(args)}`);
|
|
109
|
+
return { exitCode, signalCode: proc.signalCode, aborted: cancel.aborted, stdout: stdout as T, events };
|
|
110
|
+
} finally {
|
|
111
|
+
cancel.dispose();
|
|
112
|
+
tree.dispose();
|
|
113
|
+
}
|
|
114
|
+
}
|