@drakulavich/kesha-voice-kit 1.28.0-alpha.1 → 1.29.1

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
@@ -36,14 +36,14 @@ bun add -g @drakulavich/kesha-voice-kit
36
36
  kesha --version # confirms `kesha` resolved on PATH
37
37
 
38
38
  # 3. Download the engine and models — pick one path
39
- kesha init # guided: backend, languages, TTS voices
39
+ kesha init # guided: TTS languages and optional VAD / diarization
40
40
  kesha install --plan && kesha install # manual: preview the sizes, then download
41
41
 
42
42
  # 4. Transcribe
43
43
  kesha audio.ogg # transcript to stdout
44
44
  ```
45
45
 
46
- `kesha install` pulls ~2.5 GB on Linux/Windows and ~0.6 GB on Apple Silicon, whose CoreML engine reads a smaller model set. It is always explicit — nothing downloads behind your back — and the model step has no progress bar, so expect a few quiet minutes. If `bun --version` fails right after step 1, reload your PATH: `exec $SHELL -l`.
46
+ `kesha install` pulls ~2.5 GB on Linux/Windows and ~0.6 GB on Apple Silicon, whose CoreML engine reads a smaller model set. It is always explicit — nothing downloads behind your back — and reports download progress on stderr. If `bun --version` fails right after step 1, reload your PATH: `exec $SHELL -l`.
47
47
 
48
48
  Prefer Homebrew or Docker? 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).
@@ -90,15 +90,16 @@ $ kesha freedom.ogg tahiti.ogg
90
90
  ```
91
91
 
92
92
  - **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.
