@drakulavich/kesha-voice-kit 1.0.10 → 1.1.3
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 +43 -17
- package/package.json +2 -2
- package/src/cli.ts +127 -4
- package/src/engine-install.ts +15 -2
- package/src/engine.ts +7 -2
- package/src/lib.ts +6 -0
- package/src/say.ts +82 -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,39 @@ 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, MP3, and SSML are tracked in follow-up issues ([#122](https://github.com/drakulavich/kesha-voice-kit/issues/122)). Static-linking of `espeak-ng` to remove the system dep is [#124](https://github.com/drakulavich/kesha-voice-kit/issues/124).
|
|
73
97
|
|
|
74
|
-
**
|
|
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.
|
|
75
101
|
|
|
76
102
|
## What's Inside
|
|
77
103
|
|
|
@@ -87,7 +113,7 @@ All models run through `kesha-engine` — a Rust binary using [FluidAudio](https
|
|
|
87
113
|
|
|
88
114
|
## Performance
|
|
89
115
|
|
|
90
|
-
> **~
|
|
116
|
+
> **~15x faster than Whisper** on Apple Silicon (M3 Pro), **~2.5x faster** on CPU
|
|
91
117
|
|
|
92
118
|
Compared against Whisper `large-v3-turbo` — all engines auto-detect language.
|
|
93
119
|
|
|
@@ -148,4 +174,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
148
174
|
|
|
149
175
|
## License
|
|
150
176
|
|
|
151
|
-
Made with 💛🩵
|
|
177
|
+
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.
|
|
3
|
+
"version": "1.1.3",
|
|
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.
|
|
32
|
+
"version": "1.1.3"
|
|
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,6 +25,7 @@ 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 {
|
|
@@ -46,9 +48,26 @@ function resolveBackendFlag(coreml: boolean, onnx: boolean): string | undefined
|
|
|
46
48
|
return undefined;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
async function
|
|
51
|
+
async function askForStar() {
|
|
52
|
+
const gh = Bun.which("gh");
|
|
53
|
+
if (!gh) {
|
|
54
|
+
log.info("\nIf you enjoy Kesha Voice Kit, consider starring the repo:");
|
|
55
|
+
log.info(" https://github.com/drakulavich/kesha-voice-kit");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const authCheck = Bun.spawnSync([gh, "auth", "status"], { stdout: "ignore", stderr: "ignore" });
|
|
59
|
+
if (authCheck.exitCode !== 0) return;
|
|
60
|
+
const starred = Bun.spawnSync([gh, "api", "user/starred/drakulavich/kesha-voice-kit"], { stdout: "ignore", stderr: "ignore" });
|
|
61
|
+
if (starred.exitCode === 0) return; // already starred
|
|
62
|
+
log.info("\n⭐ If you enjoy Kesha Voice Kit, star it on GitHub:");
|
|
63
|
+
log.info(" https://github.com/drakulavich/kesha-voice-kit");
|
|
64
|
+
log.info(' Or run: gh api -X PUT /user/starred/drakulavich/kesha-voice-kit');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function performInstall(noCache: boolean, backend?: string, tts = false) {
|
|
50
68
|
try {
|
|
51
|
-
await downloadEngine(noCache, backend);
|
|
69
|
+
await downloadEngine(noCache, backend, { tts });
|
|
70
|
+
await askForStar();
|
|
52
71
|
} catch (err: unknown) {
|
|
53
72
|
const message = err instanceof Error ? err.message : String(err);
|
|
54
73
|
log.error(message);
|
|
@@ -77,13 +96,112 @@ export const installCommand = defineCommand({
|
|
|
77
96
|
description: "Re-download even if cached",
|
|
78
97
|
default: false,
|
|
79
98
|
},
|
|
99
|
+
tts: {
|
|
100
|
+
type: "boolean",
|
|
101
|
+
description: "Also install TTS models (Kokoro EN + Piper RU, ~390MB, requires espeak-ng on PATH)",
|
|
102
|
+
default: false,
|
|
103
|
+
},
|
|
80
104
|
},
|
|
81
105
|
async run({ args }: { args: InstallCommandArgs }) {
|
|
82
106
|
const backend = resolveBackendFlag(args.coreml, args.onnx);
|
|
83
|
-
await performInstall(args["no-cache"], backend);
|
|
107
|
+
await performInstall(args["no-cache"], backend, args.tts);
|
|
84
108
|
},
|
|
85
109
|
});
|
|
86
110
|
|
|
111
|
+
export const sayCommand = defineCommand({
|
|
112
|
+
meta: {
|
|
113
|
+
name: "say",
|
|
114
|
+
description: "Synthesize speech from text (TTS). Writes WAV to stdout (or --out file).",
|
|
115
|
+
},
|
|
116
|
+
args: {
|
|
117
|
+
text: { type: "positional", required: false, description: "Text to speak (stdin if omitted)" },
|
|
118
|
+
voice: { type: "string", description: "Voice id, e.g. en-af_heart" },
|
|
119
|
+
lang: { type: "string", description: "espeak language code (default en-us)" },
|
|
120
|
+
out: { type: "string", description: "Write WAV to file instead of stdout" },
|
|
121
|
+
rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
|
|
122
|
+
"list-voices": { type: "boolean", description: "List installed voices and exit" },
|
|
123
|
+
},
|
|
124
|
+
async run({ args }) {
|
|
125
|
+
if (args["list-voices"]) {
|
|
126
|
+
// The engine prints the list directly — just relay its stdout + exit code.
|
|
127
|
+
const { getEngineBinPath } = await import("./engine");
|
|
128
|
+
const proc = Bun.spawn([getEngineBinPath(), "say", "--list-voices"], {
|
|
129
|
+
stdout: "inherit",
|
|
130
|
+
stderr: "inherit",
|
|
131
|
+
});
|
|
132
|
+
process.exit(await proc.exited);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const inlineText = typeof args.text === "string" ? args.text : undefined;
|
|
136
|
+
const text = await resolveText(inlineText);
|
|
137
|
+
const explicitVoice = typeof args.voice === "string" ? args.voice : undefined;
|
|
138
|
+
const voice = explicitVoice ?? (await autoRouteVoice(text));
|
|
139
|
+
|
|
140
|
+
const opts = {
|
|
141
|
+
text,
|
|
142
|
+
voice,
|
|
143
|
+
lang: typeof args.lang === "string" ? args.lang : undefined,
|
|
144
|
+
out: typeof args.out === "string" ? args.out : undefined,
|
|
145
|
+
rate: args.rate ? Number(args.rate) : undefined,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const wav = await say(opts);
|
|
150
|
+
if (!opts.out) {
|
|
151
|
+
process.stdout.write(wav);
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err instanceof SayError) {
|
|
155
|
+
log.error(err.stderr.trim() || err.message);
|
|
156
|
+
process.exit(err.exitCode);
|
|
157
|
+
}
|
|
158
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
159
|
+
log.error(message);
|
|
160
|
+
process.exit(4);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/** Resolve the text to synthesize: inline positional, else read from stdin. */
|
|
166
|
+
async function resolveText(inline: string | undefined): Promise<string> {
|
|
167
|
+
if (inline !== undefined && inline.length > 0) return inline;
|
|
168
|
+
const chunks: Uint8Array[] = [];
|
|
169
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
170
|
+
chunks.push(chunk);
|
|
171
|
+
}
|
|
172
|
+
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
|
|
173
|
+
const merged = new Uint8Array(total);
|
|
174
|
+
let offset = 0;
|
|
175
|
+
for (const c of chunks) {
|
|
176
|
+
merged.set(c, offset);
|
|
177
|
+
offset += c.byteLength;
|
|
178
|
+
}
|
|
179
|
+
return new TextDecoder().decode(merged).trim();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Map a detected language code to a default voice id. Unknown / low-confidence → undefined. */
|
|
183
|
+
export function pickVoiceForLang(
|
|
184
|
+
code: string | undefined,
|
|
185
|
+
confidence: number,
|
|
186
|
+
): string | undefined {
|
|
187
|
+
if (!code || confidence < 0.5) return undefined;
|
|
188
|
+
switch (code) {
|
|
189
|
+
case "en":
|
|
190
|
+
return "en-af_heart";
|
|
191
|
+
case "ru":
|
|
192
|
+
return "ru-denis";
|
|
193
|
+
default:
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Run NLLanguageRecognizer (via engine) on the text and pick a default voice. */
|
|
199
|
+
async function autoRouteVoice(text: string): Promise<string | undefined> {
|
|
200
|
+
if (!text) return undefined;
|
|
201
|
+
const detected = await detectTextLanguageEngine(text);
|
|
202
|
+
return pickVoiceForLang(detected?.code, detected?.confidence ?? 0);
|
|
203
|
+
}
|
|
204
|
+
|
|
87
205
|
export const statusCommand = defineCommand({
|
|
88
206
|
meta: {
|
|
89
207
|
name: "status",
|
|
@@ -197,7 +315,7 @@ export const mainCommand = defineCommand({
|
|
|
197
315
|
},
|
|
198
316
|
});
|
|
199
317
|
|
|
200
|
-
const SUBCOMMANDS = ["install", "status"];
|
|
318
|
+
const SUBCOMMANDS = ["install", "status", "say"];
|
|
201
319
|
|
|
202
320
|
export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
|
|
203
321
|
const [firstArg, ...restArgs] = rawArgs;
|
|
@@ -212,6 +330,11 @@ export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
|
|
|
212
330
|
return;
|
|
213
331
|
}
|
|
214
332
|
|
|
333
|
+
if (firstArg === "say") {
|
|
334
|
+
await runMain(sayCommand, { rawArgs: restArgs });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
215
338
|
// Check for unknown subcommands (non-flag, non-file-path args)
|
|
216
339
|
if (firstArg && !firstArg.startsWith("-") && !firstArg.includes(".") && !firstArg.includes("/")) {
|
|
217
340
|
const suggestion = suggestCommand(firstArg, SUBCOMMANDS);
|
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
|
@@ -7,10 +7,15 @@ export interface LangDetectResult {
|
|
|
7
7
|
confidence: number;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const DEFAULT_ENGINE_BIN_PATH = join(homedir(), ".cache", "kesha", "engine", "bin", "kesha-engine");
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Path to the `kesha-engine` binary. Defaults to the install location under
|
|
14
|
+
* `~/.cache/kesha/engine/bin/`. The `KESHA_ENGINE_BIN` env var overrides — useful
|
|
15
|
+
* for running against a freshly-built engine during development or in e2e tests.
|
|
16
|
+
*/
|
|
12
17
|
export function getEngineBinPath(): string {
|
|
13
|
-
return
|
|
18
|
+
return process.env.KESHA_ENGINE_BIN ?? DEFAULT_ENGINE_BIN_PATH;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export function isEngineInstalled(): boolean {
|
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/say.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { getEngineBinPath, isEngineInstalled } from "./engine";
|
|
2
|
+
|
|
3
|
+
export interface SayOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Text to synthesize. Required for programmatic callers — `say()` does not
|
|
6
|
+
* forward the host process's stdin. The CLI (`kesha say` with no positional
|
|
7
|
+
* arg) handles stdin separately before invoking `say()`.
|
|
8
|
+
*/
|
|
9
|
+
text?: string;
|
|
10
|
+
/** Voice id, e.g. `en-af_heart`. Defaults to engine default. */
|
|
11
|
+
voice?: string;
|
|
12
|
+
/** Override the voice's default espeak language code. */
|
|
13
|
+
lang?: string;
|
|
14
|
+
/** Write audio to this path instead of returning bytes. */
|
|
15
|
+
out?: string;
|
|
16
|
+
/** Speaking rate 0.5–2.0. */
|
|
17
|
+
rate?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Build the argv passed to `kesha-engine say` (pure, unit-testable). */
|
|
21
|
+
export function buildSayArgs(o: SayOptions): string[] {
|
|
22
|
+
const args: string[] = ["say"];
|
|
23
|
+
if (o.voice) args.push("--voice", o.voice);
|
|
24
|
+
if (o.lang) args.push("--lang", o.lang);
|
|
25
|
+
if (o.out) args.push("--out", o.out);
|
|
26
|
+
if (o.rate !== undefined && o.rate !== 1.0) args.push("--rate", String(o.rate));
|
|
27
|
+
if (o.text !== undefined && o.text.length > 0) args.push(o.text);
|
|
28
|
+
return args;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class SayError extends Error {
|
|
32
|
+
constructor(
|
|
33
|
+
message: string,
|
|
34
|
+
public readonly exitCode: number,
|
|
35
|
+
public readonly stderr: string,
|
|
36
|
+
) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "SayError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Synthesize speech. Returns raw WAV bytes. If `out` is provided in options,
|
|
44
|
+
* the engine writes to the file and this function returns an empty buffer.
|
|
45
|
+
*/
|
|
46
|
+
export async function say(opts: SayOptions): Promise<Uint8Array> {
|
|
47
|
+
if (!isEngineInstalled()) {
|
|
48
|
+
throw new SayError(
|
|
49
|
+
"kesha-engine not installed. run: kesha install",
|
|
50
|
+
1,
|
|
51
|
+
"",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const args = buildSayArgs({ ...opts, text: undefined });
|
|
55
|
+
const proc = Bun.spawn([getEngineBinPath(), ...args], {
|
|
56
|
+
stdin: "pipe",
|
|
57
|
+
stdout: "pipe",
|
|
58
|
+
stderr: "pipe",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (opts.text !== undefined && opts.text.length > 0) {
|
|
62
|
+
proc.stdin.write(opts.text);
|
|
63
|
+
await proc.stdin.end();
|
|
64
|
+
} else {
|
|
65
|
+
await proc.stdin.end();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const [stdoutBuf, stderrText, exitCode] = await Promise.all([
|
|
69
|
+
new Response(proc.stdout).arrayBuffer(),
|
|
70
|
+
new Response(proc.stderr).text(),
|
|
71
|
+
proc.exited,
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
if (exitCode !== 0) {
|
|
75
|
+
throw new SayError(
|
|
76
|
+
stderrText.trim() || `kesha-engine say exited ${exitCode}`,
|
|
77
|
+
exitCode,
|
|
78
|
+
stderrText,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return new Uint8Array(stdoutBuf);
|
|
82
|
+
}
|
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
|
+
}
|