@drakulavich/kesha-voice-kit 1.21.0 → 1.22.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 +68 -7
- package/completions/kesha.fish +1 -1
- package/completions/kesha.zsh +1 -1
- package/docs/diagnostic-logs.md +6 -0
- package/man/kesha.1 +1 -1
- package/package.json +2 -2
- package/src/__tests__/error-codes-drift.test.ts +29 -0
- package/src/__tests__/error-codes.test.ts +25 -0
- package/src/__tests__/stats-error-codes.test.ts +14 -0
- package/src/cli/main.ts +13 -6
- package/src/cli/say.ts +30 -4
- package/src/error-codes.ts +44 -0
- package/src/mcp/voices.ts +16 -0
- package/src/synth.ts +11 -2
- package/src/types.ts +7 -1
- package/src/voice-routing.ts +17 -1
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://
|
|
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://
|
|
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
|
-

|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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`,
|
package/completions/kesha.fish
CHANGED
|
@@ -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'
|
package/completions/kesha.zsh
CHANGED
|
@@ -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]' \
|
package/docs/diagnostic-logs.md
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.22.0-beta.2",
|
|
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.
|
|
35
|
+
"version": "1.22.0-beta.2"
|
|
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"),
|
|
305
|
-
diagnosticLog.event("input.missing", {
|
|
306
|
-
|
|
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
|
-
|
|
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 =
|
|
414
|
-
errors.push({ file, code
|
|
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: {
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
package/src/voice-routing.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|