@drakulavich/kesha-voice-kit 2.0.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 +15 -50
- package/src/cli/install.ts +1 -1
- 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 +12 -2
- package/src/cli.ts +0 -1
- package/src/doctor.ts +8 -2
- package/src/engine/events.ts +2 -1
- package/src/engine-install.ts +135 -63
- 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/path-errnos.ts +11 -0
- package/src/progress.ts +30 -13
- package/src/support-bundle.ts +73 -8
- 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.0.
|
|
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.0.
|
|
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(),
|
|
@@ -176,14 +152,7 @@ export async function promptInitSelection(
|
|
|
176
152
|
};
|
|
177
153
|
}
|
|
178
154
|
|
|
179
|
-
|
|
180
|
-
async function printPlan(selection: InitSelection): Promise<boolean> {
|
|
181
|
-
const backendError = unavailableBackendRefusal(selection.backend);
|
|
182
|
-
if (backendError) {
|
|
183
|
-
log.error(errorMessage(backendError));
|
|
184
|
-
process.exitCode = exitCodeFor(backendError);
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
155
|
+
async function printPlan(selection: InitSelection): Promise<void> {
|
|
187
156
|
log.info(
|
|
188
157
|
await renderInstallPlan({
|
|
189
158
|
noCache: selection.noCache,
|
|
@@ -193,21 +162,6 @@ async function printPlan(selection: InitSelection): Promise<boolean> {
|
|
|
193
162
|
diarize: selection.diarize,
|
|
194
163
|
}),
|
|
195
164
|
);
|
|
196
|
-
return true;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async function runNonInteractive(selection: InitSelection): Promise<void> {
|
|
200
|
-
const canDiarize = canInstallDiarizeOnPlatform();
|
|
201
|
-
const printableSelection = omitUnsupportedDiarize(selection, canDiarize);
|
|
202
|
-
if (selection.diarize && !canDiarize) {
|
|
203
|
-
log.warn("--diarize is currently darwin-arm64 only; omitting it from non-interactive examples.");
|
|
204
|
-
}
|
|
205
|
-
log.info(renderInitOverview(canDiarize));
|
|
206
|
-
if (!(await printPlan(printableSelection))) return;
|
|
207
|
-
log.info("Run one of these commands from an interactive terminal:");
|
|
208
|
-
for (const command of initSuggestionCommands(printableSelection, canDiarize)) {
|
|
209
|
-
log.info(` ${command.join(" ")}`);
|
|
210
|
-
}
|
|
211
165
|
}
|
|
212
166
|
|
|
213
167
|
export const initCommand = defineCommand({
|
|
@@ -262,6 +216,13 @@ export const initCommand = defineCommand({
|
|
|
262
216
|
const noCache = resolveNoCacheFlag(args, rawArgs);
|
|
263
217
|
const selection = resolveInitSelection(args, backend, noCache);
|
|
264
218
|
|
|
219
|
+
const backendError = args.yes && !args.plan ? null : unavailableBackendRefusal(backend);
|
|
220
|
+
if (backendError) {
|
|
221
|
+
log.error(errorMessage(backendError));
|
|
222
|
+
process.exitCode = exitCodeFor(backendError);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
265
226
|
if (args.plan) {
|
|
266
227
|
log.info(renderInitOverview());
|
|
267
228
|
await printPlan(selection);
|
|
@@ -280,7 +241,11 @@ export const initCommand = defineCommand({
|
|
|
280
241
|
const stdinIsTty = process.stdin.isTTY === true;
|
|
281
242
|
const stdoutIsTty = process.stdout.isTTY === true;
|
|
282
243
|
if (!stdinIsTty || !stdoutIsTty) {
|
|
283
|
-
|
|
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);
|
|
284
249
|
return;
|
|
285
250
|
}
|
|
286
251
|
|
|
@@ -293,7 +258,7 @@ export const initCommand = defineCommand({
|
|
|
293
258
|
noCache,
|
|
294
259
|
);
|
|
295
260
|
log.info("");
|
|
296
|
-
|
|
261
|
+
await printPlan(prompted);
|
|
297
262
|
const confirmed = await promptConfirm(
|
|
298
263
|
`Run \`${initInstallArgs(prompted).join(" ")}\` now?`,
|
|
299
264
|
true,
|
package/src/cli/install.ts
CHANGED
|
@@ -246,7 +246,7 @@ export async function performInstall(options: PerformInstallOptions) {
|
|
|
246
246
|
throw backendError;
|
|
247
247
|
}
|
|
248
248
|
await installEngine({ noCache, backend, ttsLangs, vad, diarize, version: engineVersion });
|
|
249
|
-
await maybeAskForStar(getEngineBinPath(), packageVersion, log);
|
|
249
|
+
if (!log.quietEnabled) await maybeAskForStar(getEngineBinPath(), packageVersion, { info: log.progress, warn: log.warn });
|
|
250
250
|
finishInstallDiagnostic(diagnosticLog, startedAt, "success");
|
|
251
251
|
} catch (err: unknown) {
|
|
252
252
|
const signalExitCode = getPendingSignalExitCode();
|
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();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { errorMessage } from "../error-utils";
|
|
3
|
+
import { exitCodeFor, KeshaError } from "../engine/events";
|
|
3
4
|
import { createSupportBundle } from "../support-bundle";
|
|
4
5
|
import { log } from "../log";
|
|
5
6
|
|
|
@@ -26,16 +27,25 @@ export const supportBundleCommand = defineCommand({
|
|
|
26
27
|
},
|
|
27
28
|
async run({ args }: { args: SupportBundleCommandArgs }) {
|
|
28
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
|
+
}
|
|
29
38
|
const bundle = await createSupportBundle({
|
|
30
39
|
output: args.output,
|
|
31
40
|
includeLogs: Boolean(args["include-logs"]),
|
|
32
41
|
});
|
|
33
|
-
log.
|
|
42
|
+
log.notice(`Created support bundle: ${bundle.path}`);
|
|
34
43
|
log.status(`Entries: ${bundle.entries.length}`);
|
|
35
44
|
log.status(`Size: ${bundle.sizeBytes} bytes`);
|
|
36
45
|
} catch (err) {
|
|
46
|
+
if ((err as NodeJS.ErrnoException).code === "EPIPE") return;
|
|
37
47
|
log.error(errorMessage(err));
|
|
38
|
-
process.exit(1);
|
|
48
|
+
process.exit(err instanceof KeshaError ? exitCodeFor(err) : 1);
|
|
39
49
|
}
|
|
40
50
|
},
|
|
41
51
|
});
|
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"
|