-
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).
Pocket-TTS is Kyutai's lightweight FlowLM + Mimi-codec TTS model, ported to pure Rust on top of Candle. VoxCtrl uses the pocket-tts crate directly — no Python, no ONNX, no subprocess.
Instead of fixed precomputed voice embeddings, Pocket-TTS clones a voice from a short reference audio clip at runtime (TTSModel::get_voice_state()). VoxCtrl ships a small built-in catalogue of reference clips so users get a normal voice-picker UX without needing to record anything themselves.
Prerequisites:
- A HuggingFace account that has accepted the license for the gated
kyutai/pocket-ttsmodel repo, and a personal access token (hf_token) with read access. Set it in Settings → TTS → Pocket-TTS, or via theHF_TOKENenvironment variable.
Model weights and the per-voice reference clips are downloaded on demand via pocket_tts::weights::download_if_necessary, which resolves hf://owner/repo/filename[@revision] URIs through the standard HuggingFace cache (~/.cache/huggingface/hub/). Subsequent loads are read straight from the local cache — no network access required once downloaded.
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 the Piper neural engine:
-
Piper: Appends the
--cudaCLI flag to the spawnedpipersubprocess command dynamically at runtime.
Pocket-TTS (Candle-based) does not currently expose a GPU toggle in this crate version, so the gpu config flag and the GPU setting in Settings → TTS only apply to Piper.
- A CUDA-compatible NVIDIA GPU and drivers installed.
- Run with the
--cudafeature where applicable:If GPU initialization fails, Piper automatically and gracefully falls back to executing on the CPU without causing a crash.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.
VoxCtrl bundles a small catalogue of reference voice clips, each pulled from the public (ungated) kyutai/tts-voices dataset repo via an hf:// URI. Switching voices triggers a one-time download and embedding of that voice's reference clip (TTSModel::get_voice_state()), then the computed ModelState is cached in memory for the life of the worker thread.
| ID | Name |
|---|---|
alba |
Alba (Female, default) |
anna |
Anna (Female) |
vera |
Vera (Female) |
charles |
Charles (Male) |
michael |
Michael (Male) |
Drop a .wav reference clip into the configured pocket_tts.voice_dir (default ~/.local/share/voxctrl/pocket-tts-voices/) to add it to the voice list — no re-encoding or extra metadata needed:
- The filename (without extension) becomes the voice's id, e.g.
narrator.wavadds a voice listed as "Narrator (Custom)". - Naming a clip after a built-in voice (e.g.
alba.wav) overrides that voice's bundled reference clip instead of adding a new entry. - Any sample rate works —
TTSModel::get_voice_state()resamples to the model's rate automatically. - Custom clips are read directly from disk; they don't go through the HuggingFace cache or require
download_pocket_tts.
// 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'
});Pocket-TTS downloads the model weights (from the gated kyutai/pocket-tts repo), the tokenizer, and the selected voice's reference clip — all resolved through the HuggingFace cache.
// Check if model weights, tokenizer, and the selected voice's reference clip are cached
const ready = await invoke<boolean>('check_pocket_tts_ready', {
voice: 'alba',
voiceDir: '', // '' = default custom-voice directory
});
// Download model weights, tokenizer, and the reference clip (requires hf_token)
// No-op for custom voices resolved from voiceDir — they're already on disk.
await invoke('download_pocket_tts', {
voice: 'alba',
voiceDir: '',
hfToken: '<your HuggingFace token>',
});
// List the merged catalogue (built-ins + any .wav files found in voiceDir)
const voices = await invoke<{ id: string; label: string }[]>('list_pocket_tts_voices', {
voiceDir: '',
});After synthesis, audio is played using rodio (cross-platform):
-
Piper produces raw 16-bit signed LE PCM; rodio plays it directly via
SamplesBuffer. -
Pocket-TTS is generated and played frame-by-frame via
TTSModel::generate_stream()rather than waiting for the whole utterance: each Mimi audio frame (candle::Tensor) is converted to i16 PCM withpocket_tts::audio::pcm_i16_le_bytes()and appended to the sink as soon as it's ready, played viaSamplesBufferat 24 kHz. This overlaps playback of earlier frames with generation of later ones, cutting perceived latency from "time to generate the whole sentence" down to roughly "time to generate the first frame."stop()is checked between frames, so playback can be interrupted mid-generation instead of only after the whole utterance finishes.
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 Pocket-TTS the voice parameter overrides cfg.tts.pocket_tts.voice:
await invoke('speak_text', { text: 'Hello world', voice: 'charles' });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.fifoPocket-TTS loads its model weights on first synthesis. Enable prewarm to avoid this latency:
"tts": {
"engine": "pocket_tts",
"pocket_tts": { "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 into memory and computing the configured voice's reference embedding. Subsequent user-triggered syntheses are faster because the model is already warm. This adds startup latency 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", "pocket_tts", 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 Piper |
pocket_tts.voice |
string | "alba" |
Default Pocket-TTS voice ID: "alba", "anna", "vera", "charles", "michael"
|
pocket_tts.prewarm |
bool | false |
Pre-warm model on startup for faster first synthesis |
pocket_tts.hf_token |
string or null | null |
HuggingFace token used to download the gated model weights |
pocket_tts.voice_dir |
string | "" |
Directory scanned for custom .wav voice clips; empty = ~/.local/share/voxctrl/pocket-tts-voices/
|
Example Pocket-TTS config:
"tts": {
"enabled": true,
"engine": "pocket_tts",
"voice": "en-us-lessac-medium",
"voice_dir": "",
"stop_key": ["KEY_ESCAPE"],
"response_overlay": true,
"gpu": false,
"pocket_tts": {
"voice": "alba",
"prewarm": true,
"hf_token": "hf_...",
"voice_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_pocket_tts() (pure Rust)
│
┌─────────────────────┴──────────────────────┐
│ │
pocket_tts::TTSModel::load() download_if_necessary()
(lazy init, cached per resolves hf://owner/repo/file
worker thread) via HF cache (gated repo,
│ needs HF_TOKEN on first pull)
│ │
│ TTSModel::get_voice_state(clip_path)
│ → ModelState (cached per voice id)
└────────────────────┬────────────────────────┘
│
TTSModel::generate(text, voice_state)
│
candle::Tensor audio samples @ 24 kHz
│
pocket_tts::audio::pcm_i16_le_bytes() → i16 PCM
│
rodio::SamplesBuffer (persistent sink)
│
Sink::sleep_until_end()