@drakulavich/kesha-voice-kit 1.20.0 → 1.21.0-beta.1

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
@@ -146,6 +146,33 @@ Honored when `<prosody rate>` wraps the whole utterance. Mid-utterance prosody w
146
146
 
147
147
  macOS system voices, SSML, voice listing, and the full voice catalogue: [docs/tts.md](docs/tts.md).
148
148
 
149
+ ### Examples
150
+
151
+ ```bash
152
+ # Auto-detect language and pick a male default (en-am_michael / ru-vosk-m02)
153
+ kesha say "Hello, world" --out hello.wav
154
+ kesha say "Привет, мир" --out privet.wav
155
+
156
+ # Choose a specific voice
157
+ kesha say --voice en-am_michael "Voice in, text out." --out en.wav
158
+ kesha say --voice ru-vosk-m02 "Голос в текст." --out ru.wav
159
+
160
+ # List installed voices
161
+ kesha say --list-voices
162
+
163
+ # Output formats — inferred from the --out extension, or set with --format
164
+ kesha say "Lossless, plays in Safari/iOS" --out demo.flac # FLAC (v1.20.0+)
165
+ kesha say "Telegram-ready voice note" --format ogg-opus --out vn.ogg
166
+ kesha say "Stream raw WAV to stdout" > out.wav # default: WAV
167
+
168
+ # Speaking rate, 0.5×–2.0× (Vosk honors it on every platform)
169
+ kesha say --voice ru-vosk-m02 --rate 0.75 "Помедленнее." --out slow.flac
170
+ kesha say --voice ru-vosk-m02 --rate 1.5 "Побыстрее." --out fast.flac
171
+
172
+ # macOS system voice — zero model download
173
+ kesha say --voice macos-com.apple.voice.compact.ru-RU.Milena "Привет" --out milena.wav
174
+ ```
175
+
149
176
  ## Homebrew Install
150
177
 
151
178
  Homebrew installs the Bun-based CLI wrapper. Engine and model downloads remain
