@drakulavich/kesha-voice-kit 1.0.9 → 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 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, ~19x faster than Whisper on Apple Silicon, ~2.5x on CPU
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,46 +34,70 @@ 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
37
  ```bash
36
- bun add -g @drakulavich/kesha-voice-kit
37
- kesha install
38
+ bun add -g @drakulavich/kesha-voice-kit && kesha install
39
+ openclaw plugins install @drakulavich/kesha-voice-kit
40
+ openclaw config set tools.media.audio.models \
41
+ '[{"type":"cli","command":"kesha","args":["--format","transcript","{{MediaPath}}"],"timeoutSeconds":15}]'
38
42
  ```
39
43
 
40
- **Then register the OpenClaw plugin:**
44
+ > If audio transcription is not already enabled: `openclaw config set tools.media.audio.enabled true`
45
+
46
+ Your agent receives a voice message in Telegram/WhatsApp/Slack, Kesha transcribes it locally, and the agent sees enriched context:
41
47
 
42
- ```bash
43
- openclaw plugins install @drakulavich/kesha-voice-kit
48
+ ```
49
+ Таити, Таити! Не были мы ни в какой Таити! Нас и тут неплохо кормят.
50
+ [lang: ru, confidence: 1.00]
44
51
  ```
45
52
 
46
- OpenClaw will auto-detect [`openclaw.plugin.json`](./openclaw.plugin.json) and load [`openclaw-plugin.cjs`](./openclaw-plugin.cjs), which registers `kesha-voice-kit` as a media-understanding provider with `autoPriority.audio = 50`. Once you enable audio transcription (`tools.media.audio.enabled: true`) in your OpenClaw config, Kesha becomes the default provider — higher priority than cloud providers like Groq (20).
53
+ Manage the plugin with `openclaw plugins list`, `openclaw plugins disable kesha-voice-kit`, or `openclaw plugins uninstall kesha-voice-kit`.
47
54
 
48
- Manage the plugin afterwards with `openclaw plugins list`, `openclaw plugins disable kesha-voice-kit`, or `openclaw plugins uninstall kesha-voice-kit`.
55
+ ## CLI Tools
49
56
 
50
- Your agent receives a voice message in Telegram/WhatsApp/Slack, Kesha transcribes it locally, and the transcript flows back into the agent loop:
57
+ ```bash
58
+ kesha install # download engine and models
59
+ kesha audio.ogg # transcribe (plain text)
60
+ kesha --format transcript audio.ogg # text + language/confidence
61
+ kesha --format json audio.ogg # full JSON with lang fields
62
+ kesha --json audio.ogg # alias for --format json
63
+ kesha --verbose audio.ogg # show language detection details
64
+ kesha --lang en audio.ogg # warn if detected language differs
65
+ kesha status # show installed backend info
66
+ ```
67
+
68
+ Multiple files — headers per file, like `head`:
69
+
70
+ ```bash
71
+ $ kesha freedom.ogg tahiti.ogg
72
+ === freedom.ogg ===
73
+ Свободу попугаям! Свободу!
51
74
 
52
- ```json
53
- {
54
- "text": "Привет, как дела?",
55
- "model": "parakeet-tdt-0.6b-v3"
56
- }
75
+ === tahiti.ogg ===
76
+ Таити, Таити! Не были мы ни в какой Таити! Нас и тут неплохо кормят.
57
77
  ```
58
78
 
59
- ## CLI Tools
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.
60
84
 
61
85
  ```bash
62
- kesha install # download engine and models
63
- kesha audio.ogg # transcribe
64
- kesha --json audio.ogg # JSON output with language info
65
- kesha --verbose audio.ogg # show language detection details
66
- kesha --lang en audio.ogg # warn if detected language differs
67
- kesha status # show installed backend info
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
68
94
  ```
69
95
 
70
- Stdout: transcript. Stderr: errors. Pipe-friendly.
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).
71
97
 
72
- **Also available as `parakeet` command** (backward-compatible alias).
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.
73
101
 
74
102
  ## What's Inside
75
103
 
@@ -85,7 +113,7 @@ All models run through `kesha-engine` — a Rust binary using [FluidAudio](https
85
113
 
86
114
  ## Performance
87
115
 
88
- > **~19x faster than Whisper** on Apple Silicon, **~2.5x faster** on CPU
116
+ > **~15x faster than Whisper** on Apple Silicon (M3 Pro), **~2.5x faster** on CPU
89
117
 
90
118
  Compared against Whisper `large-v3-turbo` — all engines auto-detect language.
91
119
 
@@ -146,4 +174,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
146
174
 
147
175
  ## License
148
176
 
149
- Made with 💛🩵 Published under MIT License.
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.0.9",
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.0.2"
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 performInstall(noCache: boolean, backend?: string) {
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);
@@ -17,7 +17,16 @@ function getEngineBinaryName(): string {
17
17
  throw new Error(`Unsupported platform: ${platform} ${arch}`);
18
18
  }
19
19
 
20
- export async function downloadEngine(noCache = false, backend?: string): Promise<string> {
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 = ["install", ...(noCache ? ["--no-cache"] : [])];
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 ENGINE_BIN_PATH = join(homedir(), ".cache", "kesha", "engine", "bin", "kesha-engine");
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 ENGINE_BIN_PATH;
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
+ }