@drakulavich/kesha-voice-kit 1.17.1 → 1.18.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
@@ -11,13 +11,15 @@
11
11
  <a href="https://bun.sh"><img src="https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun" alt="Bun"></a>
12
12
  </p>
13
13
 
14
- <p align="center"><b>Open-source voice toolkit.</b> Optimized for Apple Silicon (CoreML), works on any platform (ONNX fallback).<br>A collection of small, fast, open-source audio models — packaged as CLI tools and an <a href="https://github.com/openclaw/openclaw">OpenClaw</a> skill for LLM agents.</p>
14
+ <p align="center"><b>Open-source voice toolkit.</b> Optimized for Apple Silicon (CoreML), with ONNX fallback on supported Linux/Windows builds.<br>A collection of small, fast, open-source audio models — packaged as CLI tools and an <a href="https://github.com/openclaw/openclaw">OpenClaw</a> skill for LLM agents.</p>
15
15
 
16
16
  - **Speech-to-text** — 25 languages, ~15x faster than Whisper on Apple Silicon, ~2.5x on CPU
17
17
  - **Text-to-speech** — Kokoro (EN) + Vosk-TTS (RU) + macOS system voices, SSML preview
18
18
  - **Rust engine** — single 20MB binary, no ffmpeg, no Python, no native Node addons
19
19
  - **OpenClaw-ready** — plug into your LLM agent as a voice processing skill
20
20
 
21
+ See [Product positioning](docs/product-positioning.md) for supported workflows, non-goals, maturity labels, and the platform matrix.
22
+
21
23
  <p align="center">
22
24
  <img src="./demo.gif" alt="kesha demo — English + Russian transcription with automatic language detection" width="800">
23
25
  </p>
@@ -49,6 +51,32 @@ kesha audio.ogg # transcript to stdout
49
51
 
50
52
  Air-gapped or behind a corporate mirror? See [docs/model-mirror.md](docs/model-mirror.md).
51
53
 
54
+ ## Homebrew Install
55
+
56
+ Homebrew installs the Bun-based CLI wrapper. Engine and model downloads remain
57
+ explicit:
58
+
59
+ ```bash
60
+ brew tap oven-sh/bun
61
+ brew install drakulavich/tap/kesha-voice-kit
62
+ kesha install
63
+ kesha audio.ogg
64
+ ```
65
+
66
+ See [Homebrew install](docs/homebrew.md) for package scope and maintainer
67
+ validation.
68
+
69
+ ## Support diagnostics
70
+
71
+ Kesha can collect local diagnostics without downloading models or mutating cache state:
72
+
73
+ ```bash
74
+ kesha doctor --json --redact
75
+ kesha support-bundle --output kesha-support.tar.gz
76
+ ```
77
+
78
+ `support-bundle` creates a redacted `.tar.gz` archive for GitHub issues. It includes runtime, engine, cache, optional-component, Stats status, and known Kesha environment settings. It does not include audio, transcripts, model files, or the Stats database.
79
+
52
80
  ## Nix Install
53
81
 
54
82
  Alternative reproducible-build path on `aarch64-darwin` / `x86_64-linux`:
@@ -60,6 +88,44 @@ nix run github:drakulavich/kesha-voice-kit -- audio.ogg # transcribe
60
88
 
61
89
  Full recipes (one-liner, profile install, engine-only, dev shell) live in [docs/nix-install.md](docs/nix-install.md).
62
90
 
91
+ ## Docker
92
+
93
+ Linux x64 CLI image, published to GHCR:
94
+
95
+ ```bash
96
+ docker run --rm \
97
+ -v kesha-cache:/cache/kesha \
98
+ -v "$PWD:/work" -w /work \
99
+ ghcr.io/drakulavich/kesha-voice-kit:latest install
100
+
101
+ docker run --rm \
102
+ -v kesha-cache:/cache/kesha \
103
+ -v "$PWD:/work" -w /work \
104
+ ghcr.io/drakulavich/kesha-voice-kit:latest audio.ogg
105
+ ```
106
+
107
+ The image keeps model downloads and the engine cache under `/cache/kesha`.
108
+ Mount that path as a named volume so `kesha install`, TTS models, VAD, and future
109
+ runs reuse the same cache. `compose.yml` provides the same layout:
110
+
111
+ ```bash
112
+ docker compose run --rm kesha install
113
+ docker compose run --rm kesha audio.ogg
114
+ ```
115
+
116
+ ## Linux Packages
117
+
118
+ Stable engine releases also publish `.deb` and `.rpm` packages for Linux x64.
119
+ They install the standalone CLI wrapper; engine and model downloads remain explicit:
120
+
121
+ ```bash
122
+ kesha install
123
+ kesha audio.ogg
124
+ ```
125
+
126
+ See [Linux packages](docs/linux-packages.md) for install commands and package
127
+ scope.
128
+
63
129
  ## Speech-to-text
