@drakulavich/kesha-voice-kit 1.19.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
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/assets/logo.png" alt="Kesha Voice Kit" width="200">
2
+ <img src="https://raw.githubusercontent.com/drakulavich/kesha-voice-kit/main/docs/assets/logo.png" alt="Kesha Voice Kit" width="200">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">Kesha Voice Kit</h1>
@@ -21,7 +21,7 @@
21
21
  See [Product positioning](docs/product-positioning.md) for supported workflows, non-goals, maturity labels, and the platform matrix.
22
22
 
23
23
  <p align="center">
24
- <img src="./demo.gif" alt="kesha demo — English + Russian transcription with automatic language detection" width="800">
24
+ <img src="https://raw.githubusercontent.com/drakulavich/kesha-voice-kit/main/demo.gif" alt="kesha demo — English + Russian transcription with automatic language detection" width="800">
25
25
  </p>
26
26
 
27
27
  ## Quick Start
@@ -104,6 +104,16 @@ kesha say "Hello, world" > hello.wav
104
104
  kesha say "Привет, мир" > privet.wav # auto-routes (Milena on darwin, ru-vosk-m02 elsewhere)
105
105
  ```
106
106
 
107
+ **Output formats** (`--format`, or inferred from `--out` extension):
108
+
109
+ ```bash
110
+ kesha say "Hello" --out hi.wav # WAV (default, uncompressed)
111
+ kesha say "Hello" --format ogg-opus --out hi.ogg # OGG/Opus — messenger voice notes (Telegram/WhatsApp/Signal)
112
+ kesha say "Hello" --format flac --out hi.flac # FLAC — lossless, royalty-free, plays in every browser incl. Safari/iOS
113
+ ```
114
+
115
+ FLAC is the format for web-embeddable samples: it plays natively in all modern browsers (including Safari/iOS, where OGG/Opus does not), stays royalty-free like Opus, and keeps the engine's native sample rate (no resample). `--bitrate` / `--sample-rate` apply only to `--format ogg-opus`.
116
+
107
117
  **Russian abbreviations** (`ru-vosk-*`): all-uppercase Cyrillic 2-5-char tokens auto-expand letter-by-letter when not pronounceable as a Russian syllable (ФСБ → "эф-эс-бэ", ВОЗ → "воз"). Disable with `--no-expand-abbrev`. See [docs/tts.md#russian-abbreviation-auto-expansion](docs/tts.md#russian-abbreviation-auto-expansion).
108
118
 
109
119
  **English acronyms** (`en-*`, Kokoro): three-table mechanism (letter-spell rule + STOP_LIST + IPA_LEXICON) auto-expands FBI → "ef bee eye" and gives EPAM/JSON/Anthropic the right IPA. Disable letter-spell with `--no-expand-abbrev`. See [docs/tts.md#english-acronym-auto-expansion](docs/tts.md#english-acronym-auto-expansion).
@@ -136,6 +146,33 @@ Honored when `<prosody rate>` wraps the whole utterance. Mid-utterance prosody w
136
146
 
137
147
  macOS system voices, SSML, voice listing, and the full voice catalogue: [docs/tts.md](docs/tts.md).
138
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
+
139
176
  ## Homebrew Install
140
177
 
141
178
  Homebrew installs the Bun-based CLI wrapper. Engine and model downloads remain
@@ -232,7 +269,7 @@ mandb ~/.local/share/man 2>/dev/null || true
232
269
 
233
270
  Compared against Whisper `large-v3-turbo` — all engines auto-detect language.
234
271
 
235
- ![Benchmark: openai-whisper vs faster-whisper vs Kesha Voice Kit](docs/assets/benchmark.svg)
272
+ ![Benchmark: openai-whisper vs faster-whisper vs Kesha Voice Kit](https://raw.githubusercontent.com/drakulavich/kesha-voice-kit/main/docs/assets/benchmark.svg)
236
273
 
237
274
  See [BENCHMARK.md](BENCHMARK.md) for the full per-file breakdown (Russian + English).
238
275
 
@@ -280,6 +317,10 @@ Cache boundary: `kesha install` and opt-in feature installs populate the local
280
317
  cache; ordinary transcription and speech commands fail fast if required assets
281
318
  are missing.
282
319
 
320
+ Contributors: see [`docs/architecture.md`](docs/architecture.md) for the
321
+ code-level map (repo layout, CLI↔engine boundary, backends, model pinning,
322
+ tests, and a "where to change X" table).
323
+
283
324
  ## What's Inside
284
325
 
285
326
  | Model | Task | Size | Source |
@@ -305,6 +346,26 @@ Audio decoding via [symphonia](https://github.com/pdeljanov/Symphonia) — WAV,
305
346
  - **Hermes Agent** — local STT/TTS through Hermes command providers. Setup: [docs/hermes.md](docs/hermes.md).
306
347
  - **Raycast** (macOS) — transcribe selected audio & speak clipboard from the launcher. Source + install: [`raycast/`](raycast/).
307
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
+
308
369
  ## Programmatic API
309
370
 
310
371
  ```typescript
