@drakulavich/kesha-voice-kit 1.0.10 → 1.2.0
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 +77 -17
- package/package.json +2 -2
- package/src/cli.ts +163 -4
- package/src/engine-install.ts +15 -2
- package/src/engine.ts +11 -2
- package/src/lib.ts +6 -0
- package/src/log.ts +17 -0
- package/src/say.ts +89 -0
- package/src/status.ts +44 -0
package/README.md
CHANGED
|
@@ -13,14 +13,18 @@
|
|
|
13
13
|
|
|
14
14
|
<p align="center"><b>Open-source voice toolkit.</b> Optimized for Apple Silicon (CoreML), works on any platform (ONNX fallback).<br>A collection of small, fast, open-source audio models — packaged as CLI tools and an <a href="https://github.com/openclaw/openclaw">OpenClaw</a> skill for LLM agents.</p>
|
|
15
15
|
|
|
16
|
-
- **Speech-to-text** — 25 languages, ~
|
|
16
|
+
- **Speech-to-text** — 25 languages, ~15x faster than Whisper on Apple Silicon, ~2.5x on CPU
|
|
17
17
|
- **Language detection** — 107 languages from audio, text language via NLLanguageRecognizer
|
|
18
18
|
- **Rust engine** — single 20MB binary, no ffmpeg, no Python, no native Node addons
|
|
19
19
|
- **OpenClaw-ready** — plug into your LLM agent as a voice processing skill
|
|
20
20
|
|
|
21
21
|
## Quick Start
|
|
22
22
|
|
|
23
|
+
Runtime: **[Bun](https://bun.sh)** >= 1.3.0.
|
|
24
|
+
|
|
23
25
|
```bash
|
|
26
|
+
curl -fsSL https://bun.sh/install | bash # skip if Bun is already installed
|
|
27
|
+
|
|
24
28
|
bun install -g @drakulavich/kesha-voice-kit
|
|
25
29
|
kesha install # downloads engine + models
|
|
26
30
|
kesha audio.ogg # transcript to stdout
|
|
@@ -30,27 +34,19 @@ kesha audio.ogg # transcript to stdout
|
|
|
30
34
|
|
|
31
35
|
Kesha Voice Kit ships as a plugin for [OpenClaw](https://github.com/openclaw/openclaw) — give your LLM agent ears. No API keys, everything runs locally on your machine.
|
|
32
36
|
|
|
33
|
-
**Install the `kesha` CLI first** (the plugin shells out to it):
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
bun add -g @drakulavich/kesha-voice-kit
|
|
37
|
-
kesha install
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
**Then configure OpenClaw to use Kesha for audio transcription:**
|
|
41
|
-
|
|
42
37
|
```bash
|
|
38
|
+
bun add -g @drakulavich/kesha-voice-kit && kesha install
|
|
43
39
|
openclaw plugins install @drakulavich/kesha-voice-kit
|
|
44
40
|
openclaw config set tools.media.audio.models \
|
|
45
41
|
'[{"type":"cli","command":"kesha","args":["--format","transcript","{{MediaPath}}"],"timeoutSeconds":15}]'
|
|
46
|
-
openclaw config set tools.media.audio.enabled true
|
|
47
|
-
openclaw gateway restart
|
|
48
42
|
```
|
|
49
43
|
|
|
44
|
+
> If audio transcription is not already enabled: `openclaw config set tools.media.audio.enabled true`
|
|
45
|
+
|
|
50
46
|
Your agent receives a voice message in Telegram/WhatsApp/Slack, Kesha transcribes it locally, and the agent sees enriched context:
|
|
51
47
|
|
|
52
48
|
```
|
|
53
|
-
|
|
49
|
+
Таити, Таити! Не были мы ни в какой Таити! Нас и тут неплохо кормят.
|
|
54
50
|
[lang: ru, confidence: 1.00]
|
|
55
51
|
```
|
|
56
52
|
|
|
@@ -69,9 +65,73 @@ kesha --lang en audio.ogg # warn if detected language differs
|
|
|
69
65
|
kesha status # show installed backend info
|
|
70
66
|
```
|
|
71
67
|
|
|
72
|
-
|
|
68
|
+
Multiple files — headers per file, like `head`:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
$ kesha freedom.ogg tahiti.ogg
|
|
72
|
+
=== freedom.ogg ===
|
|
73
|
+
Свободу попугаям! Свободу!
|
|
74
|
+
|
|
75
|
+
=== tahiti.ogg ===
|
|
76
|
+
Таити, Таити! Не были мы ни в какой Таити! Нас и тут неплохо кормят.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Stdout: transcript. Stderr: errors. Pipe-friendly. Also available as `parakeet` command (backward-compatible alias).
|
|
80
|
+
|
|
81
|
+
## Text-to-Speech
|
|
82
|
+
|
|
83
|
+
Kesha speaks back via Kokoro-82M (English) and Piper (Russian). Voice is auto-picked from the input text's language — `en` routes to Kokoro, `ru` to Piper. Pass `--voice` to override.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
brew install espeak-ng # one-time system dep (macOS — apt/choco elsewhere)
|
|
87
|
+
kesha install --tts # ~390MB (Kokoro + Piper RU, opt-in)
|
|
88
|
+
kesha say "Hello, world" > hello.wav
|
|
89
|
+
kesha say "Привет, мир" > privet.wav # auto-routes to ru-denis
|
|
90
|
+
echo "long text" | kesha say > reply.wav
|
|
91
|
+
kesha say --out reply.wav "text"
|
|
92
|
+
kesha say --voice en-af_heart "text" # explicit voice overrides auto-routing
|
|
93
|
+
kesha say --list-voices
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Output format: WAV mono float32 (24 kHz for Kokoro, 22.05 kHz for Piper). OGG/Opus and MP3 are tracked in follow-up issues. Static-linking of `espeak-ng` to remove the system dep is [#124](https://github.com/drakulavich/kesha-voice-kit/issues/124).
|
|
97
|
+
|
|
98
|
+
**Supported voices:**
|
|
99
|
+
- English: `en-af_heart` (default), plus any Kokoro voice you download into `~/.cache/kesha/models/kokoro-82m/voices/`
|
|
100
|
+
- Russian: `ru-denis` (default). More speakers (dmitri, irina, ruslan) are ready to drop in once needed.
|
|
101
|
+
- macOS system voices (preview — dev builds only, see below): `macos-<identifier-or-language>` routes to `AVSpeechSynthesizer`. Zero install, any of the 180+ voices already on your Mac.
|
|
102
|
+
|
|
103
|
+
### macOS system voices (preview)
|
|
104
|
+
|
|
105
|
+
`kesha say --voice macos-*` routes through `AVSpeechSynthesizer` on macOS, so you get voice synthesis for free — no 390 MB TTS bundle, no `espeak-ng` dep. Currently gated behind the `system_tts` cargo feature; release-binary integration is tracked as part of [#141](https://github.com/drakulavich/kesha-voice-kit/issues/141).
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# From a dev build: `cargo build --release --features system_tts` in rust/.
|
|
109
|
+
kesha say --voice macos-com.apple.voice.compact.en-US.Samantha "Hello from system TTS" > out.wav
|
|
110
|
+
kesha say --voice macos-ru-RU "Привет, мир" > hello-ru.wav # language-code fallback
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Voice id format: `macos-<id>` where `<id>` is either a full Apple identifier (`com.apple.voice.compact.en-US.Samantha`) or a language code (`en-US`, `ru-RU`) — the Swift helper tries the identifier first and falls back to the language. Output is mono float32 @ 22050 Hz, structurally identical to Piper.
|
|
114
|
+
|
|
115
|
+
Quality tradeoff is honest: macOS system voices are notification-grade. Use them when you want zero-install TTS on macOS; keep Kokoro/Piper for anything that needs to sound good.
|
|
116
|
+
|
|
117
|
+
### SSML (preview)
|
|
118
|
+
|
|
119
|
+
`kesha say --ssml` accepts [SSML](https://www.w3.org/TR/speech-synthesis11/) for pauses and text-structuring. v1 is deliberately small:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
kesha say --ssml '<speak>Hello <break time="500ms"/> world.</speak>'
|
|
123
|
+
kesha say --ssml --voice ru-denis '<speak>Привет <break time="1s"/> мир.</speak>'
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
| Tag | Status |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `<speak>` | ✅ required root |
|
|
129
|
+
| `<break time="Nms"\|"Ns"\|default>` | ✅ inserts silence of the given duration |
|
|
130
|
+
| plain text inside `<speak>` | ✅ synthesized via the selected engine |
|
|
131
|
+
| `<emphasis>`, `<prosody>`, `<phoneme>`, `<say-as>` | ⚠️ stripped with a stderr warning (contained text still synthesized); tracked in [#122](https://github.com/drakulavich/kesha-voice-kit/issues/122) |
|
|
132
|
+
| `<!DOCTYPE>` | ❌ rejected (hardening against XXE) |
|
|
73
133
|
|
|
74
|
-
|
|
134
|
+
SSML is opt-in via the explicit `--ssml` flag — inputs that happen to contain `<angle brackets>` aren't misinterpreted as SSML.
|
|
75
135
|
|
|
76
136
|
## What's Inside
|
|
77
137
|
|
|
@@ -87,7 +147,7 @@ All models run through `kesha-engine` — a Rust binary using [FluidAudio](https
|
|
|
87
147
|
|
|
88
148
|
## Performance
|
|
89
149
|
|
|
90
|
-
> **~
|
|
150
|
+
> **~15x faster than Whisper** on Apple Silicon (M3 Pro), **~2.5x faster** on CPU
|
|
91
151
|
|
|
92
152
|
Compared against Whisper `large-v3-turbo` — all engines auto-detect language.
|
|
93
153
|
|
|
@@ -148,4 +208,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
148
208
|
|
|
149
209
|
## License
|
|
150
210
|
|
|
151
|
-
Made with 💛🩵
|
|
211
|
+
Made with 💛🩵 and 🥤 energy under MIT License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Open-source voice toolkit for Apple Silicon. Speech-to-text, language detection. 25 languages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
]
|
|
30
30
|
},
|
|
31
31
|
"keshaEngine": {
|
|
32
|
-
"version": "1.0
|
|
32
|
+
"version": "1.2.0"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"test": "bun test",
|
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { downloadEngine } from "./engine-install";
|
|
|
7
7
|
import { detectAudioLanguageEngine, detectTextLanguageEngine } from "./engine";
|
|
8
8
|
import type { LangDetectResult } from "./engine";
|
|
9
9
|
import { log } from "./log";
|
|
10
|
+
import { say, SayError } from "./say";
|
|
10
11
|
import { showStatus } from "./status";
|
|
11
12
|
import { suggestCommand } from "./suggest-command";
|
|
12
13
|
|
|
@@ -24,12 +25,14 @@ interface InstallCommandArgs {
|
|
|
24
25
|
coreml: boolean;
|
|
25
26
|
onnx: boolean;
|
|
26
27
|
"no-cache": boolean;
|
|
28
|
+
tts: boolean;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
interface MainCommandArgs {
|
|
30
32
|
_: string[];
|
|
31
33
|
json: boolean;
|
|
32
34
|
verbose: boolean;
|
|
35
|
+
debug: boolean;
|
|
33
36
|
format?: string;
|
|
34
37
|
lang?: string;
|
|
35
38
|
}
|
|
@@ -46,9 +49,26 @@ function resolveBackendFlag(coreml: boolean, onnx: boolean): string | undefined
|
|
|
46
49
|
return undefined;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
async function
|
|
52
|
+
async function askForStar() {
|
|
53
|
+
const gh = Bun.which("gh");
|
|
54
|
+
if (!gh) {
|
|
55
|
+
log.info("\nIf you enjoy Kesha Voice Kit, consider starring the repo:");
|
|
56
|
+
log.info(" https://github.com/drakulavich/kesha-voice-kit");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const authCheck = Bun.spawnSync([gh, "auth", "status"], { stdout: "ignore", stderr: "ignore" });
|
|
60
|
+
if (authCheck.exitCode !== 0) return;
|
|
61
|
+
const starred = Bun.spawnSync([gh, "api", "user/starred/drakulavich/kesha-voice-kit"], { stdout: "ignore", stderr: "ignore" });
|
|
62
|
+
if (starred.exitCode === 0) return; // already starred
|
|
63
|
+
log.info("\n⭐ If you enjoy Kesha Voice Kit, star it on GitHub:");
|
|
64
|
+
log.info(" https://github.com/drakulavich/kesha-voice-kit");
|
|
65
|
+
log.info(' Or run: gh api -X PUT /user/starred/drakulavich/kesha-voice-kit');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function performInstall(noCache: boolean, backend?: string, tts = false) {
|
|
50
69
|
try {
|
|
51
|
-
await downloadEngine(noCache, backend);
|
|
70
|
+
await downloadEngine(noCache, backend, { tts });
|
|
71
|
+
await askForStar();
|
|
52
72
|
} catch (err: unknown) {
|
|
53
73
|
const message = err instanceof Error ? err.message : String(err);
|
|
54
74
|
log.error(message);
|
|
@@ -77,13 +97,134 @@ export const installCommand = defineCommand({
|
|
|
77
97
|
description: "Re-download even if cached",
|
|
78
98
|
default: false,
|
|
79
99
|
},
|
|
100
|
+
tts: {
|
|
101
|
+
type: "boolean",
|
|
102
|
+
description: "Also install TTS models (Kokoro EN + Piper RU, ~390MB, requires espeak-ng on PATH)",
|
|
103
|
+
default: false,
|
|
104
|
+
},
|
|
80
105
|
},
|
|
81
106
|
async run({ args }: { args: InstallCommandArgs }) {
|
|
82
107
|
const backend = resolveBackendFlag(args.coreml, args.onnx);
|
|
83
|
-
await performInstall(args["no-cache"], backend);
|
|
108
|
+
await performInstall(args["no-cache"], backend, args.tts);
|
|
84
109
|
},
|
|
85
110
|
});
|
|
86
111
|
|
|
112
|
+
export const sayCommand = defineCommand({
|
|
113
|
+
meta: {
|
|
114
|
+
name: "say",
|
|
115
|
+
description: "Synthesize speech from text (TTS). Writes WAV to stdout (or --out file).",
|
|
116
|
+
},
|
|
117
|
+
args: {
|
|
118
|
+
text: { type: "positional", required: false, description: "Text to speak (stdin if omitted)" },
|
|
119
|
+
voice: { type: "string", description: "Voice id, e.g. en-af_heart" },
|
|
120
|
+
lang: { type: "string", description: "espeak language code (default en-us)" },
|
|
121
|
+
out: { type: "string", description: "Write WAV to file instead of stdout" },
|
|
122
|
+
rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
|
|
123
|
+
"list-voices": { type: "boolean", description: "List installed voices and exit" },
|
|
124
|
+
ssml: {
|
|
125
|
+
type: "boolean",
|
|
126
|
+
description: "Parse input as SSML (supports <speak>, <break>; strips unknown tags)",
|
|
127
|
+
},
|
|
128
|
+
verbose: {
|
|
129
|
+
type: "boolean",
|
|
130
|
+
description: "Log TTS synthesis time to stderr",
|
|
131
|
+
default: false,
|
|
132
|
+
},
|
|
133
|
+
debug: {
|
|
134
|
+
type: "boolean",
|
|
135
|
+
description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
|
|
136
|
+
default: false,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
async run({ args }) {
|
|
140
|
+
if (args.debug) log.debugEnabled = true;
|
|
141
|
+
if (args["list-voices"]) {
|
|
142
|
+
// The engine prints the list directly — just relay its stdout + exit code.
|
|
143
|
+
const { getEngineBinPath } = await import("./engine");
|
|
144
|
+
const proc = Bun.spawn([getEngineBinPath(), "say", "--list-voices"], {
|
|
145
|
+
stdout: "inherit",
|
|
146
|
+
stderr: "inherit",
|
|
147
|
+
});
|
|
148
|
+
process.exit(await proc.exited);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const inlineText = typeof args.text === "string" ? args.text : undefined;
|
|
152
|
+
const text = await resolveText(inlineText);
|
|
153
|
+
const explicitVoice = typeof args.voice === "string" ? args.voice : undefined;
|
|
154
|
+
const voice = explicitVoice ?? (await autoRouteVoice(text));
|
|
155
|
+
|
|
156
|
+
const opts = {
|
|
157
|
+
text,
|
|
158
|
+
voice,
|
|
159
|
+
lang: typeof args.lang === "string" ? args.lang : undefined,
|
|
160
|
+
out: typeof args.out === "string" ? args.out : undefined,
|
|
161
|
+
rate: args.rate ? Number(args.rate) : undefined,
|
|
162
|
+
ssml: Boolean(args.ssml),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const startedAt = performance.now();
|
|
167
|
+
const wav = await say(opts);
|
|
168
|
+
const ttsTimeMs = Math.round(performance.now() - startedAt);
|
|
169
|
+
if (args.verbose) {
|
|
170
|
+
// stderr — stdout may carry raw WAV bytes when --out is omitted.
|
|
171
|
+
console.error(`TTS time: ${ttsTimeMs}ms`);
|
|
172
|
+
}
|
|
173
|
+
if (!opts.out) {
|
|
174
|
+
process.stdout.write(wav);
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err instanceof SayError) {
|
|
178
|
+
log.error(err.stderr.trim() || err.message);
|
|
179
|
+
process.exit(err.exitCode);
|
|
180
|
+
}
|
|
181
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
182
|
+
log.error(message);
|
|
183
|
+
process.exit(4);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/** Resolve the text to synthesize: inline positional, else read from stdin. */
|
|
189
|
+
async function resolveText(inline: string | undefined): Promise<string> {
|
|
190
|
+
if (inline !== undefined && inline.length > 0) return inline;
|
|
191
|
+
const chunks: Uint8Array[] = [];
|
|
192
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
193
|
+
chunks.push(chunk);
|
|
194
|
+
}
|
|
195
|
+
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
|
|
196
|
+
const merged = new Uint8Array(total);
|
|
197
|
+
let offset = 0;
|
|
198
|
+
for (const c of chunks) {
|
|
199
|
+
merged.set(c, offset);
|
|
200
|
+
offset += c.byteLength;
|
|
201
|
+
}
|
|
202
|
+
return new TextDecoder().decode(merged).trim();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Map a detected language code to a default voice id. Unknown / low-confidence → undefined. */
|
|
206
|
+
export function pickVoiceForLang(
|
|
207
|
+
code: string | undefined,
|
|
208
|
+
confidence: number,
|
|
209
|
+
): string | undefined {
|
|
210
|
+
if (!code || confidence < 0.5) return undefined;
|
|
211
|
+
switch (code) {
|
|
212
|
+
case "en":
|
|
213
|
+
return "en-af_heart";
|
|
214
|
+
case "ru":
|
|
215
|
+
return "ru-denis";
|
|
216
|
+
default:
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Run NLLanguageRecognizer (via engine) on the text and pick a default voice. */
|
|
222
|
+
async function autoRouteVoice(text: string): Promise<string | undefined> {
|
|
223
|
+
if (!text) return undefined;
|
|
224
|
+
const detected = await detectTextLanguageEngine(text);
|
|
225
|
+
return pickVoiceForLang(detected?.code, detected?.confidence ?? 0);
|
|
226
|
+
}
|
|
227
|
+
|
|
87
228
|
export const statusCommand = defineCommand({
|
|
88
229
|
meta: {
|
|
89
230
|
name: "status",
|
|
@@ -122,8 +263,14 @@ export const mainCommand = defineCommand({
|
|
|
122
263
|
type: "string",
|
|
123
264
|
description: "Expected language code (ISO 639-1), warn if mismatch",
|
|
124
265
|
},
|
|
266
|
+
debug: {
|
|
267
|
+
type: "boolean",
|
|
268
|
+
description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
|
|
269
|
+
default: false,
|
|
270
|
+
},
|
|
125
271
|
},
|
|
126
272
|
async run({ args }: { args: MainCommandArgs }) {
|
|
273
|
+
if (args.debug) log.debugEnabled = true;
|
|
127
274
|
const files = args._;
|
|
128
275
|
|
|
129
276
|
if (files.length === 0) {
|
|
@@ -137,6 +284,7 @@ export const mainCommand = defineCommand({
|
|
|
137
284
|
const wantsLangId = !!(args.lang || args.verbose || args.json || args.format === "transcript" || args.format === "json");
|
|
138
285
|
|
|
139
286
|
for (const file of files) {
|
|
287
|
+
const startedAt = performance.now();
|
|
140
288
|
try {
|
|
141
289
|
// Run audio lang-id and transcription concurrently
|
|
142
290
|
const [audioResult, text] = await Promise.all([
|
|
@@ -175,6 +323,7 @@ export const mainCommand = defineCommand({
|
|
|
175
323
|
lang,
|
|
176
324
|
audioLanguage,
|
|
177
325
|
textLanguage: textLanguage ?? (tinyldLang ? { code: tinyldLang, confidence: 0 } : undefined),
|
|
326
|
+
sttTimeMs: Math.round(performance.now() - startedAt),
|
|
178
327
|
});
|
|
179
328
|
} catch (err: unknown) {
|
|
180
329
|
hasError = true;
|
|
@@ -197,7 +346,7 @@ export const mainCommand = defineCommand({
|
|
|
197
346
|
},
|
|
198
347
|
});
|
|
199
348
|
|
|
200
|
-
const SUBCOMMANDS = ["install", "status"];
|
|
349
|
+
const SUBCOMMANDS = ["install", "status", "say"];
|
|
201
350
|
|
|
202
351
|
export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
|
|
203
352
|
const [firstArg, ...restArgs] = rawArgs;
|
|
@@ -212,6 +361,11 @@ export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
|
|
|
212
361
|
return;
|
|
213
362
|
}
|
|
214
363
|
|
|
364
|
+
if (firstArg === "say") {
|
|
365
|
+
await runMain(sayCommand, { rawArgs: restArgs });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
215
369
|
// Check for unknown subcommands (non-flag, non-file-path args)
|
|
216
370
|
if (firstArg && !firstArg.startsWith("-") && !firstArg.includes(".") && !firstArg.includes("/")) {
|
|
217
371
|
const suggestion = suggestCommand(firstArg, SUBCOMMANDS);
|
|
@@ -231,6 +385,8 @@ export type TranscribeResult = {
|
|
|
231
385
|
lang: string;
|
|
232
386
|
audioLanguage?: LangDetectResult;
|
|
233
387
|
textLanguage?: LangDetectResult;
|
|
388
|
+
/** Wall-clock time around the engine subprocess calls for this file, ms. See #139. */
|
|
389
|
+
sttTimeMs?: number;
|
|
234
390
|
};
|
|
235
391
|
|
|
236
392
|
export function formatTextOutput(results: TranscribeResult[]): string {
|
|
@@ -258,6 +414,9 @@ export function formatVerboseOutput(results: TranscribeResult[]): string {
|
|
|
258
414
|
const confStr = textLang.confidence > 0 ? ` (confidence: ${textLang.confidence.toFixed(2)})` : "";
|
|
259
415
|
lines.push(`Text language: ${textLang.code}${confStr}`);
|
|
260
416
|
}
|
|
417
|
+
if (r.sttTimeMs !== undefined) {
|
|
418
|
+
lines.push(`STT time: ${r.sttTimeMs}ms`);
|
|
419
|
+
}
|
|
261
420
|
lines.push("---");
|
|
262
421
|
lines.push(r.text);
|
|
263
422
|
return lines.join("\n");
|
package/src/engine-install.ts
CHANGED
|
@@ -17,7 +17,16 @@ function getEngineBinaryName(): string {
|
|
|
17
17
|
throw new Error(`Unsupported platform: ${platform} ${arch}`);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export
|
|
20
|
+
export interface InstallOptions {
|
|
21
|
+
/** Also install Kokoro TTS models. Requires espeak-ng on PATH. */
|
|
22
|
+
tts?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function downloadEngine(
|
|
26
|
+
noCache = false,
|
|
27
|
+
backend?: string,
|
|
28
|
+
options: InstallOptions = {},
|
|
29
|
+
): Promise<string> {
|
|
21
30
|
const binPath = getEngineBinPath();
|
|
22
31
|
|
|
23
32
|
if (!noCache && existsSync(binPath)) {
|
|
@@ -68,7 +77,11 @@ export async function downloadEngine(noCache = false, backend?: string): Promise
|
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
log.progress("Installing models...");
|
|
71
|
-
const installArgs = [
|
|
80
|
+
const installArgs = [
|
|
81
|
+
"install",
|
|
82
|
+
...(noCache ? ["--no-cache"] : []),
|
|
83
|
+
...(options.tts ? ["--tts"] : []),
|
|
84
|
+
];
|
|
72
85
|
const proc = Bun.spawnSync([binPath, ...installArgs], {
|
|
73
86
|
stdout: "pipe",
|
|
74
87
|
stderr: "pipe",
|
package/src/engine.ts
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
|
+
import { log } from "./log";
|
|
4
5
|
|
|
5
6
|
export interface LangDetectResult {
|
|
6
7
|
code: string;
|
|
7
8
|
confidence: number;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
const
|
|
11
|
+
const DEFAULT_ENGINE_BIN_PATH = join(homedir(), ".cache", "kesha", "engine", "bin", "kesha-engine");
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Path to the `kesha-engine` binary. Defaults to the install location under
|
|
15
|
+
* `~/.cache/kesha/engine/bin/`. The `KESHA_ENGINE_BIN` env var overrides — useful
|
|
16
|
+
* for running against a freshly-built engine during development or in e2e tests.
|
|
17
|
+
*/
|
|
12
18
|
export function getEngineBinPath(): string {
|
|
13
|
-
return
|
|
19
|
+
return process.env.KESHA_ENGINE_BIN ?? DEFAULT_ENGINE_BIN_PATH;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
export function isEngineInstalled(): boolean {
|
|
@@ -19,6 +25,8 @@ export function isEngineInstalled(): boolean {
|
|
|
19
25
|
|
|
20
26
|
async function runEngine(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
21
27
|
const binPath = getEngineBinPath();
|
|
28
|
+
const startedAt = performance.now();
|
|
29
|
+
log.debug(`spawn ${binPath} ${args.join(" ")}`);
|
|
22
30
|
const proc = Bun.spawn([binPath, ...args], {
|
|
23
31
|
stdout: "pipe",
|
|
24
32
|
stderr: "pipe",
|
|
@@ -30,6 +38,7 @@ async function runEngine(args: string[]): Promise<{ stdout: string; stderr: stri
|
|
|
30
38
|
proc.exited,
|
|
31
39
|
]);
|
|
32
40
|
|
|
41
|
+
log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms args=${JSON.stringify(args)}`);
|
|
33
42
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
|
34
43
|
}
|
|
35
44
|
|
package/src/lib.ts
CHANGED
|
@@ -4,6 +4,12 @@ import { downloadEngine } from "./engine-install";
|
|
|
4
4
|
|
|
5
5
|
export type { TranscribeOptions };
|
|
6
6
|
export { downloadEngine as downloadModel };
|
|
7
|
+
export { say, type SayOptions, SayError } from "./say";
|
|
8
|
+
|
|
9
|
+
/** Install Kokoro TTS models. Shorthand for `downloadModel({ tts: true })`. */
|
|
10
|
+
export async function downloadTts(noCache = false): Promise<void> {
|
|
11
|
+
await downloadEngine(noCache, undefined, { tts: true });
|
|
12
|
+
}
|
|
7
13
|
|
|
8
14
|
/** @deprecated Use `downloadModel` instead. */
|
|
9
15
|
export const downloadCoreML = downloadEngine;
|
package/src/log.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Debug mode (#148): when `KESHA_DEBUG` is truthy OR the caller has flipped
|
|
5
|
+
* `log.debugEnabled = true` (via `--debug`), `log.debug()` writes structured
|
|
6
|
+
* trace lines to stderr. Otherwise it's a no-op. Stdout is never touched.
|
|
7
|
+
*/
|
|
8
|
+
function envDebug(): boolean {
|
|
9
|
+
const v = process.env.KESHA_DEBUG;
|
|
10
|
+
return !!v && v !== "0" && v.toLowerCase() !== "false";
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
export const log = {
|
|
4
14
|
info: (msg: string) => console.log(msg),
|
|
5
15
|
success: (msg: string) => console.log(pc.green(msg)),
|
|
6
16
|
progress: (msg: string) => console.log(pc.cyan(msg)),
|
|
7
17
|
warn: (msg: string) => console.error(pc.yellow(msg)),
|
|
8
18
|
error: (msg: string) => console.error(pc.red(msg)),
|
|
19
|
+
|
|
20
|
+
debugEnabled: false,
|
|
21
|
+
debug(msg: string): void {
|
|
22
|
+
if (this.debugEnabled || envDebug()) {
|
|
23
|
+
console.error(pc.dim(`[debug] ${msg}`));
|
|
24
|
+
}
|
|
25
|
+
},
|
|
9
26
|
};
|
package/src/say.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { getEngineBinPath, isEngineInstalled } from "./engine";
|
|
2
|
+
import { log } from "./log";
|
|
3
|
+
|
|
4
|
+
export interface SayOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Text to synthesize. Required for programmatic callers — `say()` does not
|
|
7
|
+
* forward the host process's stdin. The CLI (`kesha say` with no positional
|
|
8
|
+
* arg) handles stdin separately before invoking `say()`.
|
|
9
|
+
*/
|
|
10
|
+
text?: string;
|
|
11
|
+
/** Voice id, e.g. `en-af_heart`. Defaults to engine default. */
|
|
12
|
+
voice?: string;
|
|
13
|
+
/** Override the voice's default espeak language code. */
|
|
14
|
+
lang?: string;
|
|
15
|
+
/** Write audio to this path instead of returning bytes. */
|
|
16
|
+
out?: string;
|
|
17
|
+
/** Speaking rate 0.5–2.0. */
|
|
18
|
+
rate?: number;
|
|
19
|
+
/** Parse `text` as SSML (`<speak>…<break time="500ms"/>…</speak>`). See issue #122. */
|
|
20
|
+
ssml?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Build the argv passed to `kesha-engine say` (pure, unit-testable). */
|
|
24
|
+
export function buildSayArgs(o: SayOptions): string[] {
|
|
25
|
+
const args: string[] = ["say"];
|
|
26
|
+
if (o.voice) args.push("--voice", o.voice);
|
|
27
|
+
if (o.lang) args.push("--lang", o.lang);
|
|
28
|
+
if (o.out) args.push("--out", o.out);
|
|
29
|
+
if (o.rate !== undefined && o.rate !== 1.0) args.push("--rate", String(o.rate));
|
|
30
|
+
if (o.ssml) args.push("--ssml");
|
|
31
|
+
if (o.text !== undefined && o.text.length > 0) args.push(o.text);
|
|
32
|
+
return args;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class SayError extends Error {
|
|
36
|
+
constructor(
|
|
37
|
+
message: string,
|
|
38
|
+
public readonly exitCode: number,
|
|
39
|
+
public readonly stderr: string,
|
|
40
|
+
) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "SayError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Synthesize speech. Returns raw WAV bytes. If `out` is provided in options,
|
|
48
|
+
* the engine writes to the file and this function returns an empty buffer.
|
|
49
|
+
*/
|
|
50
|
+
export async function say(opts: SayOptions): Promise<Uint8Array> {
|
|
51
|
+
if (!isEngineInstalled()) {
|
|
52
|
+
throw new SayError(
|
|
53
|
+
"kesha-engine not installed. run: kesha install",
|
|
54
|
+
1,
|
|
55
|
+
"",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const args = buildSayArgs({ ...opts, text: undefined });
|
|
59
|
+
const startedAt = performance.now();
|
|
60
|
+
log.debug(`spawn ${getEngineBinPath()} ${args.join(" ")} (text: ${opts.text?.length ?? 0} chars)`);
|
|
61
|
+
const proc = Bun.spawn([getEngineBinPath(), ...args], {
|
|
62
|
+
stdin: "pipe",
|
|
63
|
+
stdout: "pipe",
|
|
64
|
+
stderr: "pipe",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (opts.text !== undefined && opts.text.length > 0) {
|
|
68
|
+
proc.stdin.write(opts.text);
|
|
69
|
+
await proc.stdin.end();
|
|
70
|
+
} else {
|
|
71
|
+
await proc.stdin.end();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const [stdoutBuf, stderrText, exitCode] = await Promise.all([
|
|
75
|
+
new Response(proc.stdout).arrayBuffer(),
|
|
76
|
+
new Response(proc.stderr).text(),
|
|
77
|
+
proc.exited,
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms bytes=${stdoutBuf.byteLength}`);
|
|
81
|
+
if (exitCode !== 0) {
|
|
82
|
+
throw new SayError(
|
|
83
|
+
stderrText.trim() || `kesha-engine say exited ${exitCode}`,
|
|
84
|
+
exitCode,
|
|
85
|
+
stderrText,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return new Uint8Array(stdoutBuf);
|
|
89
|
+
}
|
package/src/status.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readdirSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
1
4
|
import { isEngineInstalled, getEngineBinPath, getEngineCapabilities } from "./engine";
|
|
2
5
|
import { log } from "./log";
|
|
3
6
|
import pc from "picocolors";
|
|
@@ -37,7 +40,48 @@ export async function showStatus(): Promise<void> {
|
|
|
37
40
|
log.info(formatStatusLine("Platform", `${process.platform} ${process.arch}`, true));
|
|
38
41
|
log.info("");
|
|
39
42
|
|
|
43
|
+
if (installed) {
|
|
44
|
+
const voices = listInstalledVoices();
|
|
45
|
+
if (voices.length > 0) {
|
|
46
|
+
log.info("TTS voices:");
|
|
47
|
+
for (const v of voices) {
|
|
48
|
+
log.info(` ${v}`);
|
|
49
|
+
}
|
|
50
|
+
log.info("");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
40
54
|
if (!installed) {
|
|
41
55
|
log.warn('Run "kesha install" to download the engine and models.');
|
|
42
56
|
}
|
|
43
57
|
}
|
|
58
|
+
|
|
59
|
+
function kesheCacheDir(): string {
|
|
60
|
+
return process.env.KESHA_CACHE_DIR ?? join(homedir(), ".cache", "kesha");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function listInstalledVoices(): string[] {
|
|
64
|
+
const cache = kesheCacheDir();
|
|
65
|
+
const voices: string[] = [];
|
|
66
|
+
try {
|
|
67
|
+
const kokoro = readdirSync(join(cache, "models", "kokoro-82m", "voices"));
|
|
68
|
+
for (const f of kokoro) {
|
|
69
|
+
if (f.endsWith(".bin")) voices.push(`en-${f.replace(/\.bin$/, "")}`);
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
/* Kokoro not installed */
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
// Piper RU files follow `ru_RU-<name>-<quality>.onnx` — report just `<name>`.
|
|
76
|
+
const piper = readdirSync(join(cache, "models", "piper-ru"));
|
|
77
|
+
for (const f of piper) {
|
|
78
|
+
if (!f.endsWith(".onnx")) continue;
|
|
79
|
+
const stem = f.replace(/\.onnx$/, "");
|
|
80
|
+
const name = stem.replace(/^ru_RU-/, "").split("-")[0];
|
|
81
|
+
if (name) voices.push(`ru-${name}`);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
/* Piper not installed */
|
|
85
|
+
}
|
|
86
|
+
return voices.sort();
|
|
87
|
+
}
|