64
130
 
65
131
  ```bash
@@ -100,7 +166,7 @@ Each segment gets a `speaker` integer (cluster ID, stable within one file). Linu
100
166
 
101
167
  ## Text-to-speech
102
168
 
103
- Kesha speaks back via Kokoro-82M (English) and Vosk-TTS (Russian) — voice auto-picks from the text's language:
169
+ Kesha speaks back via Kokoro-82M (English) and Vosk-TTS (Russian) — voice auto-picks from the text's language. On darwin-arm64, Kokoro uses FluidAudio CoreML instead of ONNX:
104
170
 
105
171
  ```bash
106
172
  kesha install --tts # ~990MB (Kokoro + Vosk-TTS RU, opt-in)
@@ -158,7 +224,7 @@ See [BENCHMARK.md](BENCHMARK.md) for the full per-file breakdown (Russian + Engl
158
224
  | SpeechBrain ECAPA-TDNN | Audio language detection | ~86MB | [HuggingFace](https://huggingface.co/speechbrain/lang-id-voxlingua107-ecapa) |
159
225
  | Apple NLLanguageRecognizer | Text language detection | built-in | macOS system framework |
160
226
  | Silero VAD v5 (opt-in) | Voice activity detection | ~2.3MB | [snakers4/silero-vad](https://github.com/snakers4/silero-vad) |
161
- | Kokoro-82M / Vosk-TTS (opt-in) | Text-to-speech | ~990MB | [Kokoro](https://huggingface.co/hexgrad/Kokoro-82M) · [Vosk-TTS](https://github.com/alphacep/vosk-tts) |
227
+ | Kokoro-82M / Vosk-TTS (opt-in) | Text-to-speech | ~990MB | [FluidAudio Kokoro](https://github.com/FluidInference/FluidAudio) on darwin-arm64; ONNX Kokoro elsewhere · [Vosk-TTS](https://github.com/alphacep/vosk-tts) |
162
228
 
163
229
  All models run through `kesha-engine` — a Rust binary using [FluidAudio](https://github.com/FluidInference/FluidAudio) (CoreML) on Apple Silicon and [ort](https://github.com/pykeio/ort) (ONNX Runtime) on other platforms.
164
230
 
package/SKILL.md CHANGED
@@ -22,7 +22,7 @@ Local voice toolkit: transcribe voice messages to text, synthesize speech, detec
22
22
  ## When to use
23
23
 
24
24
  - **Voice memo arrived** (Telegram, WhatsApp, Slack, Signal .ogg/.opus/.m4a): transcribe with `kesha --json <path>` and branch on the detected language.
25
- - **Need to reply with audio (file playback)**: synthesize with `kesha say "<text>" > reply.wav`. Auto-routes by detected language (Kokoro-82M for English, Vosk-TTS for Russian). For other languages and ~180 more voices use `--voice macos-*` on macOS (zero model download).
25
+ - **Need to reply with audio (file playback)**: synthesize with `kesha say "<text>" > reply.wav`. Auto-routes by detected language (Kokoro-82M for English, Vosk-TTS for Russian). On darwin-arm64, English Kokoro uses FluidAudio CoreML instead of ONNX. For other languages and ~180 more voices use `--voice macos-*` on macOS (zero model download).
26
26
  - **Need to send a voice note (Telegram, WhatsApp, Signal, Discord)**: synthesize directly into the messenger-native format with `kesha say "<text>" --format ogg-opus --out reply.ogg`. Default is mono 24 kHz @ 32 kbps — what Telegram `sendVoice` expects. No `ffmpeg` round-trip needed.
27
27
  - **Need to detect what language a file is in** before choosing a pipeline: `kesha --json audio.ogg` returns both audio-based and text-based language detection with confidence scores.
28
28
 
@@ -153,7 +153,7 @@ No system deps — English G2P is embedded (`misaki-rs`); Russian G2P is bundled
153
153
 
154
154
  **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.
155
155
 
156
- **Text-to-speech:** English (Kokoro-82M, ~70 voices), Russian (Vosk-TTS, 5 baked-in speakers — default `ru-vosk-m02`), plus any macOS system voice via `--voice macos-*`.
156
+ **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-*`.
157
157
 
158
158
  ## Performance
159
159
 
@@ -0,0 +1,76 @@
1
+ # Product positioning
2
+
3
+ Kesha Voice Kit is a local-first voice toolkit for developers and agent workflows. It is built for small, scriptable jobs where a CLI can turn audio into text, text into audio, or voice messages into structured data without sending content to a hosted API.
4
+
5
+ The product promise is not "one model for every audio problem." The promise is a boring, automatable local voice stack with clear platform limits, predictable install paths, and machine-readable output.
6
+
7
+ ## Who this is for
8
+
9
+ - Developers and agent builders who need local speech-to-text for voice messages, meetings, support clips, or batch audio files.
10
+ - OpenClaw users who want private, low-latency voice-message transcription through a CLI model route.
11
+ - macOS users who want the fastest path on Apple Silicon through CoreML, with a CPU/ONNX fallback on Linux and Windows.
12
+ - Automation-heavy users who prefer stdout/stderr contracts, JSON/TOON output, and shell-friendly commands over a hosted dashboard.
13
+ - Projects that can accept explicit model downloads and local cache management in exchange for no API keys and no cloud audio upload.
14
+
15
+ ## Primary workflows
16
+
17
+ | Workflow | Status | Command surface |
18
+ |---|---|---|
19
+ | Transcribe one or more audio files to plain text | Stable | `kesha audio.ogg` |
20
+ | Transcribe to structured output for scripts or agents | Stable | `kesha --json audio.ogg`, `kesha --toon audio.ogg`, `kesha --format transcript audio.ogg` |
21
+ | Detect likely audio/text language during transcription | Stable | `--lang`, `--verbose`, JSON/TOON language fields |
22
+ | Skip silence in long audio | Beta | `kesha --vad meeting.m4a`, auto-on for long audio when VAD is installed |
23
+ | Synthesize English or Russian speech locally | Beta | `kesha say "text"`, `kesha install --tts` |
24
+ | Use macOS system voices without model downloads | Stable on macOS | `kesha say --voice macos-*` |
25
+ | Label speakers in meeting transcripts | Preview, darwin-arm64 only | `kesha --json --vad --speakers meeting.m4a` |
26
+ | Integrate with OpenClaw as a local voice model | Stable integration surface, user-configured route | `docs/openclaw.md` |
27
+ | Use Raycast actions for selected-file transcription or clipboard speech | Beta, macOS only | `raycast/` extension |
28
+ | Install through Nix instead of Bun/npm | Beta, selected systems | `docs/nix-install.md` |
29
+
30
+ ## Non-goals
31
+
32
+ - Hosted SaaS transcription, dashboards, accounts, billing, or team management.
33
+ - Streaming phone-call transcription or real-time conversation infrastructure.
34
+ - Speaker identity across files. Diarization emits per-file cluster IDs, not names or persistent voice profiles.
35
+ - Full SSML coverage. Kesha supports a practical subset for current TTS engines; unsupported tags should fail or warn clearly.
36
+ - Universal TTS across all 25 STT languages. TTS is intentionally scoped to English, Russian, and macOS system voices.
37
+ - Model training, fine-tuning, or benchmark leadership on every dataset.
38
+ - Browser/mobile SDKs. The supported product surface is CLI-first, with thin integrations around it.
39
+
40
+ ## Feature maturity
41
+
42
+ | Feature | Maturity | Notes |
43
+ |---|---|---|
44
+ | Core STT CLI | Stable | Main product path; stdout transcript, stderr progress/errors. |
45
+ | JSON / TOON / transcript output formats | Stable | Intended for scripts, agents, and downstream tooling. |
46
+ | Audio language detection | Stable | Used for warnings and structured metadata. |
47
+ | VAD preprocessing | Beta | Useful for long or silence-heavy audio; short voice messages stay on the fast path. |
48
+ | English/Russian TTS | Beta | Requires `kesha install --tts`; model cache and first-run cost are expected. |
49
+ | macOS AVSpeech voices | Stable on macOS | Zero-install system voices; quality is OS voice quality, not neural TTS. |
50
+ | SSML subset | Preview | `<prosody rate>`, Russian stress markers, and IPA support are engine-specific. |
51
+ | Speaker diarization | Preview | darwin-arm64 only; cluster IDs are stable within one file only. |
52
+ | Raycast extension | Beta | Convenience UI over the CLI; macOS-only. |
53
+ | OpenClaw plugin | Stable integration surface | Discovery is packaged; actual audio route remains explicit user config. |
54
+ | Nix flake | Beta | Reproducible alternate install path for selected systems; Bun/npm remains canonical. |
55
+
56
+ ## Platform matrix
57
+
58
+ | Capability | macOS arm64 | macOS x64 | Linux x64 | Windows x64 | Nix path |
59
+ |---|---|---|---|---|---|
60
+ | STT | Supported, CoreML path | Not shipped | Supported, ONNX CPU path | Supported, ONNX CPU path | `aarch64-darwin`, `x86_64-linux` |
61
+ | Audio language detection | Supported | Not shipped | Supported | Supported | Supported where the flake builds |
62
+ | 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 |
63
+ | 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` |
64
+ | TTS: Russian Vosk-TTS | Supported | Not shipped | Supported | Supported | Supported except where noted in `docs/nix-install.md` |
65
+ | macOS system voices | Supported | Not shipped | Not applicable | Not applicable | Supported on `aarch64-darwin` |
66
+ | Speaker diarization | Preview, darwin-arm64 only | Not supported | Not supported | Not supported | Not wired into the Nix build yet |
67
+ | Raycast extension | Supported | Not shipped | Not applicable | Not applicable | Not applicable |
68
+ | OpenClaw integration | Supported through CLI route | Not shipped | Supported through CLI route | Supported through CLI route | Use the installed `kesha` command from the chosen path |
69
+
70
+ `macOS x64` means Intel Macs. Kesha does not currently publish a `darwin-x64` engine binary, so Intel Macs are intentionally marked as not shipped rather than implied to use the ONNX fallback.
71
+
72
+ ## When to choose something else
73
+
74
+ Use a hosted transcription provider when you need managed uptime, centralized audit logs, team permissions, or no local model cache. Use a streaming ASR stack when partial transcripts during a live call matter more than local-first batch reliability. Use a domain-specific ASR model when medical, legal, or noisy industrial audio quality is the primary requirement.
75
+
76
+ Kesha is strongest when the input is already a file, the caller is an engineer or agent, and the desired output is a transcript, JSON object, TOON payload, or WAV file that can continue through a local pipeline.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.17.1",
3
+ "version": "1.18.1",
4
4
  "description": "Open-source voice toolkit. Speech-to-text (25 langs, ~19x faster than Whisper on Apple Silicon, ONNX fallback on CPU), text-to-speech (Kokoro + Vosk-TTS + ~180 macOS system voices, SSML), voice-activity detection, language detection (107 langs). Rust engine, OpenClaw skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "tsconfig.json",
19
19
  "openclaw.plugin.json",
20
20
  "openclaw-plugin.cjs",
21
+ "docs/product-positioning.md",
21
22
  "SKILL.md",
22
23
  "scripts/postinstall.cjs",
23
24
  "LICENSE",
@@ -30,15 +31,18 @@
30
31
  ]
31
32
  },
32
33
  "keshaEngine": {
33
- "version": "1.17.0"
34
+ "version": "1.18.0"
34
35
  },
35
36
  "scripts": {
36
37
  "test": "bun test",
37
38
  "test:unit": "bun test tests/unit/",
39
+ "test:cli-fast": "bun test tests/unit/ tests/integration/cli-contracts.test.ts tests/integration/e2e-cli.test.ts tests/integration/say.test.ts",
38
40
  "test:integration": "bun test tests/integration/",
39
41
  "lint": "bunx tsc --noEmit",
40
42
  "lint:tsgo": "bunx tsgo --noEmit",
43
+ "check": "bun run lint && bun run check:versions && bun run test:cli-fast",
41
44
  "check:workflows": "bun .github/scripts/check-workflows.ts",
45
+ "check:release-manifest": "bun .github/scripts/release-manifest.mjs --check",
42
46
  "check:versions": "bun .github/scripts/check-versions.ts",
43
47
  "postinstall": "node scripts/postinstall.cjs"
44
48
  },
@@ -2,13 +2,15 @@ import { runMain } from "citty";
2
2
  import { existsSync } from "fs";
3
3
  import { log } from "../log";
4
4
  import { suggestCommand } from "../suggest-command";
5
+ import { doctorCommand } from "./doctor";
5
6
  import { installCommand } from "./install";
6
7
  import { sayCommand } from "./say";
7
8
  import { statsCommand } from "./stats";
8
9
  import { statusCommand } from "./status";
10
+ import { supportBundleCommand } from "./support-bundle";
9
11
  import { mainCommand } from "./main";
10
12
 
11
- const SUBCOMMANDS = ["install", "status", "say", "stats"];
13
+ const SUBCOMMANDS = ["doctor", "install", "status", "say", "stats", "support-bundle"];
12
14
 
13
15
  function isPathLike(arg: string): boolean {
14
16
  return arg.includes(".") || arg.includes("/") || existsSync(arg);
@@ -17,6 +19,11 @@ function isPathLike(arg: string): boolean {
17
19
  export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
18
20
  const [firstArg, ...restArgs] = rawArgs;
19
21
 
22
+ if (firstArg === "doctor") {
23
+ await runMain(doctorCommand, { rawArgs: restArgs });
24
+ return;
25
+ }
26
+
20
27
  if (firstArg === "install") {
21
28
  await runMain(installCommand, { rawArgs: restArgs });
22
29
  return;
@@ -37,6 +44,11 @@ export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
37
44
  return;
38
45
  }
39
46
 
47
+ if (firstArg === "support-bundle") {
48
+ await runMain(supportBundleCommand, { rawArgs: restArgs });
49
+ return;
50
+ }
51
+
40
52
  // Check for unknown subcommands (non-flag, non-file-path args).
41
53
  // Extensionless existing files remain valid transcription inputs; missing
42
54
  // bare tokens are more likely command typos and should not start the engine.
@@ -0,0 +1,35 @@
1
+ import { defineCommand } from "citty";
2
+ import { collectDoctorReport, formatDoctorReport } from "../doctor";
3
+ import { log } from "../log";
4
+
5
+ interface DoctorCommandArgs {
6
+ json: boolean;
7
+ redact: boolean;
8
+ }
9
+
10
+ export const doctorCommand = defineCommand({
11
+ meta: {
12
+ name: "doctor",
13
+ description: "Collect support diagnostics without changing local state",
14
+ },
15
+ args: {
16
+ json: {
17
+ type: "boolean",
18
+ description: "Output diagnostics as JSON",
19
+ default: false,
20
+ },
21
+ redact: {
22
+ type: "boolean",
23
+ description: "Redact secrets and user-home paths from diagnostic output",
24
+ default: false,
25
+ },
26
+ },
27
+ async run({ args }: { args: DoctorCommandArgs }) {
28
+ const report = await collectDoctorReport({ redact: args.redact });
29
+ if (args.json) {
30
+ console.log(JSON.stringify(report, null, 2));
31
+ return;
32
+ }
33
+ log.info(formatDoctorReport(report));
34
+ },
35
+ });
@@ -1,8 +1,10 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { downloadEngine } from "../engine-install";
3
3
  import { getEngineBinPath } from "../engine";
4
+ import { renderInstallPlan } from "../install-plan";
4
5
  import { maybeAskForStar } from "../star";
5
6
  import { log } from "../log";
7
+ import { packageVersion } from "../package-info";
6
8
 
7
9
  interface InstallCommandArgs {
8
10
  coreml: boolean;
@@ -11,10 +13,9 @@ interface InstallCommandArgs {
11
13
  tts: boolean;
12
14
  vad: boolean;
13
15
  diarize: boolean;
16
+ plan: boolean;
14
17
  }
15
18
 
16
- const pkg = await Bun.file(new URL("../../package.json", import.meta.url)).json();
17
-
18
19
  function resolveBackendFlag(coreml: boolean, onnx: boolean): string | undefined {
19
20
  if (coreml && onnx) {
20
21
  log.error('Choose only one backend: "--coreml" or "--onnx".');
@@ -31,7 +32,12 @@ async function performInstall(
31
32
  tts = false,
32
33
  vad = false,
33
34
  diarize = false,
35
+ plan = false,
34
36
  ) {
37
+ if (plan) {
38
+ log.info(await renderInstallPlan({ noCache, backend, tts, vad, diarize }));
39
+ return;
40
+ }
35
41
  if (diarize && !(process.platform === "darwin" && process.arch === "arm64")) {
36
42
  log.error(
37
43
  "--diarize is currently darwin-arm64 only " +
@@ -41,8 +47,7 @@ async function performInstall(
41
47
  }
42
48
  try {
43
49
  await downloadEngine(noCache, backend, { tts, vad, diarize });
44
- const currentVersion = typeof pkg.version === "string" ? pkg.version : null;
45
- await maybeAskForStar(getEngineBinPath(), currentVersion, log);
50
+ await maybeAskForStar(getEngineBinPath(), packageVersion, log);
46
51
  } catch (err: unknown) {
47
52
  const message = err instanceof Error ? err.message : String(err);
48
53
  log.error(message);
@@ -71,6 +76,11 @@ export const installCommand = defineCommand({
71
76
  description: "Re-download even if cached",
72
77
  default: false,
73
78
  },
79
+ plan: {
80
+ type: "boolean",
81
+ description: "Show download, disk, and warm-up plan without changing local state",
82
+ default: false,
83
+ },
74
84
  tts: {
75
85
  type: "boolean",
76
86
  description: "Also install TTS models (Kokoro EN + Vosk-TTS RU, ~990MB)",
@@ -89,6 +99,6 @@ export const installCommand = defineCommand({
89
99
  },
90
100
  async run({ args }: { args: InstallCommandArgs }) {
91
101
  const backend = resolveBackendFlag(args.coreml, args.onnx);
92
- await performInstall(args["no-cache"], backend, args.tts, args.vad, args.diarize);
102
+ await performInstall(args["no-cache"], backend, args.tts, args.vad, args.diarize, args.plan);
93
103
  },
94
104
  });
package/src/cli/main.ts CHANGED
@@ -5,18 +5,17 @@ import { transcribeWithSegments } from "../transcribe";
5
5
  import { detectAudioLanguageEngine, detectTextLanguageEngine } from "../engine";
6
6
  import type { LangDetectResult } from "../engine";
7
7
  import { log } from "../log";
8
- import type { TranscribeResult } from "../types";
8
+ import type { TranscribeErrorRecord, TranscribeResult } from "../types";
9
9
  import {
10
10
  formatJsonOutput,
11
11
  formatTextOutput,
12
12
  formatTranscriptOutput,
13
13
  formatVerboseOutput,
14
14
  } from "../format";
15
+ import { packageVersion } from "../package-info";
15
16
  import { formatToonOutput } from "../toon";
16
17
  import { artifactFromFile, createStatsRecorder } from "../stats";
17
18
 
18
- const pkg = await Bun.file(new URL("../../package.json", import.meta.url)).json();
19
-
20
19
  interface MainCommandArgs {
21
20
  _: string[];
22
21
  json: boolean;
@@ -25,8 +24,11 @@ interface MainCommandArgs {
25
24
  debug: boolean;
26
25
  vad: boolean;
27
26
  "no-vad": boolean;
27
+ noVad?: boolean;
28
+ no_vad?: boolean;
28
29
  timestamps: boolean;
29
30
  speakers: boolean;
31
+ "include-errors": boolean;
30
32
  format?: string;
31
33
  lang?: string;
32
34
  }
@@ -107,15 +109,17 @@ export function checkLanguageMismatch(expected: string | undefined, detected: st
107
109
  export const mainCommand = defineCommand({
108
110
  meta: {
109
111
  name: "kesha",
110
- version: pkg.version,
112
+ version: packageVersion,
111
113
  description:
112
114
  "Kesha Voice Kit — open-source voice toolkit for Apple Silicon.\n" +
113
115
  "\n" +
114
116
  "Commands:\n" +
117
+ " doctor Collect support diagnostics.\n" +
115
118
  " install Download engine and models.\n" +
116
119
  " status Inspect installed backend.\n" +
117
120
  " say Synthesize speech from text.\n" +
118
- " stats Manage local anonymous performance stats.",
121
+ " stats Manage local anonymous performance stats.\n" +
122
+ " support-bundle Create a redacted diagnostics archive.",
119
123
  },
120
124
  args: {
121
125
  json: {
@@ -138,6 +142,11 @@ export const mainCommand = defineCommand({
138
142
  description: "Include speaker labels in transcript segments. Requires --json / --toon / --format json. Implies --timestamps. Currently darwin-arm64 only (#199).",
139
143
  default: false,
140
144
  },
145
+ "include-errors": {
146
+ type: "boolean",
147
+ description: "With --json, output { results, errors } so scripts can read per-file failures without parsing stderr",
148
+ default: false,
149
+ },
141
150
  verbose: {
142
151
  type: "boolean",
143
152
  description: "Show language detection details",
@@ -167,7 +176,7 @@ export const mainCommand = defineCommand({
167
176
  default: false,
168
177
  },
169
178
  },
170
- async run({ args }: { args: MainCommandArgs }) {
179
+ async run({ args, rawArgs }: { args: MainCommandArgs; rawArgs: string[] }) {
171
180
  if (args.debug) log.debugEnabled = true;
172
181
  const files = args._;
173
182
 
@@ -187,7 +196,12 @@ export const mainCommand = defineCommand({
187
196
  }
188
197
  const { wantsJson, wantsToon, wantsTranscript } = fmt;
189
198
 
190
- if (args.vad && args["no-vad"]) {
199
+ // citty treats --no-vad as the negated form of --vad, so read rawArgs
200
+ // to distinguish "off" from the default auto mode and to catch both flags.
201
+ const vad = rawArgs.includes("--vad") || Boolean(args.vad);
202
+ const noVad = rawArgs.includes("--no-vad") || Boolean(args["no-vad"] ?? args.noVad ?? args.no_vad);
203
+
204
+ if (vad && noVad) {
191
205
  log.error("--vad and --no-vad are mutually exclusive.");
192
206
  process.exit(2);
193
207
  }
@@ -199,21 +213,28 @@ export const mainCommand = defineCommand({
199
213
  log.error("--speakers requires --json, --toon, or --format {json,toon}.");
200
214
  process.exit(2);
201
215
  }
202
- const vadMode = args.vad ? "on" : args["no-vad"] ? "off" : "auto";
216
+ if (args["include-errors"] && !wantsJson) {
217
+ log.error("--include-errors requires --json or --format json.");
218
+ process.exit(2);
219
+ }
220
+ const vadMode = vad ? "on" : noVad ? "off" : "auto";
203
221
 
204
222
  if (files.length === 0) {
205
223
  log.info(
206
224
  "Usage: kesha <audio_file> [audio_file ...]\n" +
225
+ " kesha doctor [--json] [--redact]\n" +
207
226
  " kesha install [--no-cache]\n" +
208
227
  " kesha status\n" +
209
228
  " kesha say <text>\n" +
210
- " kesha stats [enable|disable|status|week|errors]",
229
+ " kesha stats [enable|disable|status|week|errors]\n" +
230
+ " kesha support-bundle [--output path.tar.gz]",
211
231
  );
212
232
  process.exit(1);
213
233
  }
214
234
 
215
235
  let hasError = false;
216
236
  const results: TranscribeResult[] = [];
237
+ const errors: TranscribeErrorRecord[] = [];
217
238
  const stats = createStatsRecorder("transcribe");
218
239
 
219
240
  const wantsLangId = !!(args.lang || args.verbose || wantsJson || wantsToon || wantsTranscript);
@@ -223,6 +244,7 @@ export const mainCommand = defineCommand({
223
244
  if (!existsSync(file)) {
224
245
  hasError = true;
225
246
  stats.recordError("input", new Error("File not found"), "file_not_found");
247
+ errors.push({ file, code: "file_not_found", message: "File not found" });
226
248
  log.error(`${file}: File not found`);
227
249
  continue;
228
250
  }
@@ -290,12 +312,13 @@ export const mainCommand = defineCommand({
290
312
  hasError = true;
291
313
  stats.recordError("transcribe", err);
292
314
  const message = err instanceof Error ? err.message : String(err);
315
+ errors.push({ file, code: "transcribe_failed", message });
293
316
  log.error(`${file}: ${message}`);
294
317
  }
295
318
  }
296
319
 
297
320
  if (wantsJson) {
298
- process.stdout.write(formatJsonOutput(results));
321
+ process.stdout.write(formatJsonOutput(results, args["include-errors"] ? errors : undefined));
299
322
  } else if (wantsToon) {
300
323
  process.stdout.write(formatToonOutput(results));
301
324
  } else if (wantsTranscript) {
package/src/cli/say.ts CHANGED
@@ -29,6 +29,55 @@ async function resolveText(inline: string | undefined): Promise<string> {
29
29
  return new TextDecoder().decode(merged).trim();
30
30
  }
31
31
 
32
+ export function shouldRejectMissingSayText(
33
+ inlineText: string | undefined,
34
+ stdinIsTty: boolean | undefined,
35
+ ): boolean {
36
+ return (inlineText === undefined || inlineText.length === 0) && stdinIsTty === true;
37
+ }
38
+
39
+ function parseFiniteNumberFlag(name: string, value: unknown): number | undefined {
40
+ if (value === undefined || value === null || value === false) return undefined;
41
+ const raw = String(value).trim();
42
+ const parsed = Number(raw);
43
+ if (raw === "" || !Number.isFinite(parsed)) {
44
+ log.error(`${name} must be a finite number.`);
45
+ process.exit(2);
46
+ }
47
+ return parsed;
48
+ }
49
+
50
+ function parseRateFlag(value: unknown): number | undefined {
51
+ const rate = parseFiniteNumberFlag("--rate", value);
52
+ if (rate === undefined) return undefined;
53
+ if (rate < 0.5 || rate > 2.0) {
54
+ log.error("--rate must be between 0.5 and 2.0.");
55
+ process.exit(2);
56
+ }
57
+ return rate;
58
+ }
59
+
60
+ function parseBitrateFlag(value: unknown): number | undefined {
61
+ const bitrate = parseFiniteNumberFlag("--bitrate", value);
62
+ if (bitrate === undefined) return undefined;
63
+ if (!Number.isInteger(bitrate) || bitrate <= 0) {
64
+ log.error("--bitrate must be a positive integer.");
65
+ process.exit(2);
66
+ }
67
+ return bitrate;
68
+ }
69
+
70
+ function parseSampleRateFlag(value: unknown): number | undefined {
71
+ const sampleRate = parseFiniteNumberFlag("--sample-rate", value);
72
+ if (sampleRate === undefined) return undefined;
73
+ const supported = [8000, 12000, 16000, 24000, 48000];
74
+ if (!Number.isInteger(sampleRate) || !supported.includes(sampleRate)) {
75
+ log.error("--sample-rate must be one of 8000, 12000, 16000, 24000, 48000.");
76
+ process.exit(2);
77
+ }
78
+ return sampleRate;
79
+ }
80
+
32
81
  export const sayCommand = defineCommand({
33
82
  meta: {
34
83
  name: "say",
@@ -88,11 +137,6 @@ export const sayCommand = defineCommand({
88
137
  process.exit(await proc.exited);
89
138
  }
90
139
 
91
- const inlineText = typeof args.text === "string" ? args.text : undefined;
92
- const text = await resolveText(inlineText);
93
- const explicitVoice = typeof args.voice === "string" ? args.voice : undefined;
94
- const voice = explicitVoice ?? (await autoRouteVoice(text));
95
-
96
140
  // Validate --format up front so we surface a clear error before spawning
97
141
  // the engine subprocess. The engine repeats the check authoritatively, but
98
142
  // catching it here gives the user a faster failure mode in scripts.
@@ -109,8 +153,12 @@ export const sayCommand = defineCommand({
109
153
  }
110
154
  }
111
155
 
156
+ const rate = parseRateFlag(args.rate);
157
+ const bitrate = parseBitrateFlag(args.bitrate);
158
+ const sampleRate = parseSampleRateFlag(args["sample-rate"]);
159
+
112
160
  // Reject --bitrate / --sample-rate with WAV up front to surface the error fast.
113
- const hasOpusOnlyFlag = Boolean(args.bitrate) || Boolean(args["sample-rate"]);
161
+ const hasOpusOnlyFlag = bitrate !== undefined || sampleRate !== undefined;
114
162
  if (hasOpusOnlyFlag) {
115
163
  const outExt = typeof args.out === "string"
116
164
  ? args.out.split(".").pop()?.toLowerCase()
@@ -122,16 +170,26 @@ export const sayCommand = defineCommand({
122
170
  }
123
171
  }
124
172
 
173
+ const inlineText = typeof args.text === "string" ? args.text : undefined;
174
+ const stdinIsTty = (process.stdin as { isTTY?: boolean }).isTTY;
175
+ if (shouldRejectMissingSayText(inlineText, stdinIsTty)) {
176
+ log.error("kesha say requires text or piped stdin. Usage: kesha say <text>");
177
+ process.exit(2);
178
+ }
179
+ const text = await resolveText(inlineText);
180
+ const explicitVoice = typeof args.voice === "string" ? args.voice : undefined;
181
+ const voice = explicitVoice ?? (await autoRouteVoice(text));
182
+
125
183
  const opts = {
126
184
  text,
127
185
  voice,
128
186
  lang: typeof args.lang === "string" ? args.lang : undefined,
129
187
  out: typeof args.out === "string" ? args.out : undefined,
130
- rate: args.rate ? Number(args.rate) : undefined,
188
+ rate,
131
189
  ssml: Boolean(args.ssml),
132
190
  format,
133
- bitrate: args.bitrate ? Number(args.bitrate) : undefined,
134
- sampleRate: args["sample-rate"] ? Number(args["sample-rate"]) : undefined,
191
+ bitrate,
192
+ sampleRate,
135
193
  noExpandAbbrev: Boolean(args["no-expand-abbrev"]),
136
194
  };
137
195
  const stats = createStatsRecorder("say");
package/src/cli/status.ts CHANGED
@@ -1,12 +1,23 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { showStatus } from "../status";
3
3
 
4
+ interface StatusCommandArgs {
5
+ disk: boolean;
6
+ }
7
+
4
8
  export const statusCommand = defineCommand({
5
9
  meta: {
6
10
  name: "status",
7
11
  description: "Show backend installation status",
8
12
  },
9
- async run() {
10
- await showStatus();
13
+ args: {
14
+ disk: {
15
+ type: "boolean",
16
+ description: "Include recursive cache disk usage",
17
+ default: false,
18
+ },
19
+ },
20
+ async run({ args }: { args: StatusCommandArgs }) {
21
+ await showStatus({ disk: args.disk });
11
22
  },
12
23
  });