@drakulavich/kesha-voice-kit 2.1.0 → 2.2.0-beta.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,9 +11,9 @@
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>Give your local tools and LLM agents a voice.</b><br>Fast speech-to-text, text-to-speech, voice-activity detection, and language detection in one local-first CLI — CoreML on Apple Silicon, ONNX on Linux and Windows.</p>
14
+ <p align="center"><b>Kesha listens and talks back.</b><br>Your agent gets a voice message, Kesha turns it into text on your machine, and speaks the reply once you add <code>kesha install --tts</code> (<a href="docs/use-cases.md#sending-voice-replies">voice replies</a>). Fastest on Apple Silicon with CoreML, and it runs on Linux and Windows through ONNX.</p>
15
15
 
16
- - **Transcribe locally** — [25 languages](docs/languages.md#speech-to-text-25), up to ~19x faster than Whisper on Apple Silicon, ~2.5x on CPU
16
+ - **Transcribe locally** — [25 languages](docs/languages.md#speech-to-text-25), up to ~19x faster than Whisper on Apple Silicon, ~10x on an x86 CPU
17
17
  - **Speak back** — text-to-speech in [9 languages](docs/languages.md#text-to-speech)
18
18
  - **Plug into agents** — ship voice workflows as CLI commands, an MCP server, an <a href="docs/openclaw.md">OpenClaw</a> skill, or a <a href="docs/hermes.md">Hermes</a> agent
19
19
  - **Small Rust engine** — single ~65MB binary, no ffmpeg, no Python, no native Node addons
@@ -24,7 +24,7 @@
24
24
 
25
25
  ## Quick Start
26
26
 
27
- Runtime: **[Bun](https://bun.sh)** >= 1.3.0.
27
+ Runtime: **[Bun](https://bun.sh)** >= 1.4.0.
28
28
 
29
29
  ```bash
30
30
  # 1. Install Bun (skip if you have it)
@@ -64,6 +64,14 @@ All three targets transcribe, detect the spoken language, run VAD, and speak. Th
64
64
 
65
65
  Intel Macs get no published engine binary. Full matrix with maturity labels: [docs/product-positioning.md](docs/product-positioning.md#platform-matrix).
66
66
 
67
+ ## Plug into your agent
68
+
69
+ - **MCP server** — `kesha mcp` exposes transcribe/synthesize/list tools to any MCP client (Claude, Cursor, Codex, Gemini). Setup: [docs/mcp.md](docs/mcp.md).
70
+ - **OpenClaw** — give your LLM agent ears. Install & config: [docs/openclaw.md](docs/openclaw.md).
71
+ - **Hermes Agent** — local STT/TTS through Hermes command providers. Setup: [docs/hermes.md](docs/hermes.md).
72
+ - **Raycast** (macOS) — offline microphone dictation from the launcher: *Dictate to Clipboard* records with a live signal meter, auto-stops on silence, transcribes locally, and copies the text. [Install from the Raycast Store](https://www.raycast.com/drakulavich/kesha-voice-kit) · source: [`raycast/`](raycast/).
73
+ - **Programmatic API** — `@drakulavich/kesha-voice-kit/core` for use inside a Bun program. See [docs/api.md](docs/api.md).
74
+
67
75
  ## Speech-to-text
68
76
 
69
77
  ```bash
@@ -126,13 +134,13 @@ kesha say "Hello" --format flac --out hi.flac # FLAC — lossless, plays in
126
134
 
127
135
  ## Performance
128
136
 
129
- > **Up to ~19x faster than Whisper** on Apple Silicon (M2), **~2.5x faster** on CPU
137
+ > **Up to ~19x faster than Whisper** on Apple Silicon (M2), **~10x faster** on an x86 Linux CPU (~2.5x faster than faster-whisper)
130
138
 
131
139
  Compared against Whisper `large-v3-turbo`, all engines auto-detecting language:
132
140
 
133
141
  ![Benchmark: openai-whisper vs faster-whisper vs Kesha Voice Kit](https://github.com/drakulavich/kesha-voice-kit/raw/main/docs/assets/benchmark.svg)
134
142
 
135
- Full per-file breakdown (Russian + English): [BENCHMARK.md](BENCHMARK.md). The CPU figure is the ONNX engine on an M2's CPU cores; no x86 numbers are published yet.
143
+ Full per-file breakdown (Russian + English): [BENCHMARK.md](BENCHMARK.md). The chart is from an M2. The x86 figure comes from a GitHub-hosted `ubuntu-latest` runner (AMD EPYC, ONNX on CPU), where openai-whisper runs in FP32.
136
144
 
137
145
  ## Other install methods
138
146
 
@@ -144,14 +152,6 @@ All of these install the Bun CLI wrapper; engine + models still download explici
144
152
  - **Nix** (`aarch64-darwin` / `x86_64-linux`) — builds the engine from source (`nix build github:drakulavich/kesha-voice-kit#kesha-engine`). The full `kesha` CLI via `nix run` / `nix profile install` is **not yet available** — it needs a maintainer with Nix to populate a build hash ([#946](https://github.com/drakulavich/kesha-voice-kit/issues/946)). · [docs/distribution.md](docs/distribution.md#nix)
145
153
  - **Shell completions + manpage** — `kesha completions bash|zsh|fish` and `kesha manpage` print the packaged files to install wherever your shell expects them.
146
154
 
147
- ## Integrations
148
-
149
- - **MCP server** — `kesha mcp` exposes transcribe/synthesize/list tools to any MCP client (Claude, Cursor, Codex, Gemini). Setup: [docs/mcp.md](docs/mcp.md).
150
- - **OpenClaw** — give your LLM agent ears. Install & config: [docs/openclaw.md](docs/openclaw.md).
151
- - **Hermes Agent** — local STT/TTS through Hermes command providers. Setup: [docs/hermes.md](docs/hermes.md).
152
- - **Raycast** (macOS) — offline microphone dictation from the launcher: *Dictate to Clipboard* records with a live signal meter, auto-stops on silence, transcribes locally, and copies the text. [Install from the Raycast Store](https://www.raycast.com/drakulavich/kesha-voice-kit) · source: [`raycast/`](raycast/).
153
- - **Programmatic API** — `@drakulavich/kesha-voice-kit/core` for use inside a Bun program. See [docs/api.md](docs/api.md).
154
-
155
155
  ## More
156
156
 
157
157
  - [Architecture](docs/architecture.md) — runtime data flow, the models that ship, the CLI ↔ Rust engine boundary, model pinning, and where tests live.
@@ -1,6 +1,6 @@
1
1
  # Product positioning
2
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.
3
+ Kesha listens and talks back: an agent gets a voice message, Kesha turns it into text locally, and speaks the reply. It is a local-first voice toolkit for developers and agent workflows, and Apple Silicon is where it is fastest and most complete. 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
4
 
5
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
6
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "2.1.0",
4
- "description": "Give your tools a voice — speech to text and back, 25 languages, up to ~19× faster than Whisper. On your machine.",
3
+ "version": "2.2.0-beta.1",
4
+ "description": "Kesha listens and talks back: local speech to text and text to speech for agents and scripts, fastest on Apple Silicon.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "kesha": "bin/kesha.js"
@@ -54,7 +54,7 @@
54
54
  "check": "bun run lint && bun run check:versions && bun run check:recipes && bun run test:cli-fast",
55
55
  "check:workflows": "bun .github/scripts/check-workflows.ts",
56
56
  "check:recipes": "bun .github/scripts/check-recipes.ts",
57
- "check:specs": "bunx @fission-ai/openspec@1.4.1 validate --specs --strict",
57
+ "check:specs": "openspec validate --specs --strict",
58
58
  "check:release-manifest": "bun .github/scripts/release-manifest.mjs --check",
59
59
  "check:versions": "bun .github/scripts/check-versions.ts",
60
60
  "check:engine-targets": "bun .github/scripts/check-engine-targets.ts",
@@ -83,9 +83,10 @@
83
83
  "url": "https://github.com/drakulavich/kesha-voice-kit/issues"
84
84
  },
85
85
  "engines": {
86
- "bun": ">=1.3.0"
86
+ "bun": ">=1.4.0"
87
87
  },
88
88
  "devDependencies": {
89
+ "@fission-ai/openspec": "1.14.1",
89
90
  "@types/bun": "latest",
90
91
  "fast-xml-parser": "^5.8.0",
91
92
  "typescript": "^7.0.2",
@@ -93,7 +94,7 @@
93
94
  },
94
95
  "dependencies": {
95
96
  "@clack/prompts": "^1.5.0",
96
- "@modelcontextprotocol/sdk": "^1.29.0",
97
+ "@modelcontextprotocol/sdk": "^1.32.1",
97
98
  "@toon-format/toon": "^4.1.0",
98
99
  "citty": "^0.2.2",
99
100
  "fastest-levenshtein": "^1.0.16",
@@ -109,19 +110,19 @@
109
110
  },
110
111
  "kesha": {
111
112
  "engine": {
112
- "version": "2.1.0",
113
+ "version": "2.2.0-beta.1",
113
114
  "sha256": {
114
- "kesha-engine-darwin-arm64": "51446285f272a3d5060aa2dd21d2d16a5c31682a8554f29c944fe1af27677008",
115
- "kesha-engine-linux-x64": "8728b467ca31640739dcbe18e53eadd27e795033075f6442fc14b729e0014a62",
116
- "kesha-engine-windows-x64.exe": "82515821a6b424ff9c75462721f7723891705a2f1edcba0a1eb5c1704cfadbec",
117
- "say-avspeech-darwin-arm64": "a3aa5b75ddff68a48f1158e838310b37cac72ef86841d9cfcdf1ee233cb7451a",
115
+ "kesha-engine-darwin-arm64": "ccdd35b2433950175a6e3791ac3264969a732ea005fae4642b32842b2eb3443b",
116
+ "kesha-engine-linux-x64": "626fd32be46645e312ae3ad4cdf15838d0c4f54d56bdc67ec6d8f2a017813db8",
117
+ "kesha-engine-windows-x64.exe": "b38bc35bc2d0304f324da02b1d2857e86baf3c69e5351e5df2619c1c3a38889a",
118
+ "say-avspeech-darwin-arm64": "3a7e8b8fac92ce4ce0c34f736098a685dcb745f741db8303e1721e320ac73a98",
118
119
  "kesha-textlang-darwin-arm64": "e45e2822248b14dd72aa79e2def8fa6b9c07c97423f11c841508769225d72da4"
119
120
  },
120
121
  "size": {
121
- "kesha-engine-darwin-arm64": 73639664,
122
- "kesha-engine-linux-x64": 67394344,
123
- "kesha-engine-windows-x64.exe": 66586112,
124
- "say-avspeech-darwin-arm64": 64208,
122
+ "kesha-engine-darwin-arm64": 73639712,
123
+ "kesha-engine-linux-x64": 67404584,
124
+ "kesha-engine-windows-x64.exe": 66583040,
125
+ "say-avspeech-darwin-arm64": 82992,
125
126
  "kesha-textlang-darwin-arm64": 57648
126
127
  }
127
128
  }
@@ -1,10 +1,12 @@
1
- import { runMain, type ArgsDef, type CommandDef } from "citty";
1
+ import { renderUsage, runMain, type ArgsDef, type CommandDef } from "citty";
2
2
  import { existsSync } from "fs";
3
- import { log } from "../log";
3
+ import { stripVTControlCharacters } from "util";
4
+ import { isColorEnabled, log } from "../log";
5
+ import { packageVersion } from "../package-info";
4
6
  import { guardStdoutWrites } from "../stdout-pipe";
5
7
  import { suggestCommand } from "../suggest-command";
6
8
  import { applyCliContext, resolveCliContext } from "./context";
7
- import { rejectUnknownOptions, renderInvalidArg } from "./options";
9
+ import { rejectExtraPositionals, rejectUnknownOptions, renderInvalidArg } from "./options";
8
10
 
9
11
  // Lazy loaders so a cold CLI spawn transpiles only the invoked command, not the whole graph (#568).
10
12
  // `CommandDef<any>`: citty's generic is invariant in the arg shape and each command has its own schema.
@@ -87,7 +89,12 @@ export function unknownCommandMessages(token: string, subcommandKeys: string[]):
87
89
  if (suggestCommand(token, ["transcribe"]) === "transcribe") {
88
90
  warnLines.push("To transcribe, pass the audio path directly: kesha ./recording.ogg");
89
91
  }
90
- return { errorLine: `unknown command '${token}'`, warnLines };
92
+ return { errorLine: renderInvalidArg(`unknown command '${token}'`), warnLines };
93
+ }
94
+
95
+ async function showUsage<T extends ArgsDef>(cmd: CommandDef<T>, parent?: CommandDef<T>): Promise<void> {
96
+ const usage = await renderUsage(cmd, parent);
97
+ console.log((isColorEnabled() ? usage : stripVTControlCharacters(usage)) + "\n");
91
98
  }
92
99
 
93
100
  async function resolveArgsDef(command: CommandDef<any>): Promise<ArgsDef> {
@@ -121,9 +128,15 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
121
128
 
122
129
  switch (classifyFirstArg(firstArg, subcommandKeys)) {
123
130
  case "subcommand": {
131
+ if (restArgs.length === 1 && (restArgs[0] === "--version" || restArgs[0] === "-v")) {
132
+ process.stdout.write(`${packageVersion}\n`);
133
+ return;
134
+ }
124
135
  const command = await SUBCOMMANDS[firstArg!]!();
125
- rejectUnknownOptions(restArgs, await resolveArgsDef(command));
126
- await runMain(command, { rawArgs: restArgs });
136
+ const argsDef = await resolveArgsDef(command);
137
+ rejectUnknownOptions(restArgs, argsDef);
138
+ if (firstArg !== "install" && firstArg !== "say") rejectExtraPositionals(restArgs, argsDef);
139
+ await runMain(command, { rawArgs: restArgs, showUsage });
127
140
  return;
128
141
  }
129
142
 
@@ -132,7 +145,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
132
145
  const { errorLine, warnLines } = unknownCommandMessages(firstArg!, subcommandKeys);
133
146
  log.error(errorLine);
134
147
  for (const line of warnLines) log.warn(line);
135
- process.exit(1);
148
+ process.exit(2);
136
149
  break;
137
150
  }
138
151
 
@@ -142,10 +155,15 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
142
155
  log.error(renderInvalidArg(`put global flags after the subcommand: kesha ${misplaced} ...`));
143
156
  process.exit(2);
144
157
  }
145
- const { createMainCommand } = await import("./main");
158
+ const { briefMainUsage, createMainCommand } = await import("./main");
146
159
  const command = createMainCommand(context);
147
- rejectUnknownOptions(rawArgs, await resolveArgsDef(command));
148
- await runMain(command, { rawArgs });
160
+ const help = rawArgs.includes("--help") || rawArgs.includes("-h");
161
+ rejectUnknownOptions(help ? rawArgs.filter((arg) => arg !== "--all") : rawArgs, await resolveArgsDef(command));
162
+ if (help) {
163
+ await showUsage(rawArgs.includes("--all") ? command : briefMainUsage(command));
164
+ return;
165
+ }
166
+ await runMain(command, { rawArgs, showUsage });
149
167
  }
150
168
  }
151
169
  }
package/src/cli/init.ts CHANGED
@@ -3,7 +3,7 @@ import { confirm, multiselect, isCancel, cancel } from "@clack/prompts";
3
3
  import { installCommandTokens, renderInstallPlan } from "../install-plan";
4
4
  import { log } from "../log";
5
5
  import { errorMessage } from "../error-utils";
6
- import { exitCodeFor } from "../engine/events";
6
+ import { exitCodeFor, KeshaError } from "../engine/events";
7
7
  import { getEngineCapabilities } from "../engine";
8
8
  import {
9
9
  installableTtsLangs,
@@ -98,30 +98,6 @@ export function initInstallArgs(selection: InitSelection): string[] {
98
98
  return installCommandTokens(selection, selection.ttsLangs);
99
99
  }
100
100
 
101
- export function initSuggestionCommands(
102
- selection: InitSelection,
103
- canDiarize = canInstallDiarizeOnPlatform(),
104
- ): string[][] {
105
- const variants: InitSelection[] = [
106
- selection,
107
- { ...selection, ttsLangs: [], vad: true, diarize: false },
108
- { ...selection, ttsLangs: ["en"], vad: true, diarize: false },
109
- ];
110
-
111
- // #768: --diarize installs VAD on its own, so no explicit --vad here.
112
- if (canDiarize) {
113
- variants.push({ ...selection, ttsLangs: [], vad: false, diarize: true });
114
- }
115
-
116
- const seen = new Set<string>();
117
- return variants.map(initInstallArgs).filter((command) => {
118
- const key = command.join("\0");
119
- if (seen.has(key)) return false;
120
- seen.add(key);
121
- return true;
122
- });
123
- }
124
-
125
101
  export function omitUnsupportedDiarize(
126
102
  selection: InitSelection,
127
103
  canDiarize = canInstallDiarizeOnPlatform(),
@@ -188,20 +164,6 @@ async function printPlan(selection: InitSelection): Promise<void> {
188
164
  );
189
165
  }
190
166
 
191
- async function runNonInteractive(selection: InitSelection): Promise<void> {
192
- const canDiarize = canInstallDiarizeOnPlatform();
193
- const printableSelection = omitUnsupportedDiarize(selection, canDiarize);
194
- if (selection.diarize && !canDiarize) {
195
- log.warn("--diarize is currently darwin-arm64 only; omitting it from non-interactive examples.");
196
- }
197
- log.info(renderInitOverview(canDiarize));
198
- await printPlan(printableSelection);
199
- log.info("Run one of these commands from an interactive terminal:");
200
- for (const command of initSuggestionCommands(printableSelection, canDiarize)) {
201
- log.info(` ${command.join(" ")}`);
202
- }
203
- }
204
-
205
167
  export const initCommand = defineCommand({
206
168
  meta: {
207
169
  name: "init",
@@ -279,7 +241,11 @@ export const initCommand = defineCommand({
279
241
  const stdinIsTty = process.stdin.isTTY === true;
280
242
  const stdoutIsTty = process.stdout.isTTY === true;
281
243
  if (!stdinIsTty || !stdoutIsTty) {
282
- await runNonInteractive(selection);
244
+ const noTerminal = new KeshaError("E_INVALID_ARG", "kesha init is interactive and needs a terminal", {
245
+ hint: "run `kesha init --yes` to install the defaults, or `kesha init --plan` to show the plan",
246
+ });
247
+ log.error(errorMessage(noTerminal));
248
+ process.exitCode = exitCodeFor(noTerminal);
283
249
  return;
284
250
  }
285
251
 
package/src/cli/logs.ts CHANGED
@@ -23,6 +23,9 @@ export function runLogsAction(args: LogsCommandArgs): ActionResult {
23
23
  if (args.json && action !== "status") {
24
24
  return { ok: false, error: "usage: kesha logs status --json" };
25
25
  }
26
+ if (args.value !== undefined && action !== "mode" && SUPPORTED_ACTIONS.split(", ").includes(action)) {
27
+ return { ok: false, error: `unexpected argument '${args.value}' (kesha logs ${action} takes no value)` };
28
+ }
26
29
  switch (action) {
27
30
  case "enable": {
28
31
  const status = setDiagnosticLogMode("on");
package/src/cli/main.ts CHANGED
@@ -1,10 +1,13 @@
1
- import { defineCommand } from "citty";
1
+ import { defineCommand, type ArgsDef, type CommandDef } from "citty";
2
2
  import { errorMessage } from "../error-utils";
3
3
  import { existsSync } from "fs";
4
4
  import { isDirectoryPath, transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
5
5
 
6
6
  export { isDirectoryPath };
7
- import { detectAudioLanguageEngine, detectTextLanguageEngine } from "../engine";
7
+ import { detectAudioLanguageEngine, detectTextLanguageEngine, isEngineInstalled } from "../engine";
8
+ import { renderInstallPlan } from "../install-plan";
9
+ import { canInstallDiarizeOnPlatform, promptConfirm } from "./init";
10
+ import { performInstall } from "./install";
8
11
  import type { LangDetectResult } from "../engine";
9
12
  import { log } from "../log";
10
13
  import type { TextLangDetectResult, TranscribeErrorRecord, TranscribeResult } from "../types";
@@ -409,7 +412,8 @@ async function processFile(
409
412
  error_code: code,
410
413
  });
411
414
  log.error(`${file}: ${stderrText}`);
412
- return { ok: false, error: { file, code, message: stderrText }, exitCode: failureExitCode(err) };
415
+ const message = err instanceof Error ? err.message : String(err);
416
+ return { ok: false, error: { file, code, message }, exitCode: failureExitCode(err) };
413
417
  }
414
418
  }
415
419
 
@@ -452,13 +456,41 @@ export const MAIN_VAD_ARGS = {
452
456
  },
453
457
  } as const;
454
458
 
459
+ const BRIEF_HELP_DESCRIPTION =
460
+ "Kesha Voice Kit listens and talks back: local speech for agents and scripts.\n" +
461
+ "Try: kesha audio.ogg, or kesha say \"hello\"\n" +
462
+ "\n" +
463
+ "Commands:\n" +
464
+ " init Interactive setup guide.\n" +
465
+ " install Download engine and models.\n" +
466
+ " say Synthesize speech from text.\n" +
467
+ " record Record microphone audio.\n" +
468
+ " status Inspect installed backend.\n" +
469
+ " doctor Collect support diagnostics.\n" +
470
+ " mcp Run an MCP server over stdio.\n" +
471
+ "\n" +
472
+ "All commands and flags: kesha --help --all";
473
+
474
+ const BRIEF_HELP_ARGS: ArgsDef = {
475
+ json: { type: "boolean", description: "Output results as JSON" },
476
+ format: { type: "string", description: "transcript | json | toon" },
477
+ lang: { type: "string", description: "Expected language code, e.g. en" },
478
+ timestamps: { type: "boolean", description: "Timestamped segments in JSON/TOON" },
479
+ quiet: { type: "boolean", alias: "q", description: "Print only results and errors" },
480
+ };
481
+
482
+ /** What plain `kesha --help` renders; parsing still uses every flag of createMainCommand. */
483
+ export function briefMainUsage(command: CommandDef<any>): CommandDef<any> {
484
+ return { ...command, meta: { ...command.meta, description: BRIEF_HELP_DESCRIPTION }, args: BRIEF_HELP_ARGS };
485
+ }
486
+
455
487
  export function createMainCommand(context: CliContext = { quiet: false, disableColor: false }) {
456
488
  return defineCommand({
457
489
  meta: {
458
490
  name: "kesha",
459
491
  version: packageVersion,
460
492
  description:
461
- "Kesha Voice Kit — open-source voice toolkit for Apple Silicon.\n" +
493
+ "Kesha Voice Kit listens and talks back: local speech for agents and scripts.\n" +
462
494
  "\n" +
463
495
  "Examples:\n" +
464
496
  " kesha audio.ogg Transcribe an audio file.\n" +
@@ -572,6 +604,19 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
572
604
  process.exit(2);
573
605
  }
574
606
 
607
+ if (
608
+ !isEngineInstalled() &&
609
+ process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY &&
610
+ files.every((f) => existsSync(f) && !isDirectoryPath(f)) &&
611
+ (!args.speakers || canInstallDiarizeOnPlatform())
612
+ ) {
613
+ const modules = { vad: vadMode === "on", diarize: Boolean(args.speakers) };
614
+ log.info(await renderInstallPlan(modules));
615
+ if (await promptConfirm("Kesha needs these downloads before it can transcribe. Download them now?", true)) {
616
+ await performInstall({ noCache: false, ttsLangs: [], ...modules });
617
+ }
618
+ }
619
+
575
620
  const wantsLangId = !!(args.lang || args.verbose || outputFormat !== "text");
576
621
  const reportProgress = shouldReportTranscribeProgress({
577
622
  stderrIsTty: process.stderr.isTTY === true,
@@ -17,6 +17,10 @@ function camelCase(name: string): string {
17
17
 
18
18
  /** The first flag `argsDef` does not declare, or null; citty parses non-strict, so the flag would otherwise vanish and its value become a positional (S9-F3). */
19
19
  export function findUnknownOption(rawArgs: string[], argsDef: ArgsDef): UnknownOption | null {
20
+ return scanArgs(rawArgs, argsDef).unknown;
21
+ }
22
+
23
+ function scanArgs(rawArgs: string[], argsDef: ArgsDef): { unknown: UnknownOption | null; positionals: string[] } {
20
24
  const long = new Set(RUNNER_LONG);
21
25
  const short = new Set(RUNNER_SHORT);
22
26
  const valued = new Set<string>();
@@ -41,23 +45,30 @@ export function findUnknownOption(rawArgs: string[], argsDef: ArgsDef): UnknownO
41
45
  const match = suggestCommand(name, suggestable);
42
46
  return match === null ? null : `--${match}`;
43
47
  };
48
+ const positionals: string[] = [];
44
49
  for (let i = 0; i < rawArgs.length; i++) {
45
50
  const arg = rawArgs[i]!;
46
- if (arg === "--") break;
47
- if (!arg.startsWith("-") || arg === "-" || /^-\d/.test(arg)) continue;
51
+ if (arg === "--") {
52
+ positionals.push(...rawArgs.slice(i + 1));
53
+ break;
54
+ }
55
+ if (!arg.startsWith("-") || arg === "-" || /^-\d/.test(arg)) {
56
+ positionals.push(arg);
57
+ continue;
58
+ }
48
59
  if (arg.startsWith("--")) {
49
60
  const eq = arg.indexOf("=");
50
61
  const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
51
- if (!long.has(name)) return { name: `--${name}`, suggestion: suggest(name) };
62
+ if (!long.has(name)) return { unknown: { name: `--${name}`, suggestion: suggest(name) }, positionals };
52
63
  if (eq === -1 && valued.has(name)) i++;
53
64
  continue;
54
65
  }
55
66
  for (const letter of arg.slice(1)) {
56
- if (!short.has(letter)) return { name: `-${letter}`, suggestion: null };
67
+ if (!short.has(letter)) return { unknown: { name: `-${letter}`, suggestion: null }, positionals };
57
68
  }
58
69
  if (valued.has(arg[arg.length - 1]!)) i++;
59
70
  }
60
- return null;
71
+ return { unknown: null, positionals };
61
72
  }
62
73
 
63
74
  /** The coded line for a usage error the CLI refuses before doing anything (S9-F2). */
@@ -77,3 +88,13 @@ export function rejectUnknownOptions(rawArgs: string[], argsDef: ArgsDef): void
77
88
  log.error(renderUnknownOption(unknown));
78
89
  process.exit(2);
79
90
  }
91
+
92
+ /** Exits 2 with one coded stderr line when `rawArgs` carries more positionals than the command declares. */
93
+ export function rejectExtraPositionals(rawArgs: string[], argsDef: ArgsDef): void {
94
+ const declared = Object.values(argsDef).filter((def) => def.type === "positional").length;
95
+ const extra = scanArgs(rawArgs, argsDef).positionals[declared];
96
+ if (extra === undefined) return;
97
+ const tail = "output" in argsDef ? ` (did you mean --output ${extra}?)` : "";
98
+ log.error(renderInvalidArg(`unexpected argument '${extra}'${tail}`));
99
+ process.exit(2);
100
+ }
package/src/cli/say.ts CHANGED
@@ -358,8 +358,8 @@ export const sayCommand = defineCommand({
358
358
  log.error(errorMessage(err));
359
359
  process.exit(exitCodeFor(err));
360
360
  }
361
- await Bun.write(Bun.stdout, ids.map((id) => `${id}\n`).join(""));
362
- process.exit(0);
361
+ process.stdout.write(ids.map((id) => `${id}\n`).join(""));
362
+ return;
363
363
  }
364
364
 
365
365
  const flags = resolveSayFlags(args);
@@ -374,23 +374,26 @@ export const sayCommand = defineCommand({
374
374
  process.exit(2);
375
375
  }
376
376
 
377
- const inlineText = typeof args.text === "string" ? args.text : undefined;
377
+ const positionals = (args._ ?? []).map(String);
378
+ const inlineText =
379
+ positionals.length > 0 ? positionals.join(" ") : typeof args.text === "string" ? args.text : undefined;
378
380
  const stdinIsTty = (process.stdin as { isTTY?: boolean }).isTTY;
379
381
  if (shouldRejectMissingSayText(inlineText, stdinIsTty)) {
380
382
  log.error(renderInvalidArg("kesha say requires text or piped stdin. Usage: kesha say <text>"));
381
383
  process.exit(2);
382
384
  }
385
+ const explicitVoice = flags.voice;
386
+ const langHint = flags.lang;
383
387
  let text: string;
388
+ let voice: string | undefined;
384
389
  try {
385
390
  text = await resolveText(inlineText);
386
391
  validateSayText(text);
392
+ voice = await resolveSayVoice(explicitVoice, langHint, text);
387
393
  } catch (err) {
388
394
  log.error(errorMessage(err));
389
395
  process.exit(exitCodeFor(err));
390
396
  }
391
- const explicitVoice = flags.voice;
392
- const langHint = flags.lang;
393
- const voice = await resolveSayVoice(explicitVoice, langHint, text);
394
397
 
395
398
  const opts: SayOpts = {
396
399
  text,
package/src/cli/stats.ts CHANGED
@@ -26,6 +26,14 @@ const SUPPORTED_ACTIONS = "enable, disable, status, week, errors, export, reset,
26
26
  /** Runs one `kesha stats` action and reports what to print; all output happens in the caller. */
27
27
  export function runStatsAction(args: StatsCommandArgs): ActionResult {
28
28
  const action = args.action ?? "status";
29
+ if (
30
+ args.value !== undefined &&
31
+ action !== "export" &&
32
+ action !== "retention" &&
33
+ SUPPORTED_ACTIONS.split(", ").includes(action)
34
+ ) {
35
+ return { ok: false, error: `unexpected argument '${args.value}' (kesha stats ${action} takes no value)` };
36
+ }
29
37
  switch (action) {
30
38
  case "enable": {
31
39
  enableStats();
@@ -27,14 +27,23 @@ export const supportBundleCommand = defineCommand({
27
27
  },
28
28
  async run({ args }: { args: SupportBundleCommandArgs }) {
29
29
  try {
30
+ if (args.output === "") {
31
+ throw new KeshaError("E_INVALID_ARG", "--output needs a file path", { hint: "pass --output path.tar.gz." });
32
+ }
33
+ if (args.output === "-") {
34
+ throw new KeshaError("E_INVALID_ARG", "--output - is not a file path", {
35
+ hint: "to write the archive to stdout, pass --output /dev/stdout.",
36
+ });
37
+ }
30
38
  const bundle = await createSupportBundle({
31
39
  output: args.output,
32
40
  includeLogs: Boolean(args["include-logs"]),
33
41
  });
34
- log.status(`Created support bundle: ${bundle.path}`);
42
+ log.notice(`Created support bundle: ${bundle.path}`);
35
43
  log.status(`Entries: ${bundle.entries.length}`);
36
44
  log.status(`Size: ${bundle.sizeBytes} bytes`);
37
45
  } catch (err) {
46
+ if ((err as NodeJS.ErrnoException).code === "EPIPE") return;
38
47
  log.error(errorMessage(err));
39
48
  process.exit(err instanceof KeshaError ? exitCodeFor(err) : 1);
40
49
  }
package/src/cli.ts CHANGED
@@ -9,7 +9,6 @@ export {
9
9
  canInstallDiarizeOnPlatform,
10
10
  initCommand,
11
11
  initInstallArgs,
12
- initSuggestionCommands,
13
12
  omitUnsupportedDiarize,
14
13
  promptInitSelection,
15
14
  renderInitOverview,
package/src/doctor.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  NOT_FUNCTIONAL_STATE,
11
11
  probeExecutable,
12
12
  } from "./engine-health";
13
- import { readInstalledEngineVersion } from "./engine-version-marker";
13
+ import { MIN_ENGINE_VERSION, predatesMinimumEngine, readInstalledEngineVersion } from "./engine-version-marker";
14
14
  import { keshaCacheDir } from "./paths";
15
15
  import { installedVoiceIds } from "./voice-inventory";
16
16
  import { engineVersion, packageName, packageVersion } from "./package-info";
@@ -85,13 +85,14 @@ type DoctorDiagnosticLogStatus = DiagnosticLogStatus & { error?: string };
85
85
  * is a supported state (`kesha install --engine-version`), not a fault — `unrecorded` is
86
86
  * separate so a missing marker is never reported as a difference.
87
87
  */
88
- export type EngineVersionState = "matches-pin" | "differs-from-pin" | "unrecorded";
88
+ export type EngineVersionState = "matches-pin" | "differs-from-pin" | "unsupported" | "unrecorded";
89
89
 
90
90
  export function engineVersionState(
91
91
  versionMarker: string | null,
92
92
  pinnedVersion: string,
93
93
  ): EngineVersionState {
94
94
  if (versionMarker === null) return "unrecorded";
95
+ if (predatesMinimumEngine(versionMarker)) return "unsupported";
95
96
  return versionMarker === pinnedVersion ? "matches-pin" : "differs-from-pin";
96
97
  }
97
98
 
@@ -477,6 +478,11 @@ function formatVersionState(engine: DoctorReport["engine"]): string {
477
478
  `installed v${engine.versionMarker} differs from the pinned v${engine.pinnedVersion} — ` +
478
479
  "supported; `kesha install` without --engine-version restores the pin"
479
480
  );
481
+ case "unsupported":
482
+ return (
483
+ `installed v${engine.versionMarker} is older than v${MIN_ENGINE_VERSION}, ` +
484
+ "the oldest engine this CLI supports; run `kesha install`"
485
+ );
480
486
  case "unrecorded":
481
487
  return engine.installed
482
488
  ? "no recorded version beside the binary; `kesha install` records one"
@@ -128,7 +128,8 @@ export class KeshaError extends Error {
128
128
  const transcript = this.stderr?.trim();
129
129
  if (!transcript) return renderError(this);
130
130
  const line = renderError(this).split("\n")[0] ?? "";
131
- return transcript.split("\n").some((l) => l === line) ? transcript : `${renderError(this)}\n${transcript}`;
131
+ if (!transcript.split("\n").some((l) => l === line)) return `${renderError(this)}\n${transcript}`;
132
+ return this.hint && !transcript.includes(` hint: ${this.hint}`) ? `${transcript}\n hint: ${this.hint}` : transcript;
132
133
  }
133
134
  }
134
135
 
@@ -1,4 +1,12 @@
1
1
  import { readFileSync, writeFileSync } from "fs";
2
+ import { cmp, parseSemver, tryParseSemver } from "./semver.mjs";
3
+
4
+ export const MIN_ENGINE_VERSION = "1.26.0";
5
+
6
+ export function predatesMinimumEngine(version: string): boolean {
7
+ const parsed = tryParseSemver(version);
8
+ return parsed !== null && cmp(parsed, parseSemver(MIN_ENGINE_VERSION, "MIN_ENGINE_VERSION")) < 0;
9
+ }
2
10
 
3
11
  /**
4
12
  * Version-marker file written next to the engine binary on download (#151).
package/src/engine.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "child_process";
1
2
  import { existsSync, statSync, type Stats } from "fs";
2
3
  import { errorMessage } from "./error-utils";
3
4
  import { join } from "path";
@@ -8,6 +9,7 @@ import { defaultEngineBinPath, keshaCacheDir } from "./paths";
8
9
  import { engineAbortError, interruptedRun } from "./process-tree";
9
10
  import { runEngineProcess, spawnHint } from "./engine/spawn";
10
11
  import { engineFailure, KeshaError, renderError, type ErrorEvent } from "./engine/events";
12
+ import { MIN_ENGINE_VERSION, predatesMinimumEngine, readInstalledEngineVersion } from "./engine-version-marker";
11
13
  import {
12
14
  describeToCapabilities,
13
15
  parseDescribe,
@@ -124,6 +126,16 @@ function failed(run: EngineRun): boolean {
124
126
  return reportedFailure(run) || run.invalid.length > 0;
125
127
  }
126
128
 
129
+ function assertEngineSupported(binPath: string): void {
130
+ const installed = readInstalledEngineVersion(binPath);
131
+ if (installed === null || !predatesMinimumEngine(installed)) return;
132
+ throw new KeshaError(
133
+ "E_ENGINE_PROTOCOL",
134
+ `kesha-engine at ${binPath} is v${installed}; this CLI needs v${MIN_ENGINE_VERSION} or newer`,
135
+ { hint: "run `kesha install` to fetch the engine this CLI expects", versionMismatch: true },
136
+ );
137
+ }
138
+
127
139
  let cachedDescribe: { identity: string; doc: DescribeDocument } | null = null;
128
140
 
129
141
  /** ctime is part of it because utimes cannot set it back after a replacement that restores mtime (#1256, #1258). */
@@ -142,6 +154,7 @@ export async function getDescribe(opts: RunEngineOptions = {}): Promise<Describe
142
154
  hint: spawnHint(),
143
155
  });
144
156
  }
157
+ assertEngineSupported(binPath);
145
158
  if (cachedDescribe?.identity === identity) return cachedDescribe.doc;
146
159
  const run = await runEngine(["describe"], opts);
147
160
  let doc: DescribeDocument | null = null;
@@ -295,10 +308,29 @@ export function buildTranscribeArgs(
295
308
  return args;
296
309
  }
297
310
 
311
+ function hostIsVirtualMac(): boolean {
312
+ return spawnSync("sysctl", ["-n", "kern.hv_vmm_present"], { encoding: "utf8" }).stdout?.trim() === "1";
313
+ }
314
+
315
+ /** CoreML transcription is unreliable without an Apple Neural Engine, which no virtualised Mac has (#1419); synthesis says the same (#742). */
316
+ export function withVirtualMacHint(
317
+ err: KeshaError,
318
+ platform: string = process.platform,
319
+ isVirtualMac: () => boolean = hostIsVirtualMac,
320
+ ): KeshaError {
321
+ if (platform !== "darwin" || err.hint || !err.message.includes("Swift bridge error") || !isVirtualMac()) return err;
322
+ return new KeshaError(err.code, err.message, {
323
+ exitCode: err.exitCode,
324
+ stderr: err.stderr,
325
+ origin: err.origin,
326
+ hint: "this is a virtualised Mac with no Apple Neural Engine, where CoreML transcription is unreliable; transcribe on a physical Mac, or on Linux or Windows",
327
+ });
328
+ }
329
+
298
330
  export async function transcribeEngine(audioPath: string, opts: TranscribeEngineOptions = {}): Promise<string> {
299
331
  const args = await validatedArgs(buildTranscribeArgs(audioPath, opts), { signal: opts.signal });
300
332
  const run = await runEngine(args, { signal: opts.signal, onProgressLine: opts.onProgressLine });
301
- if (failed(run)) throw engineFailure(args[0] ?? "", run, run.exitCode);
333
+ if (failed(run)) throw withVirtualMacHint(engineFailure(args[0] ?? "", run, run.exitCode));
302
334
  return run.stdout;
303
335
  }
304
336
 
@@ -356,7 +388,7 @@ export async function transcribeEngineWithSegments(
356
388
  const args = await validatedArgs(buildTranscribeArgs(audioPath, opts, true), { signal: opts.signal });
357
389
  if (opts.speakers) assertSpeakerModelsInstalled();
358
390
  const run = await runEngine(args, { signal: opts.signal, onProgressLine: opts.onProgressLine });
359
- if (failed(run)) throw engineFailure(args[0] ?? "", run, run.exitCode);
391
+ if (failed(run)) throw withVirtualMacHint(engineFailure(args[0] ?? "", run, run.exitCode));
360
392
  try {
361
393
  return parseTranscriptionOutput(run.stdout);
362
394
  } catch (err: unknown) {
@@ -477,7 +509,7 @@ export async function recordEngine(target: RecordTarget, maxSeconds: number): Pr
477
509
  const signalled = target.live && SIGNALLED_LIVE_EXIT_CODES.has(exitCode);
478
510
  // A clean interrupt delivers the transcript and exits 128+signal saying nothing (rust/src/cli/record.rs:82),
479
511
  // so an error event beside that status is a real failure the signal must not excuse.
480
- if (events.error || events.invalid.length > 0) throw engineFailure("record", events, exitCode);
512
+ if (events.error || events.invalid.length > 0) throw withVirtualMacHint(engineFailure("record", events, exitCode));
481
513
  // A silent non-zero exit named nothing, so it stays the operational 1 it has been since #1167.
482
514
  if (!signalled && exitCode !== 0) throw new Error(`kesha-engine record exited with code ${exitCode}`);
483
515
  }
@@ -518,6 +550,7 @@ export async function detectTextLanguageEngine(
518
550
  ): Promise<LangDetectResult | null> {
519
551
  if (text.trim().length === 0) return null;
520
552
  if (!isEngineInstalled()) return null;
553
+ assertEngineSupported(getEngineBinPath());
521
554
  const run = await runEngine(["detect-text-lang"], { ...opts, stdin: text });
522
555
  if (reportedFailure(run)) {
523
556
  const warning = textLangFailureWarning(run.stderr);
package/src/log.ts CHANGED
@@ -16,6 +16,10 @@ export function setColorEnabled(enabled: boolean): void {
16
16
  colors = enabled ? pc : pc.createColors(false);
17
17
  }
18
18
 
19
+ export function isColorEnabled(): boolean {
20
+ return colors.isColorSupported;
21
+ }
22
+
19
23
  /**
20
24
  * Debug mode (#148): when `KESHA_DEBUG` is truthy OR the caller has flipped
21
25
  * `log.debugEnabled = true` (via `--debug`), `log.debug()` writes structured
package/src/mcp/tools.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { errorMessage } from "../error-utils";
3
+ import { KeshaError } from "../engine/events";
3
4
  import { installHint } from "../install-hint";
4
5
  import { z } from "zod";
5
6
  import { chmodSync, existsSync, readFileSync, statSync } from "fs";
@@ -126,7 +127,8 @@ export function registerTools(server: McpServer): void {
126
127
  const hint = isAbsolute(path)
127
128
  ? ""
128
129
  : ` (relative paths resolve against the MCP server's working directory, ${process.cwd()} — pass an absolute path instead)`;
129
- return { isError: true, content: [{ type: "text" as const, text: `File not found: ${path}${hint}` }] };
130
+ const err = new KeshaError("E_INPUT_NOT_FOUND", `File not found: ${path}${hint}`);
131
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
130
132
  }
131
133
  try {
132
134
  assertAudioFileArgument(path);
@@ -132,6 +132,7 @@ function mkdirReason(dir: string, errno: string): string {
132
132
  }
133
133
 
134
134
  function bundleWriteFailure(outputPath: string, e: unknown): unknown {
135
+ if ((e as NodeJS.ErrnoException).code === "EPIPE") return e;
135
136
  const dir = dirname(outputPath);
136
137
  const errno = mkdirReason(dir, (e as NodeJS.ErrnoException).code ?? "");
137
138
  const blocker = fileInTheWay(dir);
package/src/toon.ts CHANGED
@@ -15,5 +15,6 @@ export function formatToonOutput<T extends object, E extends object>(
15
15
  results: T[],
16
16
  errors?: E[],
17
17
  ): string {
18
- return encodeToon(errors === undefined ? results : { results, errors }) + "\n";
18
+ const payload = errors === undefined ? results : { results, errors };
19
+ return encodeToon(JSON.parse(JSON.stringify(payload))) + "\n";
19
20
  }
@@ -1,4 +1,4 @@
1
- import { detectTextLanguageEngine } from "./engine";
1
+ import { detectTextLanguageEngine, type LangDetectResult } from "./engine";
2
2
  import { listVoiceIds } from "./synth";
3
3
 
4
4
  /** The voice the Engine speaks with when `say` is given no `--voice` — mirrors `tts::voices::DEFAULT_VOICE_ID`. */
@@ -63,6 +63,12 @@ const NATIVE_SCRIPT_ROUTES: Record<string, NativeScriptRoute> = {
63
63
  };
64
64
 
65
65
  const LATIN_LETTER = /[A-Za-z]/u;
66
+ const CYRILLIC = /\p{Script=Cyrillic}/u;
67
+
68
+ function mostlyCyrillicWords(text: string): boolean {
69
+ const words = text.split(/\s+/).filter((w) => /\p{L}/u.test(w));
70
+ return words.filter((w) => CYRILLIC.test(w)).length * 2 > words.length;
71
+ }
66
72
 
67
73
  /** True when more of the text's letters are in `script` than in the Latin alphabet. */
68
74
  function dominantScript(text: string, script: RegExp): boolean {
@@ -110,6 +116,7 @@ export interface ResolveSayVoiceOptions {
110
116
  arch?: NodeJS.Architecture;
111
117
  /** The engine's `say --list-voices` union; injected so the routing decision is testable without an engine. */
112
118
  listVoices?: (signal?: AbortSignal) => Promise<string[]>;
119
+ detectLanguage?: (text: string, signal?: AbortSignal) => Promise<LangDetectResult | null>;
113
120
  }
114
121
 
115
122
  function baseLangOf(code: string | undefined): string {
@@ -145,7 +152,9 @@ async function detectLang(
145
152
  options: ResolveSayVoiceOptions,
146
153
  ): Promise<{ code?: string; confidence: number }> {
147
154
  if (!text) return { confidence: 0 };
148
- const detected = await detectTextLanguageEngine(text, { signal: options.signal });
155
+ const detected = options.detectLanguage
156
+ ? await options.detectLanguage(text, options.signal)
157
+ : await detectTextLanguageEngine(text, { signal: options.signal });
149
158
  return { code: detected?.code, confidence: detected?.confidence ?? 0 };
150
159
  }
151
160
 
@@ -153,7 +162,8 @@ async function detectLang(
153
162
  * Resolve the voice for a synthesis request. Precedence: explicit voice >
154
163
  * explicit language hint (route by the stated language, skipping detection —
155
164
  * also the path on Linux/Windows where text-language detection is unavailable) >
156
- * macOS text-language auto-detection > engine default (`undefined`). A language
165
+ * macOS text-language auto-detection > Russian when detection is inconclusive and most
166
+ * words are Cyrillic (#1387) > engine default (`undefined`). A language
157
167
  * hint the build has no voice for resolves to `undefined` (engine default)
158
168
  * rather than re-running detection — the user stated the language explicitly.
159
169
  *
@@ -170,6 +180,9 @@ export async function resolveSayVoice(
170
180
  const { code, confidence } =
171
181
  langHint !== undefined ? { code: langHint, confidence: 1 } : await detectLang(text, options);
172
182
  const voice = pickVoiceForLang(code, confidence, options.platform, options.arch);
183
+ if (confidence < 0.5 && mostlyCyrillicWords(text)) {
184
+ return pickVoiceForLang("ru", 1, options.platform, options.arch);
185
+ }
173
186
  if (confidence < 0.5) return voice;
174
187
  return (await nativeScriptOverride(baseLangOf(code), text, options)) ?? voice;
175
188
  }