@drakulavich/kesha-voice-kit 2.2.0 → 2.3.0-beta.2

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
@@ -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
 
@@ -26,7 +26,7 @@ complete -c kesha -n '__fish_use_subcommand' -l format -r -d 'Output format: tra
26
26
  complete -c kesha -n '__fish_use_subcommand' -l lang -r -d 'Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch'
27
27
  complete -c kesha -n '__fish_use_subcommand' -l debug -d 'Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)'
28
28
  complete -c kesha -n '__fish_use_subcommand' -l vad -d 'Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.'
29
- complete -c kesha -n '__fish_use_subcommand' -l no-vad -d 'Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers'
29
+ complete -c kesha -n '__fish_use_subcommand' -l no-vad -d 'Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers'
30
30
  complete -c kesha -n '__fish_use_subcommand' -l quiet -d 'Suppress progress output; print only results and errors'
31
31
  complete -c kesha -n '__fish_use_subcommand' -l no-color -d 'Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)'
32
32
  complete -c kesha -n '__fish_seen_subcommand_from completions' -l help -d 'Show help'
@@ -35,7 +35,7 @@ _kesha() {
35
35
  '--lang=[Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch]:lang:' \
36
36
  '--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
37
37
  '--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
38
- '--no-vad[Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers]' \
38
+ '--no-vad[Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers]' \
39
39
  '--quiet[Suppress progress output; print only results and errors]' \
40
40
  '--no-color[Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)]'
41
41
  else
@@ -154,7 +154,7 @@ _kesha() {
154
154
  '--lang=[Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch]:lang:' \
155
155
  '--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
156
156
  '--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
157
- '--no-vad[Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers]' \
157
+ '--no-vad[Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers]' \
158
158
  '--quiet[Suppress progress output; print only results and errors]' \
159
159
  '--no-color[Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)]' \
160
160
  '*:audio file:_files'
package/man/kesha.1 CHANGED
@@ -82,7 +82,7 @@ Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)
82
82
  Force Silero VAD preprocessing (kesha install \-\-vad first). Without this, VAD auto\-engages on audio ≥ 120s.
83
83
  .TP
84
84
  .B \-\-no\-vad
85
- Force full\-file ASR for short/medium files; long audio fails early. Incompatible with \-\-speakers
85
+ Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with \-\-speakers
86
86
  .TP
87
87
  .B \-\-quiet
88
88
  Suppress progress output; print only results and errors
package/model-plan.json CHANGED
@@ -161,6 +161,9 @@
161
161
  { "relPath": "g2pw/g2pw.mlmodelc/weights/weight.bin", "sizeBytes": 159294962 }
162
162
  ],
163
163
  "aneZhG2p": [
164
+ { "relPath": "g2p/jieba_hmm_emit.bin", "sizeBytes": 292640 },
165
+ { "relPath": "g2p/jieba_hmm_start.bin", "sizeBytes": 16 },
166
+ { "relPath": "g2p/jieba_hmm_trans.bin", "sizeBytes": 64 },
164
167
  { "relPath": "g2p/pinyin_phrases.bin", "sizeBytes": 9552083 },
165
168
  { "relPath": "g2p/pinyin_single.bin", "sizeBytes": 480169 }
166
169
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "2.2.0",
3
+ "version": "2.3.0-beta.2",
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": {
@@ -50,8 +50,8 @@
50
50
  "coverage:rust": "mkdir -p coverage/rust && cd rust && cargo llvm-cov nextest --features tts --lcov --output-path ../coverage/rust/lcov.info",
51
51
  "coverage:check:rust": "bun .github/scripts/check-coverage.ts --preset=rust --lcov=coverage/rust/lcov.info --summary=coverage/rust/summary.md",
52
52
  "generate:shell-artifacts": "bun scripts/generate-shell-artifacts.ts",
53
- "lint": "bunx tsc --noEmit",
54
- "check": "bun run lint && bun run check:versions && bun run check:recipes && bun run test:cli-fast",
53
+ "lint": "bun check",
54
+ "check:fast": "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
57
  "check:specs": "openspec validate --specs --strict",
@@ -89,7 +89,6 @@
89
89
  "@fission-ai/openspec": "1.14.1",
90
90
  "@types/bun": "latest",
91
91
  "fast-xml-parser": "^5.8.0",
92
- "typescript": "^7.0.2",
93
92
  "yaml": "^2.9.0"
94
93
  },
95
94
  "dependencies": {
@@ -110,20 +109,20 @@
110
109
  },
111
110
  "kesha": {
112
111
  "engine": {
113
- "version": "2.2.0",
112
+ "version": "2.3.0-beta.2",
114
113
  "sha256": {
115
- "kesha-engine-darwin-arm64": "916ec3dbcb84337487d953b08816f6939ee3995d0851acd8acc5e084449e87cc",
116
- "kesha-engine-linux-x64": "b25b77d3b0a64ccc6b0d276fd0a3fd320c60f06700e1ccfcb792359150248012",
117
- "kesha-engine-windows-x64.exe": "e87a497444180f1d40ae0c51c78028bc41abc73523ba1e9e5f2bb71f278bf443",
118
- "say-avspeech-darwin-arm64": "3a7e8b8fac92ce4ce0c34f736098a685dcb745f741db8303e1721e320ac73a98",
119
- "kesha-textlang-darwin-arm64": "e45e2822248b14dd72aa79e2def8fa6b9c07c97423f11c841508769225d72da4"
114
+ "kesha-engine-darwin-arm64": "febd870e9f857be6452336c74daad161f2cef74dfedb64a21b49b6b1fc2ce94c",
115
+ "kesha-engine-linux-x64": "9c16e72e5290f774b1d93cbf44b2fc6eca3c4b30659038ccb80f5e98dc748d2a",
116
+ "kesha-engine-windows-x64.exe": "231bc45eb345ef359ca94685866de8cb4c7c76ae9bef1072c20a60640c458f63",
117
+ "say-avspeech-darwin-arm64": "6c7e0708f27c1baf3d8013bfbeb6249a6c3acbb718b3d39081707c7a8aa22e5b",
118
+ "kesha-textlang-darwin-arm64": "cbe74ee1aebdb902e21d4c69769c71b6d49e037794a8623db11faa8cf2814420"
120
119
  },
121
120
  "size": {
122
- "kesha-engine-darwin-arm64": 73639712,
123
- "kesha-engine-linux-x64": 67404968,
124
- "kesha-engine-windows-x64.exe": 66583040,
125
- "say-avspeech-darwin-arm64": 82992,
126
- "kesha-textlang-darwin-arm64": 57648
121
+ "kesha-engine-darwin-arm64": 73672800,
122
+ "kesha-engine-linux-x64": 67418152,
123
+ "kesha-engine-windows-x64.exe": 66596864,
124
+ "say-avspeech-darwin-arm64": 82896,
125
+ "kesha-textlang-darwin-arm64": 57488
127
126
  }
128
127
  }
129
128
  }
@@ -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,7 @@ 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
+ await runMain(reportThrows(command), { rawArgs, showUsage });
167
187
  }
168
188
  }
169
189
  }
