@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 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,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
- Stdout: transcript. Stderr: errors. Pipe-friendly.
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
- **Also available as `parakeet` command** (backward-compatible alias).
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
- > **~19x faster than Whisper** on Apple Silicon, **~2.5x faster** on CPU
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 💛🩵 Published under MIT License.
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.10",
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.2"
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 performInstall(noCache: boolean, backend?: string) {
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");
@@ -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
@@ -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 ENGINE_BIN_PATH = join(homedir(), ".cache", "kesha", "engine", "bin", "kesha-engine");
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 ENGINE_BIN_PATH;
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
+ }