@drakulavich/kesha-voice-kit 2.3.0-beta.1 → 2.3.0-beta.3

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
@@ -16,7 +16,7 @@
16
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
- - **Small Rust engine** — single ~65MB binary, no ffmpeg, no Python, no native Node addons
19
+ - **Small Rust engine** — single ~74 MB binary, no ffmpeg, no Python, no native Node addons
20
20
 
21
21
  <p align="center">
22
22
  <img src="https://github.com/drakulavich/kesha-voice-kit/raw/main/docs/assets/demo.webp" alt="kesha demo: an English voice message transcribed, a Russian one as JSON with automatic language detection, then text-to-speech into a wav" width="800">
@@ -104,6 +104,7 @@ $ kesha freedom.ogg tahiti.ogg
104
104
  - **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)).
105
105
  - **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.
106
106
  - **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)).
107
+ - **A file name starting with `-`:** write it as `./-file.wav`, or put an option before `--` (`kesha --json -- -file.wav`). A bare `kesha -- -file.wav` fails with `unknown option`, because Bun drops a `--` that comes right after the command name ([#1464](https://github.com/drakulavich/kesha-voice-kit/issues/1464)).
107
108
 
108
109
  ## Text-to-speech
109
110
 
@@ -126,7 +127,7 @@ kesha say "Hello" --format ogg-opus --out hi.ogg # OGG/Opus — messenger voice
126
127
  kesha say "Hello" --format flac --out hi.flac # FLAC — lossless, plays in every browser incl. Safari/iOS
127
128
  ```
128
129
 
129
- `kesha say --list-voices` lists what's installed. Voices, the full catalogue, macOS system voices, SSML, speaking rate (`--rate`, `<prosody>`), Russian word stress, and Russian/English abbreviation handling are all in **[docs/tts.md](docs/tts.md)**.
130
+ `kesha say --list-voices` lists the voices kesha knows; some need `kesha install --tts <lang>` before they speak. Voices, the full catalogue, macOS system voices, SSML, speaking rate (`--rate`, `<prosody>`), Russian word stress, and Russian/English abbreviation handling are all in **[docs/tts.md](docs/tts.md)**.
130
131
 
131
132
  ## Languages
132
133
 
@@ -78,7 +78,7 @@ complete -c kesha -n '__fish_seen_subcommand_from say' -l voice -r -d 'Voice id,
78
78
  complete -c kesha -n '__fish_seen_subcommand_from say' -l lang -r -d 'Language code, e.g. en-us (default en-us; see docs/languages.md). With no --voice, routes to that language\'s default voice and skips text-language detection.'
79
79
  complete -c kesha -n '__fish_seen_subcommand_from say' -l out -r -d 'Write audio to file instead of stdout'
80
80
  complete -c kesha -n '__fish_seen_subcommand_from say' -l rate -r -d 'Speaking rate 0.5–2.0'
81
- complete -c kesha -n '__fish_seen_subcommand_from say' -l list-voices -d 'List installed voices and exit'
81
+ complete -c kesha -n '__fish_seen_subcommand_from say' -l list-voices -d 'List the voices kesha knows and exit; some need kesha install --tts <lang> first'
82
82
  complete -c kesha -n '__fish_seen_subcommand_from say' -l ssml -d 'Parse input as SSML (supports <speak>, <break>; strips unknown tags)'
83
83
  complete -c kesha -n '__fish_seen_subcommand_from say' -l format -r -d 'Output format: wav (default), ogg-opus (Telegram-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from --out extension when omitted.'
84
84
  complete -c kesha -n '__fish_seen_subcommand_from say' -l bitrate -r -d 'Opus bitrate in bits/sec (e.g. 32000). Only with --format ogg-opus.'
@@ -112,7 +112,7 @@ _kesha() {
112
112
  '--lang=[Language code, e.g. en-us (default en-us; see docs/languages.md). With no --voice, routes to that language'\''s default voice and skips text-language detection.]:lang:' \
113
113
  '--out=[Write audio to file instead of stdout]:out:' \
114
114
  '--rate=[Speaking rate 0.5–2.0]:rate:' \
115
- '--list-voices[List installed voices and exit]' \
115
+ '--list-voices[List the voices kesha knows and exit; some need kesha install --tts <lang> first]' \
116
116
  '--ssml[Parse input as SSML (supports <speak>, <break>; strips unknown tags)]' \
117
117
  '--format=[Output format: wav (default), ogg-opus (Telegram-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from --out extension when omitted.]:format:' \
118
118
  '--bitrate=[Opus bitrate in bits/sec (e.g. 32000). Only with --format ogg-opus.]:bitrate:' \
package/man/kesha.1 CHANGED
@@ -191,7 +191,7 @@ Write audio to file instead of stdout
191
191
  Speaking rate 0.5–2.0
192
192
  .TP
193
193
  .B \-\-list\-voices
194
- List installed voices and exit
194
+ List the voices kesha knows and exit; some need kesha install \-\-tts <lang> first
195
195
  .TP
196
196
  .B \-\-ssml
197
197
  Parse input as SSML (supports <speak>, <break>; strips unknown tags)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "2.3.0-beta.1",
3
+ "version": "2.3.0-beta.3",
4
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": {
@@ -109,20 +109,20 @@
109
109
  },
110
110
  "kesha": {
111
111
  "engine": {
112
- "version": "2.3.0-beta.1",
112
+ "version": "2.3.0-beta.3",
113
113
  "sha256": {
114
- "kesha-engine-darwin-arm64": "dade5c6c2d8fc838a38c1381915ee9e3a1d2840f20c87b6cabcdd3f2dcec3c9e",
115
- "kesha-engine-linux-x64": "2b6e604cb24fca040b60136df3e4e29e3005959aea0a6cd7d3fb80930e9f3380",
116
- "kesha-engine-windows-x64.exe": "269e13d10852bc01fdce2bec880bb9831bdaf19c4cb8e7a2d0a09a84c2980dc7",
117
- "say-avspeech-darwin-arm64": "3a7e8b8fac92ce4ce0c34f736098a685dcb745f741db8303e1721e320ac73a98",
118
- "kesha-textlang-darwin-arm64": "e45e2822248b14dd72aa79e2def8fa6b9c07c97423f11c841508769225d72da4"
114
+ "kesha-engine-darwin-arm64": "db444c1c0ccc368e72068f050807ab54975fb516055480b33c32d5b1f29c4e58",
115
+ "kesha-engine-linux-x64": "0ac69c63cc8e922aaaea5b6df221dd97f6add02d4d6fb09def2317c331019a89",
116
+ "kesha-engine-windows-x64.exe": "0ae3063b7d5ff00da7cd0f675cd557374d81ff94087787dc90d7c195f7347194",
117
+ "say-avspeech-darwin-arm64": "6c7e0708f27c1baf3d8013bfbeb6249a6c3acbb718b3d39081707c7a8aa22e5b",
118
+ "kesha-textlang-darwin-arm64": "cbe74ee1aebdb902e21d4c69769c71b6d49e037794a8623db11faa8cf2814420"
119
119
  },
120
120
  "size": {
121
- "kesha-engine-darwin-arm64": 73639696,
122
- "kesha-engine-linux-x64": 67403816,
123
- "kesha-engine-windows-x64.exe": 66584064,
124
- "say-avspeech-darwin-arm64": 82992,
125
- "kesha-textlang-darwin-arm64": 57648
121
+ "kesha-engine-darwin-arm64": 73672784,
122
+ "kesha-engine-linux-x64": 67422216,
123
+ "kesha-engine-windows-x64.exe": 66576384,
124
+ "say-avspeech-darwin-arm64": 82896,
125
+ "kesha-textlang-darwin-arm64": 57488
126
126
  }
127
127
  }
128
128
  }
@@ -1,6 +1,9 @@
1
1
  import { existsSync, realpathSync, statSync } from "fs";
2
2
  import { dirname, join, resolve, sep } from "path";
3
3
  import { dirSizeBytes } from "./diagnostic-paths";
4
+ import { isDarwinArm64 } from "./engine-targets";
5
+
6
+ export const KOKORO_ONNX_UNUSED_NOTE = "not used on darwin-arm64, where Kokoro runs from the TTS (Kokoro ANE) bundle";
4
7
 
5
8
  /** The sidecar executables the engine spawns from its own directory. */
6
9
  const SIDECAR_FILES = ["say-avspeech", "kesha-textlang"] as const;
@@ -10,6 +13,7 @@ export interface CacheComponentSize {
10
13
  path: string;
11
14
  exists: boolean;
12
15
  sizeBytes: number;
16
+ note?: string;
13
17
  }
14
18
 
15
19
  interface EngineFootprint {
@@ -101,14 +105,33 @@ export function cacheComponents(
101
105
  exists: existsSync(engine.path),
102
106
  sizeBytes: footprintBytes(engine, engine.members),
103
107
  };
108
+ const coremlRows = [
109
+ { label: "FluidAudio (in cache)", path: join(cacheRoot, "fluidaudio") },
110
+ { label: "Diarization (Sortformer)", path: join(cacheRoot, "models/diarize") },
111
+ ];
112
+ const onnxRows = [
113
+ { label: "ASR (Parakeet)", path: join(cacheRoot, "models/parakeet-tdt-v3") },
114
+ { label: "G2P (CharsiuG2P)", path: join(cacheRoot, "models/g2p") },
115
+ ];
116
+ const [usedRows, unusedRows] = coreml ? [coremlRows, onnxRows] : [onnxRows, coremlRows];
117
+ const sized = (row: { label: string; path: string }) => ({
118
+ ...row,
119
+ exists: existsSync(row.path),
120
+ sizeBytes: dirSizeBytes(row.path),
121
+ });
104
122
  const modelRows = [
105
- ...(coreml
106
- ? [{ label: "FluidAudio (in cache)", path: join(cacheRoot, "fluidaudio") }]
107
- : [{ label: "ASR (Parakeet)", path: join(cacheRoot, "models/parakeet-tdt-v3") }]),
123
+ ...usedRows,
108
124
  { label: "Language ID", path: join(cacheRoot, "models/lang-id-ecapa") },
109
125
  { label: "VAD (Silero)", path: join(cacheRoot, "models/silero-vad") },
110
- { label: "TTS (Kokoro)", path: join(cacheRoot, "models/kokoro-82m") },
126
+ {
127
+ label: "TTS (Kokoro)",
128
+ path: join(cacheRoot, "models/kokoro-82m"),
129
+ ...(isDarwinArm64() ? { note: KOKORO_ONNX_UNUSED_NOTE } : {}),
130
+ },
111
131
  { label: "TTS (Vosk)", path: join(cacheRoot, "models/vosk-ru") },
112
- ].map((row) => ({ ...row, exists: existsSync(row.path), sizeBytes: dirSizeBytes(row.path) }));
113
- return [engineRow, ...modelRows];
132
+ ].map(sized);
133
+ const leftoverRows = unusedRows
134
+ .map((row) => sized({ ...row, label: `${row.label}, unused by this engine, safe to delete` }))
135
+ .filter((row) => row.exists);
136
+ return [engineRow, ...modelRows, ...leftoverRows];
114
137
  }
@@ -34,7 +34,8 @@ function stripBooleanFlag(
34
34
  const valuedPrefixes = names.filter((n) => n.startsWith("--")).map((n) => `${n}=`);
35
35
  let value = false;
36
36
  const cleaned: string[] = [];
37
- for (const arg of rawArgs) {
37
+ const end = rawArgs.indexOf("--");
38
+ for (const arg of end === -1 ? rawArgs : rawArgs.slice(0, end)) {
38
39
  if (names.includes(arg)) {
39
40
  value = true;
40
41
  } else if (valuedPrefixes.some((prefix) => arg.startsWith(prefix))) {
@@ -43,7 +44,7 @@ function stripBooleanFlag(
43
44
  cleaned.push(arg);
44
45
  }
45
46
  }
46
- return { value, rawArgs: cleaned };
47
+ return { value, rawArgs: end === -1 ? cleaned : [...cleaned, ...rawArgs.slice(end)] };
47
48
  }
48
49
 
49
50
  /**
@@ -1,6 +1,8 @@
1
1
  import { renderUsage, runMain, type ArgsDef, type CommandDef } from "citty";
2
2
  import { existsSync } from "fs";
3
3
  import { stripVTControlCharacters } from "util";
4
+ import { exitCodeFor, KeshaError, SUPPORT_BUNDLE_HINT } from "../engine/events";
5
+ import { errorMessage } from "../error-utils";
4
6
  import { isColorEnabled, log } from "../log";
5
7
  import { packageVersion } from "../package-info";
6
8
  import { guardStdoutWrites } from "../stdout-pipe";
@@ -117,6 +119,24 @@ function misplacedSubcommand(rawArgs: string[], subcommandKeys: string[]): strin
117
119
  return rawArgs.slice(0, end === -1 ? undefined : end).find((arg) => subcommandKeys.includes(arg) && !existsSync(arg));
118
120
  }
119
121
 
122
+ /** citty's runMain prints whatever `run` throws as a stack trace with exit 1; only `run` is wrapped, so a command with `subCommands` needs the same. */
123
+ function reportThrows(command: CommandDef<any>): CommandDef<any> {
124
+ const run = command.run;
125
+ if (!run) return command;
126
+ return {
127
+ ...command,
128
+ async run(context) {
129
+ try {
130
+ return await run(context);
131
+ } catch (err) {
132
+ const coded = err instanceof KeshaError ? err : new KeshaError("E_INTERNAL", errorMessage(err), { hint: SUPPORT_BUNDLE_HINT });
133
+ log.error(coded.render());
134
+ process.exit(exitCodeFor(coded));
135
+ }
136
+ },
137
+ };
138
+ }
139
+
120
140
  export async function runCli(argv = process.argv.slice(2)): Promise<void> {
121
141
  guardStdoutWrites();
122
142
  const context = resolveCliContext(argv);
@@ -136,7 +156,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
136
156
  const argsDef = await resolveArgsDef(command);
137
157
  rejectUnknownOptions(restArgs, argsDef);
138
158
  if (firstArg !== "install" && firstArg !== "say") rejectExtraPositionals(restArgs, argsDef);
139
- await runMain(command, { rawArgs: restArgs, showUsage });
159
+ await runMain(reportThrows(command), { rawArgs: restArgs, showUsage });
140
160
  return;
141
161
  }
142
162
 
@@ -163,7 +183,12 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
163
183
  await showUsage(rawArgs.includes("--all") ? command : briefMainUsage(command));
164
184
  return;
165
185
  }
166
- await runMain(command, { rawArgs, showUsage });
186
+ const options = rawArgs.slice(0, rawArgs.includes("--") ? rawArgs.indexOf("--") : undefined);
187
+ if (options.includes("--version") || options.includes("-v")) {
188
+ process.stdout.write(`${packageVersion}\n`);
189
+ return;
190
+ }
191
+ await runMain(reportThrows(command), { rawArgs, showUsage });
167
192
  }
168
193
  }
169
194
  }
@@ -140,8 +140,8 @@ export function selectBackend(coreml: boolean, onnx: boolean): BackendSelection
140
140
  export function resolveBackendFlag(coreml: boolean, onnx: boolean): string | undefined {
141
141
  const selection = selectBackend(coreml, onnx);
142
142
  if (!selection.ok) {
143
- log.error(selection.error);
144
- process.exit(1);
143
+ log.error(renderInvalidArg(selection.error));
144
+ process.exit(2);
145
145
  }
146
146
  return selection.backend;
147
147
  }
package/src/cli/main.ts CHANGED
@@ -1,7 +1,15 @@
1
1
  import { defineCommand, type ArgsDef, type CommandDef } from "citty";
2
2
  import { errorMessage } from "../error-utils";
3
3
  import { existsSync } from "fs";
4
- import { isDirectoryPath, transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
4
+ import {
5
+ isDirectoryPath,
6
+ isSpecialFilePath,
7
+ missingFileHint,
8
+ SPECIAL_FILE_HINT,
9
+ transcribeWithSegments,
10
+ truncatedWavSeconds,
11
+ validateTranscribeRequest,
12
+ } from "../transcribe";
5
13
 
6
14
  export { isDirectoryPath };
7
15
  import { detectAudioLanguageEngine, detectTextLanguageEngine, isEngineInstalled } from "../engine";
@@ -28,7 +36,7 @@ import { runCommandSession, type CommandSession } from "./command-session";
28
36
  import { USAGE_MESSAGE } from "./dispatch";
29
37
  import type { CliContext } from "./context";
30
38
  import { exitCodeFor, KeshaError } from "../engine/events";
31
- import { detectTextLanguageFallback, routeLanguage } from "../language-routing";
39
+ import { detectTextLanguageFallback, routeLanguage, unsupportedAudioLanguageWarning } from "../language-routing";
32
40
  import { renderInvalidArg } from "./options";
33
41
 
34
42
  interface MainCommandArgs {
@@ -81,14 +89,12 @@ export function resolveOutputFormat(input: {
81
89
  error: `unknown --format '${input.format}'. supported: ${SUPPORTED_FORMATS.join(", ")}`,
82
90
  };
83
91
  }
84
- if (
85
- (input.json && input.toon) ||
86
- (input.json && input.format === "toon") ||
87
- (input.toon && input.format === "json")
88
- ) {
92
+ const jsonFlag = input.json ? "--json" : input.format === "json" ? "--format json" : null;
93
+ const toonFlag = input.toon ? "--toon" : input.format === "toon" ? "--format toon" : null;
94
+ if (jsonFlag && toonFlag) {
89
95
  return {
90
96
  ok: false,
91
- error: "--json and --toon are mutually exclusive (pick one output format).",
97
+ error: `${jsonFlag} and ${toonFlag} are mutually exclusive (pick one output format).`,
92
98
  };
93
99
  }
94
100
  // `--format transcript` + boolean `--json` / `--toon` was previously
@@ -264,6 +270,11 @@ async function detectLanguages(
264
270
  }
265
271
  }
266
272
 
273
+ const unsupportedWarning = unsupportedAudioLanguageWarning(audioLanguage, textLanguage ?? tinyldResult);
274
+ if (unsupportedWarning) {
275
+ warnAboveProgress(progress, `${file}: ${unsupportedWarning}`);
276
+ }
277
+
267
278
  const route = routeLanguage({ audioLanguage, textLanguage: textLanguage ?? tinyldResult });
268
279
  const { lang } = route;
269
280
 
@@ -316,8 +327,19 @@ async function processFile(
316
327
  const { vadMode, timestamps, speakers, itn, wantsLangId, expectedLang, reportProgress } = options;
317
328
  const { stats, diagnosticLog } = recorders;
318
329
 
330
+ if (file === "-") {
331
+ const err = new KeshaError("E_INVALID_ARG", "reading audio from stdin is not supported", { hint: SPECIAL_FILE_HINT });
332
+ stats.recordError("input", err, err.code);
333
+ diagnosticLog.event("input.invalid", {
334
+ command: "transcribe",
335
+ error_code: err.code,
336
+ });
337
+ log.error(`${file}: ${errorMessage(err)}`);
338
+ return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
339
+ }
340
+
319
341
  if (!existsSync(file)) {
320
- const err = new KeshaError("E_INPUT_NOT_FOUND", "File not found");
342
+ const err = new KeshaError("E_INPUT_NOT_FOUND", "File not found", { hint: missingFileHint(file) });
321
343
  stats.recordError("input", err, err.code);
322
344
  diagnosticLog.event("input.missing", {
323
345
  command: "transcribe",
@@ -340,6 +362,26 @@ async function processFile(
340
362
  return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
341
363
  }
342
364
 
365
+ if (isSpecialFilePath(file)) {
366
+ const err = new KeshaError("E_INVALID_ARG", "is not a regular file (expected an audio file)", {
367
+ hint: SPECIAL_FILE_HINT,
368
+ });
369
+ stats.recordError("input", err, err.code);
370
+ diagnosticLog.event("input.invalid", {
371
+ command: "transcribe",
372
+ error_code: err.code,
373
+ });
374
+ log.error(`${file}: ${errorMessage(err)}`);
375
+ return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
376
+ }
377
+
378
+ const truncated = truncatedWavSeconds(file);
379
+ if (truncated) {
380
+ log.warn(
381
+ `warning: ${file} is truncated: its header declares ${truncated.declared.toFixed(1)} s of audio but the file holds ${truncated.present.toFixed(1)} s; transcribing the part present`,
382
+ );
383
+ }
384
+
343
385
  const inputArtifact = artifactFromFile(file, "input_audio");
344
386
  if (inputArtifact) {
345
387
  stats.recordArtifact(inputArtifact);
@@ -508,20 +550,25 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
508
550
  " kesha init Guided first-time setup.\n" +
509
551
  "\n" +
510
552
  "Commands:\n" +
511
- " completions Print shell completion script.\n" +
512
- " doctor Collect support diagnostics.\n" +
513
- " init Interactive setup guide for Kesha features.\n" +
514
- " install Download engine and models.\n" +
515
- " logs Manage local privacy-safe diagnostic logs.\n" +
516
- " manpage Print the kesha(1) manpage.\n" +
517
- " mcp Run an MCP server over stdio.\n" +
518
- " record Record microphone audio to a WAV file.\n" +
519
- " status Inspect installed backend.\n" +
520
- " say Synthesize speech from text.\n" +
521
- " stats Manage local anonymous performance stats.\n" +
522
- " support-bundle Create a redacted diagnostics archive.",
553
+ " completions Print shell completion script.\n" +
554
+ " doctor Collect support diagnostics.\n" +
555
+ " init Interactive setup guide for Kesha features.\n" +
556
+ " install Download engine and models.\n" +
557
+ " logs Manage local privacy-safe diagnostic logs.\n" +
558
+ " manpage Print the kesha(1) manpage.\n" +
559
+ " mcp Run an MCP server over stdio.\n" +
560
+ " record Record microphone audio to a WAV file.\n" +
561
+ " status Inspect installed backend.\n" +
562
+ " say Synthesize speech from text.\n" +
563
+ " stats Manage local anonymous performance stats.\n" +
564
+ " support-bundle Create a redacted diagnostics archive.\n",
523
565
  },
524
566
  args: {
567
+ audio_file: {
568
+ type: "positional",
569
+ required: false,
570
+ description: "Audio file(s) to transcribe",
571
+ },
525
572
  json: {
526
573
  type: "boolean",
527
574
  description: "Output results as JSON",
@@ -71,6 +71,11 @@ function scanArgs(rawArgs: string[], argsDef: ArgsDef): { unknown: UnknownOption
71
71
  return { unknown: null, positionals };
72
72
  }
73
73
 
74
+ /** The positionals in `rawArgs` as the user typed them; citty reads `-5 degrees` as a bundle of short flags (#1473 S2-5). */
75
+ export function positionalArgs(rawArgs: string[], argsDef: ArgsDef): string[] {
76
+ return scanArgs(rawArgs, argsDef).positionals;
77
+ }
78
+
74
79
  /** The coded line for a usage error the CLI refuses before doing anything (S9-F2). */
75
80
  export function renderInvalidArg(message: string): string {
76
81
  return renderError({ code: "E_INVALID_ARG", message });
package/src/cli/record.ts CHANGED
@@ -3,13 +3,13 @@ import { errorMessage } from "../error-utils";
3
3
  import { exitCodeFor, KeshaError } from "../engine/events";
4
4
  import { renderInvalidArg } from "./options";
5
5
  import {
6
+ engineMissingError,
6
7
  isEngineInstalled,
7
8
  recordEngine,
8
9
  validateRecordRequest,
9
10
  type LiveAutoStopOptions,
10
11
  type RecordTarget,
11
12
  } from "../engine";
12
- import { installHint } from "../install-hint";
13
13
  import { log } from "../log";
14
14
  import { getPendingSignalExitCode, waitForPendingSignalCleanup } from "../process-tree";
15
15
 
@@ -142,12 +142,7 @@ function resolveThreshold(
142
142
 
143
143
  /** Mirrors `src/transcribe.ts`'s "no transcription backend" guard message, worded for recording. */
144
144
  export function noRecordingBackendMessage(): string {
145
- return (
146
- "Error: No recording backend is installed.\n\n" +
147
- "Run the following to get started:\n\n" +
148
- " bun add -g @drakulavich/kesha-voice-kit\n" +
149
- ` ${installHint()}`
150
- );
145
+ return errorMessage(engineMissingError("recording"));
151
146
  }
152
147
 
153
148
  export const recordCommand = defineCommand({
package/src/cli/say.ts CHANGED
@@ -1,11 +1,14 @@
1
- import { defineCommand } from "citty";
1
+ import { defineCommand, type ArgsDef } from "citty";
2
2
  import { statSync } from "fs";
3
+ import { extname } from "path";
3
4
  import { errorMessage } from "../error-utils";
5
+ import { engineMissingError, isEngineInstalled } from "../engine";
4
6
  import { exitCodeFor, KeshaError } from "../engine/events";
5
- import { renderInvalidArg } from "./options";
7
+ import { positionalArgs, renderInvalidArg } from "./options";
6
8
  import { log } from "../log";
7
9
  import {
8
10
  MAX_TEXT_CHARS,
11
+ TEXT_TOO_LONG_HINT,
9
12
  listVoiceIds,
10
13
  say,
11
14
  SUPPORTED_SAMPLE_RATES,
@@ -35,6 +38,7 @@ async function resolveText(inline: string | undefined): Promise<string> {
35
38
  throw new KeshaError(
36
39
  "E_TEXT_TOO_LONG",
37
40
  `text exceeds ${MAX_TEXT_CHARS} chars (stdin passed ${MAX_STDIN_BYTES} bytes)`,
41
+ { hint: TEXT_TOO_LONG_HINT },
38
42
  );
39
43
  }
40
44
  const merged = new Uint8Array(total);
@@ -128,6 +132,16 @@ function checkOpusOnlyFlags(
128
132
  return "--bitrate and --sample-rate are only valid with --format ogg-opus";
129
133
  }
130
134
 
135
+ const OUT_EXTENSIONS = [".wav", ".ogg", ".opus", ".oga", ".flac"];
136
+
137
+ /** Mirrors the engine's `format_from_extension`, which writes WAV under any name it does not know (#1473 S2-4). */
138
+ function checkOutExtension(outArg: string | undefined): string | null {
139
+ if (outArg === undefined) return null;
140
+ const ext = extname(outArg).toLowerCase();
141
+ if (ext === "" || OUT_EXTENSIONS.includes(ext)) return null;
142
+ return `--out ${outArg}: kesha cannot encode ${ext}; use one of ${OUT_EXTENSIONS.join(", ")}, or no extension for WAV`;
143
+ }
144
+
131
145
  export type SayFlagArgs = {
132
146
  voice?: unknown;
133
147
  lang?: unknown;
@@ -179,6 +193,8 @@ export function resolveSayFlags(args: SayFlagArgs): ResolvedSayFlags {
179
193
  const out = typeof args.out === "string" ? args.out : undefined;
180
194
  const opusOnlyError = checkOpusOnlyFlags(bitrate.value, sampleRate.value, format.value, out);
181
195
  if (opusOnlyError) return { ok: false, error: opusOnlyError };
196
+ const outExtError = checkOutExtension(out);
197
+ if (outExtError) return { ok: false, error: outExtError };
182
198
 
183
199
  return {
184
200
  ok: true,
@@ -253,6 +269,7 @@ async function synthesizeAndEmit(
253
269
  const { stats } = session;
254
270
  try {
255
271
  const startedAt = performance.now();
272
+ if (!isEngineInstalled()) throw engineMissingError("speech synthesis", "--tts");
256
273
  if (opts.out) {
257
274
  const voiceLabel = opts.voice ?? "default voice";
258
275
  log.status(`Synthesizing ${voiceLabel} -> ${opts.out}...`);
@@ -310,7 +327,7 @@ export const sayCommand = defineCommand({
310
327
  },
311
328
  out: { type: "string", description: "Write audio to file instead of stdout" },
312
329
  rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
313
- "list-voices": { type: "boolean", description: "List installed voices and exit" },
330
+ "list-voices": { type: "boolean", description: "List the voices kesha knows and exit; some need kesha install --tts <lang> first" },
314
331
  ssml: {
315
332
  type: "boolean",
316
333
  description: "Parse input as SSML (supports <speak>, <break>; strips unknown tags)",
@@ -374,7 +391,7 @@ export const sayCommand = defineCommand({
374
391
  process.exit(2);
375
392
  }
376
393
 
377
- const positionals = (args._ ?? []).map(String);
394
+ const positionals = positionalArgs(rawArgs, sayCommand.args as ArgsDef);
378
395
  const inlineText =
379
396
  positionals.length > 0 ? positionals.join(" ") : typeof args.text === "string" ? args.text : undefined;
380
397
  const stdinIsTty = (process.stdin as { isTTY?: boolean }).isTTY;
package/src/cli/stats.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  getRecentErrors,
7
7
  getStatsStatus,
8
8
  getWeekSummary,
9
+ MAX_RETENTION_DAYS,
9
10
  renderErrors,
10
11
  renderWeekSummary,
11
12
  resetStats,
@@ -13,6 +14,9 @@ import {
13
14
  vacuumStats,
14
15
  type StatsExportFormat,
15
16
  } from "../stats";
17
+ import { exitCodeFor, KeshaError } from "../engine/events";
18
+ import { errorMessage } from "../error-utils";
19
+ import { log } from "../log";
16
20
  import { emitActionResult, type ActionMessage, type ActionResult } from "./action-result";
17
21
 
18
22
  export interface StatsCommandArgs {
@@ -113,7 +117,13 @@ function runRetentionAction(value: string | undefined): ActionResult {
113
117
  return { ok: true, messages: info(`Kesha Stats retention: ${formatRetention(status.retentionDays)}`) };
114
118
  }
115
119
  const retention = parseRetention(value);
116
- if (retention === undefined) return { ok: false, error: "usage: kesha stats retention <days|off>" };
120
+ if (retention === undefined) {
121
+ return {
122
+ ok: false,
123
+ error: "usage: kesha stats retention <days|off>",
124
+ hint: `days is a whole number from 1 to ${MAX_RETENTION_DAYS}.`,
125
+ };
126
+ }
117
127
  setStatsRetentionDays(retention);
118
128
  return { ok: true, messages: info(`Kesha Stats retention set to ${formatRetention(retention)}`) };
119
129
  }
@@ -144,7 +154,15 @@ export const statsCommand = defineCommand({
144
154
  },
145
155
  },
146
156
  run({ args, rawArgs }: { args: StatsCommandArgs; rawArgs: string[] }) {
147
- emitActionResult(runStatsAction({ ...args, value: args.value ?? eatenNegativeNumber(rawArgs) }));
157
+ let result: ActionResult;
158
+ try {
159
+ result = runStatsAction({ ...args, value: args.value ?? eatenNegativeNumber(rawArgs) });
160
+ } catch (err) {
161
+ const coded = err instanceof KeshaError ? err : new KeshaError("E_INTERNAL", errorMessage(err));
162
+ log.error(coded.render());
163
+ process.exit(exitCodeFor(coded));
164
+ }
165
+ emitActionResult(result);
148
166
  },
149
167
  });
150
168
 
@@ -161,7 +179,7 @@ function parseRetention(value: string): number | null | undefined {
161
179
  const normalized = value.trim().toLowerCase();
162
180
  if (normalized === "off" || normalized === "none" || normalized === "never") return null;
163
181
  const days = Number(normalized);
164
- if (Number.isInteger(days) && days >= 1) return days;
182
+ if (Number.isInteger(days) && days >= 1 && days <= MAX_RETENTION_DAYS) return days;
165
183
  return undefined;
166
184
  }
167
185
 
@@ -1,4 +1,6 @@
1
+ import { KeshaError } from "./engine/events";
1
2
  import { errorMessage } from "./error-utils";
3
+ import { PATH_ERRNOS } from "./path-errnos";
2
4
  import { resolveStatePaths } from "./state-paths";
3
5
  import {
4
6
  closeSync,
@@ -19,7 +21,7 @@ import { packageVersion } from "./package-info";
19
21
 
20
22
  const ACTIVE_LOG_FILE = "kesha.ndjson";
21
23
  const STATE_FILE = "diagnostic-logs.json";
22
- const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
24
+ const DEFAULT_MAX_BYTES = 10_000_000;
23
25
  const DEFAULT_RETAIN = 5;
24
26
  const DEFAULT_TAIL_BYTES = 64 * 1024;
25
27
  const SAFE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
@@ -115,8 +117,16 @@ function positiveInt(value: unknown, fallback: number): number {
115
117
 
116
118
  function writeConfig(config: DiagnosticLogConfig): void {
117
119
  const statePath = resolveStatePath();
118
- mkdirSync(dirname(statePath), { recursive: true });
119
- writeFileSync(statePath, `${JSON.stringify(config, null, 2)}\n`);
120
+ try {
121
+ mkdirSync(dirname(statePath), { recursive: true });
122
+ writeFileSync(statePath, `${JSON.stringify(config, null, 2)}\n`);
123
+ } catch (err) {
124
+ const errno = (err as NodeJS.ErrnoException).code ?? "";
125
+ const why = PATH_ERRNOS[errno];
126
+ throw new KeshaError(why ? "E_INVALID_ARG" : "E_INTERNAL", `cannot write the diagnostic log settings ${statePath}: ${why ? `${why} (${errno})` : errorMessage(err)}`, {
127
+ hint: `make ${dirname(statePath)} writable, or set KESHA_LOG_DIR to a folder you can write to.`,
128
+ });
129
+ }
120
130
  }
121
131
 
122
132
  export function setDiagnosticLogMode(mode: DiagnosticLogMode): DiagnosticLogStatus {
@@ -311,12 +321,12 @@ function rotateIfNeeded(activePath: string, nextBytes: number, maxBytes: number,
311
321
  }
312
322
 
313
323
  export function humanBytes(bytes: number): string {
314
- if (bytes < 1024) return `${bytes} B`;
324
+ if (bytes < 1000) return `${bytes} B`;
315
325
  const units = ["KB", "MB", "GB", "TB"];
316
- let value = bytes / 1024;
326
+ let value = bytes / 1000;
317
327
  let unit = units[0];
318
- for (let i = 1; i < units.length && value >= 1024; i++) {
319
- value /= 1024;
328
+ for (let i = 1; i < units.length && value >= 1000; i++) {
329
+ value /= 1000;
320
330
  unit = units[i];
321
331
  }
322
332
  return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`;