Most local text-to-speech tools are batch synthesizers with a CLI wrapper. You pass text, wait for the full audio file, and play it. That works for rendering a podcast clip. It does not work when an agent needs to speak a quick status update and you need it to feel responsive, or when a newer message should interrupt the speech that is currently playing.
Public repository: github.com/1Pio/mlx-speechd
msd exists because wrapping generic MLX-Audio commands was not enough for the behavior I wanted. I needed a daemon that keeps a selected model warm, streams generated audio chunks into playback as soon as they arrive, interrupts the previous utterance when a newer request comes in, and stays small enough for any local agent to call.
The warm daemon
The core idea is a semi-persistent local daemon that holds a TTS model in memory. When you run msd say, the daemon is already warm, so the time from request to first audio is measured in the low hundreds of milliseconds rather than the seconds it takes to load, initialize, and synthesize from cold. The daemon unloads the model after an idle period, so it does not sit on memory forever when nobody is talking to it.
This is the difference between a tool that feels alive and one that feels like waiting for a render bar. Warm first-audio latency in recorded tests landed roughly between 100 and 207 milliseconds after warmup, which is fast enough that spoken agent updates feel conversational rather than queued.
Interrupt-latest-wins
The default behavior is the detail I care about most. When a new say request arrives while speech is still playing, the daemon cancels the active request, stops playback, discards stale queued audio chunks, and lets the newer request take over immediately. You do not wait for the old utterance to finish. The newer message wins.
This matters because it is how spoken communication actually works in conversation. If someone starts saying something outdated and you have a newer instruction, you interrupt. A TTS tool that makes you wait for stale speech to finish before hearing the current message is broken for interactive use. There is a --no-interrupt flag for cases where overlap is desired, but the default is the right one for most agent-driven workflows.
Three modes, one daemon
The daemon supports three distinct output paths, all sharing the same warm model lifecycle:
msd say is the primary path. It streams and plays text through the warm daemon, starting audio playback as soon as the first chunks arrive.
msd render is the cold path. It synthesizes a complete audio file without keeping a daemon warm, which is what you want for batch rendering where latency does not matter.
msd hermes is the file-in/file-out path. It reads text from an input file, uses the warm daemon to synthesize, and writes a complete output file without playback. This makes it compatible with automation and file-driven agent workflows.
All three modes are agent-native. Any local tool can call msd say and get spoken output without thinking about model loading, audio device initialization, or process management.
The engineering that holds it together
The daemon communicates over a Unix domain socket with newline-delimited JSON. It owns model state, active request IDs, cancellation state, a playback manager, an idle timer, and a serialized engine queue. The serialized queue matters because it means idle unload, cancellation, and active synthesis do not race each other. When a queued say job is cancelled, it short-circuits instead of streaming stale text. When PortAudio resets after an idle output failure, the daemon retries instead of dying silently.
The model layer supports multiple MLX-Audio Qwen3 CustomVoice quantizations, from the 1.7B model down to the 0.6B, with voice names normalized case-insensitively and natural-language instruction styles that matter more than numeric speed parameters for Qwen3 CustomVoice.
One dependency lesson worth noting: the PyPI release of mlx-audio at the time did not include the Qwen3 CustomVoice implementation I needed, so the project pins the upstream Git repository in the lockfile. When a stable PyPI release catches up, the pin can move, but until then the project depends on the source that actually has the feature.
The boundary discipline
Like cxv, msd is defined as much by what it refuses to be as by what it is. No STT. No launchd. No cron. No gateway or autostart service. No containers or VMs. No Hermes source patches. No separate daemon binary. The public command is msd, and the daemon is a subcommand of the same CLI. That narrow scope is what kept the project small, composable, and reliable.
msd pairs naturally with cxv for full voice-loop interaction, but it works perfectly on its own. Any local agent, automation, or terminal user can call it for spoken output.
What I take from it
The project taught me that the gap between a working model wrapper and a useful speech tool is entirely about lifecycle and UX. The model synthesis is solved. The hard part is keeping it warm, streaming chunks early, handling interruption gracefully, and refusing to become a half-finished assistant platform.