@@ -374,7 +435,13 @@ state and retention.
374
435
 
375
436
  ## Contributing
376
437
 
377
- 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)
378
445
 
379
446
  ## License
380
447
 
package/SKILL.md CHANGED
@@ -168,6 +168,12 @@ kesha say --list-voices # Kokoro + Vosk-TTS + ~180 ma
168
168
 
169
169
  Output: WAV mono float32 by default. `--out <path>` writes to a file instead of stdout. For Telegram/OpenClaw replies, prefer `--format ogg-opus --out reply.ogg` or the OpenClaw-provided `{{OutputPath}}`.
170
170
 
171
+ **Output formats** (`--format`, or inferred from the `--out` extension): `wav` (default, uncompressed), `ogg-opus` (messenger voice notes), `flac` (lossless, royalty-free, plays in every browser incl. Safari/iOS — the format for web-embeddable samples). FLAC keeps the engine's native rate; `--bitrate` / `--sample-rate` apply only to `ogg-opus`.
172
+
173
+ ```bash
174
+ kesha say --format flac --out sample.flac "Hello" # web-embeddable, Safari-safe
175
+ ```
176
+
171
177
  **Voice notes (Telegram / WhatsApp / Signal / Discord):** add `--format ogg-opus` to emit OGG/Opus directly — the format messenger APIs render as a native voice message:
172
178
 