@@ -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,7 @@
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 { isDirectoryPath, isSpecialFilePath, SPECIAL_FILE_HINT, transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
5
5
 
6
6
  export { isDirectoryPath };
7
7
  import { detectAudioLanguageEngine, detectTextLanguageEngine, isEngineInstalled } from "../engine";
@@ -28,7 +28,7 @@ import { runCommandSession, type CommandSession } from "./command-session";
28
28
  import { USAGE_MESSAGE } from "./dispatch";
29
29
  import type { CliContext } from "./context";
30
30
  import { exitCodeFor, KeshaError } from "../engine/events";
31
- import { detectTextLanguageFallback, routeLanguage } from "../language-routing";
31
+ import { detectTextLanguageFallback, routeLanguage, unsupportedAudioLanguageWarning } from "../language-routing";
32
32
  import { renderInvalidArg } from "./options";
33
33
 
34
34
  interface MainCommandArgs {
@@ -264,6 +264,11 @@ async function detectLanguages(
264
264
  }
265
265
  }
266
266
 
267
+ const unsupportedWarning = unsupportedAudioLanguageWarning(audioLanguage, textLanguage ?? tinyldResult);
268
+ if (unsupportedWarning) {
269
+ warnAboveProgress(progress, `${file}: ${unsupportedWarning}`);
270
+ }
271
+
267
272
  const route = routeLanguage({ audioLanguage, textLanguage: textLanguage ?? tinyldResult });
268
273
  const { lang } = route;
269
274
 
@@ -295,6 +300,14 @@ type ProcessFileSuccess = { ok: true; result: TranscribeResult };
295
300
  type ProcessFileFailure = { ok: false; error: TranscribeErrorRecord; exitCode: number };
296
301
 
297
302
  /** An argument the CLI itself rejected exits by its code (2); every runtime failure keeps the batch's operational 1 (S9-F1). */
303
+ /** `kesha <dir>/*.ogg`, pasteable: a name starting with `-` would parse as a flag, and only Windows separates with `\`. */
304
+ export function folderGlobHint(dir: string): string {
305
+ const trimmed = dir.replace(process.platform === "win32" ? /[\\/]+$/ : /\/+$/, "");
306
+ const path = trimmed.startsWith("-") ? `./${trimmed}` : trimmed;
307
+ const quoted = /^[\w./-]+$/.test(path) ? path : `'${path.replaceAll("'", `'\\''`)}'`;
308
+ return `kesha ${quoted}/*.ogg`;
309
+ }
310
+
298
311
  function failureExitCode(err: unknown): number {
299
312
  const usage = err instanceof KeshaError && err.origin === "cli" && err.code === "E_INVALID_ARG";
300
313
  return usage ? exitCodeFor(err) : 1;
@@ -320,7 +333,22 @@ async function processFile(
320
333
  }
321
334
 
322
335
  if (isDirectoryPath(file)) {
323
- const err = new KeshaError("E_INVALID_ARG", "is a directory (expected an audio file)");
336
+ const err = new KeshaError("E_INVALID_ARG", "is a directory (expected an audio file)", {
337
+ hint: `pass the audio files in it instead, e.g. ${folderGlobHint(file)}`,
338
+ });
339
+ stats.recordError("input", err, err.code);
340
+ diagnosticLog.event("input.invalid", {
341
+ command: "transcribe",
342
+ error_code: err.code,
343
+ });
344
+ log.error(`${file}: ${errorMessage(err)}`);
345
+ return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
346
+ }
347
+
348
+ if (isSpecialFilePath(file)) {
349
+ const err = new KeshaError("E_INVALID_ARG", "is not a regular file (expected an audio file)", {
350
+ hint: SPECIAL_FILE_HINT,
351
+ });
324
352
  stats.recordError("input", err, err.code);
325
353
  diagnosticLog.event("input.invalid", {
326
354
  command: "transcribe",
@@ -346,9 +374,8 @@ async function processFile(
346
374
  // so the engine's progress has to land while it happens, not once the run is over.
347
375
  const showProgressLine = (line: string) => {
348
376
  if (log.quietEnabled) return;
349
- const write = () => process.stderr.write(`${line}\n`);
350
- if (progress) progress.interrupt(write);
351
- else write();
377
+ if (progress) progress.status(line);
378
+ else process.stderr.write(`${line}\n`);
352
379
  };
353
380
  try {
354
381
  await validateTranscribeRequest({ vad: vadMode, timestamps, speakers, itn });
@@ -451,7 +478,7 @@ export const MAIN_VAD_ARGS = {
451
478
  "no-vad": {
452
479
  type: "boolean",
453
480
  description:
454
- "Force full-file ASR for short/medium files; long audio fails early. Incompatible with --speakers",
481
+ "Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers",
455
482
  default: false,
456
483
  },
457
484
  } as const;
package/src/cli/record.ts CHANGED
@@ -3,6 +3,7 @@ import { errorMessage } from "../error-utils";
3
3
  import { exitCodeFor, KeshaError } from "../engine/events";
4
4
  import { renderInvalidArg } from "./options";
5
5
  import {
6
+ getEngineBinPath,
6
7
  isEngineInstalled,
7
8
  recordEngine,
8
9
  validateRecordRequest,
@@ -142,11 +143,10 @@ function resolveThreshold(
142
143
 
143
144
  /** Mirrors `src/transcribe.ts`'s "no transcription backend" guard message, worded for recording. */
144
145
  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()}`
146
+ return errorMessage(
147
+ new KeshaError("E_ENGINE_SPAWN", `No recording backend is installed: no kesha-engine at ${getEngineBinPath()}`, {
148
+ hint: `bun add -g @drakulavich/kesha-voice-kit, then ${installHint()}`,
149
+ }),
150
150
  );
151
151
  }
152
152
 
package/src/cli/say.ts CHANGED
@@ -6,6 +6,7 @@ import { renderInvalidArg } from "./options";
6
6
  import { log } from "../log";
7
7
  import {
8
8
  MAX_TEXT_CHARS,
9
+ TEXT_TOO_LONG_HINT,
9
10
  listVoiceIds,
10
11
  say,
11
12
  SUPPORTED_SAMPLE_RATES,
@@ -35,6 +36,7 @@ async function resolveText(inline: string | undefined): Promise<string> {
35
36
  throw new KeshaError(
36
37
  "E_TEXT_TOO_LONG",
37
38
  `text exceeds ${MAX_TEXT_CHARS} chars (stdin passed ${MAX_STDIN_BYTES} bytes)`,
39
+ { hint: TEXT_TOO_LONG_HINT },
38
40
  );
39
41
  }
40
42
  const merged = new Uint8Array(total);
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,
@@ -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 {
@@ -197,7 +197,9 @@ export function validateArgv(argv: string[], doc: DescribeDocument): { argv: str
197
197
  if (name === null) continue;
198
198
  const flag = schema.flags[name];
199
199
  if (!flag) {
200
- throw new KeshaError("E_INVALID_ARG", `kesha-engine ${command} does not accept --${name}`);
200
+ throw new KeshaError("E_INVALID_ARG", `kesha-engine ${command} does not accept --${name}`, {
201
+ hint: `the installed kesha-engine is older than this CLI and lacks --${name}; run \`kesha install\` to fetch the engine this CLI expects`,
202
+ });
201
203
  }
