@drakulavich/kesha-voice-kit 1.21.0 → 1.22.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/drakulavich/kesha-voice-kit/main/docs/assets/logo.png" alt="Kesha Voice Kit" width="200">
2
+ <img src="https://github.com/drakulavich/kesha-voice-kit/raw/main/docs/assets/logo.png" alt="Kesha Voice Kit" width="200">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">Kesha Voice Kit</h1>
@@ -21,7 +21,7 @@
21
21
  See [Product positioning](docs/product-positioning.md) for supported workflows, non-goals, maturity labels, and the platform matrix.
22
22
 
23
23
  <p align="center">
24
- <img src="https://raw.githubusercontent.com/drakulavich/kesha-voice-kit/main/demo.gif" alt="kesha demo — English + Russian transcription with automatic language detection" width="800">
24
+ <img src="https://github.com/drakulavich/kesha-voice-kit/raw/main/demo.gif" alt="kesha demo — English + Russian transcription with automatic language detection" width="800">
25
25
  </p>
26
26
 
27
27
  ## Quick Start
@@ -96,12 +96,13 @@ Each segment gets a `speaker` integer (cluster ID, stable within one file). Linu
96
96
 
97
97
  ## Text-to-speech
98
98
 
99
- Kesha speaks back via Kokoro-82M (English) and Vosk-TTS (Russian) — voice auto-picks from the text's language. On darwin-arm64, Kokoro uses FluidAudio CoreML instead of ONNX; FluidAudio stores that CoreML cache at `~/.cache/fluidaudio/Models/kokoro`, outside Kesha's pinned model cache:
99
+ Kesha speaks back via Kokoro-82M (English plus selected multilingual voices on Apple Silicon) and Vosk-TTS (Russian) — voice auto-picks from the text's language. On darwin-arm64, Kokoro uses FluidAudio CoreML instead of ONNX; FluidAudio stores that CoreML cache at `~/.cache/fluidaudio/Models/kokoro`, outside Kesha's pinned model cache:
100
100
 
101
101
  ```bash
102
102
  kesha install --tts # TTS, opt-in; Darwin Kokoro uses FluidAudio cache
103
103
  kesha say "Hello, world" > hello.wav
104
104
  kesha say "Привет, мир" > privet.wav # auto-routes (Milena on darwin, ru-vosk-m02 elsewhere)
105
+ kesha say --voice es-em_alex "Hola, mundo" > hola.wav
105
106
  ```
106
107
 
107
108
  **Output formats** (`--format`, or inferred from `--out` extension):
@@ -269,7 +270,7 @@ mandb ~/.local/share/man 2>/dev/null || true
269
270
 
270
271
  Compared against Whisper `large-v3-turbo` — all engines auto-detect language.
271
272
 
