@drakulavich/kesha-voice-kit 1.27.0-alpha.1 → 1.27.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 +3 -3
- package/SKILL.md +20 -9
- package/completions/kesha.fish +2 -2
- package/completions/kesha.zsh +4 -4
- package/man/kesha.1 +2 -2
- package/package.json +2 -2
- package/src/cli/init.ts +12 -6
- package/src/cli/install.ts +40 -10
- package/src/cli/main.ts +12 -2
- package/src/cli/say.ts +3 -4
- package/src/doctor.ts +48 -19
- package/src/engine-health.ts +55 -0
- package/src/engine-install.ts +64 -40
- package/src/engine.ts +68 -16
- package/src/install-plan.ts +11 -2
- package/src/progress.ts +75 -8
- package/src/transcribe.ts +4 -0
- package/src/voice-routing.ts +8 -1
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ kesha install # ~2.5 GB on Linux/Windows; ~0.6 GB on Apple Silicon, whose
|
|
|
45
45
|
kesha audio.ogg # transcript to stdout
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
Prefer Homebrew,
|
|
48
|
+
Prefer Homebrew, Docker, or Nix? See [Other install methods](#other-install-methods).
|
|
49
49
|
Air-gapped or behind a corporate mirror? See [docs/model-mirror.md](docs/model-mirror.md).
|
|
50
50
|
|
|
51
51
|
## Speech-to-text
|
|
@@ -74,7 +74,7 @@ $ kesha freedom.ogg tahiti.ogg
|
|
|
74
74
|
|
|
75
75
|
- **Record from the mic (macOS):** `kesha record --out hello.wav` writes microphone audio to a WAV file (`kesha hello.wav` transcribes it). macOS prompts for microphone access on first use — grant it under System Settings → Privacy & Security → Microphone if it was denied. On Linux/Windows or headless boxes, pass any existing audio file straight to `kesha` instead.
|
|
76
76
|
- **Long / silence-heavy audio:** install VAD (`kesha install --vad`); Kesha auto-uses it past 120 s. Without VAD, long audio falls back to fixed ASR chunks. See [docs/vad.md](docs/vad.md).
|
|
77
|
-
- **Speaker diarization** (darwin-arm64): `kesha install --diarize
|
|
77
|
+
- **Speaker diarization** (darwin-arm64): `kesha install --diarize` (which installs VAD too), then `kesha --json --speakers meeting.m4a` stamps each segment with a `speaker` id. `--speakers` engages VAD windowing itself at any duration, so it cannot be combined with `--no-vad`. Linux/Windows return a clear "darwin-arm64 only" error ([#199](https://github.com/drakulavich/kesha-voice-kit/issues/199)).
|
|
78
78
|
|
|
79
79
|
## Text-to-speech
|
|
80
80
|
|
|
@@ -117,7 +117,7 @@ Full per-file breakdown (Russian + English): [BENCHMARK.md](BENCHMARK.md).
|
|
|
117
117
|
All of these install the Bun CLI wrapper; engine + models still download explicitly via `kesha install`.
|
|
118
118
|
|
|
119
119
|
- **Homebrew** — `brew install drakulavich/tap/kesha-voice-kit` · [docs/homebrew.md](docs/homebrew.md)
|
|
120
|
-
- **Linux packages** (`.deb`/`.rpm`, x64) — [docs/linux-packages.md](docs/linux-packages.md)
|
|
120
|
+
- **Linux packages** (`.deb`/`.rpm`, x64) — not currently published, see [docs/linux-packages.md](docs/linux-packages.md)
|
|
121
121
|
- **Docker** (GHCR image) — [docs/docker.md](docs/docker.md)
|
|
122
122
|
- **Nix** (`aarch64-darwin` / `x86_64-linux`) — `nix run github:drakulavich/kesha-voice-kit -- install` · [docs/nix-install.md](docs/nix-install.md)
|
|
123
123
|
- **Shell completions + manpage** — `kesha completions bash|zsh|fish` and `kesha manpage` print the packaged files to install wherever your shell expects them.
|
package/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kesha-voice-kit
|
|
3
|
-
description: Local multilingual voice toolkit — speech-to-text (STT), text-to-speech (TTS), and language detection. Runs entirely offline on Apple Silicon, Linux, and Windows. No API keys, no cloud. NVIDIA Parakeet TDT for STT across 25 European languages, Kokoro-82M + Vosk-TTS for TTS, plus macOS AVSpeechSynthesizer for ~180 system voices with zero install.
|
|
3
|
+
description: Local multilingual voice toolkit — speech-to-text (STT), text-to-speech (TTS), speaker diarization, and language detection, over a CLI or an MCP server. Runs entirely offline on Apple Silicon, Linux, and Windows. No API keys, no cloud. NVIDIA Parakeet TDT for STT across 25 European languages, Kokoro-82M + Vosk-TTS for TTS in 9 languages, plus macOS AVSpeechSynthesizer for ~180 system voices with zero install.
|
|
4
4
|
emoji: 🎙️
|
|
5
5
|
|
|
6
6
|
requires:
|
|
@@ -17,7 +17,7 @@ install:
|
|
|
17
17
|
|
|
18
18
|
Local voice toolkit: transcribe voice messages to text, synthesize speech, detect language of audio or text. Fully offline after `kesha install`. No API keys, no per-minute billing.
|
|
19
19
|
|
|
20
|
-
**Trigger keywords for when to use this skill:** voice message, voice memo, voice note, .ogg, .opus, .wav, .mp3, audio file, transcribe, transcription, speech-to-text, STT, text-to-speech, TTS, synthesize speech, say, telegram voice note, whatsapp voice note, ogg-opus, opus, multilingual voice, multilingual ASR, language detection, offline voice, privacy, Apple Silicon, CoreML.
|
|
20
|
+
**Trigger keywords for when to use this skill:** voice message, voice memo, voice note, .ogg, .opus, .wav, .mp3, audio file, transcribe, transcription, speech-to-text, STT, text-to-speech, TTS, synthesize speech, say, telegram voice note, whatsapp voice note, ogg-opus, opus, multilingual voice, multilingual ASR, language detection, speaker diarization, who said what, meeting transcript, MCP server, offline voice, privacy, Apple Silicon, CoreML.
|
|
21
21
|
|
|
22
22
|
## When to use
|
|
23
23
|
|
|
@@ -107,6 +107,16 @@ When invoking Kesha manually from an OpenClaw flow, write OGG/Opus into an OpenC
|
|
|
107
107
|
|
|
108
108
|
Do not configure OpenClaw Telegram TTS as `kesha say "<text>" > reply.wav`; that creates a WAV file and will not render as a native Telegram voice note.
|
|
109
109
|
|
|
110
|
+
## MCP server
|
|
111
|
+
|
|
112
|
+
`kesha mcp` serves the same capabilities over Model Context Protocol (stdio), exposing `transcribe_audio`, `synthesize_speech`, `list_voices`, and `list_languages` to any MCP client:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{ "mcpServers": { "kesha": { "command": "kesha", "args": ["mcp"] } } }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Models are never auto-downloaded — tools fail with a `kesha install` / `kesha install --tts` hint when missing. Full client setup (Claude Code, Claude Desktop, Codex, Cursor): [docs/mcp.md](docs/mcp.md).
|
|
119
|
+
|
|
110
120
|
## STT: transcribe audio
|
|
111
121
|
|
|
112
122
|
```bash
|
|
@@ -138,12 +148,12 @@ Each segment has `start`, `end`, and `text` fields. `--timestamps` is available
|
|
|
138
148
|
**Speaker diarization** (darwin-arm64, post-v1.12.0). Add `--speakers` to label each segment with a cluster ID — useful for transcribing multi-person calls / meetings:
|
|
139
149
|
|
|
140
150
|
```bash
|
|
141
|
-
kesha install --diarize # one-time, ~245MB
|
|
142
|
-
kesha --json --
|
|
151
|
+
kesha install --diarize # one-time, ~245MB (installs VAD too)
|
|
152
|
+
kesha --json --speakers meeting.m4a > out.json # --speakers engages VAD itself
|
|
143
153
|
jq '.[0].segments[] | "\(.speaker)\t\(.text)"' out.json
|
|
144
154
|
```
|
|
145
155
|
|
|
146
|
-
Each `segment.speaker` is a number (cluster id, stable within one file). On Linux / Windows the engine returns a clear "currently darwin-arm64 only" error — see [#199](https://github.com/drakulavich/kesha-voice-kit/issues/199).
|
|
156
|
+
Each `segment.speaker` is a number (cluster id, stable within one file). `--speakers` forces VAD windowing at any duration, so it cannot be combined with `--no-vad`. On Linux / Windows the engine returns a clear "currently darwin-arm64 only" error — see [#199](https://github.com/drakulavich/kesha-voice-kit/issues/199).
|
|
147
157
|
|
|
148
158
|
**Formats:** .ogg, .opus, .mp3, .m4a, .wav, .flac, .webm — decoded via symphonia, no ffmpeg required.
|
|
149
159
|
|
|
@@ -207,10 +217,11 @@ bun add -g @drakulavich/kesha-voice-kit # global CLI install (always fi
|
|
|
207
217
|
kesha init
|
|
208
218
|
|
|
209
219
|
# For agents and CI: explicit, scriptable install commands
|
|
210
|
-
kesha install # engine only
|
|
211
|
-
kesha install --
|
|
220
|
+
kesha install # engine only — ~0.6 GB on Apple Silicon, ~2.5 GB on Linux/Windows
|
|
221
|
+
kesha install --plan # preview exact download/disk sizes first — downloads nothing
|
|
222
|
+
kesha install --tts # + English Kokoro; add langs additively: --tts en ru es
|
|
212
223
|
kesha install --tts --vad # + Silero VAD (long-audio chunking)
|
|
213
|
-
kesha install --tts --
|
|
224
|
+
kesha install --tts --diarize # + speaker diarization, VAD included (darwin-arm64 only)
|
|
214
225
|
```
|
|
215
226
|
|
|
216
227
|
Kesha's runtime error/warning messages adapt to the same split: when `kesha` is invoked from a TTY, hints suggest `kesha init`; when stderr is piped (CI logs, OpenClaw, agent subprocess), hints suggest the equivalent `kesha install [...flags]`. Both run the same install code under the hood — pick the one your caller is.
|
|
@@ -223,7 +234,7 @@ No system deps — English G2P is embedded (`misaki-rs`); Russian G2P is bundled
|
|
|
223
234
|
|
|
224
235
|
**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.
|
|
225
236
|
|
|
226
|
-
**Text-to-speech:** English (Kokoro-82M; FluidAudio CoreML on darwin-arm64, ONNX elsewhere), Russian (Vosk-TTS, 5 baked-in speakers — default `ru-vosk-m02`), plus
|
|
237
|
+
**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` accept romanized Latin input only; native Devanagari and kana/kanji are rejected with `E_SCRIPT_UNSUPPORTED` — see [#492](https://github.com/drakulavich/kesha-voice-kit/issues/492). Any macOS system voice is also available via `--voice macos-*`.
|
|
227
238
|
|
|
228
239
|
## Performance
|
|
229
240
|
|
package/completions/kesha.fish
CHANGED
|
@@ -19,14 +19,14 @@ complete -c kesha -n '__fish_use_subcommand' -s v -d 'Show version'
|
|
|
19
19
|
complete -c kesha -n '__fish_use_subcommand' -l json -d 'Output results as JSON'
|
|
20
20
|
complete -c kesha -n '__fish_use_subcommand' -l toon -d 'Output results as TOON (compact, LLM-friendly encoding of the same data as --json)'
|
|
21
21
|
complete -c kesha -n '__fish_use_subcommand' -l timestamps -d 'Include timestamped transcript segments in JSON/TOON output'
|
|
22
|
-
complete -c kesha -n '__fish_use_subcommand' -l speakers -d 'Include speaker labels in segments. Needs --json/--toon and darwin-arm64; run `kesha install --diarize` first. Implies --timestamps.'
|
|
22
|
+
complete -c kesha -n '__fish_use_subcommand' -l speakers -d 'Include speaker labels in segments. Needs --json/--toon and darwin-arm64; run `kesha install --diarize` first (it installs VAD too). Implies --timestamps, and engages VAD windowing at any duration (so it cannot be combined with --no-vad).'
|
|
23
23
|
complete -c kesha -n '__fish_use_subcommand' -l include-errors -d 'With --json, output { results, errors } so scripts can read per-file failures without parsing stderr'
|
|
24
24
|
complete -c kesha -n '__fish_use_subcommand' -l verbose -d 'Show language detection details'
|
|
25
25
|
complete -c kesha -n '__fish_use_subcommand' -l format -r -d 'Output format: transcript | json | toon (long-form alias for --json / --toon)'
|
|
26
26
|
complete -c kesha -n '__fish_use_subcommand' -l lang -r -d 'Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch'
|
|
27
27
|
complete -c kesha -n '__fish_use_subcommand' -l debug -d 'Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)'
|
|
28
28
|
complete -c kesha -n '__fish_use_subcommand' -l vad -d 'Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.'
|
|
29
|
-
complete -c kesha -n '__fish_use_subcommand' -l no-vad -d 'Force full-file ASR for short/medium files; long audio fails early'
|
|
29
|
+
complete -c kesha -n '__fish_use_subcommand' -l no-vad -d 'Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers'
|
|
30
30
|
complete -c kesha -n '__fish_use_subcommand' -l quiet -d 'Suppress progress output; print only results and errors'
|
|
31
31
|
complete -c kesha -n '__fish_use_subcommand' -l no-color -d 'Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)'
|
|
32
32
|
complete -c kesha -n '__fish_seen_subcommand_from completions' -l help -d 'Show help'
|
package/completions/kesha.zsh
CHANGED
|
@@ -27,14 +27,14 @@ _kesha() {
|
|
|
27
27
|
'--json[Output results as JSON]' \
|
|
28
28
|
'--toon[Output results as TOON (compact, LLM-friendly encoding of the same data as --json)]' \
|
|
29
29
|
'--timestamps[Include timestamped transcript segments in JSON/TOON output]' \
|
|
30
|
-
'--speakers[Include speaker labels in segments. Needs --json/--toon and darwin-arm64; run `kesha install --diarize` first. Implies --timestamps.]' \
|
|
30
|
+
'--speakers[Include speaker labels in segments. Needs --json/--toon and darwin-arm64; run `kesha install --diarize` first (it installs VAD too). Implies --timestamps, and engages VAD windowing at any duration (so it cannot be combined with --no-vad).]' \
|
|
31
31
|
'--include-errors[With --json, output { results, errors } so scripts can read per-file failures without parsing stderr]' \
|
|
32
32
|
'--verbose[Show language detection details]' \
|
|
33
33
|
'--format=[Output format: transcript | json | toon (long-form alias for --json / --toon)]:format:' \
|
|
34
34
|
'--lang=[Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch]:lang:' \
|
|
35
35
|
'--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
|
|
36
36
|
'--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
|
|
37
|
-
'--no-vad[Force full-file ASR for short/medium files; long audio fails early]' \
|
|
37
|
+
'--no-vad[Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers]' \
|
|
38
38
|
'--quiet[Suppress progress output; print only results and errors]' \
|
|
39
39
|
'--no-color[Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)]'
|
|
40
40
|
else
|
|
@@ -139,14 +139,14 @@ _kesha() {
|
|
|
139
139
|
'--json[Output results as JSON]' \
|
|
140
140
|
'--toon[Output results as TOON (compact, LLM-friendly encoding of the same data as --json)]' \
|
|
141
141
|
'--timestamps[Include timestamped transcript segments in JSON/TOON output]' \
|
|
142
|
-
'--speakers[Include speaker labels in segments. Needs --json/--toon and darwin-arm64; run `kesha install --diarize` first. Implies --timestamps.]' \
|
|
142
|
+
'--speakers[Include speaker labels in segments. Needs --json/--toon and darwin-arm64; run `kesha install --diarize` first (it installs VAD too). Implies --timestamps, and engages VAD windowing at any duration (so it cannot be combined with --no-vad).]' \
|
|
143
143
|
'--include-errors[With --json, output { results, errors } so scripts can read per-file failures without parsing stderr]' \
|
|
144
144
|
'--verbose[Show language detection details]' \
|
|
145
145
|
'--format=[Output format: transcript | json | toon (long-form alias for --json / --toon)]:format:' \
|
|
146
146
|
'--lang=[Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch]:lang:' \
|
|
147
147
|
'--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
|
|
148
148
|
'--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
|
|
149
|
-
'--no-vad[Force full-file ASR for short/medium files; long audio fails early]' \
|
|
149
|
+
'--no-vad[Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers]' \
|
|
150
150
|
'--quiet[Suppress progress output; print only results and errors]' \
|
|
151
151
|
'--no-color[Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)]'
|
|
152
152
|
;;
|
package/man/kesha.1
CHANGED
|
@@ -58,7 +58,7 @@ Output results as TOON (compact, LLM\-friendly encoding of the same data as \-\-
|
|
|
58
58
|
Include timestamped transcript segments in JSON/TOON output
|
|
59
59
|
.TP
|
|
60
60
|
.B \-\-speakers
|
|
61
|
-
Include speaker labels in segments. Needs \-\-json/\-\-toon and darwin\-arm64; run `kesha install \-\-diarize` first. Implies \-\-timestamps.
|
|
61
|
+
Include speaker labels in segments. Needs \-\-json/\-\-toon and darwin\-arm64; run `kesha install \-\-diarize` first (it installs VAD too). Implies \-\-timestamps, and engages VAD windowing at any duration (so it cannot be combined with \-\-no\-vad).
|
|
62
62
|
.TP
|
|
63
63
|
.B \-\-include\-errors
|
|
64
64
|
With \-\-json, output { results, errors } so scripts can read per\-file failures without parsing stderr
|
|
@@ -79,7 +79,7 @@ Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)
|
|
|
79
79
|
Force Silero VAD preprocessing (kesha install \-\-vad first). Without this, VAD auto\-engages on audio ≥ 120s.
|
|
80
80
|
.TP
|
|
81
81
|
.B \-\-no\-vad
|
|
82
|
-
Force full\-file ASR for short/medium files; long audio fails early
|
|
82
|
+
Force full\-file ASR for short/medium files; long audio fails early. Incompatible with \-\-speakers
|
|
83
83
|
.TP
|
|
84
84
|
.B \-\-quiet
|
|
85
85
|
Suppress progress output; print only results and errors
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "1.27.0
|
|
3
|
+
"version": "1.27.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": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
}
|
|
41
41
|
},
|
|
42
42
|
"keshaEngine": {
|
|
43
|
-
"version": "1.24.
|
|
43
|
+
"version": "1.24.9"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"test": "bun test --timeout 30000",
|
package/src/cli/init.ts
CHANGED
|
@@ -4,10 +4,10 @@ import { renderInstallPlan } from "../install-plan";
|
|
|
4
4
|
import { log } from "../log";
|
|
5
5
|
import { getEngineCapabilities } from "../engine";
|
|
6
6
|
import {
|
|
7
|
+
installableTtsLangs,
|
|
7
8
|
performInstall,
|
|
8
9
|
resolveBackendFlag,
|
|
9
10
|
resolveNoCacheFlag,
|
|
10
|
-
TTS_LANG_FALLBACK,
|
|
11
11
|
unavailableBackendError,
|
|
12
12
|
} from "./install";
|
|
13
13
|
import type { SharedInstallArgs } from "./types";
|
|
@@ -40,8 +40,10 @@ function exitIfCancelled<T>(answer: T | symbol): T {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
async function promptTtsLangs(preselect: string[]): Promise<string[]> {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// A corrupt engine must leave init offering the fallback list rather than aborting the
|
|
44
|
+
// guided setup that would replace it (#770).
|
|
45
|
+
const caps = await getEngineCapabilities().catch(() => null);
|
|
46
|
+
const supported = caps?.tts?.languages.map((l) => l.code) ?? installableTtsLangs();
|
|
45
47
|
const selected = await multiselect({
|
|
46
48
|
message:
|
|
47
49
|
"Select TTS languages to install (space to toggle, enter to confirm; none = skip TTS):",
|
|
@@ -113,8 +115,9 @@ export function initSuggestionCommands(
|
|
|
113
115
|
{ ...selection, ttsLangs: ["en"], vad: true, diarize: false },
|
|
114
116
|
];
|
|
115
117
|
|
|
118
|
+
// #768: --diarize installs VAD on its own, so no explicit --vad here.
|
|
116
119
|
if (canDiarize) {
|
|
117
|
-
variants.push({ ...selection, ttsLangs: [], vad:
|
|
120
|
+
variants.push({ ...selection, ttsLangs: [], vad: false, diarize: true });
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
const seen = new Set<string>();
|
|
@@ -143,7 +146,7 @@ export function renderInitOverview(canDiarize = canInstallDiarizeOnPlatform()):
|
|
|
143
146
|
" - Text-to-speech: enables `kesha say` with Kokoro English voices (more languages via `kesha install --tts <langs>`).",
|
|
144
147
|
" - VAD: skips silence in long audio and improves meeting, lecture, and podcast transcripts (~2.3MB).",
|
|
145
148
|
canDiarize
|
|
146
|
-
? " - Speaker diarization: labels speakers in JSON/TOON transcript segments (~245MB, darwin-arm64)."
|
|
149
|
+
? " - Speaker diarization: labels speakers in JSON/TOON transcript segments (~245MB, darwin-arm64; installs VAD too, since labels attach to VAD segments)."
|
|
147
150
|
: " - Speaker diarization: labels speakers, but the install is currently darwin-arm64 only.",
|
|
148
151
|
"",
|
|
149
152
|
"Nothing downloads until you confirm the final install plan.",
|
|
@@ -163,7 +166,10 @@ export async function promptInitSelection(
|
|
|
163
166
|
const vad = await prompt("Install VAD for long or silence-heavy audio?", args.vad);
|
|
164
167
|
let diarize = false;
|
|
165
168
|
if (canDiarize) {
|
|
166
|
-
diarize = await prompt(
|
|
169
|
+
diarize = await prompt(
|
|
170
|
+
"Install speaker diarization for `--speakers`? (installs VAD as well)",
|
|
171
|
+
args.diarize,
|
|
172
|
+
);
|
|
167
173
|
} else if (args.diarize) {
|
|
168
174
|
log.warn("--diarize is currently darwin-arm64 only; omitting it from the interactive install.");
|
|
169
175
|
}
|
package/src/cli/install.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { defineCommand } from "citty";
|
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
3
|
import { installEngine } from "../engine-install";
|
|
4
4
|
import { engineTarget } from "../engine-targets";
|
|
5
|
-
import {
|
|
5
|
+
import { isDarwinArm64 } from "../fluid-kokoro-cache";
|
|
6
|
+
import { getEngineBinPath, getEngineCapabilities, type EngineCapabilities } from "../engine";
|
|
6
7
|
import { renderInstallPlan } from "../install-plan";
|
|
7
8
|
import { maybeAskForStar } from "../star";
|
|
8
9
|
import { log } from "../log";
|
|
@@ -19,12 +20,24 @@ export interface InstallCommandArgs extends SharedInstallArgs {
|
|
|
19
20
|
engineVersion?: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** TTS languages every build installs. */
|
|
24
|
+
const TTS_LANGS_EVERYWHERE = ["en", "es", "fr", "it", "pt", "ru"];
|
|
25
|
+
|
|
26
|
+
/** hi/ja/zh need FluidAudio's ANE Kokoro, which only the darwin-arm64 engine is built with. */
|
|
27
|
+
const DARWIN_ARM64_ONLY_TTS_LANGS = ["hi", "ja", "zh"];
|
|
28
|
+
|
|
22
29
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* can't be assumed without capabilities, so they're excluded here.
|
|
30
|
+
* What `--tts` may name when the Engine cannot be asked. Without it, a bogus code is only
|
|
31
|
+
* caught by the engine that a full download had to install first (#770).
|
|
26
32
|
*/
|
|
27
|
-
export
|
|
33
|
+
export function installableTtsLangs(
|
|
34
|
+
platform = process.platform,
|
|
35
|
+
arch = process.arch,
|
|
36
|
+
): string[] {
|
|
37
|
+
return isDarwinArm64(platform, arch)
|
|
38
|
+
? [...TTS_LANGS_EVERYWHERE, ...DARWIN_ARM64_ONLY_TTS_LANGS]
|
|
39
|
+
: TTS_LANGS_EVERYWHERE;
|
|
40
|
+
}
|
|
28
41
|
|
|
29
42
|
export interface TtsArgInput {
|
|
30
43
|
tts: boolean;
|
|
@@ -34,9 +47,9 @@ export interface TtsArgInput {
|
|
|
34
47
|
/**
|
|
35
48
|
* Resolve the requested TTS language list. Bare `--tts` defaults to English.
|
|
36
49
|
* Positionals without `--tts` are an error. When `supported` is provided,
|
|
37
|
-
* unsupported codes are a hard error (nothing downloads);
|
|
38
|
-
*
|
|
39
|
-
* the
|
|
50
|
+
* unsupported codes are a hard error (nothing downloads); the CLI passes the
|
|
51
|
+
* Engine's own list when it has one and `installableTtsLangs()` otherwise, and
|
|
52
|
+
* the installed Engine re-validates authoritatively at download time.
|
|
40
53
|
*/
|
|
41
54
|
export function resolveTtsLangs(input: TtsArgInput, supported: string[] | undefined): string[] {
|
|
42
55
|
if (!input.tts) {
|
|
@@ -90,6 +103,23 @@ export function resolveNoCacheFlag(
|
|
|
90
103
|
);
|
|
91
104
|
}
|
|
92
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Capabilities for `--tts` validation, tolerating an engine that cannot be spawned (#770).
|
|
108
|
+
*
|
|
109
|
+
* A corrupt or wrong-arch binary used to throw here and kill the command before the
|
|
110
|
+
* re-download that repairs it. Without capabilities the language codes simply go
|
|
111
|
+
* unvalidated locally — the freshly installed engine rejects unsupported ones at
|
|
112
|
+
* download time, which is authoritative anyway.
|
|
113
|
+
*/
|
|
114
|
+
export async function probeCapabilitiesForInstall(): Promise<EngineCapabilities | null> {
|
|
115
|
+
try {
|
|
116
|
+
return await getEngineCapabilities();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log.debug(`capabilities probe failed before install: ${errorMessage(err)}`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
93
123
|
export type BackendSelection =
|
|
94
124
|
| { ok: true; backend: string | undefined }
|
|
95
125
|
| { ok: false; error: string };
|
|
@@ -274,8 +304,8 @@ export const installCommand = defineCommand({
|
|
|
274
304
|
}
|
|
275
305
|
const backend = resolveBackendFlag(args.coreml, args.onnx);
|
|
276
306
|
const positionals = (args._ ?? []).map(String);
|
|
277
|
-
const caps = await
|
|
278
|
-
const supported = caps?.tts?.languages.map((l) => l.code);
|
|
307
|
+
const caps = await probeCapabilitiesForInstall();
|
|
308
|
+
const supported = caps?.tts?.languages.map((l) => l.code) ?? installableTtsLangs();
|
|
279
309
|
let ttsLangs: string[];
|
|
280
310
|
try {
|
|
281
311
|
ttsLangs = resolveTtsLangs({ tts: args.tts === true, positionals }, supported);
|
package/src/cli/main.ts
CHANGED
|
@@ -175,6 +175,16 @@ export function validateTranscribeArgs(
|
|
|
175
175
|
if (args.speakers && !(wantsJson || wantsToon)) {
|
|
176
176
|
return { ok: false, error: "--speakers requires --json, --toon, or --format {json,toon}." };
|
|
177
177
|
}
|
|
178
|
+
// #768: diarization labels VAD-windowed segments, so --no-vad leaves nothing to label.
|
|
179
|
+
if (args.speakers && noVad) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
error:
|
|
183
|
+
"--speakers cannot be combined with --no-vad: speaker labels attach to VAD-windowed " +
|
|
184
|
+
"speech segments, and --no-vad leaves the whole file as one segment. Drop --no-vad " +
|
|
185
|
+
"(VAD engages automatically for --speakers), or drop --speakers.",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
178
188
|
if (args["include-errors"] && !wantsJson) {
|
|
179
189
|
return { ok: false, error: "--include-errors requires --json or --format json." };
|
|
180
190
|
}
|
|
@@ -437,7 +447,7 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
|
|
|
437
447
|
},
|
|
438
448
|
speakers: {
|
|
439
449
|
type: "boolean",
|
|
440
|
-
description: "Include speaker labels in segments. Needs --json/--toon and darwin-arm64; run `kesha install --diarize` first. Implies --timestamps.",
|
|
450
|
+
description: "Include speaker labels in segments. Needs --json/--toon and darwin-arm64; run `kesha install --diarize` first (it installs VAD too). Implies --timestamps, and engages VAD windowing at any duration (so it cannot be combined with --no-vad).",
|
|
441
451
|
default: false,
|
|
442
452
|
},
|
|
443
453
|
"include-errors": {
|
|
@@ -470,7 +480,7 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
|
|
|
470
480
|
},
|
|
471
481
|
"no-vad": {
|
|
472
482
|
type: "boolean",
|
|
473
|
-
description: "Force full-file ASR for short/medium files; long audio fails early",
|
|
483
|
+
description: "Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers",
|
|
474
484
|
default: false,
|
|
475
485
|
},
|
|
476
486
|
// quiet and no-color are resolved before citty in dispatch.ts (so they
|
package/src/cli/say.ts
CHANGED
|
@@ -25,10 +25,9 @@ async function autoRouteVoice(text: string): Promise<string | undefined> {
|
|
|
25
25
|
* Resolve the voice for `kesha say`. Precedence: explicit `--voice` > explicit
|
|
26
26
|
* `--lang` (route by the stated language, skipping detection — also the path on
|
|
27
27
|
* Linux/Windows where text-language detection is unavailable) > macOS
|
|
28
|
-
* text-language auto-detection > engine default (`undefined`). A `--lang`
|
|
29
|
-
* no
|
|
30
|
-
*
|
|
31
|
-
* detection — the user stated the language explicitly.
|
|
28
|
+
* text-language auto-detection > engine default (`undefined`). A `--lang` the
|
|
29
|
+
* build has no voice for resolves to `undefined` (engine default) rather than
|
|
30
|
+
* re-running detection — the user stated the language explicitly.
|
|
32
31
|
*/
|
|
33
32
|
export async function resolveSayVoice(
|
|
34
33
|
explicitVoice: string | undefined,
|
package/src/doctor.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
isEngineInstalled,
|
|
9
9
|
type EngineCapabilities,
|
|
10
10
|
} from "./engine";
|
|
11
|
+
import { probeExecutable } from "./engine-health";
|
|
11
12
|
import { readInstalledEngineVersion } from "./engine-version-marker";
|
|
12
13
|
import { keshaCacheDir } from "./paths";
|
|
13
14
|
import { engineVersion, packageName, packageVersion } from "./package-info";
|
|
@@ -51,6 +52,8 @@ interface CacheComponent extends PathSummary {
|
|
|
51
52
|
interface OptionalComponent extends PathSummary {
|
|
52
53
|
name: string;
|
|
53
54
|
note?: string;
|
|
55
|
+
/** Binaries only: whether the file actually executes. Absent for model directories. */
|
|
56
|
+
runnable?: boolean;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
type DoctorDiagnosticLogStatus = DiagnosticLogStatus & { error?: string };
|
|
@@ -88,6 +91,8 @@ export interface DoctorReport {
|
|
|
88
91
|
versionMarker: string | null;
|
|
89
92
|
pinnedVersion: string;
|
|
90
93
|
versionState: EngineVersionState;
|
|
94
|
+
/** False when the binary is present but the OS refuses to run it (#770). */
|
|
95
|
+
runnable: boolean;
|
|
91
96
|
capabilities: EngineCapabilities | null;
|
|
92
97
|
probeError: string | null;
|
|
93
98
|
};
|
|
@@ -197,8 +202,15 @@ async function collectEngine(redact: boolean): Promise<DoctorReport["engine"]> {
|
|
|
197
202
|
const installed = isEngineInstalled();
|
|
198
203
|
let capabilities: EngineCapabilities | null = null;
|
|
199
204
|
let probeError: string | null = null;
|
|
200
|
-
|
|
201
|
-
|
|
205
|
+
// A truncated download exists, so `installed` alone would report a bricked engine as
|
|
206
|
+
// healthy; probing the loader separates that from an old engine with no capabilities JSON.
|
|
207
|
+
const health = installed
|
|
208
|
+
? await probeExecutable(binPath, ["--version"])
|
|
209
|
+
: ({ status: "missing" } as const);
|
|
210
|
+
|
|
211
|
+
if (health.status === "unusable") {
|
|
212
|
+
probeError = `binary is present but does not run (${health.detail}); re-run \`kesha install\``;
|
|
213
|
+
} else if (installed) {
|
|
202
214
|
try {
|
|
203
215
|
capabilities = await getEngineCapabilities();
|
|
204
216
|
if (!capabilities) probeError = "capabilities probe returned no data";
|
|
@@ -215,6 +227,7 @@ async function collectEngine(redact: boolean): Promise<DoctorReport["engine"]> {
|
|
|
215
227
|
versionMarker,
|
|
216
228
|
pinnedVersion: engineVersion,
|
|
217
229
|
versionState: engineVersionState(versionMarker, engineVersion),
|
|
230
|
+
runnable: health.status === "ok",
|
|
218
231
|
capabilities,
|
|
219
232
|
probeError: redactString("probeError", probeError, redact),
|
|
220
233
|
};
|
|
@@ -269,10 +282,27 @@ function collectCache(
|
|
|
269
282
|
};
|
|
270
283
|
}
|
|
271
284
|
|
|
272
|
-
|
|
285
|
+
const SIDECAR_COMPONENTS = [
|
|
286
|
+
{ name: "AVSpeech sidecar", note: "macOS voices", file: "say-avspeech" },
|
|
287
|
+
{ name: "Text language sidecar", note: "darwin text language fast path", file: "kesha-textlang" },
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
/** Reporting a truncated sidecar as "installed" is a false clean — spawn it instead (#770). */
|
|
291
|
+
async function sidecarComponent(
|
|
292
|
+
spec: (typeof SIDECAR_COMPONENTS)[number],
|
|
293
|
+
sidecarDir: string,
|
|
294
|
+
): Promise<OptionalComponent> {
|
|
295
|
+
const summary = pathSummary(join(sidecarDir, spec.file));
|
|
296
|
+
const base = { name: spec.name, note: spec.note, ...summary };
|
|
297
|
+
if (!summary.exists) return base;
|
|
298
|
+
const health = await probeExecutable(summary.path);
|
|
299
|
+
return { ...base, runnable: health.status === "ok" };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function collectOptionalComponents(
|
|
273
303
|
redact: boolean,
|
|
274
304
|
fluidKokoro: FluidKokoroCacheInfo,
|
|
275
|
-
): OptionalComponent[] {
|
|
305
|
+
): Promise<OptionalComponent[]> {
|
|
276
306
|
const cache = keshaCacheDir();
|
|
277
307
|
const sidecarDir = dirname(getEngineBinPath());
|
|
278
308
|
const components: OptionalComponent[] = [
|
|
@@ -311,16 +341,7 @@ function collectOptionalComponents(
|
|
|
311
341
|
note: "enabled with `kesha install --diarize` (darwin-arm64); runs in-engine",
|
|
312
342
|
...pathSummary(join(cache, "models/diarize")),
|
|
313
343
|
},
|
|
314
|
-
|
|
315
|
-
name: "AVSpeech sidecar",
|
|
316
|
-
note: "macOS voices",
|
|
317
|
-
...pathSummary(join(sidecarDir, "say-avspeech")),
|
|
318
|
-
},
|
|
319
|
-
{
|
|
320
|
-
name: "Text language sidecar",
|
|
321
|
-
note: "darwin text language fast path",
|
|
322
|
-
...pathSummary(join(sidecarDir, "kesha-textlang")),
|
|
323
|
-
},
|
|
344
|
+
...(await Promise.all(SIDECAR_COMPONENTS.map((spec) => sidecarComponent(spec, sidecarDir)))),
|
|
324
345
|
];
|
|
325
346
|
return components.map((component) => redactComponent(component, redact));
|
|
326
347
|
}
|
|
@@ -396,15 +417,23 @@ export async function collectDoctorReport(
|
|
|
396
417
|
},
|
|
397
418
|
engine,
|
|
398
419
|
cache: collectCache(redact, fluidKokoro, engine.capabilities?.backend),
|
|
399
|
-
optionalComponents: collectOptionalComponents(redact, fluidKokoro),
|
|
420
|
+
optionalComponents: await collectOptionalComponents(redact, fluidKokoro),
|
|
400
421
|
stats: collectStats(redact),
|
|
401
422
|
diagnosticLogs: collectDiagnosticLogs(redact),
|
|
402
423
|
env: collectEnv(redact),
|
|
403
424
|
};
|
|
404
425
|
}
|
|
405
426
|
|
|
406
|
-
|
|
407
|
-
|
|
427
|
+
const CORRUPT_STATE = "installed but not executable (corrupt) - re-run `kesha install`";
|
|
428
|
+
|
|
429
|
+
function formatComponentState(component: OptionalComponent): string {
|
|
430
|
+
if (!component.exists) return "missing";
|
|
431
|
+
return component.runnable === false ? CORRUPT_STATE : "installed";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function formatEngineBinaryState(engine: DoctorReport["engine"]): string {
|
|
435
|
+
if (!engine.installed) return "missing";
|
|
436
|
+
return engine.runnable ? "installed" : CORRUPT_STATE;
|
|
408
437
|
}
|
|
409
438
|
|
|
410
439
|
function formatVersionState(engine: DoctorReport["engine"]): string {
|
|
@@ -441,7 +470,7 @@ function formatOptionalSection(components: DoctorReport["optionalComponents"]):
|
|
|
441
470
|
const lines = ["", "Optional components:"];
|
|
442
471
|
for (const c of components) {
|
|
443
472
|
const note = c.note ? ` - ${c.note}` : "";
|
|
444
|
-
lines.push(` ${c.name}: ${
|
|
473
|
+
lines.push(` ${c.name}: ${formatComponentState(c)}${note}`);
|
|
445
474
|
}
|
|
446
475
|
return lines;
|
|
447
476
|
}
|
|
@@ -490,7 +519,7 @@ export function formatDoctorReport(report: DoctorReport): string {
|
|
|
490
519
|
` Platform: ${report.runtime.platform} ${report.runtime.arch}`,
|
|
491
520
|
"",
|
|
492
521
|
"Engine:",
|
|
493
|
-
` Binary: ${report.engine.path} (${
|
|
522
|
+
` Binary: ${report.engine.path} (${formatEngineBinaryState(report.engine)})`,
|
|
494
523
|
` Version marker: ${report.engine.versionMarker ?? "missing"}`,
|
|
495
524
|
` Pinned version: ${report.engine.pinnedVersion}`,
|
|
496
525
|
` Version state: ${formatVersionState(report.engine)}`,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { errorMessage } from "./error-utils";
|
|
3
|
+
|
|
4
|
+
export type ExecutableHealth =
|
|
5
|
+
| { status: "ok" }
|
|
6
|
+
| { status: "missing" }
|
|
7
|
+
| { status: "unusable"; detail: string };
|
|
8
|
+
|
|
9
|
+
const PROBE_TIMEOUT_MS = 15_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Proves a downloaded binary actually executes — existence is not health (#770).
|
|
13
|
+
*
|
|
14
|
+
* Any exit code counts as healthy: the probe only has to show that the loader accepted the
|
|
15
|
+
* image, and the sidecars legitimately exit non-zero when invoked with no work to do. A
|
|
16
|
+
* truncated download is refused before `main` runs — the spawn throws, or the kernel kills
|
|
17
|
+
* the process outright (SIGKILL on an invalid Mach-O) — and those are what this reports.
|
|
18
|
+
*
|
|
19
|
+
* The timeout guards against a binary that starts but never exits; macOS Gatekeeper can take
|
|
20
|
+
* several seconds to scan a freshly written binary on its first run, so it is generous.
|
|
21
|
+
*/
|
|
22
|
+
export async function probeExecutable(
|
|
23
|
+
binPath: string,
|
|
24
|
+
args: string[] = [],
|
|
25
|
+
): Promise<ExecutableHealth> {
|
|
26
|
+
if (!existsSync(binPath)) return { status: "missing" };
|
|
27
|
+
|
|
28
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
29
|
+
try {
|
|
30
|
+
proc = Bun.spawn([binPath, ...args], {
|
|
31
|
+
stdin: "ignore",
|
|
32
|
+
stdout: "ignore",
|
|
33
|
+
stderr: "ignore",
|
|
34
|
+
});
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return { status: "unusable", detail: errorMessage(err) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let timedOut = false;
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
timedOut = true;
|
|
42
|
+
proc.kill();
|
|
43
|
+
}, PROBE_TIMEOUT_MS);
|
|
44
|
+
try {
|
|
45
|
+
await proc.exited;
|
|
46
|
+
} finally {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (timedOut) {
|
|
51
|
+
return { status: "unusable", detail: `no exit within ${PROBE_TIMEOUT_MS / 1000}s` };
|
|
52
|
+
}
|
|
53
|
+
if (proc.signalCode) return { status: "unusable", detail: `killed by ${proc.signalCode}` };
|
|
54
|
+
return { status: "ok" };
|
|
55
|
+
}
|
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 { probeExecutable } from "./engine-health";
|
|
11
12
|
import { engineTarget } from "./engine-targets";
|
|
12
13
|
import { isDarwinArm64 } from "./fluid-kokoro-cache";
|
|
13
14
|
import { log } from "./log";
|
|
@@ -312,7 +313,8 @@ export function buildEngineInstallArgs(opts: {
|
|
|
312
313
|
"install",
|
|
313
314
|
...(opts.noCache ? ["--no-cache"] : []),
|
|
314
315
|
...(opts.ttsLangs && opts.ttsLangs.length > 0 ? ["--tts", ...opts.ttsLangs] : []),
|
|
315
|
-
|
|
316
|
+
// #768: speaker labels attach to VAD-windowed segments, so --diarize pulls VAD in.
|
|
317
|
+
...(opts.vad || opts.diarize ? ["--vad"] : []),
|
|
316
318
|
...(opts.diarize ? ["--diarize"] : []),
|
|
317
319
|
];
|
|
318
320
|
}
|
|
@@ -327,9 +329,21 @@ export interface InstallOptions {
|
|
|
327
329
|
diarize?: boolean;
|
|
328
330
|
}
|
|
329
331
|
|
|
332
|
+
/** A sidecar that is present but unrunnable is as useless as an absent one, and `kesha install` is the repair (#770). */
|
|
333
|
+
async function sidecarNeedsDownload(spec: SidecarSpec, engineDir: string): Promise<boolean> {
|
|
334
|
+
const health = await probeExecutable(join(engineDir, spec.fileBasename));
|
|
335
|
+
if (health.status === "ok") return false;
|
|
336
|
+
if (health.status === "unusable") {
|
|
337
|
+
log.warn(
|
|
338
|
+
`${spec.displayName} is installed but does not run (${health.detail}); re-downloading it.`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
|
|
330
344
|
/**
|
|
331
345
|
* Cache-valid path: the binary at binPath already matches the requested version.
|
|
332
|
-
* Re-trusts it and
|
|
346
|
+
* Re-trusts it and re-fetches any sidecar the cached install is missing or cannot run.
|
|
333
347
|
*/
|
|
334
348
|
async function refreshCachedEngine(
|
|
335
349
|
binPath: string,
|
|
@@ -350,18 +364,19 @@ async function refreshCachedEngine(
|
|
|
350
364
|
if (canWriteEngineDir && existsSync(binPath)) {
|
|
351
365
|
darwinTrustBinary(binPath, "kesha-engine binary");
|
|
352
366
|
}
|
|
353
|
-
// Top up missing sidecars (pre-#141/#199 cached binaries never had them);
|
|
367
|
+
// Top up missing or broken sidecars (pre-#141/#199 cached binaries never had them);
|
|
354
368
|
// skip on read-only fs (Nix-store) to avoid confusing "install failed" warnings.
|
|
355
369
|
if (canWriteEngineDir) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
);
|
|
359
|
-
await Promise.all(missing.map((s) => downloadSidecar(s, binPath, version)));
|
|
360
|
-
// Also re-trust already-present sidecars for the Sequoia upgrade scenario.
|
|
370
|
+
// Re-trust before probing, for the Sequoia upgrade scenario: a provenance-blocked
|
|
371
|
+
// sidecar is SIGKILLed on spawn, and re-downloading it would not lift the block.
|
|
361
372
|
for (const s of SIDECARS) {
|
|
362
373
|
const p = join(engineDir, s.fileBasename);
|
|
363
374
|
if (existsSync(p)) darwinTrustBinary(p, s.displayName);
|
|
364
375
|
}
|
|
376
|
+
const needed = await Promise.all(SIDECARS.map((s) => sidecarNeedsDownload(s, engineDir)));
|
|
377
|
+
await Promise.all(
|
|
378
|
+
SIDECARS.filter((_, i) => needed[i]).map((s) => downloadSidecar(s, binPath, version)),
|
|
379
|
+
);
|
|
365
380
|
}
|
|
366
381
|
}
|
|
367
382
|
|
|
@@ -376,45 +391,34 @@ export function isTransientSpawnLock(message: string): boolean {
|
|
|
376
391
|
* A security scanner (Windows Defender is the one this was found on) holds a lock on a newly
|
|
377
392
|
* written 60 MB PE while it scans, so the first spawn fails with EBUSY 15 ms after the download
|
|
378
393
|
* reported success (#216). The lock is transient, so probe until it clears.
|
|
394
|
+
*
|
|
395
|
+
* The probe is a full health check: a Gatekeeper-SIGKILLed binary spawns without throwing, and
|
|
396
|
+
* accepting that would let the `.version` marker vouch for an engine that never ran (#770).
|
|
379
397
|
*/
|
|
380
|
-
export async function waitUntilSpawnable(
|
|
381
|
-
binPath: string,
|
|
382
|
-
deadlineMs = 60_000,
|
|
383
|
-
probeTimeoutMs = 5_000,
|
|
384
|
-
): Promise<void> {
|
|
398
|
+
export async function waitUntilSpawnable(binPath: string, deadlineMs = 60_000): Promise<void> {
|
|
385
399
|
const deadline = Date.now() + deadlineMs;
|
|
386
400
|
let lastError = "";
|
|
387
401
|
let warned = false;
|
|
388
402
|
let delay = 100;
|
|
389
403
|
|
|
390
404
|
for (;;) {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
throw new Error(
|
|
406
|
-
`Downloaded the engine to ${binPath} but it could not be started: ${lastError}\n` +
|
|
407
|
-
` Fix: delete ${dirname(binPath)} and re-run \`kesha install\`.`,
|
|
408
|
-
);
|
|
409
|
-
}
|
|
410
|
-
if (Date.now() + delay > deadline) break;
|
|
411
|
-
if (!warned) {
|
|
412
|
-
warned = true;
|
|
413
|
-
log.progress("Waiting for the engine binary to be released by the system...");
|
|
414
|
-
}
|
|
415
|
-
await Bun.sleep(delay);
|
|
416
|
-
delay = Math.min(delay * 2, 2_000);
|
|
405
|
+
const health = await probeExecutable(binPath, ["--version"]);
|
|
406
|
+
if (health.status === "ok") return;
|
|
407
|
+
lastError = health.status === "unusable" ? health.detail : "the binary is gone";
|
|
408
|
+
// Only a lock is worth waiting out — a missing or corrupt binary never becomes spawnable.
|
|
409
|
+
if (!isTransientSpawnLock(lastError)) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`Downloaded the engine to ${binPath} but it could not be started: ${lastError}\n` +
|
|
412
|
+
` Fix: delete ${dirname(binPath)} and re-run \`kesha install\`.`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
if (Date.now() + delay > deadline) break;
|
|
416
|
+
if (!warned) {
|
|
417
|
+
warned = true;
|
|
418
|
+
log.progress("Waiting for the engine binary to be released by the system...");
|
|
417
419
|
}
|
|
420
|
+
await Bun.sleep(delay);
|
|
421
|
+
delay = Math.min(delay * 2, 2_000);
|
|
418
422
|
}
|
|
419
423
|
|
|
420
424
|
throw new Error(
|
|
@@ -425,6 +429,21 @@ export async function waitUntilSpawnable(
|
|
|
425
429
|
);
|
|
426
430
|
}
|
|
427
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Cache-validity health check: an install interrupted before `chmod`/marker-write, or a
|
|
434
|
+
* binary from another architecture, exists at the right version yet cannot start (#770).
|
|
435
|
+
*/
|
|
436
|
+
async function engineRuns(binPath: string): Promise<boolean> {
|
|
437
|
+
const health = await probeExecutable(binPath, ["--version"]);
|
|
438
|
+
if (health.status === "ok") return true;
|
|
439
|
+
const detail = health.status === "unusable" ? health.detail : "binary disappeared";
|
|
440
|
+
log.warn(
|
|
441
|
+
`Installed engine at ${binPath} does not run (${detail}); it is corrupt or built for ` +
|
|
442
|
+
"another architecture — re-downloading it.",
|
|
443
|
+
);
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
|
|
428
447
|
/** Cold path: download the engine binary (and sidecars, concurrently). */
|
|
429
448
|
async function fetchEngineBinary(
|
|
430
449
|
binPath: string,
|
|
@@ -589,7 +608,12 @@ export async function installEngine(request: EngineInstallRequest = {}): Promise
|
|
|
589
608
|
// Read-only engine dir = Nix-store install; skip download/sidecar writes to avoid EROFS errors.
|
|
590
609
|
const canWriteEngineDir = checkEngineWritable(engineDir);
|
|
591
610
|
|
|
592
|
-
const
|
|
611
|
+
const markerMatches = existsSync(binPath) && installedVersion === version;
|
|
612
|
+
// The marker vouches for a file, not for a working binary. Skipped on a read-only engine
|
|
613
|
+
// dir: nothing there can be repaired, so a failed probe would only turn a usable Nix
|
|
614
|
+
// install into a hard error.
|
|
615
|
+
const versionMatches =
|
|
616
|
+
markerMatches && (!canWriteEngineDir || (await engineRuns(binPath)));
|
|
593
617
|
// On read-only fs, --no-cache can't re-download; treat as cache-valid and forward flag to model install.
|
|
594
618
|
const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
|
|
595
619
|
|
package/src/engine.ts
CHANGED
|
@@ -168,6 +168,19 @@ export interface TranscribeEngineOptions {
|
|
|
168
168
|
speakers?: boolean;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/** #768: speakers force VAD windowing, so `vad: "off"` is an invalid pair. Callers
|
|
172
|
+
* must run this before any capability or model resolution — an invalid request
|
|
173
|
+
* has to read as invalid whatever happens to be installed. */
|
|
174
|
+
export function assertSpeakersVadCompatible(opts: TranscribeEngineOptions): void {
|
|
175
|
+
if (!opts.speakers || opts.vad !== "off") return;
|
|
176
|
+
throw new Error(
|
|
177
|
+
`error [${TS_NATIVE_CODES.INVALID_ARG}]: speakers cannot be combined with vad: "off": ` +
|
|
178
|
+
"speaker labels attach to VAD-windowed speech segments, and disabling VAD leaves the " +
|
|
179
|
+
'whole file as one segment with nothing to label. Drop vad: "off" (VAD engages ' +
|
|
180
|
+
"automatically for speakers), or drop speakers.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
171
184
|
function defaultDiarizeModelPath(): string {
|
|
172
185
|
return join(keshaCacheDir(), "models", "diarize", "SortformerNvidiaLow_v2.mlpackage");
|
|
173
186
|
}
|
|
@@ -181,9 +194,39 @@ function hasDiarizeModelLayout(modelPath: string): boolean {
|
|
|
181
194
|
);
|
|
182
195
|
}
|
|
183
196
|
|
|
197
|
+
function assertDiarizeModelInstalled(): void {
|
|
198
|
+
const envPath = process.env.KESHA_DIARIZE_MODEL_PATH;
|
|
199
|
+
if (envPath !== undefined) {
|
|
200
|
+
if (existsSync(envPath)) return;
|
|
201
|
+
throw new Error(
|
|
202
|
+
`speaker diarization requires a model path\n\nCaused by:\n KESHA_DIARIZE_MODEL_PATH set but path does not exist: ${envPath}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const modelPath = defaultDiarizeModelPath();
|
|
207
|
+
if (hasDiarizeModelLayout(modelPath)) return;
|
|
208
|
+
throw new Error(
|
|
209
|
+
`speaker diarization requires a model path\n\nCaused by:\n diarization model not found at ${modelPath}. ` +
|
|
210
|
+
`Run \`${installHint("--diarize")}\` (or set KESHA_DIARIZE_MODEL_PATH).`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** #768: `--speakers` forces VAD windowing, so the VAD model is a hard dependency. */
|
|
215
|
+
function assertVadModelInstalled(): void {
|
|
216
|
+
const modelPath = join(keshaCacheDir(), "models", "silero-vad", "silero_vad.onnx");
|
|
217
|
+
if (existsSync(modelPath)) return;
|
|
218
|
+
throw new Error(
|
|
219
|
+
"speaker diarization requires the VAD model: --speakers windows the audio with Silero VAD " +
|
|
220
|
+
`so each speech span can be labeled.\n\nCaused by:\n VAD model not found at ${modelPath}. ` +
|
|
221
|
+
`Run \`${installHint("--vad")}\`.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
184
225
|
export async function preflightTranscribeEngineWithSegments(
|
|
185
226
|
opts: TranscribeEngineOptions = {},
|
|
186
227
|
): Promise<void> {
|
|
228
|
+
assertSpeakersVadCompatible(opts);
|
|
229
|
+
|
|
187
230
|
const caps = await getEngineCapabilities();
|
|
188
231
|
if (!caps?.features.includes(TRANSCRIBE_SEGMENTS_FEATURE)) {
|
|
189
232
|
throw new Error(
|
|
@@ -200,20 +243,8 @@ export async function preflightTranscribeEngineWithSegments(
|
|
|
200
243
|
);
|
|
201
244
|
}
|
|
202
245
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (existsSync(envPath)) return;
|
|
206
|
-
throw new Error(
|
|
207
|
-
`speaker diarization requires a model path\n\nCaused by:\n KESHA_DIARIZE_MODEL_PATH set but path does not exist: ${envPath}`,
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const modelPath = defaultDiarizeModelPath();
|
|
212
|
-
if (hasDiarizeModelLayout(modelPath)) return;
|
|
213
|
-
throw new Error(
|
|
214
|
-
`speaker diarization requires a model path\n\nCaused by:\n diarization model not found at ${modelPath}. ` +
|
|
215
|
-
`Run \`${installHint("--diarize")}\` (or set KESHA_DIARIZE_MODEL_PATH).`,
|
|
216
|
-
);
|
|
246
|
+
assertDiarizeModelInstalled();
|
|
247
|
+
assertVadModelInstalled();
|
|
217
248
|
}
|
|
218
249
|
|
|
219
250
|
function vadArg(vad: VadMode | undefined): string[] {
|
|
@@ -324,11 +355,32 @@ export async function detectTextLanguageEngine(
|
|
|
324
355
|
): Promise<LangDetectResult | null> {
|
|
325
356
|
if (text.trim().length === 0) return null;
|
|
326
357
|
if (!isEngineInstalled()) return null;
|
|
327
|
-
const { stdout, exitCode } = await runEngine(["detect-text-lang", text], opts);
|
|
328
|
-
if (exitCode !== 0)
|
|
358
|
+
const { stdout, stderr, exitCode } = await runEngine(["detect-text-lang", text], opts);
|
|
359
|
+
if (exitCode !== 0) {
|
|
360
|
+
const warning = textLangFailureWarning(stderr);
|
|
361
|
+
if (warning) log.warn(warning);
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
329
364
|
return parseLangResult(stdout);
|
|
330
365
|
}
|
|
331
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Null off darwin, where text detection is unsupported by design and failing is expected.
|
|
369
|
+
*
|
|
370
|
+
* On darwin a failure means a broken `kesha-textlang` sidecar, and swallowing it routed
|
|
371
|
+
* `kesha say` to the default voice with no hint that detection had stopped working (#770).
|
|
372
|
+
*/
|
|
373
|
+
export function textLangFailureWarning(
|
|
374
|
+
stderr: string,
|
|
375
|
+
platform: string = process.platform,
|
|
376
|
+
): string | null {
|
|
377
|
+
if (platform !== "darwin") return null;
|
|
378
|
+
return (
|
|
379
|
+
`Text language detection failed${stderr ? `: ${stderr}` : ""}\n` +
|
|
380
|
+
" Falling back to the default voice. Fix: run `kesha install` to reinstall the kesha-textlang sidecar."
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
332
384
|
export interface TtsLanguageCapability {
|
|
333
385
|
code: string;
|
|
334
386
|
engines: string[];
|
package/src/install-plan.ts
CHANGED
|
@@ -332,8 +332,17 @@ function assembleComponents(input: {
|
|
|
332
332
|
),
|
|
333
333
|
...input.tts.components,
|
|
334
334
|
];
|
|
335
|
-
|
|
336
|
-
|
|
335
|
+
// #768: --diarize implies VAD — speaker labels attach to VAD-windowed segments.
|
|
336
|
+
if (options.vad || options.diarize) {
|
|
337
|
+
components.push(
|
|
338
|
+
modelBundle(
|
|
339
|
+
"VAD Silero v5",
|
|
340
|
+
VAD_FILES,
|
|
341
|
+
options.vad
|
|
342
|
+
? "long-audio preprocessing"
|
|
343
|
+
: "long-audio preprocessing; --diarize installs it to window audio into labelable segments",
|
|
344
|
+
),
|
|
345
|
+
);
|
|
337
346
|
}
|
|
338
347
|
if (options.diarize) {
|
|
339
348
|
components.push(
|
package/src/progress.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { copyFileSync, readdirSync, renameSync, rmSync, statSync } from "fs";
|
|
2
|
+
import { basename, dirname, join } from "path";
|
|
3
|
+
import { errorMessage } from "./error-utils";
|
|
1
4
|
import { log } from "./log";
|
|
2
5
|
|
|
3
6
|
const BAR_WIDTH = 20;
|
|
@@ -126,6 +129,62 @@ export async function applyBackpressure(
|
|
|
126
129
|
if (written < 0) await writer.flush();
|
|
127
130
|
}
|
|
128
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Age threshold and platform gate mirror `rust/src/models.rs::cleanup_orphan_staging`: a
|
|
134
|
+
* concurrent installer's staging file keeps a fresh mtime while bytes stream into it, and
|
|
135
|
+
* Windows leaves last-write time stale while a handle is open, so an in-flight download
|
|
136
|
+
* there could not be told apart from an orphan.
|
|
137
|
+
*/
|
|
138
|
+
const STALE_STAGING_MS = 24 * 60 * 60 * 1000;
|
|
139
|
+
|
|
140
|
+
/** A Ctrl-C kills the process before any cleanup runs, so last run's staging file is swept by the next one (#770). */
|
|
141
|
+
function sweepStagingFiles(destPath: string): void {
|
|
142
|
+
if (process.platform === "win32") return;
|
|
143
|
+
const dir = dirname(destPath);
|
|
144
|
+
const prefix = `${basename(destPath)}.part.`;
|
|
145
|
+
const cutoffMs = Date.now() - STALE_STAGING_MS;
|
|
146
|
+
|
|
147
|
+
let names: string[];
|
|
148
|
+
try {
|
|
149
|
+
names = readdirSync(dir);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
log.debug(`Could not scan ${dir} for orphaned download staging: ${errorMessage(err)}`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const name of names) {
|
|
156
|
+
if (!name.startsWith(prefix)) continue;
|
|
157
|
+
const path = join(dir, name);
|
|
158
|
+
try {
|
|
159
|
+
if (statSync(path).mtimeMs > cutoffMs) continue;
|
|
160
|
+
rmSync(path, { force: true });
|
|
161
|
+
log.debug(`Cleaned up orphaned download staging: ${name}`);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
log.debug(`Could not sweep orphaned download staging ${name}: ${errorMessage(err)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Windows refuses `rename` onto a destination another handle still holds; POSIX overwrites. */
|
|
169
|
+
function publishStaging(stagingPath: string, destPath: string): void {
|
|
170
|
+
try {
|
|
171
|
+
renameSync(stagingPath, destPath);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (process.platform !== "win32") throw err;
|
|
174
|
+
copyFileSync(stagingPath, destPath);
|
|
175
|
+
rmSync(stagingPath, { force: true });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The pid alone would collide between two concurrent calls in one process, which then share a staging file. */
|
|
180
|
+
let stagingSeq = 0;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Downloads into a sibling `.part.<pid>.<n>` file and renames it into place, so an
|
|
184
|
+
* interrupted download can never leave a truncated binary at `destPath` (#770). Mirrors the
|
|
185
|
+
* staging `rust/src/models.rs::write_verified` already does for model files. Staging beside
|
|
186
|
+
* the destination keeps the rename within one filesystem, where it is atomic.
|
|
187
|
+
*/
|
|
129
188
|
export async function streamResponseToFile(
|
|
130
189
|
res: Response,
|
|
131
190
|
destPath: string,
|
|
@@ -140,17 +199,25 @@ export async function streamResponseToFile(
|
|
|
140
199
|
const totalBytes = Number(res.headers.get("content-length") || 0);
|
|
141
200
|
const progress = createProgressBar(label, totalBytes);
|
|
142
201
|
|
|
143
|
-
|
|
202
|
+
sweepStagingFiles(destPath);
|
|
203
|
+
const stagingPath = `${destPath}.part.${process.pid}.${stagingSeq++}`;
|
|
204
|
+
const writer = Bun.file(stagingPath).writer();
|
|
144
205
|
let bytes = 0;
|
|
145
206
|
try {
|
|
146
|
-
|
|
147
|
-
await
|
|
148
|
-
|
|
149
|
-
|
|
207
|
+
try {
|
|
208
|
+
for await (const chunk of res.body) {
|
|
209
|
+
await applyBackpressure(writer, writer.write(chunk));
|
|
210
|
+
bytes += chunk.length;
|
|
211
|
+
progress.update(chunk.length);
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
// Must await: an open write handle makes the freshly-downloaded engine unspawnable — EBUSY on Windows, ETXTBSY on Linux.
|
|
215
|
+
await writer.end();
|
|
150
216
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
217
|
+
publishStaging(stagingPath, destPath);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
rmSync(stagingPath, { force: true });
|
|
220
|
+
throw err;
|
|
154
221
|
}
|
|
155
222
|
|
|
156
223
|
progress.finish();
|
package/src/transcribe.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
assertSpeakersVadCompatible,
|
|
2
3
|
isEngineInstalled,
|
|
3
4
|
preflightTranscribeEngineWithSegments,
|
|
4
5
|
transcribeEngine,
|
|
@@ -30,6 +31,9 @@ export async function transcribe(audioPath: string, opts: TranscribeOptions = {}
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export async function preflightTranscribeWithSegments(opts: TranscribeOptions = {}): Promise<void> {
|
|
34
|
+
// #768: ahead of isEngineInstalled(), so an invalid pair never reads as a missing install.
|
|
35
|
+
assertSpeakersVadCompatible(opts);
|
|
36
|
+
|
|
33
37
|
if (!isEngineInstalled()) {
|
|
34
38
|
throw new Error(
|
|
35
39
|
"Error: No transcription backend is installed.\n\n" +
|
package/src/voice-routing.ts
CHANGED
|
@@ -5,8 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
const RU_DARWIN_FALLBACK_VOICE = "macos-com.apple.voice.compact.ru-RU.Milena";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* darwin-arm64 (FluidAudio ANE voice pack) defaults. This table plus the
|
|
10
|
+
* `en`/`ru` switch arms must together cover every code `tts_languages()`
|
|
11
|
+
* advertises on a system_kokoro build — a missing entry silently drops
|
|
12
|
+
* `--voice` and the engine falls back to English (#769).
|
|
13
|
+
* fr is the documented brand-rule exception (no male French voice in Kokoro v1.0).
|
|
14
|
+
*/
|
|
8
15
|
const DARWIN_KOKORO_DEFAULTS: Record<string, string> = {
|
|
9
16
|
es: "es-em_alex",
|
|
17
|
+
fr: "fr-ff_siwis",
|
|
10
18
|
hi: "hi-hm_omega",
|
|
11
19
|
it: "it-im_nicola",
|
|
12
20
|
ja: "ja-jm_kumo",
|
|
@@ -40,7 +48,6 @@ export function pickVoiceForLang(
|
|
|
40
48
|
case "ru":
|
|
41
49
|
return platform === "darwin" ? RU_DARWIN_FALLBACK_VOICE : "ru-vosk-m02";
|
|
42
50
|
default:
|
|
43
|
-
// darwin-arm64: FluidAudio ANE voice pack (full multilingual set).
|
|
44
51
|
if (platform === "darwin" && arch === "arm64") return DARWIN_KOKORO_DEFAULTS[baseCode];
|
|
45
52
|
// ONNX: es/fr/it/pt via CharsiuG2P; hi/ja/zh have no ONNX pack → undefined.
|
|
46
53
|
return ONNX_KOKORO_DEFAULTS[baseCode];
|