-
Notifications
You must be signed in to change notification settings - Fork 0
tts
Crate: crates/voxctrl-tts/
VoxCtrl includes a neural TTS engine for voice output. This is useful for reading back transcriptions, confirming commands, or building conversational voice interactions via the MCP server.
Piper is a fast, local neural TTS system using ONNX models. It produces high-quality natural-sounding speech entirely offline.
VoxCtrl invokes the piper binary directly (looks first in ~/.local/share/voxctrl/piper/piper, then on PATH). It pipes text to Piper's stdin, receives raw 16-bit PCM on stdout, and plays via rodio (cross-platform).
Kokoro is a high-quality, natural-sounding neural TTS engine with English voices (American and British accents). VoxCtrl runs Kokoro entirely in Rust: text is phonemised via espeak-ng, tokenised against an embedded IPA vocabulary, and synthesised via native ONNX inference (ort crate). No Python is required.
Prerequisites:
-
espeak-nginstalled on the system (apt install espeak-ngon Debian/Ubuntu)
Model files are downloaded from GitHub releases to ~/.local/share/voxctrl/kokoro/. During download, the voices pack is automatically unzipped into the voices/ subdirectory, and the zip archive is deleted to save space and avoid ZIP-parsing disk overhead at runtime. Audio is played via rodio using the raw PCM output from the ONNX model.
Model quality levels:
| Quality | File | Size | Use case |
|---|---|---|---|
f32 |
kokoro-v1.0.onnx |
310 MB | Highest quality (recommended with GPU acceleration) |
fp16 |
kokoro-v1.0.fp16.onnx |
169 MB | Default (balanced quality, smaller) |
If Piper is unavailable or no voice is downloaded, VoxCtrl can use espeak-ng. It is invoked as a subprocess with the text as an argument. Quality is lower but espeak-ng is always available as a system package.
VoxCtrl supports GPU Acceleration for both the Kokoro and Piper neural engines:
- Kokoro: Enables the ONNX Runtime CUDA Execution Provider natively inside the Rust backend.
-
Piper: Appends the
--cudaCLI flag to the spawnedpipersubprocess command dynamically at runtime.
- A CUDA-compatible NVIDIA GPU and drivers installed.
- Pointing VoxCtrl to a GPU-enabled ONNX Runtime shared library. Because the app loads the shared library dynamically via
load-dynamic, you can link the GPU library (e.g. from Python'sonnxruntime-gpu) using theORT_DYLIB_PATHenvironment variable:If GPU initialization fails, the engines will automatically and gracefully fall back to executing on the CPU without causing a crash.export ORT_DYLIB_PATH=/path/to/libonnxruntime.so cargo tauri dev --features cuda
Voices are downloaded as .tar.gz archives from the Piper GitHub release (v0.0.2). Extracted .onnx and .onnx.json files are stored in the configured voice directory (see Configuration Options below). The default is ~/.local/share/voxctrl/piper-voices/.
| Voice name | Quality | Sample rate |
|---|---|---|
en-us-libritts-high |
high | 22050 Hz |
en-us-ryan-high |
high | 22050 Hz |
en-us-ryan-medium |
medium | 22050 Hz |
en-us-ryan-low |
low | 16000 Hz |
en-us-lessac-medium |
medium | 16000 Hz |
en-us-lessac-low |
low | 16000 Hz |
en-us-amy-low |
low | 16000 Hz |
en-us-kathleen-low |
low | 16000 Hz |
en-us-danny-low |
low | 16000 Hz |
en-gb-southern_english_female-low |
low | 16000 Hz |
en-gb-alan-low |
low | 16000 Hz |
The default voice is en-us-lessac-medium.
Kokoro ships voices split across four accent groups. All voices share the same model and voices pack (voices-v1.0.bin). Switching voices requires no additional downloads.
American Female (af_*)
| ID | Name |
|---|---|
af_heart |
Heart (default) |
af_bella |
Bella |
af_sarah |
Sarah |
af_nicole |
Nicole |
af_sky |
Sky |
af_alloy |
Alloy |
af_aoede |
Aoede |
af_jessica |
Jessica |
af_kore |
Kore |
af_nova |
Nova |
af_river |
River |
American Male (am_*)
| ID | Name |
|---|---|
am_adam |
Adam |
am_michael |
Michael |
am_puck |
Puck |
am_echo |
Echo |
am_eric |
Eric |
am_fenrir |
Fenrir |
am_liam |
Liam |
am_onyx |
Onyx |
am_santa |
Santa |
British Female (bf_*)
| ID | Name |
|---|---|
bf_emma |
Emma |
bf_alice |
Alice |
bf_isabella |
Isabella |
bf_lily |
Lily |
British Male (bm_*)
| ID | Name |
|---|---|
bm_george |
George |
bm_lewis |
Lewis |
bm_daniel |
Daniel |
bm_fable |
Fable |
// Check
const downloaded = await invoke<boolean>('check_voice_downloaded', {
voiceName: 'en-us-lessac-medium',
voiceDir: '', // '' = use default directory
});
// Download
await invoke('download_voice', {
voiceName: 'en-us-ryan-high',
voiceDir: '', // '' = default; or a custom path, e.g. '~/my-voices'
});Kokoro downloads the model file and the voices pack ZIP file. It automatically extracts the ZIP into a voices/ directory (containing standalone {voice}.npy files) and deletes the ZIP archive to save space.
// Check if model files and unzipped voices folder are present
const ready = await invoke<boolean>('check_kokoro_ready', {
quality: 'fp16', // "f32" | "fp16"
dataDir: '', // '' = ~/.local/share/voxctrl/kokoro/
});
// Download and automatically extract model and voices pack
await invoke('download_kokoro', {
quality: 'fp16',
dataDir: '',
});After synthesis, audio is played using rodio (cross-platform):
-
Piper produces raw 16-bit signed LE PCM; rodio plays it directly via
SamplesBuffer. -
Kokoro produces raw float32 PCM samples from ONNX inference; samples are converted to i16 and played via
SamplesBufferat 24 kHz.
The TTS engine queues requests in a bounded channel (capacity 32). Utterances play sequentially — subsequent calls are queued and played in order without overlapping.
{"method": "tools/call", "params": {"name": "speak_text", "arguments": {"text": "Recording complete."}}}await invoke('speak_text', { text: 'Hello world', voice: 'en-us-ryan-high' });
// For Kokoro the voice parameter overrides cfg.tts.kokoro.voice:
await invoke('speak_text', { text: 'Hello world', voice: 'af_bella' });The voice parameter is optional; if omitted, the configured default voice is used.
If a target has a response_pipe path configured, VoxCtrl watches that FIFO for newline-terminated text and speaks each line:
echo "Recording started" > /tmp/voxctrl-tts.fifoKokoro loads its ONNX model on first synthesis. Enable prewarm to avoid this latency:
"tts": {
"engine": "kokoro",
"kokoro": { "prewarm": true }
}When prewarm is true, TtsEngineWorker::start() enqueues a silent synthesis immediately after spawning the worker thread. The worker processes this short request (a single space) at startup, loading the model files into the OS page cache. Subsequent user-triggered syntheses are faster because the model is already in memory. This adds roughly 5–15 seconds to startup time depending on model size and disk speed.
The stop_key config field lists keys that interrupt current TTS playback when pressed:
"tts": {
"stop_key": ["KEY_ESCAPE"]
}Sending None through the TTS engine channel (via TtsEngineHandle::stop()) clears the current utterance.
Under tts in config.json:
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable TTS functionality |
engine |
string | "piper" |
"piper", "kokoro", or "espeak"
|
voice |
string | "en-us-lessac-medium" |
Default voice for Piper (hyphen-delimited) |
voice_dir |
string | "" |
Directory for Piper voice files; empty = ~/.local/share/voxctrl/piper-voices/
|
stop_key |
string[] | ["KEY_ESCAPE"] |
Keys that interrupt playback |
response_overlay |
bool | true |
Show overlay indicator while TTS is speaking |
gpu |
bool | false |
Enable GPU acceleration (CUDA) for Kokoro and Piper |
kokoro.voice |
string | "af_heart" |
Default Kokoro voice ID |
kokoro.quality |
string | "fp16" |
Model precision: "f32" or "fp16"
|
kokoro.speed |
float | 1.0 |
Speech rate multiplier (0.5 – 2.0) |
kokoro.prewarm |
bool | false |
Pre-warm model on startup for faster first synthesis |
kokoro.data_dir |
string | "" |
Directory for Kokoro model/voices files; empty = ~/.local/share/voxctrl/kokoro/
|
Example Kokoro config:
"tts": {
"enabled": true,
"engine": "kokoro",
"voice": "en-us-lessac-medium",
"voice_dir": "",
"stop_key": ["KEY_ESCAPE"],
"response_overlay": true,
"gpu": true,
"kokoro": {
"voice": "af_heart",
"quality": "fp16",
"speed": 1.0,
"prewarm": true,
"data_dir": ""
}
}The TTS handle is stored in AppState and shared with the MCP server, routing system, and IPC commands. It uses a robust, generation-based queue cancellation architecture so that calling stop() instantly interrupts active audio and safely discards all pending queued utterances without killing the worker thread:
pub struct Utterance {
pub text: String,
pub voice: Option<String>, // None = use config default
pub source_label: Option<String>, // "prewarm" = suppress audio output
}
pub enum TtsCommand {
Play {
utterance: Utterance,
generation: u32,
},
Shutdown,
}
pub struct TtsEngineHandle {
tx: Sender<TtsCommand>,
generation: Arc<AtomicU32>,
}
impl TtsEngineHandle {
pub fn speak(&self, text: impl Into<String>);
pub fn speak_utterance(&self, u: Utterance);
pub fn stop(&self); // Increments generation, interrupts active audio and discards queue
pub fn shutdown(&self); // Sends Shutdown command, terminating the worker thread
}The handle is Clone — multiple callers can hold a copy and enqueue utterances concurrently.
User speaks → transcription → speak_text IPC
│
TtsEngineHandle::speak_utterance()
│
(bounded channel, cap 32)
│
speak_kokoro() (pure Rust)
│
┌─────────────────────┼──────────────────────┐
│ │ │
phonemize_espeak() load_voice_embedding() ort::Session
espeak-ng --ipa -q NPY File → Cached mem (lazy init,
(subprocess) [num_tokens, 256] cached per
│ │ worker thread)
│ kokoro_tokenize() │
└──────────────────── │ ──────────────────────┘
│
run_kokoro_inference()
input_ids (int64, 1×T)
style (float32, 1×256)
speed (float32, 1)
│
f32 audio samples @ 24 kHz
│
i16 PCM → rodio::SamplesBuffer (persistent sink)
│
Sink::sleep_until_end()