@drakulavich/kesha-voice-kit 2.1.0 → 2.2.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 +13 -13
- package/docs/product-positioning.md +1 -1
- package/package.json +15 -14
- package/src/cli/dispatch.ts +28 -10
- package/src/cli/init.ts +6 -40
- package/src/cli/logs.ts +3 -0
- package/src/cli/main.ts +49 -4
- package/src/cli/options.ts +26 -5
- package/src/cli/say.ts +9 -6
- package/src/cli/stats.ts +8 -0
- package/src/cli/support-bundle.ts +10 -1
- package/src/cli.ts +0 -1
- package/src/doctor.ts +8 -2
- package/src/engine/events.ts +2 -1
- package/src/engine-version-marker.ts +8 -0
- package/src/engine.ts +36 -3
- package/src/log.ts +4 -0
- package/src/mcp/tools.ts +3 -1
- package/src/support-bundle.ts +1 -0
- package/src/toon.ts +2 -1
- package/src/voice-routing.ts +16 -3
package/README.md
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
<a href="https://bun.sh"><img src="https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun" alt="Bun"></a>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
|
-
<p align="center"><b>
|
|
14
|
+
<p align="center"><b>Kesha listens and talks back.</b><br>Your agent gets a voice message, Kesha turns it into text on your machine, and speaks the reply once you add <code>kesha install --tts</code> (<a href="docs/use-cases.md#sending-voice-replies">voice replies</a>). Fastest on Apple Silicon with CoreML, and it runs on Linux and Windows through ONNX.</p>
|
|
15
15
|
|
|
16
|
-
- **Transcribe locally** — [25 languages](docs/languages.md#speech-to-text-25), up to ~19x faster than Whisper on Apple Silicon, ~
|
|
16
|
+
- **Transcribe locally** — [25 languages](docs/languages.md#speech-to-text-25), up to ~19x faster than Whisper on Apple Silicon, ~10x on an x86 CPU
|
|
17
17
|
- **Speak back** — text-to-speech in [9 languages](docs/languages.md#text-to-speech)
|
|
18
18
|
- **Plug into agents** — ship voice workflows as CLI commands, an MCP server, an <a href="docs/openclaw.md">OpenClaw</a> skill, or a <a href="docs/hermes.md">Hermes</a> agent
|
|
19
19
|
- **Small Rust engine** — single ~65MB binary, no ffmpeg, no Python, no native Node addons
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
## Quick Start
|
|
26
26
|
|
|
27
|
-
Runtime: **[Bun](https://bun.sh)** >= 1.
|
|
27
|
+
Runtime: **[Bun](https://bun.sh)** >= 1.4.0.
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
# 1. Install Bun (skip if you have it)
|
|
@@ -64,6 +64,14 @@ All three targets transcribe, detect the spoken language, run VAD, and speak. Th
|
|
|
64
64
|
|
|
65
65
|
Intel Macs get no published engine binary. Full matrix with maturity labels: [docs/product-positioning.md](docs/product-positioning.md#platform-matrix).
|
|
66
66
|
|
|
67
|
+
## Plug into your agent
|
|
68
|
+
|
|
69
|
+
- **MCP server** — `kesha mcp` exposes transcribe/synthesize/list tools to any MCP client (Claude, Cursor, Codex, Gemini). Setup: [docs/mcp.md](docs/mcp.md).
|
|
70
|
+
- **OpenClaw** — give your LLM agent ears. Install & config: [docs/openclaw.md](docs/openclaw.md).
|
|
71
|
+
- **Hermes Agent** — local STT/TTS through Hermes command providers. Setup: [docs/hermes.md](docs/hermes.md).
|
|
72
|
+
- **Raycast** (macOS) — offline microphone dictation from the launcher: *Dictate to Clipboard* records with a live signal meter, auto-stops on silence, transcribes locally, and copies the text. [Install from the Raycast Store](https://www.raycast.com/drakulavich/kesha-voice-kit) · source: [`raycast/`](raycast/).
|
|
73
|
+
- **Programmatic API** — `@drakulavich/kesha-voice-kit/core` for use inside a Bun program. See [docs/api.md](docs/api.md).
|
|
74
|
+
|
|
67
75
|
## Speech-to-text
|
|
68
76
|
|
|
69
77
|
```bash
|
|
@@ -126,13 +134,13 @@ kesha say "Hello" --format flac --out hi.flac # FLAC — lossless, plays in
|
|
|
126
134
|
|
|
127
135
|
## Performance
|
|
128
136
|
|
|
129
|
-
> **Up to ~19x faster than Whisper** on Apple Silicon (M2), **~2.5x faster
|
|
137
|
+
> **Up to ~19x faster than Whisper** on Apple Silicon (M2), **~10x faster** on an x86 Linux CPU (~2.5x faster than faster-whisper)
|
|
130
138
|
|
|
131
139
|
Compared against Whisper `large-v3-turbo`, all engines auto-detecting language:
|
|
132
140
|
|
|
133
141
|

|
|
134
142
|
|
|
135
|
-
Full per-file breakdown (Russian + English): [BENCHMARK.md](BENCHMARK.md). The
|
|
143
|
+
Full per-file breakdown (Russian + English): [BENCHMARK.md](BENCHMARK.md). The chart is from an M2. The x86 figure comes from a GitHub-hosted `ubuntu-latest` runner (AMD EPYC, ONNX on CPU), where openai-whisper runs in FP32.
|
|
136
144
|
|
|
137
145
|
## Other install methods
|
|
138
146
|
|
|
@@ -144,14 +152,6 @@ All of these install the Bun CLI wrapper; engine + models still download explici
|
|
|
144
152
|
- **Nix** (`aarch64-darwin` / `x86_64-linux`) — builds the engine from source (`nix build github:drakulavich/kesha-voice-kit#kesha-engine`). The full `kesha` CLI via `nix run` / `nix profile install` is **not yet available** — it needs a maintainer with Nix to populate a build hash ([#946](https://github.com/drakulavich/kesha-voice-kit/issues/946)). · [docs/distribution.md](docs/distribution.md#nix)
|
|
145
153
|
- **Shell completions + manpage** — `kesha completions bash|zsh|fish` and `kesha manpage` print the packaged files to install wherever your shell expects them.
|
|
146
154
|
|
|
147
|
-
## Integrations
|
|
148
|
-
|
|
149
|
-
- **MCP server** — `kesha mcp` exposes transcribe/synthesize/list tools to any MCP client (Claude, Cursor, Codex, Gemini). Setup: [docs/mcp.md](docs/mcp.md).
|
|
150
|
-
- **OpenClaw** — give your LLM agent ears. Install & config: [docs/openclaw.md](docs/openclaw.md).
|
|
151
|
-
- **Hermes Agent** — local STT/TTS through Hermes command providers. Setup: [docs/hermes.md](docs/hermes.md).
|
|
152
|
-
- **Raycast** (macOS) — offline microphone dictation from the launcher: *Dictate to Clipboard* records with a live signal meter, auto-stops on silence, transcribes locally, and copies the text. [Install from the Raycast Store](https://www.raycast.com/drakulavich/kesha-voice-kit) · source: [`raycast/`](raycast/).
|
|
153
|
-
- **Programmatic API** — `@drakulavich/kesha-voice-kit/core` for use inside a Bun program. See [docs/api.md](docs/api.md).
|
|
154
|
-
|
|
155
155
|
## More
|
|
156
156
|
|
|
157
157
|
- [Architecture](docs/architecture.md) — runtime data flow, the models that ship, the CLI ↔ Rust engine boundary, model pinning, and where tests live.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Product positioning
|
|
2
2
|
|
|
3
|
-
Kesha
|
|
3
|
+
Kesha listens and talks back: an agent gets a voice message, Kesha turns it into text locally, and speaks the reply. It is a local-first voice toolkit for developers and agent workflows, and Apple Silicon is where it is fastest and most complete. It is built for small, scriptable jobs where a CLI can turn audio into text, text into audio, or voice messages into structured data without sending content to a hosted API.
|
|
4
4
|
|
|
5
5
|
The product promise is not "one model for every audio problem." The promise is a boring, automatable local voice stack with clear platform limits, predictable install paths, and machine-readable output.
|
|
6
6
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.2.0-beta.1",
|
|
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": {
|
|
7
7
|
"kesha": "bin/kesha.js"
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"check": "bun run lint && bun run check:versions && bun run check:recipes && bun run test:cli-fast",
|
|
55
55
|
"check:workflows": "bun .github/scripts/check-workflows.ts",
|
|
56
56
|
"check:recipes": "bun .github/scripts/check-recipes.ts",
|
|
57
|
-
"check:specs": "
|
|
57
|
+
"check:specs": "openspec validate --specs --strict",
|
|
58
58
|
"check:release-manifest": "bun .github/scripts/release-manifest.mjs --check",
|
|
59
59
|
"check:versions": "bun .github/scripts/check-versions.ts",
|
|
60
60
|
"check:engine-targets": "bun .github/scripts/check-engine-targets.ts",
|
|
@@ -83,9 +83,10 @@
|
|
|
83
83
|
"url": "https://github.com/drakulavich/kesha-voice-kit/issues"
|
|
84
84
|
},
|
|
85
85
|
"engines": {
|
|
86
|
-
"bun": ">=1.
|
|
86
|
+
"bun": ">=1.4.0"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
|
89
|
+
"@fission-ai/openspec": "1.14.1",
|
|
89
90
|
"@types/bun": "latest",
|
|
90
91
|
"fast-xml-parser": "^5.8.0",
|
|
91
92
|
"typescript": "^7.0.2",
|
|
@@ -93,7 +94,7 @@
|
|
|
93
94
|
},
|
|
94
95
|
"dependencies": {
|
|
95
96
|
"@clack/prompts": "^1.5.0",
|
|
96
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
97
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
97
98
|
"@toon-format/toon": "^4.1.0",
|
|
98
99
|
"citty": "^0.2.2",
|
|
99
100
|
"fastest-levenshtein": "^1.0.16",
|
|
@@ -109,19 +110,19 @@
|
|
|
109
110
|
},
|
|
110
111
|
"kesha": {
|
|
111
112
|
"engine": {
|
|
112
|
-
"version": "2.
|
|
113
|
+
"version": "2.2.0-beta.1",
|
|
113
114
|
"sha256": {
|
|
114
|
-
"kesha-engine-darwin-arm64": "
|
|
115
|
-
"kesha-engine-linux-x64": "
|
|
116
|
-
"kesha-engine-windows-x64.exe": "
|
|
117
|
-
"say-avspeech-darwin-arm64": "
|
|
115
|
+
"kesha-engine-darwin-arm64": "ccdd35b2433950175a6e3791ac3264969a732ea005fae4642b32842b2eb3443b",
|
|
116
|
+
"kesha-engine-linux-x64": "626fd32be46645e312ae3ad4cdf15838d0c4f54d56bdc67ec6d8f2a017813db8",
|
|
117
|
+
"kesha-engine-windows-x64.exe": "b38bc35bc2d0304f324da02b1d2857e86baf3c69e5351e5df2619c1c3a38889a",
|
|
118
|
+
"say-avspeech-darwin-arm64": "3a7e8b8fac92ce4ce0c34f736098a685dcb745f741db8303e1721e320ac73a98",
|
|
118
119
|
"kesha-textlang-darwin-arm64": "e45e2822248b14dd72aa79e2def8fa6b9c07c97423f11c841508769225d72da4"
|
|
119
120
|
},
|
|
120
121
|
"size": {
|
|
121
|
-
"kesha-engine-darwin-arm64":
|
|
122
|
-
"kesha-engine-linux-x64":
|
|
123
|
-
"kesha-engine-windows-x64.exe":
|
|
124
|
-
"say-avspeech-darwin-arm64":
|
|
122
|
+
"kesha-engine-darwin-arm64": 73639712,
|
|
123
|
+
"kesha-engine-linux-x64": 67404584,
|
|
124
|
+
"kesha-engine-windows-x64.exe": 66583040,
|
|
125
|
+
"say-avspeech-darwin-arm64": 82992,
|
|
125
126
|
"kesha-textlang-darwin-arm64": 57648
|
|
126
127
|
}
|
|
127
128
|
}
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { runMain, type ArgsDef, type CommandDef } from "citty";
|
|
1
|
+
import { renderUsage, runMain, type ArgsDef, type CommandDef } from "citty";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
|
-
import {
|
|
3
|
+
import { stripVTControlCharacters } from "util";
|
|
4
|
+
import { isColorEnabled, log } from "../log";
|
|
5
|
+
import { packageVersion } from "../package-info";
|
|
4
6
|
import { guardStdoutWrites } from "../stdout-pipe";
|
|
5
7
|
import { suggestCommand } from "../suggest-command";
|
|
6
8
|
import { applyCliContext, resolveCliContext } from "./context";
|
|
7
|
-
import { rejectUnknownOptions, renderInvalidArg } from "./options";
|
|
9
|
+
import { rejectExtraPositionals, rejectUnknownOptions, renderInvalidArg } from "./options";
|
|
8
10
|
|
|
9
11
|
// Lazy loaders so a cold CLI spawn transpiles only the invoked command, not the whole graph (#568).
|
|
10
12
|
// `CommandDef<any>`: citty's generic is invariant in the arg shape and each command has its own schema.
|
|
@@ -87,7 +89,12 @@ export function unknownCommandMessages(token: string, subcommandKeys: string[]):
|
|
|
87
89
|
if (suggestCommand(token, ["transcribe"]) === "transcribe") {
|
|
88
90
|
warnLines.push("To transcribe, pass the audio path directly: kesha ./recording.ogg");
|
|
89
91
|
}
|
|
90
|
-
return { errorLine: `unknown command '${token}'
|
|
92
|
+
return { errorLine: renderInvalidArg(`unknown command '${token}'`), warnLines };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function showUsage<T extends ArgsDef>(cmd: CommandDef<T>, parent?: CommandDef<T>): Promise<void> {
|
|
96
|
+
const usage = await renderUsage(cmd, parent);
|
|
97
|
+
console.log((isColorEnabled() ? usage : stripVTControlCharacters(usage)) + "\n");
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
async function resolveArgsDef(command: CommandDef<any>): Promise<ArgsDef> {
|
|
@@ -121,9 +128,15 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
|
|
121
128
|
|
|
122
129
|
switch (classifyFirstArg(firstArg, subcommandKeys)) {
|
|
123
130
|
case "subcommand": {
|
|
131
|
+
if (restArgs.length === 1 && (restArgs[0] === "--version" || restArgs[0] === "-v")) {
|
|
132
|
+
process.stdout.write(`${packageVersion}\n`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
124
135
|
const command = await SUBCOMMANDS[firstArg!]!();
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
const argsDef = await resolveArgsDef(command);
|
|
137
|
+
rejectUnknownOptions(restArgs, argsDef);
|
|
138
|
+
if (firstArg !== "install" && firstArg !== "say") rejectExtraPositionals(restArgs, argsDef);
|
|
139
|
+
await runMain(command, { rawArgs: restArgs, showUsage });
|
|
127
140
|
return;
|
|
128
141
|
}
|
|
129
142
|
|
|
@@ -132,7 +145,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
|
|
132
145
|
const { errorLine, warnLines } = unknownCommandMessages(firstArg!, subcommandKeys);
|
|
133
146
|
log.error(errorLine);
|
|
134
147
|
for (const line of warnLines) log.warn(line);
|
|
135
|
-
process.exit(
|
|
148
|
+
process.exit(2);
|
|
136
149
|
break;
|
|
137
150
|
}
|
|
138
151
|
|
|
@@ -142,10 +155,15 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
|
|
142
155
|
log.error(renderInvalidArg(`put global flags after the subcommand: kesha ${misplaced} ...`));
|
|
143
156
|
process.exit(2);
|
|
144
157
|
}
|
|
145
|
-
const { createMainCommand } = await import("./main");
|
|
158
|
+
const { briefMainUsage, createMainCommand } = await import("./main");
|
|
146
159
|
const command = createMainCommand(context);
|
|
147
|
-
|
|
148
|
-
|
|
160
|
+
const help = rawArgs.includes("--help") || rawArgs.includes("-h");
|
|
161
|
+
rejectUnknownOptions(help ? rawArgs.filter((arg) => arg !== "--all") : rawArgs, await resolveArgsDef(command));
|
|
162
|
+
if (help) {
|
|
163
|
+
await showUsage(rawArgs.includes("--all") ? command : briefMainUsage(command));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
await runMain(command, { rawArgs, showUsage });
|
|
149
167
|
}
|
|
150
168
|
}
|
|
151
169
|
}
|
package/src/cli/init.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { confirm, multiselect, isCancel, cancel } from "@clack/prompts";
|
|
|
3
3
|
import { installCommandTokens, renderInstallPlan } from "../install-plan";
|
|
4
4
|
import { log } from "../log";
|
|
5
5
|
import { errorMessage } from "../error-utils";
|
|
6
|
-
import { exitCodeFor } from "../engine/events";
|
|
6
|
+
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
7
7
|
import { getEngineCapabilities } from "../engine";
|
|
8
8
|
import {
|
|
9
9
|
installableTtsLangs,
|
|
@@ -98,30 +98,6 @@ export function initInstallArgs(selection: InitSelection): string[] {
|
|
|
98
98
|
return installCommandTokens(selection, selection.ttsLangs);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
export function initSuggestionCommands(
|
|
102
|
-
selection: InitSelection,
|
|
103
|
-
canDiarize = canInstallDiarizeOnPlatform(),
|
|
104
|
-
): string[][] {
|
|
105
|
-
const variants: InitSelection[] = [
|
|
106
|
-
selection,
|
|
107
|
-
{ ...selection, ttsLangs: [], vad: true, diarize: false },
|
|
108
|
-
{ ...selection, ttsLangs: ["en"], vad: true, diarize: false },
|
|
109
|
-
];
|
|
110
|
-
|
|
111
|
-
// #768: --diarize installs VAD on its own, so no explicit --vad here.
|
|
112
|
-
if (canDiarize) {
|
|
113
|
-
variants.push({ ...selection, ttsLangs: [], vad: false, diarize: true });
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const seen = new Set<string>();
|
|
117
|
-
return variants.map(initInstallArgs).filter((command) => {
|
|
118
|
-
const key = command.join("\0");
|
|
119
|
-
if (seen.has(key)) return false;
|
|
120
|
-
seen.add(key);
|
|
121
|
-
return true;
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
|
|
125
101
|
export function omitUnsupportedDiarize(
|
|
126
102
|
selection: InitSelection,
|
|
127
103
|
canDiarize = canInstallDiarizeOnPlatform(),
|
|
@@ -188,20 +164,6 @@ async function printPlan(selection: InitSelection): Promise<void> {
|
|
|
188
164
|
);
|
|
189
165
|
}
|
|
190
166
|
|
|
191
|
-
async function runNonInteractive(selection: InitSelection): Promise<void> {
|
|
192
|
-
const canDiarize = canInstallDiarizeOnPlatform();
|
|
193
|
-
const printableSelection = omitUnsupportedDiarize(selection, canDiarize);
|
|
194
|
-
if (selection.diarize && !canDiarize) {
|
|
195
|
-
log.warn("--diarize is currently darwin-arm64 only; omitting it from non-interactive examples.");
|
|
196
|
-
}
|
|
197
|
-
log.info(renderInitOverview(canDiarize));
|
|
198
|
-
await printPlan(printableSelection);
|
|
199
|
-
log.info("Run one of these commands from an interactive terminal:");
|
|
200
|
-
for (const command of initSuggestionCommands(printableSelection, canDiarize)) {
|
|
201
|
-
log.info(` ${command.join(" ")}`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
167
|
export const initCommand = defineCommand({
|
|
206
168
|
meta: {
|
|
207
169
|
name: "init",
|
|
@@ -279,7 +241,11 @@ export const initCommand = defineCommand({
|
|
|
279
241
|
const stdinIsTty = process.stdin.isTTY === true;
|
|
280
242
|
const stdoutIsTty = process.stdout.isTTY === true;
|
|
281
243
|
if (!stdinIsTty || !stdoutIsTty) {
|
|
282
|
-
|
|
244
|
+
const noTerminal = new KeshaError("E_INVALID_ARG", "kesha init is interactive and needs a terminal", {
|
|
245
|
+
hint: "run `kesha init --yes` to install the defaults, or `kesha init --plan` to show the plan",
|
|
246
|
+
});
|
|
247
|
+
log.error(errorMessage(noTerminal));
|
|
248
|
+
process.exitCode = exitCodeFor(noTerminal);
|
|
283
249
|
return;
|
|
284
250
|
}
|
|
285
251
|
|
package/src/cli/logs.ts
CHANGED
|
@@ -23,6 +23,9 @@ export function runLogsAction(args: LogsCommandArgs): ActionResult {
|
|
|
23
23
|
if (args.json && action !== "status") {
|
|
24
24
|
return { ok: false, error: "usage: kesha logs status --json" };
|
|
25
25
|
}
|
|
26
|
+
if (args.value !== undefined && action !== "mode" && SUPPORTED_ACTIONS.split(", ").includes(action)) {
|
|
27
|
+
return { ok: false, error: `unexpected argument '${args.value}' (kesha logs ${action} takes no value)` };
|
|
28
|
+
}
|
|
26
29
|
switch (action) {
|
|
27
30
|
case "enable": {
|
|
28
31
|
const status = setDiagnosticLogMode("on");
|
package/src/cli/main.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { defineCommand } from "citty";
|
|
1
|
+
import { defineCommand, type ArgsDef, type CommandDef } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
4
|
import { isDirectoryPath, transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
|
|
5
5
|
|
|
6
6
|
export { isDirectoryPath };
|
|
7
|
-
import { detectAudioLanguageEngine, detectTextLanguageEngine } from "../engine";
|
|
7
|
+
import { detectAudioLanguageEngine, detectTextLanguageEngine, isEngineInstalled } from "../engine";
|
|
8
|
+
import { renderInstallPlan } from "../install-plan";
|
|
9
|
+
import { canInstallDiarizeOnPlatform, promptConfirm } from "./init";
|
|
10
|
+
import { performInstall } from "./install";
|
|
8
11
|
import type { LangDetectResult } from "../engine";
|
|
9
12
|
import { log } from "../log";
|
|
10
13
|
import type { TextLangDetectResult, TranscribeErrorRecord, TranscribeResult } from "../types";
|
|
@@ -409,7 +412,8 @@ async function processFile(
|
|
|
409
412
|
error_code: code,
|
|
410
413
|
});
|
|
411
414
|
log.error(`${file}: ${stderrText}`);
|
|
412
|
-
|
|
415
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
416
|
+
return { ok: false, error: { file, code, message }, exitCode: failureExitCode(err) };
|
|
413
417
|
}
|
|
414
418
|
}
|
|
415
419
|
|
|
@@ -452,13 +456,41 @@ export const MAIN_VAD_ARGS = {
|
|
|
452
456
|
},
|
|
453
457
|
} as const;
|
|
454
458
|
|
|
459
|
+
const BRIEF_HELP_DESCRIPTION =
|
|
460
|
+
"Kesha Voice Kit listens and talks back: local speech for agents and scripts.\n" +
|
|
461
|
+
"Try: kesha audio.ogg, or kesha say \"hello\"\n" +
|
|
462
|
+
"\n" +
|
|
463
|
+
"Commands:\n" +
|
|
464
|
+
" init Interactive setup guide.\n" +
|
|
465
|
+
" install Download engine and models.\n" +
|
|
466
|
+
" say Synthesize speech from text.\n" +
|
|
467
|
+
" record Record microphone audio.\n" +
|
|
468
|
+
" status Inspect installed backend.\n" +
|
|
469
|
+
" doctor Collect support diagnostics.\n" +
|
|
470
|
+
" mcp Run an MCP server over stdio.\n" +
|
|
471
|
+
"\n" +
|
|
472
|
+
"All commands and flags: kesha --help --all";
|
|
473
|
+
|
|
474
|
+
const BRIEF_HELP_ARGS: ArgsDef = {
|
|
475
|
+
json: { type: "boolean", description: "Output results as JSON" },
|
|
476
|
+
format: { type: "string", description: "transcript | json | toon" },
|
|
477
|
+
lang: { type: "string", description: "Expected language code, e.g. en" },
|
|
478
|
+
timestamps: { type: "boolean", description: "Timestamped segments in JSON/TOON" },
|
|
479
|
+
quiet: { type: "boolean", alias: "q", description: "Print only results and errors" },
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/** What plain `kesha --help` renders; parsing still uses every flag of createMainCommand. */
|
|
483
|
+
export function briefMainUsage(command: CommandDef<any>): CommandDef<any> {
|
|
484
|
+
return { ...command, meta: { ...command.meta, description: BRIEF_HELP_DESCRIPTION }, args: BRIEF_HELP_ARGS };
|
|
485
|
+
}
|
|
486
|
+
|
|
455
487
|
export function createMainCommand(context: CliContext = { quiet: false, disableColor: false }) {
|
|
456
488
|
return defineCommand({
|
|
457
489
|
meta: {
|
|
458
490
|
name: "kesha",
|
|
459
491
|
version: packageVersion,
|
|
460
492
|
description:
|
|
461
|
-
"Kesha Voice Kit
|
|
493
|
+
"Kesha Voice Kit listens and talks back: local speech for agents and scripts.\n" +
|
|
462
494
|
"\n" +
|
|
463
495
|
"Examples:\n" +
|
|
464
496
|
" kesha audio.ogg Transcribe an audio file.\n" +
|
|
@@ -572,6 +604,19 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
|
|
|
572
604
|
process.exit(2);
|
|
573
605
|
}
|
|
574
606
|
|
|
607
|
+
if (
|
|
608
|
+
!isEngineInstalled() &&
|
|
609
|
+
process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY &&
|
|
610
|
+
files.every((f) => existsSync(f) && !isDirectoryPath(f)) &&
|
|
611
|
+
(!args.speakers || canInstallDiarizeOnPlatform())
|
|
612
|
+
) {
|
|
613
|
+
const modules = { vad: vadMode === "on", diarize: Boolean(args.speakers) };
|
|
614
|
+
log.info(await renderInstallPlan(modules));
|
|
615
|
+
if (await promptConfirm("Kesha needs these downloads before it can transcribe. Download them now?", true)) {
|
|
616
|
+
await performInstall({ noCache: false, ttsLangs: [], ...modules });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
575
620
|
const wantsLangId = !!(args.lang || args.verbose || outputFormat !== "text");
|
|
576
621
|
const reportProgress = shouldReportTranscribeProgress({
|
|
577
622
|
stderrIsTty: process.stderr.isTTY === true,
|
package/src/cli/options.ts
CHANGED
|
@@ -17,6 +17,10 @@ function camelCase(name: string): string {
|
|
|
17
17
|
|
|
18
18
|
/** The first flag `argsDef` does not declare, or null; citty parses non-strict, so the flag would otherwise vanish and its value become a positional (S9-F3). */
|
|
19
19
|
export function findUnknownOption(rawArgs: string[], argsDef: ArgsDef): UnknownOption | null {
|
|
20
|
+
return scanArgs(rawArgs, argsDef).unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function scanArgs(rawArgs: string[], argsDef: ArgsDef): { unknown: UnknownOption | null; positionals: string[] } {
|
|
20
24
|
const long = new Set(RUNNER_LONG);
|
|
21
25
|
const short = new Set(RUNNER_SHORT);
|
|
22
26
|
const valued = new Set<string>();
|
|
@@ -41,23 +45,30 @@ export function findUnknownOption(rawArgs: string[], argsDef: ArgsDef): UnknownO
|
|
|
41
45
|
const match = suggestCommand(name, suggestable);
|
|
42
46
|
return match === null ? null : `--${match}`;
|
|
43
47
|
};
|
|
48
|
+
const positionals: string[] = [];
|
|
44
49
|
for (let i = 0; i < rawArgs.length; i++) {
|
|
45
50
|
const arg = rawArgs[i]!;
|
|
46
|
-
if (arg === "--")
|
|
47
|
-
|
|
51
|
+
if (arg === "--") {
|
|
52
|
+
positionals.push(...rawArgs.slice(i + 1));
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
if (!arg.startsWith("-") || arg === "-" || /^-\d/.test(arg)) {
|
|
56
|
+
positionals.push(arg);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
48
59
|
if (arg.startsWith("--")) {
|
|
49
60
|
const eq = arg.indexOf("=");
|
|
50
61
|
const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
|
51
|
-
if (!long.has(name)) return { name: `--${name}`, suggestion: suggest(name) };
|
|
62
|
+
if (!long.has(name)) return { unknown: { name: `--${name}`, suggestion: suggest(name) }, positionals };
|
|
52
63
|
if (eq === -1 && valued.has(name)) i++;
|
|
53
64
|
continue;
|
|
54
65
|
}
|
|
55
66
|
for (const letter of arg.slice(1)) {
|
|
56
|
-
if (!short.has(letter)) return { name: `-${letter}`, suggestion: null };
|
|
67
|
+
if (!short.has(letter)) return { unknown: { name: `-${letter}`, suggestion: null }, positionals };
|
|
57
68
|
}
|
|
58
69
|
if (valued.has(arg[arg.length - 1]!)) i++;
|
|
59
70
|
}
|
|
60
|
-
return null;
|
|
71
|
+
return { unknown: null, positionals };
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
/** The coded line for a usage error the CLI refuses before doing anything (S9-F2). */
|
|
@@ -77,3 +88,13 @@ export function rejectUnknownOptions(rawArgs: string[], argsDef: ArgsDef): void
|
|
|
77
88
|
log.error(renderUnknownOption(unknown));
|
|
78
89
|
process.exit(2);
|
|
79
90
|
}
|
|
91
|
+
|
|
92
|
+
/** Exits 2 with one coded stderr line when `rawArgs` carries more positionals than the command declares. */
|
|
93
|
+
export function rejectExtraPositionals(rawArgs: string[], argsDef: ArgsDef): void {
|
|
94
|
+
const declared = Object.values(argsDef).filter((def) => def.type === "positional").length;
|
|
95
|
+
const extra = scanArgs(rawArgs, argsDef).positionals[declared];
|
|
96
|
+
if (extra === undefined) return;
|
|
97
|
+
const tail = "output" in argsDef ? ` (did you mean --output ${extra}?)` : "";
|
|
98
|
+
log.error(renderInvalidArg(`unexpected argument '${extra}'${tail}`));
|
|
99
|
+
process.exit(2);
|
|
100
|
+
}
|
package/src/cli/say.ts
CHANGED
|
@@ -358,8 +358,8 @@ export const sayCommand = defineCommand({
|
|
|
358
358
|
log.error(errorMessage(err));
|
|
359
359
|
process.exit(exitCodeFor(err));
|
|
360
360
|
}
|
|
361
|
-
|
|
362
|
-
|
|
361
|
+
process.stdout.write(ids.map((id) => `${id}\n`).join(""));
|
|
362
|
+
return;
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
const flags = resolveSayFlags(args);
|
|
@@ -374,23 +374,26 @@ export const sayCommand = defineCommand({
|
|
|
374
374
|
process.exit(2);
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
-
const
|
|
377
|
+
const positionals = (args._ ?? []).map(String);
|
|
378
|
+
const inlineText =
|
|
379
|
+
positionals.length > 0 ? positionals.join(" ") : typeof args.text === "string" ? args.text : undefined;
|
|
378
380
|
const stdinIsTty = (process.stdin as { isTTY?: boolean }).isTTY;
|
|
379
381
|
if (shouldRejectMissingSayText(inlineText, stdinIsTty)) {
|
|
380
382
|
log.error(renderInvalidArg("kesha say requires text or piped stdin. Usage: kesha say <text>"));
|
|
381
383
|
process.exit(2);
|
|
382
384
|
}
|
|
385
|
+
const explicitVoice = flags.voice;
|
|
386
|
+
const langHint = flags.lang;
|
|
383
387
|
let text: string;
|
|
388
|
+
let voice: string | undefined;
|
|
384
389
|
try {
|
|
385
390
|
text = await resolveText(inlineText);
|
|
386
391
|
validateSayText(text);
|
|
392
|
+
voice = await resolveSayVoice(explicitVoice, langHint, text);
|
|
387
393
|
} catch (err) {
|
|
388
394
|
log.error(errorMessage(err));
|
|
389
395
|
process.exit(exitCodeFor(err));
|
|
390
396
|
}
|
|
391
|
-
const explicitVoice = flags.voice;
|
|
392
|
-
const langHint = flags.lang;
|
|
393
|
-
const voice = await resolveSayVoice(explicitVoice, langHint, text);
|
|
394
397
|
|
|
395
398
|
const opts: SayOpts = {
|
|
396
399
|
text,
|
package/src/cli/stats.ts
CHANGED
|
@@ -26,6 +26,14 @@ const SUPPORTED_ACTIONS = "enable, disable, status, week, errors, export, reset,
|
|
|
26
26
|
/** Runs one `kesha stats` action and reports what to print; all output happens in the caller. */
|
|
27
27
|
export function runStatsAction(args: StatsCommandArgs): ActionResult {
|
|
28
28
|
const action = args.action ?? "status";
|
|
29
|
+
if (
|
|
30
|
+
args.value !== undefined &&
|
|
31
|
+
action !== "export" &&
|
|
32
|
+
action !== "retention" &&
|
|
33
|
+
SUPPORTED_ACTIONS.split(", ").includes(action)
|
|
34
|
+
) {
|
|
35
|
+
return { ok: false, error: `unexpected argument '${args.value}' (kesha stats ${action} takes no value)` };
|
|
36
|
+
}
|
|
29
37
|
switch (action) {
|
|
30
38
|
case "enable": {
|
|
31
39
|
enableStats();
|
|
@@ -27,14 +27,23 @@ export const supportBundleCommand = defineCommand({
|
|
|
27
27
|
},
|
|
28
28
|
async run({ args }: { args: SupportBundleCommandArgs }) {
|
|
29
29
|
try {
|
|
30
|
+
if (args.output === "") {
|
|
31
|
+
throw new KeshaError("E_INVALID_ARG", "--output needs a file path", { hint: "pass --output path.tar.gz." });
|
|
32
|
+
}
|
|
33
|
+
if (args.output === "-") {
|
|
34
|
+
throw new KeshaError("E_INVALID_ARG", "--output - is not a file path", {
|
|
35
|
+
hint: "to write the archive to stdout, pass --output /dev/stdout.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
30
38
|
const bundle = await createSupportBundle({
|
|
31
39
|
output: args.output,
|
|
32
40
|
includeLogs: Boolean(args["include-logs"]),
|
|
33
41
|
});
|
|
34
|
-
log.
|
|
42
|
+
log.notice(`Created support bundle: ${bundle.path}`);
|
|
35
43
|
log.status(`Entries: ${bundle.entries.length}`);
|
|
36
44
|
log.status(`Size: ${bundle.sizeBytes} bytes`);
|
|
37
45
|
} catch (err) {
|
|
46
|
+
if ((err as NodeJS.ErrnoException).code === "EPIPE") return;
|
|
38
47
|
log.error(errorMessage(err));
|
|
39
48
|
process.exit(err instanceof KeshaError ? exitCodeFor(err) : 1);
|
|
40
49
|
}
|
package/src/cli.ts
CHANGED
package/src/doctor.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
NOT_FUNCTIONAL_STATE,
|
|
11
11
|
probeExecutable,
|
|
12
12
|
} from "./engine-health";
|
|
13
|
-
import { readInstalledEngineVersion } from "./engine-version-marker";
|
|
13
|
+
import { MIN_ENGINE_VERSION, predatesMinimumEngine, readInstalledEngineVersion } from "./engine-version-marker";
|
|
14
14
|
import { keshaCacheDir } from "./paths";
|
|
15
15
|
import { installedVoiceIds } from "./voice-inventory";
|
|
16
16
|
import { engineVersion, packageName, packageVersion } from "./package-info";
|
|
@@ -85,13 +85,14 @@ type DoctorDiagnosticLogStatus = DiagnosticLogStatus & { error?: string };
|
|
|
85
85
|
* is a supported state (`kesha install --engine-version`), not a fault — `unrecorded` is
|
|
86
86
|
* separate so a missing marker is never reported as a difference.
|
|
87
87
|
*/
|
|
88
|
-
export type EngineVersionState = "matches-pin" | "differs-from-pin" | "unrecorded";
|
|
88
|
+
export type EngineVersionState = "matches-pin" | "differs-from-pin" | "unsupported" | "unrecorded";
|
|
89
89
|
|
|
90
90
|
export function engineVersionState(
|
|
91
91
|
versionMarker: string | null,
|
|
92
92
|
pinnedVersion: string,
|
|
93
93
|
): EngineVersionState {
|
|
94
94
|
if (versionMarker === null) return "unrecorded";
|
|
95
|
+
if (predatesMinimumEngine(versionMarker)) return "unsupported";
|
|
95
96
|
return versionMarker === pinnedVersion ? "matches-pin" : "differs-from-pin";
|
|
96
97
|
}
|
|
97
98
|
|
|
@@ -477,6 +478,11 @@ function formatVersionState(engine: DoctorReport["engine"]): string {
|
|
|
477
478
|
`installed v${engine.versionMarker} differs from the pinned v${engine.pinnedVersion} — ` +
|
|
478
479
|
"supported; `kesha install` without --engine-version restores the pin"
|
|
479
480
|
);
|
|
481
|
+
case "unsupported":
|
|
482
|
+
return (
|
|
483
|
+
`installed v${engine.versionMarker} is older than v${MIN_ENGINE_VERSION}, ` +
|
|
484
|
+
"the oldest engine this CLI supports; run `kesha install`"
|
|
485
|
+
);
|
|
480
486
|
case "unrecorded":
|
|
481
487
|
return engine.installed
|
|
482
488
|
? "no recorded version beside the binary; `kesha install` records one"
|
package/src/engine/events.ts
CHANGED
|
@@ -128,7 +128,8 @@ export class KeshaError extends Error {
|
|
|
128
128
|
const transcript = this.stderr?.trim();
|
|
129
129
|
if (!transcript) return renderError(this);
|
|
130
130
|
const line = renderError(this).split("\n")[0] ?? "";
|
|
131
|
-
|
|
131
|
+
if (!transcript.split("\n").some((l) => l === line)) return `${renderError(this)}\n${transcript}`;
|
|
132
|
+
return this.hint && !transcript.includes(` hint: ${this.hint}`) ? `${transcript}\n hint: ${this.hint}` : transcript;
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { cmp, parseSemver, tryParseSemver } from "./semver.mjs";
|
|
3
|
+
|
|
4
|
+
export const MIN_ENGINE_VERSION = "1.26.0";
|
|
5
|
+
|
|
6
|
+
export function predatesMinimumEngine(version: string): boolean {
|
|
7
|
+
const parsed = tryParseSemver(version);
|
|
8
|
+
return parsed !== null && cmp(parsed, parseSemver(MIN_ENGINE_VERSION, "MIN_ENGINE_VERSION")) < 0;
|
|
9
|
+
}
|
|
2
10
|
|
|
3
11
|
/**
|
|
4
12
|
* Version-marker file written next to the engine binary on download (#151).
|
package/src/engine.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "child_process";
|
|
1
2
|
import { existsSync, statSync, type Stats } from "fs";
|
|
2
3
|
import { errorMessage } from "./error-utils";
|
|
3
4
|
import { join } from "path";
|
|
@@ -8,6 +9,7 @@ import { defaultEngineBinPath, keshaCacheDir } from "./paths";
|
|
|
8
9
|
import { engineAbortError, interruptedRun } from "./process-tree";
|
|
9
10
|
import { runEngineProcess, spawnHint } from "./engine/spawn";
|
|
10
11
|
import { engineFailure, KeshaError, renderError, type ErrorEvent } from "./engine/events";
|
|
12
|
+
import { MIN_ENGINE_VERSION, predatesMinimumEngine, readInstalledEngineVersion } from "./engine-version-marker";
|
|
11
13
|
import {
|
|
12
14
|
describeToCapabilities,
|
|
13
15
|
parseDescribe,
|
|
@@ -124,6 +126,16 @@ function failed(run: EngineRun): boolean {
|
|
|
124
126
|
return reportedFailure(run) || run.invalid.length > 0;
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
function assertEngineSupported(binPath: string): void {
|
|
130
|
+
const installed = readInstalledEngineVersion(binPath);
|
|
131
|
+
if (installed === null || !predatesMinimumEngine(installed)) return;
|
|
132
|
+
throw new KeshaError(
|
|
133
|
+
"E_ENGINE_PROTOCOL",
|
|
134
|
+
`kesha-engine at ${binPath} is v${installed}; this CLI needs v${MIN_ENGINE_VERSION} or newer`,
|
|
135
|
+
{ hint: "run `kesha install` to fetch the engine this CLI expects", versionMismatch: true },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
127
139
|
let cachedDescribe: { identity: string; doc: DescribeDocument } | null = null;
|
|
128
140
|
|
|
129
141
|
/** ctime is part of it because utimes cannot set it back after a replacement that restores mtime (#1256, #1258). */
|
|
@@ -142,6 +154,7 @@ export async function getDescribe(opts: RunEngineOptions = {}): Promise<Describe
|
|
|
142
154
|
hint: spawnHint(),
|
|
143
155
|
});
|
|
144
156
|
}
|
|
157
|
+
assertEngineSupported(binPath);
|
|
145
158
|
if (cachedDescribe?.identity === identity) return cachedDescribe.doc;
|
|
146
159
|
const run = await runEngine(["describe"], opts);
|
|
147
160
|
let doc: DescribeDocument | null = null;
|
|
@@ -295,10 +308,29 @@ export function buildTranscribeArgs(
|
|
|
295
308
|
return args;
|
|
296
309
|
}
|
|
297
310
|
|
|
311
|
+
function hostIsVirtualMac(): boolean {
|
|
312
|
+
return spawnSync("sysctl", ["-n", "kern.hv_vmm_present"], { encoding: "utf8" }).stdout?.trim() === "1";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** CoreML transcription is unreliable without an Apple Neural Engine, which no virtualised Mac has (#1419); synthesis says the same (#742). */
|
|
316
|
+
export function withVirtualMacHint(
|
|
317
|
+
err: KeshaError,
|
|
318
|
+
platform: string = process.platform,
|
|
319
|
+
isVirtualMac: () => boolean = hostIsVirtualMac,
|
|
320
|
+
): KeshaError {
|
|
321
|
+
if (platform !== "darwin" || err.hint || !err.message.includes("Swift bridge error") || !isVirtualMac()) return err;
|
|
322
|
+
return new KeshaError(err.code, err.message, {
|
|
323
|
+
exitCode: err.exitCode,
|
|
324
|
+
stderr: err.stderr,
|
|
325
|
+
origin: err.origin,
|
|
326
|
+
hint: "this is a virtualised Mac with no Apple Neural Engine, where CoreML transcription is unreliable; transcribe on a physical Mac, or on Linux or Windows",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
298
330
|
export async function transcribeEngine(audioPath: string, opts: TranscribeEngineOptions = {}): Promise<string> {
|
|
299
331
|
const args = await validatedArgs(buildTranscribeArgs(audioPath, opts), { signal: opts.signal });
|
|
300
332
|
const run = await runEngine(args, { signal: opts.signal, onProgressLine: opts.onProgressLine });
|
|
301
|
-
if (failed(run)) throw engineFailure(args[0] ?? "", run, run.exitCode);
|
|
333
|
+
if (failed(run)) throw withVirtualMacHint(engineFailure(args[0] ?? "", run, run.exitCode));
|
|
302
334
|
return run.stdout;
|
|
303
335
|
}
|
|
304
336
|
|
|
@@ -356,7 +388,7 @@ export async function transcribeEngineWithSegments(
|
|
|
356
388
|
const args = await validatedArgs(buildTranscribeArgs(audioPath, opts, true), { signal: opts.signal });
|
|
357
389
|
if (opts.speakers) assertSpeakerModelsInstalled();
|
|
358
390
|
const run = await runEngine(args, { signal: opts.signal, onProgressLine: opts.onProgressLine });
|
|
359
|
-
if (failed(run)) throw engineFailure(args[0] ?? "", run, run.exitCode);
|
|
391
|
+
if (failed(run)) throw withVirtualMacHint(engineFailure(args[0] ?? "", run, run.exitCode));
|
|
360
392
|
try {
|
|
361
393
|
return parseTranscriptionOutput(run.stdout);
|
|
362
394
|
} catch (err: unknown) {
|
|
@@ -477,7 +509,7 @@ export async function recordEngine(target: RecordTarget, maxSeconds: number): Pr
|
|
|
477
509
|
const signalled = target.live && SIGNALLED_LIVE_EXIT_CODES.has(exitCode);
|
|
478
510
|
// A clean interrupt delivers the transcript and exits 128+signal saying nothing (rust/src/cli/record.rs:82),
|
|
479
511
|
// so an error event beside that status is a real failure the signal must not excuse.
|
|
480
|
-
if (events.error || events.invalid.length > 0) throw engineFailure("record", events, exitCode);
|
|
512
|
+
if (events.error || events.invalid.length > 0) throw withVirtualMacHint(engineFailure("record", events, exitCode));
|
|
481
513
|
// A silent non-zero exit named nothing, so it stays the operational 1 it has been since #1167.
|
|
482
514
|
if (!signalled && exitCode !== 0) throw new Error(`kesha-engine record exited with code ${exitCode}`);
|
|
483
515
|
}
|
|
@@ -518,6 +550,7 @@ export async function detectTextLanguageEngine(
|
|
|
518
550
|
): Promise<LangDetectResult | null> {
|
|
519
551
|
if (text.trim().length === 0) return null;
|
|
520
552
|
if (!isEngineInstalled()) return null;
|
|
553
|
+
assertEngineSupported(getEngineBinPath());
|
|
521
554
|
const run = await runEngine(["detect-text-lang"], { ...opts, stdin: text });
|
|
522
555
|
if (reportedFailure(run)) {
|
|
523
556
|
const warning = textLangFailureWarning(run.stderr);
|
package/src/log.ts
CHANGED
|
@@ -16,6 +16,10 @@ export function setColorEnabled(enabled: boolean): void {
|
|
|
16
16
|
colors = enabled ? pc : pc.createColors(false);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function isColorEnabled(): boolean {
|
|
20
|
+
return colors.isColorSupported;
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
/**
|
|
20
24
|
* Debug mode (#148): when `KESHA_DEBUG` is truthy OR the caller has flipped
|
|
21
25
|
* `log.debugEnabled = true` (via `--debug`), `log.debug()` writes structured
|
package/src/mcp/tools.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
|
+
import { KeshaError } from "../engine/events";
|
|
3
4
|
import { installHint } from "../install-hint";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { chmodSync, existsSync, readFileSync, statSync } from "fs";
|
|
@@ -126,7 +127,8 @@ export function registerTools(server: McpServer): void {
|
|
|
126
127
|
const hint = isAbsolute(path)
|
|
127
128
|
? ""
|
|
128
129
|
: ` (relative paths resolve against the MCP server's working directory, ${process.cwd()} — pass an absolute path instead)`;
|
|
129
|
-
|
|
130
|
+
const err = new KeshaError("E_INPUT_NOT_FOUND", `File not found: ${path}${hint}`);
|
|
131
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
|
|
130
132
|
}
|
|
131
133
|
try {
|
|
132
134
|
assertAudioFileArgument(path);
|
package/src/support-bundle.ts
CHANGED
|
@@ -132,6 +132,7 @@ function mkdirReason(dir: string, errno: string): string {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
function bundleWriteFailure(outputPath: string, e: unknown): unknown {
|
|
135
|
+
if ((e as NodeJS.ErrnoException).code === "EPIPE") return e;
|
|
135
136
|
const dir = dirname(outputPath);
|
|
136
137
|
const errno = mkdirReason(dir, (e as NodeJS.ErrnoException).code ?? "");
|
|
137
138
|
const blocker = fileInTheWay(dir);
|
package/src/toon.ts
CHANGED
|
@@ -15,5 +15,6 @@ export function formatToonOutput<T extends object, E extends object>(
|
|
|
15
15
|
results: T[],
|
|
16
16
|
errors?: E[],
|
|
17
17
|
): string {
|
|
18
|
-
|
|
18
|
+
const payload = errors === undefined ? results : { results, errors };
|
|
19
|
+
return encodeToon(JSON.parse(JSON.stringify(payload))) + "\n";
|
|
19
20
|
}
|
package/src/voice-routing.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { detectTextLanguageEngine } from "./engine";
|
|
1
|
+
import { detectTextLanguageEngine, type LangDetectResult } from "./engine";
|
|
2
2
|
import { listVoiceIds } from "./synth";
|
|
3
3
|
|
|
4
4
|
/** The voice the Engine speaks with when `say` is given no `--voice` — mirrors `tts::voices::DEFAULT_VOICE_ID`. */
|
|
@@ -63,6 +63,12 @@ const NATIVE_SCRIPT_ROUTES: Record<string, NativeScriptRoute> = {
|
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
const LATIN_LETTER = /[A-Za-z]/u;
|
|
66
|
+
const CYRILLIC = /\p{Script=Cyrillic}/u;
|
|
67
|
+
|
|
68
|
+
function mostlyCyrillicWords(text: string): boolean {
|
|
69
|
+
const words = text.split(/\s+/).filter((w) => /\p{L}/u.test(w));
|
|
70
|
+
return words.filter((w) => CYRILLIC.test(w)).length * 2 > words.length;
|
|
71
|
+
}
|
|
66
72
|
|
|
67
73
|
/** True when more of the text's letters are in `script` than in the Latin alphabet. */
|
|
68
74
|
function dominantScript(text: string, script: RegExp): boolean {
|
|
@@ -110,6 +116,7 @@ export interface ResolveSayVoiceOptions {
|
|
|
110
116
|
arch?: NodeJS.Architecture;
|
|
111
117
|
/** The engine's `say --list-voices` union; injected so the routing decision is testable without an engine. */
|
|
112
118
|
listVoices?: (signal?: AbortSignal) => Promise<string[]>;
|
|
119
|
+
detectLanguage?: (text: string, signal?: AbortSignal) => Promise<LangDetectResult | null>;
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
function baseLangOf(code: string | undefined): string {
|
|
@@ -145,7 +152,9 @@ async function detectLang(
|
|
|
145
152
|
options: ResolveSayVoiceOptions,
|
|
146
153
|
): Promise<{ code?: string; confidence: number }> {
|
|
147
154
|
if (!text) return { confidence: 0 };
|
|
148
|
-
const detected =
|
|
155
|
+
const detected = options.detectLanguage
|
|
156
|
+
? await options.detectLanguage(text, options.signal)
|
|
157
|
+
: await detectTextLanguageEngine(text, { signal: options.signal });
|
|
149
158
|
return { code: detected?.code, confidence: detected?.confidence ?? 0 };
|
|
150
159
|
}
|
|
151
160
|
|
|
@@ -153,7 +162,8 @@ async function detectLang(
|
|
|
153
162
|
* Resolve the voice for a synthesis request. Precedence: explicit voice >
|
|
154
163
|
* explicit language hint (route by the stated language, skipping detection —
|
|
155
164
|
* also the path on Linux/Windows where text-language detection is unavailable) >
|
|
156
|
-
* macOS text-language auto-detection >
|
|
165
|
+
* macOS text-language auto-detection > Russian when detection is inconclusive and most
|
|
166
|
+
* words are Cyrillic (#1387) > engine default (`undefined`). A language
|
|
157
167
|
* hint the build has no voice for resolves to `undefined` (engine default)
|
|
158
168
|
* rather than re-running detection — the user stated the language explicitly.
|
|
159
169
|
*
|
|
@@ -170,6 +180,9 @@ export async function resolveSayVoice(
|
|
|
170
180
|
const { code, confidence } =
|
|
171
181
|
langHint !== undefined ? { code: langHint, confidence: 1 } : await detectLang(text, options);
|
|
172
182
|
const voice = pickVoiceForLang(code, confidence, options.platform, options.arch);
|
|
183
|
+
if (confidence < 0.5 && mostlyCyrillicWords(text)) {
|
|
184
|
+
return pickVoiceForLang("ru", 1, options.platform, options.arch);
|
|
185
|
+
}
|
|
173
186
|
if (confidence < 0.5) return voice;
|
|
174
187
|
return (await nativeScriptOverride(baseLangOf(code), text, options)) ?? voice;
|
|
175
188
|
}
|