93
- - **Dictate straight to text (darwin-arm64):** `kesha record --live` transcribes the mic as it captures and prints the transcript to stdout — no WAV in between, so it pipes (`kesha record --live | pbcopy`). Progress goes to stderr. Other platforms keep the two-step `record --out` + transcribe flow. An interruption is recoverable: Ctrl-C (or SIGTERM) stops the session, still prints what you dictated, and exits 130/143, and the audio is spilled to a recovery WAV under `~/.cache/kesha/recordings/` — named on stderr when the session starts, deleted once the transcript has actually been delivered, kept if anything — a signal, a crash, a closed terminal, a dead pipe — got in the way first ([#962](https://github.com/drakulavich/kesha-voice-kit/issues/962)).
93
+ - **Dictate straight to text (darwin-arm64):** `kesha record --live` transcribes the mic as it captures and prints the transcript to stdout — no WAV in between, so it pipes (`kesha record --live | pbcopy`). To end after trailing silence, explicitly install VAD then opt in: `kesha install --vad && kesha record --live --auto-stop`. The defaults are 1,000 ms of silence after 250 ms of speech; tune them with `--auto-stop-silence-ms`, `--auto-stop-min-speech-ms`, and `--auto-stop-threshold`. Progress goes to stderr. Linux and Windows do not capture the microphone; pass an existing audio file to `kesha` to transcribe it. An interruption is recoverable: Ctrl-C (or SIGTERM) stops the session, still prints what you dictated, and exits 130/143, and the audio is spilled to a recovery WAV under `~/.cache/kesha/recordings/` — named on stderr when the session starts, deleted once the transcript has actually been delivered, kept if anything — a signal, a crash, a closed terminal, a dead pipe — got in the way first ([#962](https://github.com/drakulavich/kesha-voice-kit/issues/962)).
94
94
  - **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).
95
95
  - **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)).
96
96
  - **Word-level timestamps** (every platform): `kesha --json --timestamps audio.ogg` adds a `words` array to each segment — `{ "word": "email", "start": 0.72, "end": 1.12 }` — on the same file-relative clock as the segment, so a word always lies inside the segment carrying it. Read them off the decoder's own frame grid, so: times are quantised to 0.08 s, consecutive spans may overlap (each `end` is a per-word duration prediction, not the next word's `start`), `end >= start` rather than strictly greater, and punctuation stays attached to its word. The key is simply absent where a segment has none — any segment `--itn` rewrote, for one — so check the `transcribe.words` capability rather than expecting an empty array ([#720](https://github.com/drakulavich/kesha-voice-kit/issues/720)).
97
- - **Written-form numbers:** `--itn` rewrites what the model spells out — `"two hundred thirty two"` → `"232"`, `"five dollars and fifty cents"` → `"$5.50"`. Opt-in, every platform, timestamps untouched. English-only in practice; Russian and the rest pass through unchanged. Spoken punctuation names stay words (`"dot"`, `"comma"`, `"the period of growth"`) because Kesha transcribes speech rather than dictation — so `"example dot com"` keeps its words too ([#822](https://github.com/drakulavich/kesha-voice-kit/issues/822)).
97
+ - **Text-language detection:** JSON and TOON results include `textLanguage` with a language code, confidence, and its `source`. On macOS Kesha uses Apple `NLLanguageRecognizer`; elsewhere it uses the bundled `tinyld` fallback, whose confidence scale is different. This is separate from `audioLanguage`, which identifies the spoken audio when available.
98
+ - **Written-form numbers:** `--itn` rewrites what the model spells out — `"two hundred thirty two"` → `"232"`, `"five dollars and fifty cents"` → `"$5.50"`. Opt-in, every platform, timestamps untouched. English-only in practice; Russian and the rest pass through unchanged. Spoken punctuation names stay words (`"dot"`, `"comma"`, `"the period of growth"`) because Kesha transcribes speech rather than dictation — so `"example dot com"` keeps its words too ([#822](https://github.com/drakulavich/kesha-voice-kit/issues/822)). A sentence `"and"` survives the number that follows it (`"cats and three dogs"` → `"cats and 3 dogs"`), while an `"and"` the number owns still joins it (`"three hundred and five"` → `"305"`) ([#1000](https://github.com/drakulavich/kesha-voice-kit/issues/1000)) — and no longer splits the number around it (`"two hundred and thirty two"` → `"232"`, not `"230 2"`) ([#1006](https://github.com/drakulavich/kesha-voice-kit/issues/1006)). A hyphenated number reads the same as the spaced form (`"twenty-five apples"` → `"25 apples"`), while a hyphen between ordinary words is left alone (`"well-known"`, `"state-of-the-art"`, `"twenty-something"`) ([#1004](https://github.com/drakulavich/kesha-voice-kit/issues/1004)).
98
99
 
99
100
  ## Text-to-speech
100
101
 
101
- Kesha speaks back in [9 languages](docs/languages.md#text-to-speech). On macOS it picks the voice from the text's own language; on Linux and Windows text detection needs Apple frameworks, so state the language with `--lang <code>` (or the voice with `--voice <id>`) — otherwise the engine default speaks.
102
+ Kesha speaks back in [9 languages](docs/languages.md#text-to-speech). Kokoro runs natively through FluidAudio CoreML/ANE on Apple Silicon and through ONNX on Linux and Windows; Russian uses Vosk-TTS, while `macos-*` system voices need no model download. On macOS Kesha picks the voice from the text's own language; on Linux and Windows, state the language with `--lang <code>` (or the voice with `--voice <id>`) — otherwise the engine default speaks.
102
103
 
103
104
  ```bash
104
105
  kesha install --tts # English voices; sizes differ per platform — preview: kesha install --plan
package/SKILL.md CHANGED
@@ -130,12 +130,14 @@ kesha --json voice.ogg
130
130
  "text": "Привет, как дела?",
131
131
  "lang": "ru",
132
132
  "audioLanguage": { "code": "ru", "confidence": 0.98 },
133
- "textLanguage": { "code": "ru", "confidence": 0.99 }
133
+ "textLanguage": { "code": "ru", "confidence": 0.99, "source": "engine" }
134
134
  }]
135
135
  ```
136
136
 
137
137
  Use `lang` (or the more detailed `audioLanguage`/`textLanguage`) to decide how to respond.
138
138
 
139
+ **Read `textLanguage.source` before you compare its `confidence` to a threshold.** `"engine"` (macOS `NLLanguageRecognizer`) and `"tinyld"` (the CLI fallback used on Linux/Windows) score on different scales — the same correct detection reads ~0.98 from the engine and ~0.2 from `tinyld`, so a fixed `> 0.5` gate discards every correct fallback result. `audioLanguage` has one detector and carries no `source`.
140
+
139
141
  Need timestamped transcript segments for navigation, chapters, or downstream editing:
140
142
 
141
143
  ```bash
@@ -204,7 +206,7 @@ Format is also inferred from `--out` extension (`.ogg` / `.opus` / `.oga` → OG
204
206
 
205
207
  ## Language detection standalone
206
208
 
207
- `kesha --json audio.ogg` includes both audio-based (`audioLanguage`) and text-based (`textLanguage`) detection. Use audio detection to identify the language before running language-specific logic.
209
+ `kesha --json audio.ogg` includes both audio-based (`audioLanguage`) and text-based (`textLanguage`) detection. Use audio detection to identify the language before running language-specific logic. `textLanguage` also names its detector in `source` — see the threshold rule under [STT](#stt-transcribe-audio).
208
210
 
209
211
  ## Install
210
212
 
@@ -25,7 +25,7 @@ _kesha_completion() {
25
25
  logs) opts="--help -h --json" ;;
26
26
  manpage) opts="--help -h" ;;
27
27
  mcp) opts="--help -h" ;;
28
- record) opts="--help -h --out --live --max-seconds --debug" ;;
28
+ record) opts="--help -h --out --live --max-seconds --auto-stop --auto-stop-silence-ms --auto-stop-threshold --auto-stop-min-speech-ms --debug" ;;
29
29
  say) opts="--help -h --voice --lang --out --rate --list-voices --ssml --format --bitrate --sample-rate --no-expand-abbrev --verbose --debug" ;;
30
30
  stats) opts="--help -h --format" ;;
31
31
  status) opts="--help -h --disk --json" ;;
@@ -68,6 +68,10 @@ complete -c kesha -n '__fish_seen_subcommand_from record' -s h -d 'Show help'
68
68
  complete -c kesha -n '__fish_seen_subcommand_from record' -l out -r -d 'Write recorded WAV audio to this path'
69
69
  complete -c kesha -n '__fish_seen_subcommand_from record' -l live -d 'Transcribe the microphone live and print the transcript to stdout (CoreML on Apple Silicon only)'
70
70
  complete -c kesha -n '__fish_seen_subcommand_from record' -l max-seconds -r -d 'Maximum recording duration in seconds'
71
+ complete -c kesha -n '__fish_seen_subcommand_from record' -l auto-stop -d 'End a live recording after trailing silence (requires `kesha install --vad`)'
72
+ complete -c kesha -n '__fish_seen_subcommand_from record' -l auto-stop-silence-ms -r -d 'Trailing silence before auto-stop (default: 1000)'
73
+ complete -c kesha -n '__fish_seen_subcommand_from record' -l auto-stop-threshold -r -d 'Silero speech threshold for auto-stop (default: 0.5)'
74
+ complete -c kesha -n '__fish_seen_subcommand_from record' -l auto-stop-min-speech-ms -r -d 'Minimum speech before auto-stop may end a recording (default: 250)'
71
75
  complete -c kesha -n '__fish_seen_subcommand_from record' -l debug -d 'Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)'
72
76
  complete -c kesha -n '__fish_seen_subcommand_from say' -l help -d 'Show help'
73
77
  complete -c kesha -n '__fish_seen_subcommand_from say' -s h -d 'Show help'
@@ -98,6 +98,10 @@ _kesha() {
98
98
  '--out=[Write recorded WAV audio to this path]:out:' \
99
99
  '--live[Transcribe the microphone live and print the transcript to stdout (CoreML on Apple Silicon only)]' \
100
100
  '--max-seconds=[Maximum recording duration in seconds]:max seconds:' \
101
+ '--auto-stop[End a live recording after trailing silence (requires `kesha install --vad`)]' \
102
+ '--auto-stop-silence-ms=[Trailing silence before auto-stop (default: 1000)]:auto stop silence ms:' \
103
+ '--auto-stop-threshold=[Silero speech threshold for auto-stop (default: 0.5)]:auto stop threshold:' \
104
+ '--auto-stop-min-speech-ms=[Minimum speech before auto-stop may end a recording (default: 250)]:auto stop min speech ms:' \
101
105
  '--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]'
102
106
  ;;
103
107
  say)
@@ -21,7 +21,7 @@ The product promise is not "one model for every audio problem." The promise is a
21
21
  | Transcribe to structured output for scripts or agents | Stable | `kesha --json audio.ogg`, `kesha --toon audio.ogg`, `kesha --format transcript audio.ogg` |
22
22
  | Detect likely audio/text language during transcription | Stable | `--lang`, `--verbose`, JSON/TOON language fields |
23
23
  | Skip silence in long audio | Beta | `kesha --vad meeting.m4a`, auto-on for long audio when VAD is installed |
24
- | Synthesize English or Russian speech locally | Beta | `kesha say "text"`, `kesha install --tts` |
24
+ | Synthesize supported languages locally | Beta | `kesha say "text"`, `kesha install --tts` |
25
25
  | Use macOS system voices without model downloads | Stable on macOS | `kesha say --voice macos-*` |
26
26
  | Label speakers in meeting transcripts | Preview, darwin-arm64 only | `kesha --json --vad --speakers meeting.m4a` |
27
27
  | Integrate with OpenClaw as a local voice model | Stable integration surface, user-configured route | `docs/openclaw.md` |
@@ -35,7 +35,7 @@ The product promise is not "one model for every audio problem." The promise is a
35
35
  - Streaming phone-call transcription or real-time conversation infrastructure.
36
36
  - Speaker identity across files. Diarization emits per-file cluster IDs, not names or persistent voice profiles.
37
37
  - Full SSML coverage. Kesha supports a practical subset for current TTS engines; unsupported tags should fail or warn clearly.
38
- - Universal TTS across all 25 STT languages. TTS is intentionally scoped to English, Russian, and macOS system voices.
38
+ - Universal TTS across all 25 STT languages. TTS is intentionally scoped to its documented nine-language set and macOS system voices.
39
39
  - Model training, fine-tuning, or benchmark leadership on every dataset.
40
40
  - Browser/mobile SDKs. The supported product surface is CLI-first, with thin integrations around it.
41
41
 
@@ -47,7 +47,7 @@ The product promise is not "one model for every audio problem." The promise is a
47
47
  | JSON / TOON / transcript output formats | Stable | Intended for scripts, agents, and downstream tooling. |
48
48
  | Audio language detection | Stable | Used for warnings and structured metadata. |
49
49
  | VAD preprocessing | Beta | Useful for long or silence-heavy audio; short voice messages stay on the fast path. |
50
- | English/Russian TTS | Beta | Requires `kesha install --tts`; model cache and first-run cost are expected. |
50
+ | Kokoro and Vosk TTS | Beta | Nine documented languages; requires `kesha install --tts`, so model cache and first-run cost are expected. |
51
51
  | macOS AVSpeech voices | Stable on macOS | Zero-install system voices; quality is OS voice quality, not neural TTS. |
52
52
  | SSML subset | Preview | `<prosody rate>`, Russian stress markers, and IPA support are engine-specific. |
53
53
  | Speaker diarization | Preview | darwin-arm64 only; cluster IDs are stable within one file only. |
@@ -63,8 +63,9 @@ The product promise is not "one model for every audio problem." The promise is a
63
63
  | STT | Supported, CoreML path | Not shipped | Supported, ONNX CPU path | Supported, ONNX CPU path | `aarch64-darwin`, `x86_64-linux` |
64
64
  | Audio language detection | Supported | Not shipped | Supported | Supported | Supported where the flake builds |
65
65
  | VAD | Supported with `kesha install --vad` | Not shipped | Supported with `kesha install --vad` | Supported with `kesha install --vad` | Supported where the engine path includes VAD assets |
66
- | TTS: English Kokoro | Supported, FluidAudio/CoreML in release builds | Not shipped | Supported, ONNX path | Supported, ONNX path | Supported except where noted in `docs/nix-install.md` |
66
+ | TTS: Kokoro (`en` `es` `fr` `it` `pt`) | Supported, FluidAudio/CoreML in release builds | Not shipped | Supported, ONNX path | Supported, ONNX path | Supported except where noted in `docs/nix-install.md` |
67
67
  | TTS: Russian Vosk-TTS | Supported | Not shipped | Supported | Supported | Supported except where noted in `docs/nix-install.md` |
68
+ | TTS: Kokoro (`hi` `ja` `zh`) | Supported, darwin-arm64 only | Not shipped | Not supported | Not supported | `aarch64-darwin` only |
68
69
  | macOS system voices | Supported | Not shipped | Not applicable | Not applicable | Supported on `aarch64-darwin` |
69
70
  | Speaker diarization | Preview, darwin-arm64 only | Not supported | Not supported | Not supported | Not wired into the Nix build yet |
70
71
  | Raycast extension | Supported | Not shipped | Not applicable | Not applicable | Not applicable |
package/man/kesha.1 CHANGED
@@ -162,6 +162,18 @@ Transcribe the microphone live and print the transcript to stdout (CoreML on App
162
162
  .B \-\-max\-seconds
163
163
  Maximum recording duration in seconds
164
164
  .TP
165
+ .B \-\-auto\-stop
166
+ End a live recording after trailing silence (requires `kesha install \-\-vad`)
167
+ .TP
168
+ .B \-\-auto\-stop\-silence\-ms
169
+ Trailing silence before auto\-stop (default: 1000)
170
+ .TP
171
+ .B \-\-auto\-stop\-threshold
172
+ Silero speech threshold for auto\-stop (default: 0.5)
173
+ .TP
174
+ .B \-\-auto\-stop\-min\-speech\-ms
175
+ Minimum speech before auto\-stop may end a recording (default: 250)
176
+ .TP
165
177
  .B \-\-debug
166
178
  Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)
167
179
  .SS say
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.28.0-alpha.1",
3
+ "version": "1.29.1",
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,13 +40,13 @@
40
40
  }
41
41
  },
42
42
  "keshaEngine": {
43
- "version": "1.24.9"
43
+ "version": "1.24.11"
44
44
  },
45
45
  "scripts": {
46
46
  "test": "bun test --timeout 30000",
47
47
  "test:watch": "bun test --timeout 30000 --watch",
48
48
  "test:unit": "bun test --timeout 30000 tests/unit/",
49
- "test:cli-fast": "bun test --timeout 30000 tests/unit/ tests/integration/cli-contracts.test.ts tests/integration/say.test.ts",
49
+ "test:cli-fast": "bun test --timeout 30000 tests/unit/ tests/integration/cli-contracts.test.ts tests/integration/error-codes-cli.test.ts tests/integration/say.test.ts",
50
50
  "test:integration": "bun test --timeout 30000 tests/integration/",
51
51
  "coverage:ts": "bun run test:cli-fast --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage/ts",
52
52
  "coverage:check:ts": "bun .github/scripts/check-coverage.ts --preset=ts --lcov=coverage/ts/lcov.info --summary=coverage/ts/summary.md",
@@ -1,6 +1,7 @@
1
1
  import { runMain, type CommandDef } from "citty";
2
2
  import { existsSync } from "fs";
3
3
  import { log } from "../log";
4
+ import { guardStdoutWrites } from "../stdout-pipe";
4
5
  import { suggestCommand } from "../suggest-command";
5
6
  import { applyCliContext, resolveCliContext } from "./context";
6
7
 
@@ -89,6 +90,7 @@ export function unknownCommandMessages(token: string, subcommandKeys: string[]):
89
90
  }
90
91
 
91
92
  export async function runCli(argv = process.argv.slice(2)): Promise<void> {
93
+ guardStdoutWrites();
92
94
  const context = resolveCliContext(argv);
93
95
  applyCliContext(context);
94
96
 
@@ -9,6 +9,7 @@ import { log } from "../log";
9
9
  import { packageVersion } from "../package-info";
10
10
  import { isSemver } from "../semver.mjs";
11
11
  import { createDiagnosticLogSession, type DiagnosticLogSession } from "../diagnostic-log";
12
+ import { getPendingSignalExitCode, waitForPendingSignalCleanup } from "../process-tree";
12
13
  import type { SharedInstallArgs } from "./types";
13
14
 
14
15
  export interface InstallCommandArgs extends SharedInstallArgs {
@@ -239,6 +240,11 @@ export async function performInstall(options: PerformInstallOptions) {
239
240
  await maybeAskForStar(getEngineBinPath(), packageVersion, log);
240
241
  finishInstallDiagnostic(diagnosticLog, startedAt, "success");
241
242
  } catch (err: unknown) {
243
+ const signalExitCode = getPendingSignalExitCode();
244
+ if (signalExitCode !== null) {
245
+ await waitForPendingSignalCleanup();
246
+ process.exit(signalExitCode);
247
+ }
242
248
  const message = errorMessage(err);
243
249
  finishInstallDiagnostic(diagnosticLog, startedAt, "failed", errorKind);
244
250
  log.error(message);
package/src/cli/main.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { errorMessage } from "../error-utils";
3
3
  import { existsSync, statSync } from "fs";
4
- import { detect } from "tinyld";
4
+ import { detectAll } from "tinyld";
5
5
  import { preflightTranscribeWithSegments, transcribeWithSegments } from "../transcribe";
6
6
  import { detectAudioLanguageEngine, detectTextLanguageEngine } from "../engine";
7
7
  import type { LangDetectResult } from "../engine";
8
8
  import { log } from "../log";
9
- import type { TranscribeErrorRecord, TranscribeResult } from "../types";
9
+ import type { TextLangDetectResult, TranscribeErrorRecord, TranscribeResult } from "../types";
10
10
  import {
11
11
  formatJsonOutput,
12
12
  formatTextOutput,
@@ -33,8 +33,6 @@ interface MainCommandArgs {
33
33
  debug: boolean;
34
34
  vad: boolean;
35
35
  "no-vad": boolean;
36
- noVad?: boolean;
37
- no_vad?: boolean;
38
36
  timestamps: boolean;
39
37
  speakers: boolean;
40
38
  itn: boolean;
@@ -45,9 +43,21 @@ interface MainCommandArgs {
45
43
  "no-color": boolean;
46
44
  }
47
45
 
46
+ /**
47
+ * The CLI-side `tinyld` fallback, tagged `source: "tinyld"` so a consumer can
48
+ * tell it apart from an Engine detection instead of reading the placeholder
49
+ * `confidence: 0` it used to carry as a genuine zero (#941). The score is
50
+ * tinyld's own and is not on the Engine's scale.
51
+ */
52
+ export function detectTextLanguageFallback(text: string): TextLangDetectResult | undefined {
53
+ if (!text) return undefined;
54
+ const [best] = detectAll(text);
55
+ if (!best) return undefined;
56
+ return { code: best.lang, confidence: best.accuracy, source: "tinyld" };
57
+ }
58
+
48
59
  export function detectLanguage(text: string): string {
49
- if (!text) return "";
50
- return detect(text);
60
+ return detectTextLanguageFallback(text)?.code ?? "";
51
61
  }
52
62
 
53
63
  /** True when `path` exists and is a directory. Used to reject directory positionals before any progress/engine spawn. */
@@ -65,31 +75,31 @@ export function isDirectoryPath(path: string): boolean {
65
75
  * with its own clearer error first.
66
76
  */
67
77
  export type ResolvedOutputFormat =
68
- | {
69
- ok: true;
70
- wantsJson: boolean;
71
- wantsToon: boolean;
72
- wantsTranscript: boolean;
73
- }
78
+ | { ok: true; format: "json" | "toon" | "transcript" | "text" }
74
79
  | { ok: false; error: string };
75
80
 
76
81
  const SUPPORTED_FORMATS = ["transcript", "json", "toon"] as const;
77
82
 
83
+ function isSupportedFormat(format: string): format is (typeof SUPPORTED_FORMATS)[number] {
84
+ return SUPPORTED_FORMATS.some((supported) => supported === format);
85
+ }
86
+
78
87
  export function resolveOutputFormat(input: {
79
88
  json?: boolean;
80
89
  toon?: boolean;
81
90
  format?: string;
82
91
  }): ResolvedOutputFormat {
83
- if (input.format !== undefined && !SUPPORTED_FORMATS.includes(input.format as never)) {
92
+ if (input.format !== undefined && !isSupportedFormat(input.format)) {
84
93
  return {
85
94
  ok: false,
86
95
  error: `unknown --format '${input.format}'. supported: ${SUPPORTED_FORMATS.join(", ")}`,
87
96
  };
88
97
  }
89
- const wantsJson = !!input.json || input.format === "json";
90
- const wantsToon = !!input.toon || input.format === "toon";
91
- const wantsTranscript = input.format === "transcript";
92
- if (wantsJson && wantsToon) {
98
+ if (
99
+ (input.json && input.toon) ||
100
+ (input.json && input.format === "toon") ||
101
+ (input.toon && input.format === "json")
102
+ ) {
93
103
  return {
94
104
  ok: false,
95
105
  error: "--json and --toon are mutually exclusive (pick one output format).",
@@ -100,7 +110,7 @@ export function resolveOutputFormat(input: {
100
110
  // dispatch checked wantsJson/wantsToon first). Greptile P2 on #300
101
111
  // flagged the silent override. Fail loudly with the same shape as
102
112
  // the json/toon mutex — symmetric across all three formats.
103
- if (wantsTranscript && (wantsJson || wantsToon)) {
113
+ if (input.format === "transcript" && (input.json || input.toon)) {
104
114
  return {
105
115
  ok: false,
106
116
  error:
@@ -108,7 +118,7 @@ export function resolveOutputFormat(input: {
108
118
  "(pick one output format).",
109
119
  };
110
120
  }
111
- return { ok: true, wantsJson, wantsToon, wantsTranscript };
121
+ return { ok: true, format: input.json ? "json" : input.toon ? "toon" : (input.format ?? "text") };
112
122
  }
113
123
 
114
124
  export function checkLanguageMismatch(expected: string | undefined, detected: string): string | null {
@@ -151,6 +161,19 @@ export type ValidatedTranscribeArgs = {
151
161
  outputFormat: "json" | "toon" | "transcript" | "text";
152
162
  };
153
163
 
164
+ type MainVadArgs = Pick<MainCommandArgs, "vad" | "no-vad">;
165
+
166
+ export function normalizeMainCommandArgs<T extends MainVadArgs>(
167
+ args: T,
168
+ rawArgs: string[],
169
+ ): T & { noVad: boolean } {
170
+ return {
171
+ ...args,
172
+ vad: rawArgs.includes("--vad") || args.vad,
173
+ noVad: rawArgs.includes("--no-vad") || args["no-vad"] === true,
174
+ };
175
+ }
176
+
154
177
  export type TranscribeArgsValidation =
155
178
  | ({ ok: true } & ValidatedTranscribeArgs)
156
179
  | { ok: false; error: string };
@@ -161,20 +184,16 @@ export function validateTranscribeArgs(
161
184
  rawArgs: string[],
162
185
  fmt: ResolvedOutputFormat & { ok: true },
163
186
  ): TranscribeArgsValidation {
164
- const { wantsJson, wantsToon, wantsTranscript } = fmt;
165
-
166
- // citty treats --no-vad as the negated form of --vad, so read rawArgs
167
- // to distinguish "off" from the default auto mode and to catch both flags.
168
- const vad = rawArgs.includes("--vad") || Boolean(args.vad);
169
- const noVad = rawArgs.includes("--no-vad") || Boolean(args["no-vad"] ?? args.noVad ?? args.no_vad);
187
+ const normalizedArgs = normalizeMainCommandArgs(args, rawArgs);
188
+ const { vad, noVad } = normalizedArgs;
170
189
 
171
190
  if (vad && noVad) {
172
191
  return { ok: false, error: "--vad and --no-vad are mutually exclusive." };
173
192
  }
174
- if (args.timestamps && !(wantsJson || wantsToon)) {
193
+ if (args.timestamps && fmt.format !== "json" && fmt.format !== "toon") {
175
194
  return { ok: false, error: "--timestamps requires --json, --toon, or --format {json,toon}." };
176
195
  }
177
- if (args.speakers && !(wantsJson || wantsToon)) {
196
+ if (args.speakers && fmt.format !== "json" && fmt.format !== "toon") {
178
197
  return { ok: false, error: "--speakers requires --json, --toon, or --format {json,toon}." };
179
198
  }
180
199
  // #768: diarization labels VAD-windowed segments, so --no-vad leaves nothing to label.
@@ -187,20 +206,12 @@ export function validateTranscribeArgs(
187
206
  "(VAD engages automatically for --speakers), or drop --speakers.",
188
207
  };
189
208
  }
190
- if (args["include-errors"] && !(wantsJson || wantsToon)) {
209
+ if (args["include-errors"] && fmt.format !== "json" && fmt.format !== "toon") {
191
210
  return { ok: false, error: "--include-errors requires --json, --toon, or --format {json,toon}." };
192
211
  }
193
212
 
194
213
  const vadMode: ValidatedTranscribeArgs["vadMode"] = vad ? "on" : noVad ? "off" : "auto";
195
- const outputFormat: ValidatedTranscribeArgs["outputFormat"] = wantsJson
196
- ? "json"
197
- : wantsToon
198
- ? "toon"
199
- : wantsTranscript
200
- ? "transcript"
201
- : "text";
202
-
203
- return { ok: true, vadMode, outputFormat };
214
+ return { ok: true, vadMode, outputFormat: fmt.format };
204
215
  }
205
216
 
206
217
  async function detectLanguages(
@@ -215,7 +226,7 @@ async function detectLanguages(
215
226
  },
216
227
  ): Promise<{
217
228
  audioLanguage: LangDetectResult | undefined;
218
- textLanguage: LangDetectResult | undefined;
229
+ textLanguage: TextLangDetectResult | undefined;
219
230
  lang: string;
220
231
  ranAudioLangId: boolean;
221
232
  }> {
@@ -243,17 +254,17 @@ async function detectLanguages(
243
254
  }
244
255
  }
245
256
 
246
- const tinyldLang = wantsLangId ? detectLanguage(text) : "";
247
- let textLanguage: LangDetectResult | undefined;
257
+ const tinyldResult = wantsLangId ? detectTextLanguageFallback(text) : undefined;
258
+ let textLanguage: TextLangDetectResult | undefined;
248
259
 
249
260
  if (wantsLangId) {
250
261
  const engineTextResult = await stats.timeStage("lang_id_text", () => detectTextLanguageEngine(text));
251
262
  if (engineTextResult && engineTextResult.code) {
252
- textLanguage = engineTextResult;
263
+ textLanguage = { ...engineTextResult, source: "engine" };
253
264
  }
254
265
  }
255
266
 
256
- const lang = textLanguage?.code || tinyldLang;
267
+ const lang = textLanguage?.code || tinyldResult?.code || "";
257
268
 
258
269
  const mismatchWarning = checkLanguageMismatch(expectedLang, lang);
259
270
  if (mismatchWarning) {
@@ -262,7 +273,7 @@ async function detectLanguages(
262
273
 
263
274
  return {
264
275
  audioLanguage,
265
- textLanguage: textLanguage ?? (tinyldLang ? { code: tinyldLang, confidence: 0 } : undefined),
276
+ textLanguage: textLanguage ?? tinyldResult,
266
277
  lang,
267
278
  ranAudioLangId: audioResult !== null,
268
279
  };
@@ -295,20 +306,20 @@ async function processFile(
295
306
  command: "transcribe",
296
307
  error_code: TS_NATIVE_CODES.INPUT_NOT_FOUND,
297
308
  });
298
- log.error(`${file}: File not found`);
309
+ log.error(`${file}: error [${TS_NATIVE_CODES.INPUT_NOT_FOUND}]: File not found`);
299
310
  return { ok: false, error: { file, code: TS_NATIVE_CODES.INPUT_NOT_FOUND, message: "File not found" } };
300
311
  }
301
312
 
302
313
  if (isDirectoryPath(file)) {
303
- stats.recordError("input", new Error("is a directory"), ENGINE_CODES.BAD_AUDIO);
314
+ stats.recordError("input", new Error("is a directory"), TS_NATIVE_CODES.INVALID_ARG);
304
315
  diagnosticLog.event("input.invalid", {
305
316
  command: "transcribe",
306
- error_code: ENGINE_CODES.BAD_AUDIO,
317
+ error_code: TS_NATIVE_CODES.INVALID_ARG,
307
318
  });
308
- log.error(`${file}: is a directory (expected an audio file)`);
319
+ log.error(`${file}: error [${TS_NATIVE_CODES.INVALID_ARG}]: is a directory (expected an audio file)`);
309
320
  return {
310
321
  ok: false,
311
- error: { file, code: ENGINE_CODES.BAD_AUDIO, message: "is a directory (expected an audio file)" },
322
+ error: { file, code: TS_NATIVE_CODES.INVALID_ARG, message: "is a directory (expected an audio file)" },
312
323
  };
313
324
  }
314
325
 
@@ -324,6 +335,13 @@ async function processFile(
324
335
 
325
336
  const startedAt = performance.now();
326
337
  let progress: ReturnType<typeof createPercentProgress> | null = null;
338
+ // #1002: the bar sitting at 0% through a ~100 s diarization model load reads as a hang,
339
+ // so the engine's progress has to land while it happens, not once the run is over.
340
+ const showProgressLine = (line: string) => {
341
+ const write = () => process.stderr.write(`${line}\n`);
342
+ if (progress) progress.interrupt(write);
343
+ else write();
344
+ };
327
345
  try {
328
346
  await preflightTranscribeWithSegments({ vad: vadMode, timestamps, speakers, itn });
329
347
  progress = reportProgress
@@ -332,7 +350,13 @@ async function processFile(
332
350
  })
333
351
  : null;
334
352
  const transcript = await stats.timeStage("transcribe", () =>
335
- transcribeWithSegments(file, { vad: vadMode, timestamps, speakers, itn }),
353
+ transcribeWithSegments(file, {
354
+ vad: vadMode,
355
+ timestamps,
356
+ speakers,
357
+ itn,
358
+ onProgressLine: showProgressLine,
359
+ }),
336
360
  );
337
361
  const { text, segments } = transcript;
338
362
  const transcriptDurationSeconds = estimateTranscriptDurationSeconds(segments);
@@ -409,6 +433,21 @@ function writeOutput(
409
433
  }
410
434
 
411
435
  /** The transcribe command, bound to the global flags dispatch resolved before citty. */
436
+ export const MAIN_VAD_ARGS = {
437
+ vad: {
438
+ type: "boolean",
439
+ description:
440
+ "Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.",
441
+ default: false,
442
+ },
443
+ "no-vad": {
444
+ type: "boolean",
445
+ description:
446
+ "Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers",
447
+ default: false,
448
+ },
449
+ } as const;
450
+
412
451
  export function createMainCommand(context: CliContext = { quiet: false, disableColor: false }) {
413
452
  return defineCommand({
414
453
  meta: {
@@ -486,16 +525,7 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
486
525
  description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
487
526
  default: false,
488
527
  },
489
- vad: {
490
- type: "boolean",
491
- description: "Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.",
492
- default: false,
493
- },
494
- "no-vad": {
495
- type: "boolean",
496
- description: "Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers",
497
- default: false,
498
- },
528
+ ...MAIN_VAD_ARGS,
499
529
  // quiet and no-color are resolved before citty in dispatch.ts (so they
500
530
  // apply to every command, not just transcribe); declared here only so they
501
531
  // appear in `kesha --help`.