202
204
  if (!gateSatisfied(flag.gate, doc.features)) {
203
205
  const needs = `--${name} needs ${gateText(flag.gate)}, which this kesha-engine build (${build}) does not provide`;
@@ -228,8 +228,10 @@ export function exitCodeFor(err: unknown): number {
228
228
 
229
229
  const MAX_QUOTED_LINE_CHARS = 200;
230
230
 
231
+ export const SUPPORT_BUNDLE_HINT = "re-run with KESHA_DEBUG=1, then file a bug with the `kesha support-bundle` archive";
232
+
231
233
  /** A library line can carry the user's whole input back, so the quote is bounded and the transcript no longer repeats it (#T1-14). */
232
- function quoteInvalidLine(line: string): string {
234
+ export function quoteInvalidLine(line: string): string {
233
235
  const chars = Array.from(line);
234
236
  return chars.length <= MAX_QUOTED_LINE_CHARS ? line : `${chars.slice(0, MAX_QUOTED_LINE_CHARS).join("")}…`;
235
237
  }
@@ -247,9 +249,9 @@ export function engineFailure(command: string, outcome: StderrOutcome, exitCode:
247
249
  return new KeshaError(
248
250
  "E_INTERNAL",
249
251
  offProtocolLine(command, outcome.invalid[0]!),
250
- { ...extra, stderr: rest },
252
+ { ...extra, stderr: rest, hint: `the engine and CLI may not match: run \`kesha install\`; if it persists, ${SUPPORT_BUNDLE_HINT}` },
251
253
  );
252
254
  }
253
255
  if (outcome.error) return new KeshaError(outcome.error.code, outcome.error.message, { ...extra, hint: outcome.error.hint });
254
- return new KeshaError("E_INTERNAL", `kesha-engine ${command} exited with code ${exitCode}`, extra);
256
+ return new KeshaError("E_INTERNAL", `kesha-engine ${command} exited with code ${exitCode}`, { ...extra, hint: SUPPORT_BUNDLE_HINT });
255
257
  }
@@ -83,6 +83,28 @@ export interface EngineProcessRun<T> {
83
83
  events: StderrOutcome;
84
84
  }
85
85
 
86
+ /** Fault signals, by number. SIGBUS is 10 on darwin but 7 on linux. */
87
+ const FAULT_SIGNALS: Record<number, string> = { 4: "SIGILL", 6: "SIGABRT", 8: "SIGFPE", 11: "SIGSEGV" };
88
+
89
+ /** The signal that killed the engine, or null; the wait status outranks Bun's signalCode (10 is SIGBUS on darwin, SIGUSR1 to Bun). */
90
+ export function engineSignal(exitCode: number, signalCode: string | null, platform: string = process.platform): string | null {
91
+ const number = exitCode > 128 ? exitCode - 128 : undefined;
92
+ const sigbus = platform === "darwin" ? 10 : 7;
93
+ const signal =
94
+ number === undefined
95
+ ? (signalCode ?? undefined)
96
+ : number === sigbus
97
+ ? "SIGBUS"
98
+ : (FAULT_SIGNALS[number] ?? signalCode ?? undefined);
99
+ return signal ?? null;
100
+ }
101
+
102
+ /** What a run that reported nothing ended with: the signal that killed it, else its exit status. */
103
+ export function engineExitCause(command: string, exitCode: number, signalCode: string | null): string {
104
+ const signal = engineSignal(exitCode, signalCode);
105
+ return signal ? `kesha-engine ${command} was killed by ${signal}` : `kesha-engine ${command} exited with code ${exitCode}`;
106
+ }
107
+
86
108
  /** Spawns the engine (or a sidecar) as its own process tree and waits for it; the tree is unregistered however the run ends. */
87
109
  export async function runEngineProcess<T = undefined>(
88
110
  binPath: string,
@@ -14,8 +14,8 @@ import {
14
14
  targetKey,
15
15
  } from "./engine-targets";
16
16
  import { validateArgv } from "./engine/describe";
17
- import { engineFailure, KeshaError, offProtocolLine } from "./engine/events";
18
- import { runEngineProcess } from "./engine/spawn";
17
+ import { engineFailure, KeshaError, offProtocolLine, SUPPORT_BUNDLE_HINT } from "./engine/events";
18
+ import { engineExitCause, runEngineProcess } from "./engine/spawn";
19
19
  import { acquireInstallLock } from "./install-lock";
20
20
  import { log } from "./log";
21
21
  import { engineVersion, injectedEnginePins } from "./package-info";
@@ -108,7 +108,12 @@ export function releaseChecksums(
108
108
  return async (assetName) => {
109
109
  if (version === pins?.version) {
110
110
  const pinned = pins.sha256[assetName];
111
- if (!pinned) throw new Error(`No pinned SHA-256 for ${assetName} of engine v${version}; this CLI build is incomplete.\n Fix: report it at https://github.com/${GITHUB_REPO}/issues.`);
111
+ if (!pinned) {
112
+ throw new KeshaError("E_INTERNAL", `No pinned SHA-256 for ${assetName} of engine v${version}; this CLI build is incomplete`, {
113
+ exitCode: 1,
114
+ hint: `report it at https://github.com/${GITHUB_REPO}/issues`,
115
+ });
116
+ }
112
117
  return { sha256: pinned, source: "its pinned SHA-256" };
113
118
  }
114
119
  sums ??= fetchSha256Sums(version, pins);
@@ -779,8 +784,11 @@ async function runEngineModelInstall(binPath: string, installArgs: string[]): Pr
779
784
  }
780
785
  if (events.error || events.invalid.length > 0) throw engineFailure("install", events, exitCode);
781
786
  if (exitCode !== 0) {
782
- // Nothing coded and nothing off-protocol: the engine failed without saying why, so neither do we.
783
- throw new Error(`Failed to install models: kesha-engine install exited with code ${exitCode}.`);
787
+ throw new KeshaError("E_INTERNAL", `Failed to install models: ${engineExitCause("install", exitCode, run.signalCode)}`, {
788
+ exitCode: 1,
789
+ stderr: events.stderr.trim(),
790
+ hint: SUPPORT_BUNDLE_HINT,
791
+ });
784
792
  }
785
793
  }
786
794
 
package/src/engine.ts CHANGED
@@ -7,8 +7,8 @@ import { log } from "./log";
7
7
  import { createLiveStatus } from "./progress";
8
8
  import { defaultEngineBinPath, keshaCacheDir } from "./paths";
9
9
  import { engineAbortError, interruptedRun } from "./process-tree";
10
- import { runEngineProcess, spawnHint } from "./engine/spawn";
11
- import { engineFailure, KeshaError, renderError, type ErrorEvent } from "./engine/events";
10
+ import { engineExitCause, engineSignal, runEngineProcess, spawnHint } from "./engine/spawn";
11
+ import { engineFailure, KeshaError, quoteInvalidLine, renderError, SUPPORT_BUNDLE_HINT, type ErrorEvent } from "./engine/events";
12
12
  import { MIN_ENGINE_VERSION, predatesMinimumEngine, readInstalledEngineVersion } from "./engine-version-marker";
13
13
  import {
14
14
  describeToCapabilities,
@@ -90,6 +90,7 @@ interface EngineRun {
90
90
  stdout: string;
91
91
  stderr: string;
92
92
  exitCode: number;
93
+ signalCode: string | null;
93
94
  error: ErrorEvent | null;
94
95
  invalid: string[];
95
96
  }
@@ -113,7 +114,7 @@ async function runEngine(args: string[], opts: RunEngineOptions = {}): Promise<E
113
114
  // #275 D4: warnings reach the user on success; on failure they travel inside the KeshaError.
114
115
  if (run.exitCode === 0 && events.invalid.length === 0 && events.error === null && stderr.length > 0)
115
116
  process.stderr.write(`${stderr}\n`);
116
- return { stdout: run.stdout.trim(), stderr, exitCode: run.exitCode, error: events.error, invalid: events.invalid };
117
+ return { stdout: run.stdout.trim(), stderr, exitCode: run.exitCode, signalCode: run.signalCode, error: events.error, invalid: events.invalid };
117
118
  }
118
119
 
119
120
  /** The run failed and said so: a non-zero status, or an error event whatever the status. */
@@ -302,9 +303,10 @@ export function buildTranscribeArgs(
302
303
  opts: TranscribeEngineOptions,
303
304
  json = false,
304
305
  ): string[] {
305
- const args = ["transcribe", audioPath, ...(json ? ["--json"] : []), ...vadArg(opts.vad)];
306
+ const args = ["transcribe", ...(json ? ["--json"] : []), ...vadArg(opts.vad)];
306
307
  if (opts.itn) args.push("--itn");
307
308
  if (json && opts.speakers) args.push("--speakers");
309
+ args.push("--", audioPath);
308
310
  return args;
309
311
  }
310
312
 
@@ -318,7 +320,7 @@ export function withVirtualMacHint(
318
320
  platform: string = process.platform,
319
321
  isVirtualMac: () => boolean = hostIsVirtualMac,
320
322
  ): KeshaError {
321
- if (platform !== "darwin" || err.hint || !err.message.includes("Swift bridge error") || !isVirtualMac()) return err;
323
+ if (platform !== "darwin" || !err.message.includes("Swift bridge error") || !isVirtualMac()) return err;
322
324
  return new KeshaError(err.code, err.message, {
323
325
  exitCode: err.exitCode,
324
326
  stderr: err.stderr,
@@ -327,10 +329,22 @@ export function withVirtualMacHint(
327
329
  });
328
330
  }
329
331
 
332
+ function transcribeFailure(command: string, run: EngineRun): KeshaError {
333
+ const failure = engineFailure(command, run, run.exitCode);
334
+ const signal = run.error === null && run.invalid.length === 0 ? engineSignal(run.exitCode, run.signalCode) : null;
335
+ if (!signal) return failure;
336
+ return new KeshaError("E_INTERNAL", `kesha-engine ${command} was killed by ${signal}`, {
337
+ exitCode: failure.exitCode,
338
+ stderr: failure.stderr,
339
+ origin: failure.origin,
340
+ hint: SUPPORT_BUNDLE_HINT,
341
+ });
342
+ }
343
+
330
344
  export async function transcribeEngine(audioPath: string, opts: TranscribeEngineOptions = {}): Promise<string> {
331
345
  const args = await validatedArgs(buildTranscribeArgs(audioPath, opts), { signal: opts.signal });
332
346
  const run = await runEngine(args, { signal: opts.signal, onProgressLine: opts.onProgressLine });
333
- if (failed(run)) throw withVirtualMacHint(engineFailure(args[0] ?? "", run, run.exitCode));
347
+ if (failed(run)) throw withVirtualMacHint(transcribeFailure(args[0] ?? "", run));
334
348
  return run.stdout;
335
349
  }
336
350
 
@@ -388,12 +402,14 @@ export async function transcribeEngineWithSegments(
388
402
  const args = await validatedArgs(buildTranscribeArgs(audioPath, opts, true), { signal: opts.signal });
389
403
  if (opts.speakers) assertSpeakerModelsInstalled();
390
404
  const run = await runEngine(args, { signal: opts.signal, onProgressLine: opts.onProgressLine });
391
- if (failed(run)) throw withVirtualMacHint(engineFailure(args[0] ?? "", run, run.exitCode));
405
+ if (failed(run)) throw withVirtualMacHint(transcribeFailure(args[0] ?? "", run));
392
406
  try {
393
407
  return parseTranscriptionOutput(run.stdout);
394
408
  } catch (err: unknown) {
395
409
  const message = err instanceof Error ? err.message : String(err);
396
- throw new KeshaError(err instanceof KeshaError ? err.code : "E_INTERNAL", `${message}: ${run.stdout}`);
410
+ throw new KeshaError(err instanceof KeshaError ? err.code : "E_INTERNAL", `${message}: ${quoteInvalidLine(run.stdout)}`, {
411
+ hint: SUPPORT_BUNDLE_HINT,
412
+ });
397
413
  }
398
414
  }
399
415
 
@@ -417,7 +433,7 @@ export type RecordTarget =
417
433
  const SIGNALLED_LIVE_EXIT_CODES = new Set([130, 143]);
418
434
 
419
435
  export function buildRecordArgs(target: RecordTarget, maxSeconds: number): string[] {
420
- if (!target.live) return ["record", "--out", target.out, "--max-seconds", String(maxSeconds)];
436
+ if (!target.live) return ["record", `--out=${target.out}`, "--max-seconds", String(maxSeconds)];
421
437
  const args = ["record", "--live", "--max-seconds", String(maxSeconds)];
422
438
  if (target.autoStop) {
423
439
  args.push(
@@ -511,7 +527,13 @@ export async function recordEngine(target: RecordTarget, maxSeconds: number): Pr
511
527
  // so an error event beside that status is a real failure the signal must not excuse.
512
528
  if (events.error || events.invalid.length > 0) throw withVirtualMacHint(engineFailure("record", events, exitCode));
513
529
  // A silent non-zero exit named nothing, so it stays the operational 1 it has been since #1167.
514
- if (!signalled && exitCode !== 0) throw new Error(`kesha-engine record exited with code ${exitCode}`);
530
+ if (!signalled && exitCode !== 0) {
531
+ throw new KeshaError("E_INTERNAL", engineExitCause("record", exitCode, run.signalCode), {
532
+ exitCode: 1,
533
+ stderr: events.stderr.trim(),
534
+ hint: SUPPORT_BUNDLE_HINT,
535
+ });
536
+ }
515
537
  }
516
538
 
517
539
  export function parseLangResult(stdout: string): LangDetectResult | null {
@@ -531,14 +553,13 @@ export async function detectAudioLanguageEngine(
531
553
  opts: RunEngineOptions = {},
532
554
  ): Promise<LangDetectResult | null> {
533
555
  if (!isEngineInstalled()) return null;
534
- const run = await runEngine(["detect-lang", audioPath], opts);
556
+ const run = await runEngine(["detect-lang", "--", audioPath], opts);
535
557
  // The tolerant form: a noisy onnxruntime warning must not blind a best-effort guess.
536
558
  if (reportedFailure(run)) {
537
- const cause = run.error ? renderError(run.error) : `kesha-engine detect-lang exited with code ${run.exitCode}`;
538
- log.warn(
539
- `Audio language detection failed: ${cause}\n` +
540
- " Fix: run `kesha install` to reinstall the engine and its language-ID model.",
541
- );
559
+ const crashed = !run.error && engineSignal(run.exitCode, run.signalCode) !== null;
560
+ const cause = run.error ? renderError(run.error) : engineExitCause("detect-lang", run.exitCode, run.signalCode);
561
+ const fix = crashed ? SUPPORT_BUNDLE_HINT : "run `kesha install` to reinstall the engine and its language-ID model.";
562
+ log.warn(`Audio language detection failed: ${cause}\n Fix: ${fix}`);
542
563
  return null;
543
564
  }
544
565
  return parseLangResult(run.stdout);
package/src/format.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { LANG_CONFIDENCE_FLOOR, routeLanguage } from "./language-routing";
1
+ import { LANG_CONFIDENCE_FLOOR, routeLanguage, unsupportedAudioLanguageWarning } from "./language-routing";
2
2
  import type { TranscribeErrorRecord, TranscribeJsonOutput, TranscribeResult } from "./types";
3
3
 
4
4
  const BELOW_FLOOR_NOTE = `below the ${LANG_CONFIDENCE_FLOOR} floor, ignored for lang`;
@@ -51,8 +51,9 @@ export function formatTranscriptOutput(results: TranscribeResult[]): string {
51
51
  lines.push(`=== ${r.file} ===`);
52
52
  }
53
53
  lines.push(r.text);
54
- const lang = r.textLanguage?.code || r.audioLanguage?.code || r.lang;
55
- const confidence = r.textLanguage?.confidence ?? r.audioLanguage?.confidence;
54
+ const detected = unsupportedAudioLanguageWarning(r.audioLanguage, r.textLanguage) ? r.audioLanguage : (r.textLanguage ?? r.audioLanguage);
55
+ const lang = detected?.code || r.lang;
56
+ const confidence = detected?.confidence;
56
57
  if (lang) lines.push(`[lang: ${lang}${confidence != null ? `, confidence: ${confidence.toFixed(2)}` : ""}]`);
57
58
  return lines.join("\n");
58
59
  })
@@ -7,6 +7,8 @@
7
7
  // `--no-cache`) — they preselect modules in the interactive wizard rather than
8
8
  // being install-only flags. Source of truth: `src/cli/init.ts::initCommand.args`.
9
9
  export function installHint(...flags: string[]): string {
10
- const verb = process.stderr.isTTY ? "kesha init" : "kesha install";
10
+ // `kesha init` refuses unless stdin and stdout are terminals (#1373), so a piped run gets `install` (#1380).
11
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY;
12
+ const verb = interactive ? "kesha init" : "kesha install";
11
13
  return [verb, ...flags].join(" ");
12
14
  }
@@ -5,6 +5,25 @@ import type { TextLangDetectResult } from "./types";
5
5
  /** A guess scoring below this is kept in its raw field but never names the top-level `lang` (Exploratory S11-2, S11-4). */
6
6
  export const LANG_CONFIDENCE_FLOOR = 0.5;
7
7
 
8
+ /** The speech-to-text languages in docs/languages.md; keep the two in step. */
9
+ const ASR_LANGUAGES = new Set([
10
+ "bg", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el", "hu", "it",
11
+ "lv", "lt", "mt", "pl", "pt", "ro", "ru", "sk", "sl", "es", "sv", "uk",
12
+ ]);
13
+
14
+ /** Audio lang-id this sure of a non-ASR language outranks text detection on the transcript (#1463: zh audio 0.999 vs text 0.63). */
15
+ export const UNSUPPORTED_AUDIO_LANGUAGE_CONFIDENCE = 0.95;
16
+
17
+ export function unsupportedAudioLanguageWarning(
18
+ audioLanguage: LangDetectResult | undefined,
19
+ textLanguage: TextLangDetectResult | undefined,
20
+ ): string | null {
21
+ if (!audioLanguage?.code || audioLanguage.confidence < UNSUPPORTED_AUDIO_LANGUAGE_CONFIDENCE) return null;
22
+ if (ASR_LANGUAGES.has(audioLanguage.code)) return null;
23
+ if (textLanguage && ASR_LANGUAGES.has(textLanguage.code) && textLanguage.confidence >= LANG_CONFIDENCE_FLOOR) return null;
24
+ return `warning: audio language "${audioLanguage.code}" is not one kesha can transcribe; the text is unreliable (supported: docs/languages.md)`;
25
+ }
26
+
8
27
  export type LangRouteSource = "engine" | "tinyld" | "audio";
9
28
 
10
29
  export interface LanguageRoute {
@@ -14,7 +33,8 @@ export interface LanguageRoute {
14
33
  }
15
34
 
16
35
  /**
17
- * Picks the top-level `lang` from what the detectors returned: the Engine's text result as-is,
36
+ * Picks the top-level `lang` from what the detectors returned: confident audio in a language outside the ASR set,
37
+ * else the Engine's text result as-is,
18
38
  * else a `tinyld` guess that clears `LANG_CONFIDENCE_FLOOR`, else audio that clears it, else "".
19
39
  */
20
40
  export function routeLanguage(input: {
@@ -28,6 +48,9 @@ export function routeLanguage(input: {
28
48
  textLanguage.confidence < LANG_CONFIDENCE_FLOOR;
29
49
  const audioBelowFloor = audioLanguage !== undefined && audioLanguage.confidence < LANG_CONFIDENCE_FLOOR;
30
50
  const belowFloor = { audio: audioBelowFloor, text: textBelowFloor };
51
+ if (audioLanguage && unsupportedAudioLanguageWarning(audioLanguage, textLanguage)) {
52
+ return { lang: audioLanguage.code, source: "audio", belowFloor };
53
+ }
31
54
  if (textLanguage?.code && !textBelowFloor) {
32
55
  return { lang: textLanguage.code, source: textLanguage.source, belowFloor };
33
56
  }
package/src/mcp/tools.ts CHANGED
@@ -2,6 +2,7 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
2
2
  import { errorMessage } from "../error-utils";
3
3
  import { KeshaError } from "../engine/events";
4
4
  import { installHint } from "../install-hint";
5
+ import { PATH_ERRNOS } from "../path-errnos";
5
6
  import { z } from "zod";
6
7
  import { chmodSync, existsSync, readFileSync, statSync } from "fs";
7
8
  import { basename, isAbsolute, join } from "path";
@@ -29,7 +30,11 @@ export function registerTools(server: McpServer): void {
29
30
  try {
30
31
  bytes = readFileSync(filePath);
31
32
  } catch {
32
- throw new Error(`synthesized-audio: file not found or already swept: ${name}`);
33
+ // The SDK sends only `message`, so the code and hint go inside it.
34
+ const coded = new KeshaError("E_INPUT_NOT_FOUND", `synthesized-audio: file not found or already swept: ${name}`, {
35
+ hint: "synthesized files are deleted after 24 hours; call synthesize_speech again.",
36
+ });
37
+ throw new Error(errorMessage(coded));
33
38
  }
34
39
  return { contents: [{ uri: uri.href, mimeType: mimeForExt(name), blob: bytes.toString("base64") }] };
35
40
  },
@@ -59,13 +64,24 @@ export function registerTools(server: McpServer): void {
59
64
  },
60
65
  async ({ text, voice, rate, format }, extra) => {
61
66
  if (extra.signal?.aborted) {
62
- return { isError: true, content: [{ type: "text" as const, text: "request cancelled" }] };
67
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(new KeshaError("E_INTERRUPTED", "request cancelled")) }] };
63
68
  }
64
69
  if (rate !== undefined && !(rate >= 0.5 && rate <= 2.0)) {
65
- return { isError: true, content: [{ type: "text" as const, text: `rate ${rate} out of range (0.5-2.0)` }] };
70
+ const err = new KeshaError("E_INVALID_ARG", `rate ${rate} out of range (0.5-2.0)`);
71
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
66
72
  }
67
73
  const fmt: SayFormat = format ?? "wav";
68
- const outPath = allocAudioPath(fmt);
74
+ let outPath: string;
75
+ try {
76
+ outPath = allocAudioPath(fmt);
77
+ } catch (err) {
78
+ const errno = (err as NodeJS.ErrnoException).code ?? "";
79
+ const why = PATH_ERRNOS[errno];
80
+ const coded = new KeshaError(why ? "E_INVALID_ARG" : "E_INTERNAL", `cannot create the audio folder ${audioDir()}: ${why ? `${why} (${errno})` : errorMessage(err)}`, {
81
+ hint: "set KESHA_HOME to a directory you can write to.",
82
+ });
83
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(coded) }] };
84
+ }
69
85
  try {
70
86
  // An MCP caller has no stderr to read: the voice it is told is its only record (#942).
71
87
  const resolvedVoice = (await resolveSayVoice(voice, undefined, text, { signal: extra.signal })) ?? DEFAULT_VOICE_ID;
@@ -121,7 +137,7 @@ export function registerTools(server: McpServer): void {
121
137
  },
122
138
  async ({ path, timestamps }, extra) => {
123
139
  if (extra.signal?.aborted) {
124
- return { isError: true, content: [{ type: "text" as const, text: "request cancelled" }] };
140
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(new KeshaError("E_INTERRUPTED", "request cancelled")) }] };
125
141
  }
126
142
  if (!existsSync(path)) {
127
143
  const hint = isAbsolute(path)
@@ -172,7 +188,7 @@ export function registerTools(server: McpServer): void {
172
188
  structuredContent: { voices },
173
189
  };
174
190
  } catch (err) {
175
- return { isError: true, content: [{ type: "text" as const, text: `list_voices failed: ${errorMessage(err)}` }] };
191
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
176
192
  }
177
193
  },
178
194
  );
@@ -202,7 +218,7 @@ export function registerTools(server: McpServer): void {
202
218
  structuredContent: { languages },
203
219
  };
204
220
  } catch (err) {
205
- return { isError: true, content: [{ type: "text" as const, text: `list_languages failed: ${errorMessage(err)}` }] };
221
+ return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
206
222
  }
207
223
  },
208
224
  );
