@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 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, `.deb`/`.rpm`, Docker, or Nix? See [Other install methods](#other-install-methods).
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`, then `kesha --json --vad --speakers meeting.m4a` stamps each segment with a `speaker` id. Linux/Windows return a clear "darwin-arm64 only" error ([#199](https://github.com/drakulavich/kesha-voice-kit/issues/199)).
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 --vad --speakers meeting.m4a > out.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 (~350 MB)
211
- kesha install --tts # + Kokoro + Vosk-TTS RU (~990 MB more)
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 --vad --diarize # + speaker diarization (darwin-arm64 only)
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 any macOS system voice via `--voice macos-*`.
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
 
@@ -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'
@@ -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-alpha.1",
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.7"
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
- const caps = await getEngineCapabilities();
44
- const supported = caps?.tts?.languages.map((l) => l.code) ?? TTS_LANG_FALLBACK;
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: true, diarize: true });
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("Install speaker diarization for `--speakers`?", args.diarize);
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
  }
@@ -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 { getEngineBinPath, getEngineCapabilities } from "../engine";
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
- * TTS languages installable on EVERY build, used when the engine binary isn't
24
- * installed yet (capabilities unavailable). hi/ja/zh are darwin-arm64-only and
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 const TTS_LANG_FALLBACK = ["en", "es", "fr", "it", "pt", "ru"];
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); when it's undefined
38
- * (engine not yet installed, capabilities unavailable) the check is skipped and
39
- * the engine validates authoritatively at download time.
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 getEngineCapabilities();
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` with
29
- * no mapped voice (an unsupported language, or French which has no male Kokoro
30
- * voice) resolves to `undefined` (engine default) rather than re-running
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
- if (installed) {
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
- function collectOptionalComponents(
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
- function formatInstalled(installed: boolean): string {
407
- return installed ? "installed" : "missing";
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}: ${formatInstalled(c.exists)}${note}`);
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} (${formatInstalled(report.engine.installed)})`,
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
+ }
@@ -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
- ...(opts.vad ? ["--vad"] : []),
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 tops up any sidecars the cached install is missing.
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
- const missing = SIDECARS.filter(
357
- (s) => !existsSync(join(engineDir, s.fileBasename)),
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
- try {
392
- // Only a throw means locked; the timeout is for a scanner that detains rather than refuses.
393
- const proc = Bun.spawn([binPath, "--version"], { stdout: "ignore", stderr: "ignore" });
394
- const timer = setTimeout(() => proc.kill(), probeTimeoutMs);
395
- try {
396
- await proc.exited;
397
- } finally {
398
- clearTimeout(timer);
399
- }
400
- return;
401
- } catch (e) {
402
- lastError = errorMessage(e);
403
- // Only a lock is worth waiting out — a missing or corrupt binary never becomes spawnable.
404
- if (!isTransientSpawnLock(lastError)) {
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 versionMatches = existsSync(binPath) && installedVersion === version;
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
- const envPath = process.env.KESHA_DIARIZE_MODEL_PATH;
204
- if (envPath !== undefined) {
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) return null;
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[];
@@ -332,8 +332,17 @@ function assembleComponents(input: {
332
332
  ),
333
333
  ...input.tts.components,
334
334
  ];
335
- if (options.vad) {
336
- components.push(modelBundle("VAD Silero v5", VAD_FILES, "long-audio preprocessing"));
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
- const writer = Bun.file(destPath).writer();
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
- for await (const chunk of res.body) {
147
- await applyBackpressure(writer, writer.write(chunk));
148
- bytes += chunk.length;
149
- progress.update(chunk.length);
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
- } finally {
152
- // Must await: an open write handle makes the freshly-downloaded engine unspawnable — EBUSY on Windows, ETXTBSY on Linux.
153
- await writer.end();
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" +
@@ -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];