272
- ![Benchmark: openai-whisper vs faster-whisper vs Kesha Voice Kit](https://raw.githubusercontent.com/drakulavich/kesha-voice-kit/main/docs/assets/benchmark.svg)
273
+ ![Benchmark: openai-whisper vs faster-whisper vs Kesha Voice Kit](https://github.com/drakulavich/kesha-voice-kit/raw/main/docs/assets/benchmark.svg)
273
274
 
274
275
  See [BENCHMARK.md](BENCHMARK.md) for the full per-file breakdown (Russian + English).
275
276
 
@@ -359,9 +360,65 @@ Add to your client config:
359
360
  { "mcpServers": { "kesha": { "command": "kesha", "args": ["mcp"] } } }
360
361
  ```
361
362
 
362
- - **Claude Desktop:** `claude_desktop_config.json`
363
- - **Claude Code:** `claude mcp add kesha -- kesha mcp`
364
- - **Cursor:** `.cursor/mcp.json`
363
+ Claude Desktop users can place the same JSON in `claude_desktop_config.json`.
364
+
365
+ <details>
366
+ <summary>Claude Code</summary>
367
+
368
+ ```bash
369
+ claude mcp add kesha -- kesha mcp
370
+ ```
371
+
372
+ </details>
373
+
374
+ <details>
375
+ <summary>Codex</summary>
376
+
377
+ Add to `~/.codex/config.toml`:
378
+
379
+ ```toml
380
+ [mcp_servers.kesha]
381
+ command = "kesha"
382
+ args = ["mcp"]
383
+ ```
384
+
385
+ </details>
386
+
387
+ <details>
388
+ <summary>Gemini CLI</summary>
389
+
390
+ Add to `~/.gemini/settings.json`:
391
+
392
+ ```json
393
+ {
394
+ "mcpServers": {
395
+ "kesha": {
396
+ "command": "kesha",
397
+ "args": ["mcp"]
398
+ }
399
+ }
400
+ }
401
+ ```
402
+
403
+ </details>
404
+
405
+ <details>
406
+ <summary>Cursor</summary>
407
+
408
+ Add to `.cursor/mcp.json`:
409
+
410
+ ```json
411
+ {
412
+ "mcpServers": {
413
+ "kesha": {
414
+ "command": "kesha",
415
+ "args": ["mcp"]
416
+ }
417
+ }
418
+ }
419
+ ```
420
+
421
+ </details>
365
422
 
366
423
  If a tool returns "models not installed", run `kesha install` (ASR) or
367
424
  `kesha install --tts` (TTS) once, then retry.
@@ -400,6 +457,10 @@ exit codes, and coarse audio metadata only. They must not store audio,
400
457
  transcripts, input text, generated speech text, file names, full paths, raw
401
458
  stdout/stderr, environment variables, tokens, or URLs. See [Diagnostic logs](docs/diagnostic-logs.md).
402
459
 
460
+ Every user-facing failure prints a stable `error [CODE]: …` line on stderr. See
461
+ [Error codes](docs/errors.md) for the full reference; engine codes are also
462
+ available via `kesha-engine --error-codes-json`.
463
+
403
464
  ## Local Stats privacy and lifecycle
404
465
 
405
466
  Kesha Stats is disabled by default. When you opt in with `kesha stats enable`,
@@ -67,7 +67,7 @@ complete -c kesha -n '__fish_seen_subcommand_from record' -l debug -d 'Trace eng
67
67
  complete -c kesha -n '__fish_seen_subcommand_from say' -l help -d 'Show help'
68
68
  complete -c kesha -n '__fish_seen_subcommand_from say' -s h -d 'Show help'
69
69
  complete -c kesha -n '__fish_seen_subcommand_from say' -l voice -r -d 'Voice id, e.g. en-am_michael'
70
- complete -c kesha -n '__fish_seen_subcommand_from say' -l lang -r -d 'BCP 47 language code (default en-us)'
70
+ complete -c kesha -n '__fish_seen_subcommand_from say' -l lang -r -d 'BCP 47 language code (default en-us). With no --voice, routes to that language\'s default voice and skips text-language detection.'
71
71
  complete -c kesha -n '__fish_seen_subcommand_from say' -l out -r -d 'Write audio to file instead of stdout'
72
72
  complete -c kesha -n '__fish_seen_subcommand_from say' -l rate -r -d 'Speaking rate 0.5–2.0'
73
73
  complete -c kesha -n '__fish_seen_subcommand_from say' -l list-voices -d 'List installed voices and exit'
@@ -99,7 +99,7 @@ _kesha() {
99
99
  _arguments '--help[Show help]' \
100
100
  '-h[Show help]' \
101
101
  '--voice=[Voice id, e.g. en-am_michael]:voice:' \
102
- '--lang=[BCP 47 language code (default en-us)]:lang:' \
102
+ '--lang=[BCP 47 language code (default en-us). With no --voice, routes to that language'\''s default voice and skips text-language detection.]:lang:' \
103
103
  '--out=[Write audio to file instead of stdout]:out:' \
104
104
  '--rate=[Speaking rate 0.5–2.0]:rate:' \
105
105
  '--list-voices[List installed voices and exit]' \
@@ -91,3 +91,9 @@ kesha logs mode retain-on-failure
91
91
  # reproduce the failure
92
92
  kesha support-bundle --include-logs --output kesha-support-with-logs.tar.gz
93
93
  ```
94
+
95
+ ## Error codes
96
+
97
+ Failure events record a stable `error_code` field (leak-free by construction).
98
+ The same code is printed to stderr as `error [CODE]: …` and recorded in Stats.
99
+ See [Error codes](errors.md) for the full reference.
package/man/kesha.1 CHANGED
@@ -155,7 +155,7 @@ Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)
155
155
  Voice id, e.g. en\-am_michael
156
156
  .TP
157
157
  .B \-\-lang
158
- BCP 47 language code (default en\-us)
158
+ BCP 47 language code (default en\-us). With no \-\-voice, routes to that language's default voice and skips text\-language detection.
159
159
  .TP
160
160
  .B \-\-out
161
161
  Write audio to file instead of stdout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.21.0",
3
+ "version": "1.22.0-beta.1",
4
4
  "description": "Give your local tools a voice: fast STT in 25 languages (~19x faster than Whisper on Apple Silicon, ONNX CPU fallback), TTS via Kokoro/Vosk/macOS voices, VAD + 107-language detection. Rust engine, Bun CLI, OpenClaw skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  ]
33
33
  },