package/src/progress.ts CHANGED
@@ -36,12 +36,28 @@ export function estimatePercent(elapsedMs: number, estimatedTotalMs: number): nu
36
36
  return Math.max(1, Math.min(99, Math.floor((elapsedMs / targetMs) * 99)));
37
37
  }
38
38
 
39
+ // Whatever was painted on the row before, by this bar or another, would show past a shorter frame (#1440).
40
+ const ERASE_TO_EOL = "\x1b[K";
41
+
42
+ /** Cuts `text` to `max` terminal columns, ending in `…` when cut; wide (CJK) characters count as two. */
43
+ function fitWidth(text: string, max: number): string {
44
+ if (Bun.stringWidth(text) <= max) return text;
45
+ if (max <= 0) return "";
46
+ let out = "";
47
+ for (const { segment } of new Intl.Segmenter().segment(text)) {
48
+ if (Bun.stringWidth(`${out}${segment}…`) > max) break;
49
+ out += segment;
50
+ }
51
+ return `${out}…`;
52
+ }
53
+
39
54
  export function createPercentProgress(
40
55
  label: string,
41
56
  options: { estimatedTotalMs?: number; intervalMs?: number } = {},
42
57
  ): {
43
58
  finish(finalLabel: string): void;
44
59
  interrupt(writeLine: () => void): void;
60
+ status(line: string): void;
45
61
  stop(): void;
46
62
  } {
47
63
  const isTTY = process.stderr.isTTY;
@@ -55,6 +71,9 @@ export function createPercentProgress(
55
71
  interrupt(writeLine: () => void) {
56
72
  writeLine();
57
73
  },
74
+ status(line: string) {
75
+ process.stderr.write(`${line}\n`);
76
+ },
58
77
  stop() {},
59
78
  };
60
79
  }
