@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 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,39 @@ 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, 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
- **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.
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
- > **~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
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 💛🩵 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.10",
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
+ }