173
179
  ```bash
@@ -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'
@@ -69,7 +72,7 @@ complete -c kesha -n '__fish_seen_subcommand_from say' -l out -r -d 'Write audio
69
72
  complete -c kesha -n '__fish_seen_subcommand_from say' -l rate -r -d 'Speaking rate 0.5–2.0'
70
73
  complete -c kesha -n '__fish_seen_subcommand_from say' -l list-voices -d 'List installed voices and exit'
71
74
  complete -c kesha -n '__fish_seen_subcommand_from say' -l ssml -d 'Parse input as SSML (supports <speak>, <break>; strips unknown tags)'
72
- complete -c kesha -n '__fish_seen_subcommand_from say' -l format -r -d 'Output format: wav (default) or ogg-opus (Telegram-ready voice note). Inferred from --out extension when omitted.'
75
+ complete -c kesha -n '__fish_seen_subcommand_from say' -l format -r -d 'Output format: wav (default), ogg-opus (Telegram-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from --out extension when omitted.'
73
76
  complete -c kesha -n '__fish_seen_subcommand_from say' -l bitrate -r -d 'Opus bitrate in bits/sec (e.g. 32000). Only with --format ogg-opus.'
74
77
  complete -c kesha -n '__fish_seen_subcommand_from say' -l sample-rate -r -d 'Opus encoder sample rate (8000/12000/16000/24000/48000). Only with --format ogg-opus.'
75
78
  complete -c kesha -n '__fish_seen_subcommand_from say' -l no-expand-abbrev -d 'Disable Russian acronym auto-expansion (ВОЗ → \'вэ о зэ\') for ru-vosk-* voices. <say-as interpret-as=\'characters\'> still works. Applies to Russian (ru-vosk-*) and English (en-*) voices.'
@@ -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]' \
@@ -99,7 +104,7 @@ _kesha() {
99
104
  '--rate=[Speaking rate 0.5–2.0]:rate:' \
100
105
  '--list-voices[List installed voices and exit]' \
101
106
  '--ssml[Parse input as SSML (supports <speak>, <break>; strips unknown tags)]' \
102
- '--format=[Output format: wav (default) or ogg-opus (Telegram-ready voice note). Inferred from --out extension when omitted.]:format:' \
107
+ '--format=[Output format: wav (default), ogg-opus (Telegram-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from --out extension when omitted.]:format:' \
103
108
  '--bitrate=[Opus bitrate in bits/sec (e.g. 32000). Only with --format ogg-opus.]:bitrate:' \
104
109
  '--sample-rate=[Opus encoder sample rate (8000/12000/16000/24000/48000). Only with --format ogg-opus.]:sample rate:' \
105
110
  '--no-expand-abbrev[Disable Russian acronym auto-expansion (ВОЗ → '\''вэ о зэ'\'') for ru-vosk-* voices. <say-as interpret-as='\''characters'\''> still works. Applies to Russian (ru-vosk-*) and English (en-*) voices.]' \
package/man/kesha.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH KESHA 1 "May 2026" "kesha 1.19.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
@@ -167,7 +170,7 @@ List installed voices and exit
167
170
  Parse input as SSML (supports <speak>, <break>; strips unknown tags)
168
171
  .TP
169
172
  .B \-\-format
170
- Output format: wav (default) or ogg\-opus (Telegram\-ready voice note). Inferred from \-\-out extension when omitted.
173
+ Output format: wav (default), ogg\-opus (Telegram\-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from \-\-out extension when omitted.
171
174
  .TP
172
175
  .B \-\-bitrate
173
176
  Opus bitrate in bits/sec (e.g. 32000). Only with \-\-format ogg\-opus.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.19.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.19.0"
35
+ "version": "1.21.0-beta.1"
36
36
  },
37
37
  "scripts": {
38
38
  "test": "bun test",
@@ -79,15 +79,17 @@
79
79
  "devDependencies": {
80
80
  "@types/bun": "latest",
81
81
  "@typescript/native-preview": "^7.0.0-dev.20260423.1",
82
- "fast-xml-parser": "^5.5.10",
82
+ "fast-xml-parser": "^5.8.0",
83
83
  "typescript": "^6.0.2",
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
+ });
package/src/cli/say.ts CHANGED
@@ -100,7 +100,7 @@ export const sayCommand = defineCommand({
100
100
  format: {
101
101
  type: "string",
102
102
  description:
103
- "Output format: wav (default) or ogg-opus (Telegram-ready voice note). Inferred from --out extension when omitted.",
103
+ "Output format: wav (default), ogg-opus (Telegram-ready voice note), or flac (lossless, plays in all browsers incl. Safari). Inferred from --out extension when omitted.",
104
104
  },
105
105
  bitrate: {
106
106
  type: "string",
@@ -145,12 +145,12 @@ export const sayCommand = defineCommand({
145
145
  const fmtArg = typeof args.format === "string" ? args.format.toLowerCase() : undefined;
146
146
  let format: SayFormat | undefined;
147
147
  if (fmtArg) {
148
- if (fmtArg === "wav" || fmtArg === "ogg-opus") {
148
+ if (fmtArg === "wav" || fmtArg === "ogg-opus" || fmtArg === "flac") {
149
149
  format = fmtArg;
150
150
  } else if (fmtArg === "opus" || fmtArg === "ogg") {
151
151
  format = "ogg-opus";
152
152
  } else {
153
- log.error(`unknown --format '${args.format}'. supported: wav, ogg-opus`);
153
+ log.error(`unknown --format '${args.format}'. supported: wav, ogg-opus, flac`);
154
154
  process.exit(2);
155
155
  }
156
156
  }
@@ -166,7 +166,10 @@ export const sayCommand = defineCommand({
166
166
  ? args.out.split(".").pop()?.toLowerCase()
167
167
  : undefined;
168
168
  const impliesOpus = outExt && ["ogg", "opus", "oga"].includes(outExt);
169
- if (format === "wav" || (format === undefined && !impliesOpus)) {
169
+ // --bitrate / --sample-rate are Opus-only. Reject for wav and flac
170
+ // (both lossless / no encoder knobs) and for any non-Opus extension.
171
+ const resolvesToOpus = format === "ogg-opus" || (format === undefined && impliesOpus);
172
+ if (!resolvesToOpus) {
170
173
  log.error("--bitrate and --sample-rate are only valid with --format ogg-opus");
171
174
  process.exit(2);
172
175
  }
@@ -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 },
package/src/synth.ts CHANGED
@@ -14,8 +14,10 @@ import { registerProcessTree } from "./process-tree";
14
14
  * - `wav` (default): RIFF WAV at the engine's native sample rate.
15
15
  * - `ogg-opus`: OGG-encapsulated Opus, mono. The format Telegram, WhatsApp,
16
16
  * Signal, and Discord render as native voice messages. See #223.
17
+ * - `flac`: lossless, royalty-free, plays in every modern browser including
18
+ * Safari/iOS. Keeps the engine's native rate; no bitrate knob.
17
19
  */
18
- export type SayFormat = "wav" | "ogg-opus";
20
+ export type SayFormat = "wav" | "ogg-opus" | "flac";
19
21
  export const MAX_TEXT_CHARS = 5000;
20
22
 
21
23
  export interface SayOptions {