@@ -62,18 +81,23 @@ export function createPercentProgress(
62
81
  const intervalMs = options.intervalMs ?? 250;
63
82
  const estimatedTotalMs = options.estimatedTotalMs ?? DEFAULT_ESTIMATED_PROGRESS_MS;
64
83
  const startedAt = performance.now();
65
- let lastLineLength = 0;
84
+ let painted = false;
66
85
  let timer: Timer | undefined;
67
86
  let stopped = false;
68
87
  let firstRender = true;
88
+ let statusText = "";
69
89
 
70
90
  const render = () => {
71
91
  if (stopped) return;
72
92
  const percent = firstRender ? 0 : estimatePercent(performance.now() - startedAt, estimatedTotalMs);
73
93
  firstRender = false;
74
- const line = formatPercentProgress(label, percent);
75
- lastLineLength = line.length;
76
- process.stderr.write(`\r${line}`);
94
+ const rest = formatPercentProgress("", percent) + (statusText ? ` ${statusText}` : "");
95
+ // A row wider than the terminal wraps, and `\r` then repaints only its last part; the file name gives way first.
96
+ const columns = process.stderr.columns;
97
+ const name = columns ? fitWidth(label, columns - 1 - Bun.stringWidth(rest)) : label;
98
+ const line = columns ? fitWidth(name + rest, columns - 1) : name + rest;
99
+ painted = true;
100
+ process.stderr.write(`\r${line}${ERASE_TO_EOL}`);
77
101
  };
78
102
 
79
103
  const clearLine = (stopTimer: boolean) => {
@@ -82,9 +106,9 @@ export function createPercentProgress(
82
106
  clearInterval(timer);
83
107
  timer = undefined;
84
108
  }
85
- if (lastLineLength > 0) {
86
- process.stderr.write(`\r${" ".repeat(lastLineLength)}\r`);
87
- lastLineLength = 0;
109
+ if (painted) {
110
+ process.stderr.write(`\r${ERASE_TO_EOL}`);
111
+ painted = false;
88
112
  }
89
113
  };
90
114
 
@@ -106,6 +130,14 @@ export function createPercentProgress(
106
130
  render();
107
131
  timer = setInterval(render, intervalMs);
108
132
  },
133
+ status(line: string) {
134
+ if (stopped) {
135
+ process.stderr.write(`${line}\n`);
136
+ return;
137
+ }
138
+ statusText = line;
139
+ render();
140
+ },
109
141
  stop() {
110
142
  clearLine(true);
111
143
  },
@@ -119,11 +151,11 @@ export function createPercentProgress(
119
151
  */
120
152
  export function createLiveStatus(): { update(line: string): void; clear(): void } {
121
153
  const isTTY = process.stderr.isTTY && !log.quietEnabled;
122
- let painted = 0;
154
+ let painted = false;
123
155
  const erase = () => {
124
- if (painted === 0) return;
125
- process.stderr.write(`\r${" ".repeat(painted)}\r`);
126
- painted = 0;
156
+ if (!painted) return;
157
+ process.stderr.write(`\r${ERASE_TO_EOL}`);
158
+ painted = false;
127
159
  };
128
160
  // Ctrl-C exits through `process.exit` without unwinding the spawn's `finally` (src/process-tree.ts),
129
161
  // so without this the painted row survives into the shell prompt.
@@ -131,13 +163,12 @@ export function createLiveStatus(): { update(line: string): void; clear(): void
131
163
  return {
132
164
  update(line: string) {
133
165
  if (!isTTY) return;
134
- if (painted === 0) process.once("exit", onExit);
135
- const pad = painted > line.length ? " ".repeat(painted - line.length) : "";
136
- process.stderr.write(`\r${line}${pad}`);
137
- painted = line.length;
166
+ if (!painted) process.once("exit", onExit);
167
+ process.stderr.write(`\r${line}${ERASE_TO_EOL}`);
168
+ painted = true;
138
169
  },
139
170
  clear() {
140
- if (painted === 0) return;
171
+ if (!painted) return;
141
172
  erase();
142
173
  process.removeListener("exit", onExit);
143
174
  },
@@ -303,11 +334,11 @@ export function createProgressBar(label: string, totalBytes: number): {
303
334
  if (pct === lastPct) return;
304
335
  lastPct = pct;
305
336
  const line = formatProgressBar(label, current, totalBytes);
306
- process.stderr.write(`\r${line}`);
337
+ process.stderr.write(`\r${line}${ERASE_TO_EOL}`);
307
338
  },
308
339
  finish() {
309
340
  const line = formatProgressBar(label, totalBytes, totalBytes);
310
- process.stderr.write(`\r${line}\n`);
341
+ process.stderr.write(`\r${line}${ERASE_TO_EOL}\n`);
311
342
  },
312
343
  };
313
344
  }
package/src/stats.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { KeshaError } from "./engine/events";
2
3
  import { errorMessage } from "./error-utils";
3
4
  import { humanBytes } from "./format";
4
5
  import { existsSync, mkdirSync, statSync } from "fs";
@@ -11,6 +12,8 @@ import { resolveStatePaths } from "./state-paths";
11
12
  const SCHEMA_VERSION = 1;
12
13
  const MAX_ERROR_MESSAGE_CHARS = 300;
13
14
  const DEFAULT_RETENTION_DAYS = 90;
15
+ // A Date reaches back only 100,000,000 days before 1970: a longer retention makes the cutoff an Invalid Date.
16
+ export const MAX_RETENTION_DAYS = 100_000_000;
14
17
  const APP_VERSION = readPackageVersion();
15
18
 
16
19
  export type StatsCommandName = "transcribe" | "say";
@@ -148,7 +151,9 @@ let warnedStatsWriteFailed = false;
148
151
 
149
152
  function warnStatsWriteFailed(err: unknown): void {
150
153
  if (!warnedStatsWriteFailed) {
151
- log.warn("warning: failed to write Kesha Stats; continuing without stats for this event");
154
+ log.warn(
155
+ `warning: failed to write Kesha Stats at ${resolveStatsDbPath()} (${errorMessage(err)}); continuing without stats for this event; fix that, or run \`kesha stats disable\` to stop recording.`,
156
+ );
152
157
  warnedStatsWriteFailed = true;
153
158
  }
154
159
  const message = errorMessage(err);
@@ -175,9 +180,28 @@ export function createStatsRecorder(command: StatsCommandName): StatsRecorder {
175
180
 
176
181
  /** Open the stats DB (creating it if needed), run fn, always close. */
177
182
  function withStatsDb<T>(fn: (db: Database) => T): T {
178
- const db = openStatsDatabase(resolveStatsDbPath());
183
+ const dbPath = resolveStatsDbPath();
184
+ let db: Database;
185
+ try {
186
+ db = openStatsDatabase(dbPath);
187
+ } catch (err) {
188
+ const code = (err as { code?: unknown }).code;
189
+ const corrupt = code === "SQLITE_NOTADB" || code === "SQLITE_CORRUPT";
190
+ throw new KeshaError("E_INTERNAL", `cannot open the Kesha Stats database ${dbPath}: ${errorMessage(err)}`, {
191
+ hint: corrupt
192
+ ? `delete ${dbPath} to start over; Stats stays off until \`kesha stats enable\` creates a new one.`
193
+ : `check that ${dbPath} and its folder can be read and written, or point KESHA_STATS_DB at another file.`,
194
+ });
195
+ }
179
196
  try {
180
197
  return fn(db);
198
+ } catch (err) {
199
+ const busy = String((err as { code?: unknown }).code ?? "").startsWith("SQLITE_BUSY");
200
+ throw new KeshaError("E_INTERNAL", `cannot read or write the Kesha Stats database ${dbPath}: ${errorMessage(err)}`, {
201
+ hint: busy
202
+ ? "another kesha command is writing to it; retry when that command finishes."
203
+ : `check that ${dbPath} and its folder can be written and that its disk has free space, or point KESHA_STATS_DB at another file.`,
204
+ });
181
205
  } finally {
182
206
  db.close();
183
207
  }
@@ -958,7 +982,7 @@ function parseRetentionSetting(raw: string): number | null {
958
982
  const normalized = raw.trim().toLowerCase();
959
983
  if (normalized === "off" || normalized === "none" || normalized === "never") return null;
960
984
  const days = Number(normalized);
961
- if (Number.isInteger(days) && days >= 1) return days;
985
+ if (Number.isInteger(days) && days >= 1 && days <= MAX_RETENTION_DAYS) return days;
962
986
  return DEFAULT_RETENTION_DAYS;
963
987
  }
964
988
 
@@ -1,3 +1,4 @@
1
+ import { renderError } from "./engine/events";
1
2
  import { errorMessage } from "./error-utils";
2
3
  import { log } from "./log";
3
4
 
@@ -9,6 +10,7 @@ let guarded = false;
9
10
  * try/catch around the write, so it escaped as an uncaught stack trace and turned a
10
11
  * successful synthesis into exit 1 (#1001). EPIPE alone means the reader left; every other
11
12
  * write failure still reports and exits non-zero.
13
+ * A stdout closed before launch (`>&-`) never errors here: Bun reopens fd 1 as /dev/null (#1471).
12
14
  */
13
15
  export function guardStdoutWrites(): void {
14
16
  if (guarded) return;
@@ -19,7 +21,11 @@ export function guardStdoutWrites(): void {
19
21
  return;
20
22
  }
21
23
  log.error(
22
- `failed writing to stdout: ${errorMessage(err)}. Check that the destination is writable and has free space.`,
24
+ renderError({
25
+ code: "E_INTERNAL",
26
+ message: `failed writing to stdout: ${errorMessage(err)}`,
27
+ hint: "free disk space or check what stdout is redirected to.",
28
+ }),
23
29
  );
24
30
  process.exitCode = 1;
25
31
  });
package/src/synth.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { getDescribe, getEngineBinPath, isEngineInstalled } from "./engine";
2
- import { runEngineProcess } from "./engine/spawn";
2
+ import { engineSignal, runEngineProcess } from "./engine/spawn";
3
3
  import { validateArgv } from "./engine/describe";
4
4
  import { engineFailure, exitCodeFor, KeshaError, type EventSinks } from "./engine/events";
5
5
  import { installHint } from "./install-hint";
@@ -18,6 +18,7 @@ export type SayFormat = "wav" | "ogg-opus" | "flac";
18
18
  export const SUPPORTED_SAMPLE_RATES = [8000, 12000, 16000, 24000, 48000] as const;
19
19
  export type SupportedSampleRate = (typeof SUPPORTED_SAMPLE_RATES)[number];
20
20
  export const MAX_TEXT_CHARS = 5000;
21
+ export const TEXT_TOO_LONG_HINT = `split the text into requests of at most ${MAX_TEXT_CHARS} characters`;
21
22
 
22
23
  type SayBase = {
23
24
  /**
@@ -57,7 +58,7 @@ export function buildSayArgs(o: SayOptions): string[] {
57
58
  const args: string[] = ["say"];
58
59
  if (o.voice) args.push("--voice", o.voice);
59
60
  if (o.lang) args.push("--lang", o.lang);
60
- if (o.out) args.push("--out", o.out);
61
+ if (o.out) args.push(`--out=${o.out}`);
61
62
  if (o.rate !== undefined && o.rate !== 1.0) args.push("--rate", String(o.rate));
62
63
  if (o.ssml) args.push("--ssml");
63
64
  if (o.format) args.push("--format", o.format);
@@ -68,23 +69,13 @@ export function buildSayArgs(o: SayOptions): string[] {
68
69
  return args;
69
70
  }
70
71
 
71
- /** Fault signals, by number. SIGBUS is 10 on darwin but 7 on linux. */
72
- const FAULT_SIGNALS: Record<number, string> = { 4: "SIGILL", 6: "SIGABRT", 8: "SIGFPE", 11: "SIGSEGV" };
73
-
74
- /** Explains a signal death, which leaves no error event; the wait status outranks Bun's signalCode (10 is SIGBUS on darwin, SIGUSR1 to Bun). */
72
+ /** Explains a signal death, which leaves no error event. */
75
73
  export function engineCrashMessage(
76
74
  exitCode: number,
77
75
  signalCode: string | null,
78
76
  platform: string = process.platform,
79
77
  ): string | null {
80
- const number = exitCode > 128 ? exitCode - 128 : undefined;
81
- const sigbus = platform === "darwin" ? 10 : 7;
82
- const signal =
83
- number === undefined
84
- ? (signalCode ?? undefined)
85
- : number === sigbus
86
- ? "SIGBUS"
87
- : (FAULT_SIGNALS[number] ?? signalCode ?? undefined);
78
+ const signal = engineSignal(exitCode, signalCode, platform);
88
79
  if (!signal) return null;
89
80
  const base = `kesha-engine was killed by ${signal} and produced no audio`;
90
81
  // Every physical Apple Silicon Mac has a Neural Engine; virtualised macOS has
@@ -102,6 +93,42 @@ export function engineCrashMessage(
102
93
  return `${base}.`;
103
94
  }
104
95
 
96
+ // ©, ® and ™ stay, emoji presentation (U+FE0F) or not: read aloud they still mean something.
97
+ const KEPT_SYMBOL = /([©®™])️/g;
98
+ const EMOJI = /\p{RGI_Emoji}|[\p{Extended_Pictographic}--[©®™]]/gv;
99
+
100
+ /**
101
+ * Emoji are decoration in a reply; a voice would read out their Unicode names (#1388).
102
+ * Emoji-only text, SSML tags aside, goes through unchanged, so the engine still refuses it as E_SCRIPT_UNSUPPORTED.
103
+ */
104
+ export function dropEmoji(text: string, ssml = false): string {
105
+ const spoken = (ssml ? decodeCharRefs(text) : text).replace(KEPT_SYMBOL, "$1").replace(EMOJI, "");
106
+ const speech = ssml ? spoken.replace(CDATA, "$1").replace(/<[^>]*>/g, "") : spoken;
107
+ return speech.trim().length > 0 ? spoken : text;
108
+ }
109
+
110
+ const CDATA = /<!\[CDATA\[([\s\S]*?)\]\]>/g;
111
+ const NUMERIC_REF = /&#(?:x([0-9a-f]+)|(\d+));/gi;
112
+
113
+ /**
114
+ * The engine decodes SSML character references, so `&#x1F60A;` is an emoji too. Decoding them first lets the
115
+ * emoji filter see sequences spelled across references; CDATA keeps its text literal, and markup characters stay escaped.
116
+ */
117
+ function decodeCharRefs(ssml: string): string {
118
+ return ssml
119
+ .split(/(<!\[CDATA\[[\s\S]*?\]\]>)/)
120
+ .map((part) =>
121
+ part.startsWith("<![CDATA[")
122
+ ? part
123
+ : part.replace(NUMERIC_REF, (ref, hex?: string, dec?: string) => {
124
+ const cp = hex !== undefined ? parseInt(hex, 16) : Number(dec);
125
+ const plain = cp >= 0x20 && cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff) && !`<>&'"`.includes(String.fromCodePoint(cp));
126
+ return plain ? String.fromCodePoint(cp) : ref;
127
+ }),
128
+ )
129
+ .join("");
130
+ }
131
+
105
132
  /**
106
133
  * The text contract both doors enforce, before anything that costs a subprocess.
107
134
  * Throws `KeshaError`; the CLI runs it before voice routing, `say()` for programmatic callers.
@@ -112,7 +139,11 @@ export function validateSayText(text: string): void {
112
139
  }
113
140
  const chars = Array.from(text).length;
114
141
  if (chars > MAX_TEXT_CHARS) {
115
- throw new KeshaError("E_TEXT_TOO_LONG", `text exceeds ${MAX_TEXT_CHARS} chars (${chars})`, { exitCode: 5, stderr: "" });
142
+ throw new KeshaError("E_TEXT_TOO_LONG", `text exceeds ${MAX_TEXT_CHARS} chars (${chars})`, {
143
+ exitCode: 5,
144
+ stderr: "",
145
+ hint: TEXT_TOO_LONG_HINT,
146
+ });
116
147
  }
117
148
  if (text.includes("\0")) {
118
149
  throw new KeshaError("E_INVALID_ARG", "text contains a NUL byte", { exitCode: 2, stderr: "" });
@@ -133,7 +164,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
133
164
  for (const warning of warnings) log.warn(warning);
134
165
  const { exitCode, signalCode, aborted, stdout, events } = await runEngineProcess(getEngineBinPath(), args, {
135
166
  stdio: ["pipe", "pipe", "pipe"],
136
- stdin: text,
167
+ stdin: dropEmoji(text, opts.ssml),
137
168
  readStdout: (stream) => new Response(stream).arrayBuffer(),
138
169
  signal: opts.signal,
139
170
  });
package/src/transcribe.ts CHANGED
@@ -24,6 +24,19 @@ export function isDirectoryPath(path: string): boolean {
24
24
  }
25
25
  }
26
26
 
27
+ export const SPECIAL_FILE_HINT =
28
+ "kesha reads its input more than once, so a named pipe, /dev/stdin or <(...) cannot work; save the audio to a file and pass that.";
29
+
30
+ /** True when `path` exists and is neither a regular file nor a directory: the engine opens its input more than once, so a FIFO, `/dev/stdin` or `<(...)` hangs it (#1468). */
31
+ export function isSpecialFilePath(path: string): boolean {
32
+ try {
33
+ const st = statSync(path);
34
+ return !st.isFile() && !st.isDirectory();
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
27
40
  export interface TranscribeOptions {
28
41
  /** Silero VAD preprocessing selector. Defaults to `"auto"`. */
29
42
  vad?: VadMode;
@@ -54,6 +67,9 @@ export function assertAudioFileArgument(audioPath: string): void {
54
67
  if (isDirectoryPath(audioPath)) {
55
68
  throw new KeshaError("E_INVALID_ARG", `${audioPath}: is a directory (expected an audio file)`);
56
69
  }
70
+ if (isSpecialFilePath(audioPath)) {
71
+ throw new KeshaError("E_INVALID_ARG", `${audioPath}: is not a regular file (expected an audio file)`, { hint: SPECIAL_FILE_HINT });
72
+ }
57
73
  }
58
74
 
59
75
  /** The CLI's gate before any progress UI: the engine, its describe document, the request's flags, and the model files a request needs; the argv actually sent is validated again at the spawn. */