34
34
  "keshaEngine": {
35
- "version": "1.21.0"
35
+ "version": "1.22.0-beta.1"
36
36
  },
37
37
  "scripts": {
38
38
  "test": "bun test",
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { join } from "node:path";
5
+ import { KNOWN_TS_CODES } from "../error-codes";
6
+
7
+ const ENGINE_BIN =
8
+ process.env.KESHA_ENGINE_BIN ?? join(import.meta.dir, "../../rust/target/release/kesha-engine");
9
+
10
+ const describeOrSkip = existsSync(ENGINE_BIN) ? describe : describe.skip;
11
+
12
+ describeOrSkip("error-code drift", () => {
13
+ test("engine codes ∪ TS-native codes == codes documented in docs/errors.md", () => {
14
+ const res = spawnSync(ENGINE_BIN, ["--error-codes-json"], { encoding: "utf8" });
15
+ expect(res.status).toBe(0);
16
+ const engineCodes: string[] = JSON.parse(res.stdout).map((e: { code: string }) => e.code);
17
+
18
+ const known = new Set<string>([...engineCodes, ...KNOWN_TS_CODES]);
19
+
20
+ const doc = readFileSync(join(import.meta.dir, "../../docs/errors.md"), "utf8");
21
+ const documented = new Set<string>();
22
+ for (const m of doc.matchAll(/`(E_[A-Z0-9_]+)`/g)) documented.add(m[1]);
23
+
24
+ // every known code is documented
25
+ for (const c of known) expect(documented.has(c)).toBe(true);
26
+ // every documented code is known (no stale doc rows)
27
+ for (const c of documented) expect(known.has(c)).toBe(true);
28
+ });
29
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { extractEngineErrorCode, TS_NATIVE_CODES, KNOWN_TS_CODES } from "../error-codes";
3
+
4
+ describe("extractEngineErrorCode", () => {
5
+ test("extracts the code from a coded engine stderr line", () => {
6
+ const stderr = "error [E_MODEL_MISSING]: voice 'ru-vosk-m02' not installed. run: kesha install --tts";
7
+ expect(extractEngineErrorCode(stderr)).toBe("E_MODEL_MISSING");
8
+ });
9
+
10
+ test("extracts even when the message embeds a path or token", () => {
11
+ const stderr = "warning: foo\nerror [E_BAD_AUDIO]: decode error in: /Users/alice/secret-token-abc.wav";
12
+ expect(extractEngineErrorCode(stderr)).toBe("E_BAD_AUDIO");
13
+ });
14
+
15
+ test("returns undefined for an uncoded stderr so caller can fall back", () => {
16
+ expect(extractEngineErrorCode("Error: something went wrong")).toBeUndefined();
17
+ });
18
+
19
+ test("TS-native codes are exposed and included in KNOWN_TS_CODES", () => {
20
+ expect(TS_NATIVE_CODES.INPUT_NOT_FOUND).toBe("E_INPUT_NOT_FOUND");
21
+ expect(TS_NATIVE_CODES.ENGINE_SPAWN).toBe("E_ENGINE_SPAWN");
22
+ expect(TS_NATIVE_CODES.INVALID_ARG).toBe("E_INVALID_ARG");
23
+ expect(KNOWN_TS_CODES.has("E_INTERNAL")).toBe(true);
24
+ });
25
+ });
@@ -0,0 +1,14 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SayError } from "../synth";
3
+
4
+ describe("SayError carries a taxonomy code", () => {
5
+ test("pre-check throws carry text codes", () => {
6
+ const e = new SayError("text is empty", 2, "", "E_TEXT_EMPTY");
7
+ expect(e.code).toBe("E_TEXT_EMPTY");
8
+ expect(e.exitCode).toBe(2);
9
+ });
10
+ test("defaults to E_INTERNAL when unspecified", () => {
11
+ const e = new SayError("boom", 4, "");
12
+ expect(e.code).toBe("E_INTERNAL");
13
+ });
14
+ });
package/src/cli/main.ts CHANGED
@@ -20,6 +20,7 @@ import { getPendingSignalExitCode, waitForPendingSignalCleanup } from "../proces
20
20
  import type { TranscriptionSegment } from "../types";
21
21
  import { createDiagnosticLogSession } from "../diagnostic-log";
22
22
  import { diagnosticSizeBucket } from "../diagnostic-events";
23
+ import { ENGINE_CODES, extractEngineErrorCode, TS_NATIVE_CODES } from "../error-codes";
23
24
 
24
25
  interface MainCommandArgs {
25
26
  _: string[];
@@ -301,9 +302,12 @@ export const mainCommand = defineCommand({
301
302
  for (const file of files) {
302
303
  if (!existsSync(file)) {
303
304
  hasError = true;
304
- stats.recordError("input", new Error("File not found"), "file_not_found");
305
- diagnosticLog.event("input.missing", { command: "transcribe" });
306
- errors.push({ file, code: "file_not_found", message: "File not found" });
305
+ stats.recordError("input", new Error("File not found"), TS_NATIVE_CODES.INPUT_NOT_FOUND);
306
+ diagnosticLog.event("input.missing", {
307
+ command: "transcribe",
308
+ error_code: TS_NATIVE_CODES.INPUT_NOT_FOUND,
309
+ });
310
+ errors.push({ file, code: TS_NATIVE_CODES.INPUT_NOT_FOUND, message: "File not found" });
307
311
  log.error(`${file}: File not found`);
308
312
  continue;
309
313
  }
@@ -404,14 +408,17 @@ export const mainCommand = defineCommand({
404
408
  } catch (err: unknown) {
405
409
  progress?.stop();
406
410
  hasError = true;
407
- stats.recordError("transcribe", err);
411
+ const stderrText = err instanceof Error ? err.message : String(err);
412
+ const code = extractEngineErrorCode(stderrText) ?? ENGINE_CODES.TRANSCRIBE_FAILED;
413
+ stats.recordError("transcribe", err, code);
408
414
  diagnosticLog.event("engine.exit", {
409
415
  command: "transcribe",
410
416
  status: "failed",
411
417
  errorKind: "transcribe_failed",
418
+ error_code: code,
412
419
  });
413
- const message = err instanceof Error ? err.message : String(err);
414
- errors.push({ file, code: "transcribe_failed", message });
420
+ const message = stderrText;
421
+ errors.push({ file, code, message });
415
422
  log.error(`${file}: ${message}`);
416
423
  }
417
424
  }
package/src/cli/say.ts CHANGED
@@ -14,6 +14,25 @@ async function autoRouteVoice(text: string): Promise<string | undefined> {
14
14
  return pickVoiceForLang(detected?.code, detected?.confidence ?? 0);
15
15
  }
16
16
 
17
+ /**
18
+ * Resolve the voice for `kesha say`. Precedence: explicit `--voice` > explicit
19
+ * `--lang` (route by the stated language, skipping detection — also the path on
20
+ * Linux/Windows where text-language detection is unavailable) > macOS
21
+ * text-language auto-detection > engine default (`undefined`). A `--lang` with
22
+ * no mapped voice (an unsupported language, or French which has no male Kokoro
23
+ * voice) resolves to `undefined` (engine default) rather than re-running
24
+ * detection — the user stated the language explicitly.
25
+ */
26
+ export async function resolveSayVoice(
27
+ explicitVoice: string | undefined,
28
+ langHint: string | undefined,
29
+ text: string,
30
+ ): Promise<string | undefined> {
31
+ if (explicitVoice !== undefined) return explicitVoice;
32
+ if (langHint !== undefined) return pickVoiceForLang(langHint, 1);
33
+ return autoRouteVoice(text);
34
+ }
35
+
17
36
  /** Resolve the text to synthesize: inline positional, else read from stdin. */
18
37
  async function resolveText(inline: string | undefined): Promise<string> {
19
38
  if (inline !== undefined && inline.length > 0) return inline;
@@ -89,7 +108,11 @@ export const sayCommand = defineCommand({
89
108
  args: {
90
109
  text: { type: "positional", required: false, description: "Text to speak (stdin if omitted)" },
91
110
  voice: { type: "string", description: "Voice id, e.g. en-am_michael" },
92
- lang: { type: "string", description: "BCP 47 language code (default en-us)" },
111
+ lang: {
112
+ type: "string",
113
+ description:
114
+ "BCP 47 language code (default en-us). With no --voice, routes to that language's default voice and skips text-language detection.",
115
+ },
93
116
  out: { type: "string", description: "Write audio to file instead of stdout" },
94
117
  rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
95
118
  "list-voices": { type: "boolean", description: "List installed voices and exit" },
@@ -183,12 +206,13 @@ export const sayCommand = defineCommand({
183
206
  }
184
207
  const text = await resolveText(inlineText);
185
208
  const explicitVoice = typeof args.voice === "string" ? args.voice : undefined;
186
- const voice = explicitVoice ?? (await autoRouteVoice(text));
209
+ const langHint = typeof args.lang === "string" ? args.lang : undefined;
210
+ const voice = await resolveSayVoice(explicitVoice, langHint, text);
187
211
 
188
212
  const opts = {
189
213
  text,
190
214
  voice,
191
- lang: typeof args.lang === "string" ? args.lang : undefined,
215
+ lang: langHint,
192
216
  out: typeof args.out === "string" ? args.out : undefined,
193
217
  rate,
194
218
  ssml: Boolean(args.ssml),
@@ -250,13 +274,15 @@ export const sayCommand = defineCommand({
250
274
  stats.finish("success", 1);
251
275
  diagnosticLog.finish("success");
252
276
  } catch (err) {
253
- stats.recordError("tts", err);
277
+ const code = err instanceof SayError ? err.code : "E_INTERNAL";
278
+ stats.recordError("tts", err, code);
254
279
  stats.finish("failed", 1);
255
280
  diagnosticLog.event("command.finish", {
256
281
  command: "say",
257
282
  status: "failed",
258
283
  errorKind: err instanceof SayError ? "say_error" : "error",
259
284
  exitCode: err instanceof SayError ? err.exitCode : 4,
285
+ error_code: code,
260
286
  });
261
287
  diagnosticLog.finish("failed");
262
288
  if (err instanceof SayError) {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Error-code taxonomy bridge. The engine is the source of truth for engine
3
+ * codes (see `kesha-engine --error-codes-json`); these are the codes that
4
+ * originate in TS, before/around the engine. See
5
+ * `docs/superpowers/specs/2026-05-30-structured-error-taxonomy-design.md`.
6
+ */
7
+
8
+ /** Matches the engine's `error [CODE]:` line; CODE charset is constrained. */
9
+ const ENGINE_CODE_RE = /^error \[([A-Z0-9_]+)\]:/m;
10
+
11
+ /** Extract the engine error code from captured stderr, if present. */
12
+ export function extractEngineErrorCode(stderr: string): string | undefined {
13
+ const m = stderr.match(ENGINE_CODE_RE);
14
+ return m ? m[1] : undefined;
15
+ }
16
+
17
+ /** Codes that originate in TS (engine never ran, or isn't the failing party). */
18
+ export const TS_NATIVE_CODES = {
19
+ INPUT_NOT_FOUND: "E_INPUT_NOT_FOUND",
20
+ ENGINE_SPAWN: "E_ENGINE_SPAWN",
21
+ INVALID_ARG: "E_INVALID_ARG",
22
+ INTERNAL: "E_INTERNAL",
23
+ } as const;
24
+
25
+ export type TsNativeCode = (typeof TS_NATIVE_CODES)[keyof typeof TS_NATIVE_CODES];
26
+
27
+ /**
28
+ * Engine-owned codes the TS CLI also references by name (e.g. as a fallback
29
+ * when the engine died without printing an `error [CODE]:` line). These are NOT
30
+ * TS-native — the engine is the source of truth — so they stay out of
31
+ * {@link KNOWN_TS_CODES} and the drift test. Named here to avoid bare string
32
+ * literals scattered through the CLI.
33
+ */
34
+ export const ENGINE_CODES = {
35
+ TRANSCRIBE_FAILED: "E_TRANSCRIBE_FAILED",
36
+ } as const;
37
+
38
+ /** The full set of TS-native codes, for the drift test. */
39
+ export const KNOWN_TS_CODES: ReadonlySet<string> = new Set(Object.values(TS_NATIVE_CODES));
40
+
41
+ /** Resolve a code from engine stderr, falling back to E_INTERNAL. */
42
+ export function engineErrorCode(stderr: string): string {
43
+ return extractEngineErrorCode(stderr) ?? TS_NATIVE_CODES.INTERNAL;
44
+ }
package/src/mcp/voices.ts CHANGED
@@ -60,6 +60,22 @@ function parseVoiceInfo(id: string): VoiceInfo {
60
60
  };
61
61
  }
62
62
 
63
+ const kokoro = id.match(/^(es|fr|hi|it|ja|pt|zh)-([a-z][fm]_.+)$/);
64
+ if (kokoro) {
65
+ const languageCode = kokoro[1];
66
+ const bareVoice = kokoro[2];
67
+ const genderChar = bareVoice[1];
68
+ const gender: "male" | "female" = genderChar === "f" ? "female" : "male";
69
+ return {
70
+ voiceId: id,
71
+ modelId: "kokoro",
72
+ modelName: "Kokoro-82M",
73
+ languageCode,
74
+ languageName: langNameFor(languageCode),
75
+ gender,
76
+ };
77
+ }
78
+
63
79
  if (id.startsWith("macos-")) {
64
80
  const m = id.match(/[a-z]{2}-[A-Z]{2}/);
65
81
  const languageCode = m ? m[0] : "";
package/src/synth.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  spawnStdioWithDebugFd,
6
6
  type EngineCapabilities,
7
7
  } from "./engine";
8
+ import { engineErrorCode, TS_NATIVE_CODES } from "./error-codes";
8
9
  import { installHint } from "./install-hint";
9
10
  import { log } from "./log";
10
11
  import { registerProcessTree } from "./process-tree";
@@ -97,6 +98,7 @@ export class SayError extends Error {
97
98
  message: string,
98
99
  public readonly exitCode: number,
99
100
  public readonly stderr: string,
101
+ public readonly code: string = "E_INTERNAL",
100
102
  ) {
101
103
  super(message);
102
104
  this.name = "SayError";
@@ -110,11 +112,16 @@ export class SayError extends Error {
110
112
  export async function say(opts: SayOptions): Promise<Uint8Array> {
111
113
  const text = opts.text ?? "";
112
114
  if (text.length === 0) {
113
- throw new SayError("text is empty", 2, "");
115
+ throw new SayError("text is empty", 2, "", "E_TEXT_EMPTY");
114
116
  }
115
117
  const chars = Array.from(text).length;
116
118
  if (chars > MAX_TEXT_CHARS) {
117
- throw new SayError(`text exceeds ${MAX_TEXT_CHARS} chars (${chars})`, 5, "");
119
+ throw new SayError(
120
+ `text exceeds ${MAX_TEXT_CHARS} chars (${chars})`,
121
+ 5,
122
+ "",
123
+ "E_TEXT_TOO_LONG",
124
+ );
118
125
  }
119
126
 
120
127
  if (!isEngineInstalled()) {
@@ -122,6 +129,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
122
129
  `kesha-engine not installed. run: ${installHint("--tts")}`,
123
130
  1,
124
131
  "",
132
+ TS_NATIVE_CODES.ENGINE_SPAWN,
125
133
  );
126
134
  }
127
135
  const capabilities = opts.noExpandAbbrev ? await getEngineCapabilities() : null;
@@ -176,6 +184,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
176
184
  stderrText.trim() || `kesha-engine say exited ${exitCode}`,
177
185
  exitCode,
178
186
  stderrText,
187
+ engineErrorCode(stderrText),
179
188
  );
180
189
  }
181
190
  return new Uint8Array(stdoutBuf);
package/src/types.ts CHANGED
@@ -19,7 +19,13 @@ export type TranscribeResult = {
19
19
 
20
20
  export type TranscribeErrorRecord = {
21
21
  file: string;
22
- code: "file_not_found" | "transcribe_failed";
22
+ /**
23
+ * Taxonomy error code. The canonical set lives in Rust and is documented in
24
+ * `docs/errors.md`; we type it as `string` so the precise engine code (e.g.
25
+ * `E_DIARIZE_TIMEOUT`, `E_MODEL_MISSING`) flows through to `--include-errors`
26
+ * output instead of being collapsed to a narrow union.
27
+ */
28
+ code: string;
23
29
  message: string;
24
30
  };
25
31
 
@@ -5,19 +5,35 @@
5
5
  */
6
6
  const RU_DARWIN_FALLBACK_VOICE = "macos-com.apple.voice.compact.ru-RU.Milena";
7
7
 
8
+ const DARWIN_KOKORO_DEFAULTS: Record<string, string> = {
9
+ es: "es-em_alex",
10
+ hi: "hi-hm_omega",
11
+ it: "it-im_nicola",
12
+ ja: "ja-jm_kumo",
13
+ pt: "pt-pm_alex",
14
+ zh: "zh-zm_yunjian",
15
+ };
16
+
8
17
  /** Map a detected language code to a default voice id. Unknown / low-confidence → undefined. */
9
18
  export function pickVoiceForLang(
10
19
  code: string | undefined,
11
20
  confidence: number,
12
21
  platform: NodeJS.Platform = process.platform,
22
+ arch: NodeJS.Architecture = process.arch,
13
23
  ): string | undefined {
14
24
  if (!code || confidence < 0.5) return undefined;
15
- switch (code) {
25
+ const baseCode = code.toLowerCase().split(/[-_]/, 1)[0];
26
+ switch (baseCode) {
16
27
  case "en":
17
28
  return "en-am_michael";
18
29
  case "ru":
30
+ // AVSpeech Milena is a macOS system voice (any arch); Vosk elsewhere.
19
31
  return platform === "darwin" ? RU_DARWIN_FALLBACK_VOICE : "ru-vosk-m02";
20
32
  default:
33
+ // Multilingual Kokoro voices ship only on darwin-arm64 (FluidAudio ANE).
34
+ // Intel Macs have no such voice pack, so don't auto-route to ids the
35
+ // engine can't honour — fall through to the engine default instead.
36
+ if (platform === "darwin" && arch === "arm64") return DARWIN_KOKORO_DEFAULTS[baseCode];
21
37
  return undefined;
22
38
  }
23
39
  }