@drakulavich/kesha-voice-kit 2.3.0-beta.1 → 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
 
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.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": {
@@ -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.2",
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": "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"
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": 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
126
126
  }
127
127
  }
128
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
 
@@ -340,6 +345,19 @@ async function processFile(
340
345
  return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
341
346
  }
342
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
+ });
352
+ stats.recordError("input", err, err.code);
353
+ diagnosticLog.event("input.invalid", {
354
+ command: "transcribe",
355
+ error_code: err.code,
356
+ });
357
+ log.error(`${file}: ${errorMessage(err)}`);
358
+ return { ok: false, error: { file, code: err.code, message: err.message }, exitCode: failureExitCode(err) };
359
+ }
360
+
343
361
  const inputArtifact = artifactFromFile(file, "input_audio");
344
362
  if (inputArtifact) {
345
363
  stats.recordArtifact(inputArtifact);
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
  })
@@ -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
  },
@@ -66,7 +71,17 @@ export function registerTools(server: McpServer): void {
66
71
  return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
67
72
  }
68
73
  const fmt: SayFormat = format ?? "wav";
69
- 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
+ }
70
85
  try {
71
86
  // An MCP caller has no stderr to read: the voice it is told is its only record (#942).
72
87
  const resolvedVoice = (await resolveSayVoice(voice, undefined, text, { signal: extra.signal })) ?? DEFAULT_VOICE_ID;
@@ -173,7 +188,7 @@ export function registerTools(server: McpServer): void {
173
188
  structuredContent: { voices },
174
189
  };
175
190
  } catch (err) {
176
- 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) }] };
177
192
  }
178
193
  },
179
194
  );
@@ -203,7 +218,7 @@ export function registerTools(server: McpServer): void {
203
218
  structuredContent: { languages },
204
219
  };
205
220
  } catch (err) {
206
- 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) }] };
207
222
  }
208
223
  },
209
224
  );
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
@@ -148,7 +139,11 @@ export function validateSayText(text: string): void {
148
139
  }
149
140
  const chars = Array.from(text).length;
150
141
  if (chars > MAX_TEXT_CHARS) {
151
- 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
+ });
152
147
  }
153
148
  if (text.includes("\0")) {
154
149
  throw new KeshaError("E_INVALID_ARG", "text contains a NUL byte", { exitCode: 2, stderr: "" });
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. */