@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 +71 -4
- package/SKILL.md +6 -0
- package/completions/kesha.bash +2 -1
- package/completions/kesha.fish +4 -1
- package/completions/kesha.zsh +6 -1
- package/man/kesha.1 +5 -2
- package/package.json +6 -4
- package/src/cli/dispatch.ts +2 -0
- package/src/cli/mcp.ts +17 -0
- package/src/cli/say.ts +7 -4
- package/src/mcp/audio-output.ts +47 -0
- package/src/mcp/server.ts +11 -0
- package/src/mcp/tools.ts +200 -0
- package/src/mcp/voices.ts +118 -0
- package/src/shell-artifacts.ts +2 -0
- package/src/synth.ts +3 -1
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="
|
|
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
|
-

|
|
272
|
+

|
|
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
|
package/completions/kesha.bash
CHANGED
|
@@ -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" ;;
|
package/completions/kesha.fish
CHANGED
|
@@ -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)
|
|
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.'
|
package/completions/kesha.zsh
CHANGED
|
@@ -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)
|
|
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.
|
|
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)
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
+
}
|
package/src/mcp/tools.ts
ADDED
|
@@ -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
|
+
}
|
package/src/shell-artifacts.ts
CHANGED
|
@@ -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 {
|