@@ -319,6 +346,26 @@ Audio decoding via [symphonia](https://github.com/pdeljanov/Symphonia) — WAV,
319
346
  - **Hermes Agent** — local STT/TTS through Hermes command providers. Setup: [docs/hermes.md](docs/hermes.md).
320
347
  - **Raycast** (macOS) — transcribe selected audio & speak clipboard from the launcher. Source + install: [`raycast/`](raycast/).
321
348
 
349
+ ## MCP server
350
+
351
+ `kesha mcp` runs a local Model Context Protocol server over stdio, exposing
352
+ `transcribe_audio`, `synthesize_speech`, `list_voices`, and `list_languages` to
353
+ any MCP client. Models are never auto-downloaded — tools fail with a
354
+ `kesha install` / `kesha install --tts` hint when missing.
355
+
356
+ Add to your client config:
357
+
358
+ ```json
359
+ { "mcpServers": { "kesha": { "command": "kesha", "args": ["mcp"] } } }
360
+ ```
361
+
362
+ - **Claude Desktop:** `claude_desktop_config.json`
363
+ - **Claude Code:** `claude mcp add kesha -- kesha mcp`
364
+ - **Cursor:** `.cursor/mcp.json`
365
+
366
+ If a tool returns "models not installed", run `kesha install` (ASR) or
367
+ `kesha install --tts` (TTS) once, then retry.
368
+
322
369
  ## Programmatic API
323
370
 
324
371
  ```typescript
@@ -388,7 +435,13 @@ state and retention.
388
435
 
389
436
  ## Contributing
390
437
 
391
- See [CONTRIBUTING.md](CONTRIBUTING.md).
438
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Project docs:
439
+
440
+ - [Architecture](docs/architecture.md) — TS CLI ↔ Rust engine ↔ ASR/TTS backends, model cache, where tests live
441
+ - [Use cases](docs/use-cases.md) — copy-paste recipes (transcribe a meeting, speak from OpenClaw, run offline, move the cache)
442
+ - [Roadmap](ROADMAP.md) — Now / Next / Later
443
+ - [Decision log](docs/decision-log.md) — why the platform/model choices were made (and reversed)
444
+ - Dev setup: `make dev-setup` (Bun, Rust, nextest, platform libs)
392
445
 
393
446
  ## License
394
447
 
@@ -6,7 +6,7 @@ _kesha_completion() {
6
6
  COMPREPLY=()
7
7
  cur="${COMP_WORDS[COMP_CWORD]}"
8
8
  command="${COMP_WORDS[1]}"
9
- commands="completions doctor init install logs manpage record say stats status support-bundle"
9
+ commands="completions doctor init install logs manpage mcp record say stats status support-bundle"
10
10
 
11
11
  if [[ "$COMP_CWORD" -eq 1 ]]; then
12
12
  if [[ "$cur" == -* ]]; then
@@ -24,6 +24,7 @@ _kesha_completion() {
24
24
  install) opts="--help -h --coreml --onnx --no-cache --plan --tts --vad --diarize" ;;
25
25
  logs) opts="--help -h --json" ;;
26
26
  manpage) opts="--help -h" ;;
27
+ mcp) opts="--help -h" ;;
27
28
  record) opts="--help -h --out --max-seconds --debug" ;;
28
29
  say) opts="--help -h --voice --lang --out --rate --list-voices --ssml --format --bitrate --sample-rate --no-expand-abbrev --verbose --debug" ;;
29
30
  stats) opts="--help -h --format" ;;
@@ -6,6 +6,7 @@ complete -c kesha -n '__fish_use_subcommand' -a 'init' -d 'Interactive setup gui
6
6
  complete -c kesha -n '__fish_use_subcommand' -a 'install' -d 'Download inference engine and models'
7
7
  complete -c kesha -n '__fish_use_subcommand' -a 'logs' -d 'Manage local privacy-safe diagnostic logs'
8
8
  complete -c kesha -n '__fish_use_subcommand' -a 'manpage' -d 'Print the kesha(1) manpage'
9
+ complete -c kesha -n '__fish_use_subcommand' -a 'mcp' -d 'Run a Model Context Protocol server over stdio (transcribe_audio, synthesize_speech, list_voices, list_languages). Configure an MCP client with: { command: \'kesha\', args: [\'mcp\'] }.'
9
10
  complete -c kesha -n '__fish_use_subcommand' -a 'record' -d 'Record microphone audio to a WAV file'
10
11
  complete -c kesha -n '__fish_use_subcommand' -a 'say' -d 'Synthesize speech from text (TTS). Writes audio to stdout (or --out file). Defaults to WAV; use --format ogg-opus for messenger-ready voice notes.'
11
12
  complete -c kesha -n '__fish_use_subcommand' -a 'stats' -d 'Manage local anonymous Kesha Stats'
@@ -56,6 +57,8 @@ complete -c kesha -n '__fish_seen_subcommand_from logs' -s h -d 'Show help'
56
57
  complete -c kesha -n '__fish_seen_subcommand_from logs' -l json -d 'Output status as JSON'
57
58
  complete -c kesha -n '__fish_seen_subcommand_from manpage' -l help -d 'Show help'
58
59
  complete -c kesha -n '__fish_seen_subcommand_from manpage' -s h -d 'Show help'
60
+ complete -c kesha -n '__fish_seen_subcommand_from mcp' -l help -d 'Show help'
61
+ complete -c kesha -n '__fish_seen_subcommand_from mcp' -s h -d 'Show help'
59
62
  complete -c kesha -n '__fish_seen_subcommand_from record' -l help -d 'Show help'
60
63
  complete -c kesha -n '__fish_seen_subcommand_from record' -s h -d 'Show help'
61
64
  complete -c kesha -n '__fish_seen_subcommand_from record' -l out -r -d 'Write recorded WAV audio to this path'
@@ -10,6 +10,7 @@ _kesha() {
10
10
  'install:Download inference engine and models'
11
11
  'logs:Manage local privacy-safe diagnostic logs'
12
12
  'manpage:Print the kesha(1) manpage'
13
+ 'mcp:Run a Model Context Protocol server over stdio (transcribe_audio, synthesize_speech, list_voices, list_languages). Configure an MCP client with: { command: '\''kesha'\'', args: \['\''mcp'\''\] }.'
13
14
  'record:Record microphone audio to a WAV file'
14
15
  'say:Synthesize speech from text (TTS). Writes audio to stdout (or --out file). Defaults to WAV; use --format ogg-opus for messenger-ready voice notes.'
15
16
  'stats:Manage local anonymous Kesha Stats'
@@ -83,6 +84,10 @@ _kesha() {
83
84
  _arguments '--help[Show help]' \
84
85
  '-h[Show help]'
85
86
  ;;
87
+ mcp)
88
+ _arguments '--help[Show help]' \
89
+ '-h[Show help]'
90
+ ;;
86
91
  record)
87
92
  _arguments '--help[Show help]' \
88
93
  '-h[Show help]' \
package/man/kesha.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH KESHA 1 "May 2026" "kesha 1.20.0" "User Commands"
1
+ .TH KESHA 1 "May 2026" "kesha 1.21.0-beta.1" "User Commands"
2
2
  .SH NAME
3
3
  kesha \- open-source local voice toolkit
4
4
  .SH SYNOPSIS
@@ -29,6 +29,9 @@ Manage local privacy\-safe diagnostic logs
29
29
  .B manpage
30
30
  Print the kesha(1) manpage
31
31
  .TP
32
+ .B mcp
33
+ Run a Model Context Protocol server over stdio (transcribe_audio, synthesize_speech, list_voices, list_languages). Configure an MCP client with: { command: 'kesha', args: ['mcp'] }.
34
+ .TP
32
35
  .B record
33
36
  Record microphone audio to a WAV file
34
37
  .TP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.20.0",
3
+ "version": "1.21.0-beta.1",
4
4
  "description": "Give your local tools a voice: fast STT in 25 languages (~19x faster than Whisper on Apple Silicon, ONNX CPU fallback), TTS via Kokoro/Vosk/macOS voices, VAD + 107-language detection. Rust engine, Bun CLI, OpenClaw skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  ]
33
33
  },
34
34
  "keshaEngine": {
35
- "version": "1.20.0"
35
+ "version": "1.21.0-beta.1"
36
36
  },
37
37
  "scripts": {
38
38
  "test": "bun test",
@@ -84,10 +84,12 @@
84
84
  "yaml": "^2.9.0"
85
85
  },
86
86
  "dependencies": {
87
+ "@modelcontextprotocol/sdk": "^1.29.0",
87
88
  "@toon-format/toon": "^2.1.0",
88
89
  "citty": "^0.2.2",
89
90
  "fastest-levenshtein": "^1.0.16",
90
91
  "picocolors": "^1.1.1",
91
- "tinyld": "^1.3.4"
92
+ "tinyld": "^1.3.4",
93
+ "zod": "^4.4.3"
92
94
  }
93
95
  }
@@ -14,6 +14,7 @@ import { statsCommand } from "./stats";
14
14
  import { statusCommand } from "./status";
15
15
  import { supportBundleCommand } from "./support-bundle";
16
16
  import { mainCommand } from "./main";
17
+ import { mcpCommand } from "./mcp";
17
18
 
18
19
  // Single source of truth: keyed lookup also feeds the `did you mean` suggester.
19
20
  // `CommandDef<any>` is intentional — citty's generic is invariant in the args
@@ -31,6 +32,7 @@ const SUBCOMMANDS: Record<string, CommandDef<any>> = {
31
32
  "support-bundle": supportBundleCommand,
32
33
  completions: completionsCommand,
33
34
  manpage: manpageCommand,
35
+ mcp: mcpCommand,
34
36
  };
35
37
 
36
38
  function isPathLike(arg: string): boolean {
package/src/cli/mcp.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { defineCommand } from "citty";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createKeshaMcpServer } from "../mcp/server";
4
+
5
+ export const mcpCommand = defineCommand({
6
+ meta: {
7
+ name: "mcp",
8
+ description:
9
+ "Run a Model Context Protocol server over stdio (transcribe_audio, synthesize_speech, list_voices, list_languages). " +
10
+ "Configure an MCP client with: { command: 'kesha', args: ['mcp'] }.",
11
+ },
12
+ async run() {
13
+ const server = createKeshaMcpServer();
14
+ const transport = new StdioServerTransport();
15
+ await server.connect(transport);
16
+ },
17
+ });
@@ -0,0 +1,47 @@
1
+ import { tmpdir } from "os";
2
+ import { join } from "path";
3
+ import { mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
4
+ import { randomUUID } from "crypto";
5
+ import type { SayFormat } from "../synth";
6
+
7
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000;
8
+
9
+ export function audioDir(): string {
10
+ return join(tmpdir(), "kesha-mcp");
11
+ }
12
+
13
+ function extFor(format: SayFormat): string {
14
+ switch (format) {
15
+ case "ogg-opus":
16
+ return "ogg";
17
+ case "flac":
18
+ return "flac";
19
+ case "wav":
20
+ default:
21
+ return "wav";
22
+ }
23
+ }
24
+
25
+ export function allocAudioPath(format: SayFormat): string {
26
+ mkdirSync(audioDir(), { recursive: true, mode: 0o700 });
27
+ return join(audioDir(), `${randomUUID()}.${extFor(format)}`);
28
+ }
29
+
30
+ export function sweepOldAudio(): void {
31
+ const dir = audioDir();
32
+ let entries: string[];
33
+ try {
34
+ entries = readdirSync(dir);
35
+ } catch {
36
+ return;
37
+ }
38
+ const cutoff = Date.now() - MAX_AGE_MS;
39
+ for (const name of entries) {
40
+ const p = join(dir, name);
41
+ try {
42
+ if (statSync(p).mtimeMs < cutoff) unlinkSync(p);
43
+ } catch {
44
+ // best-effort: race with another session or perms — ignore
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,11 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { packageVersion } from "../package-info";
3
+ import { sweepOldAudio } from "./audio-output";
4
+ import { registerTools } from "./tools";
5
+
6
+ export function createKeshaMcpServer(): McpServer {
7
+ sweepOldAudio();
8
+ const server = new McpServer({ name: "kesha-voice-kit", version: packageVersion });
9
+ registerTools(server);
10
+ return server;
11
+ }
@@ -0,0 +1,200 @@
1
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { chmodSync, existsSync, readFileSync, statSync } from "fs";
4
+ import { basename, join } from "path";
5
+ import { transcribe, transcribeWithTimestamps } from "../lib";
6
+ import { listVoices, aggregateLanguages } from "./voices";
7
+ import { say, type SayFormat } from "../synth";
8
+ import { allocAudioPath, audioDir } from "./audio-output";
9
+
10
+ export function registerTools(server: McpServer): void {
11
+ function mimeForExt(file: string): string {
12
+ if (file.endsWith(".flac")) return "audio/flac";
13
+ if (file.endsWith(".ogg")) return "audio/ogg";
14
+ return "audio/wav";
15
+ }
16
+
17
+ server.registerResource(
18
+ "synthesized-audio",
19
+ new ResourceTemplate("kesha-audio://{file}", { list: undefined }),
20
+ { title: "Synthesized audio", description: "WAV/OGG/FLAC produced by synthesize_speech." },
21
+ async (uri, { file }) => {
22
+ const name = basename(String(file)); // sandbox: reject path traversal
23
+ const filePath = join(audioDir(), name);
24
+ let bytes: Buffer;
25
+ try {
26
+ bytes = readFileSync(filePath);
27
+ } catch {
28
+ throw new Error(`synthesized-audio: file not found or already swept: ${name}`);
29
+ }
30
+ return { contents: [{ uri: uri.href, mimeType: mimeForExt(name), blob: bytes.toString("base64") }] };
31
+ },
32
+ );
33
+
34
+ server.registerTool(
35
+ "synthesize_speech",
36
+ {
37
+ title: "Synthesize speech",
38
+ description:
39
+ "Synthesize speech from text into an audio file and return a resource link. " +
40
+ "Omit voice to auto-route by language (male defaults en-am_michael / ru-vosk-m02).",
41
+ inputSchema: {
42
+ text: z.string().min(1).describe("Text to speak"),
43
+ voice: z.string().optional().describe("Voice id, e.g. en-am_michael"),
44
+ rate: z.number().optional().describe("Speaking rate 0.5-2.0"),
45
+ format: z.enum(["wav", "ogg-opus", "flac"]).optional().describe("Output format (default wav)"),
46
+ },
47
+ outputSchema: {
48
+ uri: z.string(),
49
+ path: z.string(),
50
+ format: z.string(),
51
+ voice: z.string(),
52
+ bytes: z.number(),
53
+ },
54
+ annotations: { readOnlyHint: false, openWorldHint: false },
55
+ },
56
+ async ({ text, voice, rate, format }, extra) => {
57
+ if (extra.signal?.aborted) {
58
+ return { isError: true, content: [{ type: "text" as const, text: "request cancelled" }] };
59
+ }
60
+ if (rate !== undefined && !(rate >= 0.5 && rate <= 2.0)) {
61
+ return { isError: true, content: [{ type: "text" as const, text: `rate ${rate} out of range (0.5-2.0)` }] };
62
+ }
63
+ const fmt: SayFormat = format ?? "wav";
64
+ const outPath = allocAudioPath(fmt);
65
+ try {
66
+ await say({ text, voice, rate, format: fmt, out: outPath });
67
+ chmodSync(outPath, 0o600);
68
+ const bytes = statSync(outPath).size;
69
+ const file = basename(outPath);
70
+ const uri = `kesha-audio://${file}`;
71
+ const mimeType = mimeForExt(file);
72
+ const resolvedVoice = voice ?? "(auto)";
73
+ return {
74
+ content: [
75
+ { type: "resource_link" as const, uri, name: file, mimeType },
76
+ { type: "text" as const, text: `Synthesized ${bytes} bytes (voice=${resolvedVoice}, format=${fmt}); read via resources/read ${uri}.` },
77
+ ],
78
+ structuredContent: { uri, path: outPath, format: fmt, voice: resolvedVoice, bytes },
79
+ };
80
+ } catch (err) {
81
+ return { isError: true, content: [{ type: "text" as const, text: toToolError(err) }] };
82
+ }
83
+ },
84
+ );
85
+
86
+ server.registerTool(
87
+ "transcribe_audio",
88
+ {
89
+ title: "Transcribe audio",
90
+ description: "Transcribe a local audio file to text. Set timestamps for segment timings.",
91
+ inputSchema: {
92
+ path: z.string().describe("Absolute or relative path to a local audio file"),
93
+ timestamps: z.boolean().optional().describe("Return per-segment start/end times"),
94
+ },
95
+ outputSchema: {
96
+ text: z.string(),
97
+ segments: z.array(
98
+ z.object({
99
+ text: z.string(),
100
+ start: z.number(),
101
+ end: z.number(),
102
+ speaker: z.number().optional(),
103
+ }),
104
+ ),
105
+ },
106
+ annotations: { readOnlyHint: true, openWorldHint: false },
107
+ },
108
+ async ({ path, timestamps }, extra) => {
109
+ if (extra.signal?.aborted) {
110
+ return { isError: true, content: [{ type: "text" as const, text: "request cancelled" }] };
111
+ }
112
+ if (!existsSync(path)) {
113
+ return { isError: true, content: [{ type: "text" as const, text: `File not found: ${path}` }] };
114
+ }
115
+ try {
116
+ if (timestamps) {
117
+ const out = await transcribeWithTimestamps(path);
118
+ return {
119
+ content: [{ type: "text" as const, text: out.text }],
120
+ structuredContent: { text: out.text, segments: out.segments ?? [] },
121
+ };
122
+ }
123
+ const text = await transcribe(path);
124
+ return {
125
+ content: [{ type: "text" as const, text: text }],
126
+ structuredContent: { text, segments: [] },
127
+ };
128
+ } catch (err) {
129
+ return { isError: true, content: [{ type: "text" as const, text: toToolError(err) }] };
130
+ }
131
+ },
132
+ );
133
+
134
+ server.registerTool(
135
+ "list_voices",
136
+ {
137
+ title: "List voices",
138
+ description: "List installed TTS voices with their engine and language.",
139
+ inputSchema: {},
140
+ outputSchema: {
141
+ voices: z.array(
142
+ z.object({
143
+ voiceId: z.string(),
144
+ modelId: z.enum(["kokoro", "vosk", "avspeech", "unknown"]),
145
+ modelName: z.string(),
146
+ languageCode: z.string(),
147
+ languageName: z.string(),
148
+ gender: z.enum(["male", "female"]).nullable(),
149
+ }),
150
+ ),
151
+ },
152
+ annotations: { readOnlyHint: true, openWorldHint: false },
153
+ },
154
+ async () => {
155
+ try {
156
+ const voices = await listVoices();
157
+ return {
158
+ content: [{ type: "text" as const, text: `${voices.length} voices installed.` }],
159
+ structuredContent: { voices },
160
+ };
161
+ } catch (err) {
162
+ return { isError: true, content: [{ type: "text" as const, text: `list_voices failed: ${toToolError(err)}` }] };
163
+ }
164
+ },
165
+ );
166
+
167
+ server.registerTool(
168
+ "list_languages",
169
+ {
170
+ title: "List languages",
171
+ description: "List languages available for synthesis, derived from installed voices.",
172
+ inputSchema: {},
173
+ outputSchema: {
174
+ languages: z.array(
175
+ z.object({
176
+ languageCode: z.string(),
177
+ languageName: z.string(),
178
+ voiceCount: z.number(),
179
+ }),
180
+ ),
181
+ },
182
+ annotations: { readOnlyHint: true, openWorldHint: false },
183
+ },
184
+ async () => {
185
+ try {
186
+ const languages = aggregateLanguages(await listVoices());
187
+ return {
188
+ content: [{ type: "text" as const, text: `${languages.length} languages available.` }],
189
+ structuredContent: { languages },
190
+ };
191
+ } catch (err) {
192
+ return { isError: true, content: [{ type: "text" as const, text: `list_languages failed: ${toToolError(err)}` }] };
193
+ }
194
+ },
195
+ );
196
+ }
197
+
198
+ function toToolError(err: unknown): string {
199
+ return err instanceof Error ? err.message : String(err);
200
+ }
@@ -0,0 +1,118 @@
1
+ import { getEngineBinPath } from "../engine";
2
+
3
+ export interface VoiceInfo {
4
+ voiceId: string;
5
+ modelId: "kokoro" | "vosk" | "avspeech" | "unknown";
6
+ modelName: string;
7
+ languageCode: string;
8
+ languageName: string;
9
+ gender: "male" | "female" | null;
10
+ }
11
+
12
+ export interface LanguageInfo {
13
+ languageCode: string;
14
+ languageName: string;
15
+ voiceCount: number;
16
+ }
17
+
18
+ const langNames = new Intl.DisplayNames(["en"], { type: "language" });
19
+
20
+ function langNameFor(code: string): string {
21
+ if (!code) return "Unknown";
22
+ try {
23
+ const name = langNames.of(code);
24
+ if (!name || name === code) return code;
25
+ return name;
26
+ } catch {
27
+ return code;
28
+ }
29
+ }
30
+
31
+ function parseVoiceInfo(id: string): VoiceInfo {
32
+ if (id.startsWith("ru-vosk-")) {
33
+ const suffix = id.slice("ru-vosk-".length); // e.g. "m02" or "f01"
34
+ const genderChar = suffix[0];
35
+ const gender: "male" | "female" | null =
36
+ genderChar === "m" ? "male" : genderChar === "f" ? "female" : null;
37
+ return {
38
+ voiceId: id,
39
+ modelId: "vosk",
40
+ modelName: "Vosk-TTS",
41
+ languageCode: "ru",
42
+ languageName: langNameFor("ru"),
43
+ gender,
44
+ };
45
+ }
46
+
47
+ if (id.startsWith("en-") && /^[ab][fm]_/.test(id.slice(3))) {
48
+ const suffix = id.slice("en-".length); // e.g. "am_michael" or "bf_emma"
49
+ const accent = suffix[0]; // 'a' = American, 'b' = British
50
+ const genderChar = suffix[1]; // 'f' = female, 'm' = male
51
+ const languageCode = accent === "a" ? "en-US" : "en-GB";
52
+ const gender: "male" | "female" = genderChar === "f" ? "female" : "male";
53
+ return {
54
+ voiceId: id,
55
+ modelId: "kokoro",
56
+ modelName: "Kokoro-82M",
57
+ languageCode,
58
+ languageName: langNameFor(languageCode),
59
+ gender,
60
+ };
61
+ }
62
+
63
+ if (id.startsWith("macos-")) {
64
+ const m = id.match(/[a-z]{2}-[A-Z]{2}/);
65
+ const languageCode = m ? m[0] : "";
66
+ return {
67
+ voiceId: id,
68
+ modelId: "avspeech",
69
+ modelName: "macOS AVSpeech",
70
+ languageCode,
71
+ languageName: langNameFor(languageCode),
72
+ gender: null,
73
+ };
74
+ }
75
+
76
+ return {
77
+ voiceId: id,
78
+ modelId: "unknown",
79
+ modelName: "Unknown",
80
+ languageCode: "",
81
+ languageName: "Unknown",
82
+ gender: null,
83
+ };
84
+ }
85
+
86
+ export function parseVoiceLines(text: string): VoiceInfo[] {
87
+ return text
88
+ .split("\n")
89
+ .map((l) => l.trim())
90
+ .filter((l) => l.length > 0)
91
+ .map((id) => parseVoiceInfo(id));
92
+ }
93
+
94
+ export async function listVoices(): Promise<VoiceInfo[]> {
95
+ const proc = Bun.spawn([getEngineBinPath(), "say", "--list-voices"], {
96
+ stdout: "pipe",
97
+ stderr: "pipe",
98
+ });
99
+ const [out, err, code] = await Promise.all([
100
+ new Response(proc.stdout).text(),
101
+ new Response(proc.stderr).text(),
102
+ proc.exited,
103
+ ]);
104
+ if (code !== 0) {
105
+ throw new Error(`engine list-voices failed (exit ${code}): ${err.trim()}`);
106
+ }
107
+ return parseVoiceLines(out);
108
+ }
109
+
110
+ export function aggregateLanguages(voices: VoiceInfo[]): LanguageInfo[] {
111
+ const map = new Map<string, LanguageInfo>();
112
+ for (const v of voices) {
113
+ const existing = map.get(v.languageCode);
114
+ if (existing) existing.voiceCount++;
115
+ else map.set(v.languageCode, { languageCode: v.languageCode, languageName: v.languageName, voiceCount: 1 });
116
+ }
117
+ return [...map.values()].sort((a, b) => a.languageCode.localeCompare(b.languageCode));
118
+ }
@@ -6,6 +6,7 @@ import { installCommand } from "./cli/install";
6
6
  import { logsCommand } from "./cli/logs";
7
7
  import { mainCommand } from "./cli/main";
8
8
  import { manpageCommand } from "./cli/manpage";
9
+ import { mcpCommand } from "./cli/mcp";
9
10
  import { recordCommand } from "./cli/record";
10
11
  import { sayCommand } from "./cli/say";
11
12
  import { statsCommand } from "./cli/stats";
@@ -50,6 +51,7 @@ const CLI_COMMANDS: CliCommand[] = [
50
51
  { name: "install", command: installCommand },
51
52
  { name: "logs", command: logsCommand },
52
53
  { name: "manpage", command: manpageCommand },
54
+ { name: "mcp", command: mcpCommand },
53
55
  { name: "record", command: recordCommand },
54
56
  { name: "say", command: sayCommand },
55
57
  { name: "stats", command: statsCommand },