tts · aztts · oaitts · Rust · one daemon
tts
tts is a text-to-speech command and daemon over two providers: aztts for Azure Speech and its SSML voices, oaitts for OpenAI-compatible speech endpoints, and tts to route between them. It speaks on the local machine, or hands text to a daemon on another one, which plays requests in order, can cancel them, pans them left or right, and has a mute that drops what arrives while muted instead of playing it later.
tts 'Build finished.' # Azure, then OpenAI, until one speaks
oaitts --lang en-GB --style cheerful 'Your build has finished.'
aztts --print-ssml --speed .9 'Hello' # the exact XML, offline
tts --remote kitchen --pan -0.6 'Dinner' # played on another machine, left of centre
tts mute # stop everything; nothing replays
Routing
The CLI, the daemon, the playback queue, configuration, mute and the remote protocol live once, in the tts-cli crate. A provider adapter does only what differs between services: it encodes the request and handles that service's HTTP. aztts and oaitts are the same implementation as tts --provider azure and --provider openai, pinned, so a fixed frontend never wanders to another provider. There is no Python, no SDK, no chat or realtime pipeline and no local model.
tts providers --json
{"providers":[{"cli":"aztts","endpoint_env":"AZURE_SPEECH_ENDPOINT","key_env":"AZURE_SPEECH_API_KEY","provider":"azure"},
{"cli":"oaitts","endpoint_env":"OPENAI_BASE_URL","key_env":"OPENAI_API_KEY","provider":"openai"}]}
The registry, in fallback order, read without touching a credential.
Azure Speech
SSML built from plain text by the sibling ssml library, MAI voices and their Flash and HD models, speaker embeddings, styles and roles, and authentication by a resource key or a Microsoft Entra token. More on aztts →
OpenAI-compatible
The standard /v1/audio/speech JSON against any base URL. Language, style, role, pitch and volume become natural-language instructions rather than invented fields, and models that cannot follow instructions refuse instead of ignoring them. More on oaitts →
Fallback
Unpinned, tts tries providers in order until one produces audio. The interesting part is where it refuses to: the moment any audio has been delivered, the request belongs to that provider, so a listener never hears a sentence start in one voice and restart in another.
A timeout is not proof that nothing happened: an upstream service may have accepted, and billed, a request whose answer never arrived. The documentation says so plainly, and pinning a provider is how to switch fallback off.
The daemon
tts daemon holds the provider keys and plays speech on its own machine; clients need only its URL and a bearer token. Every route needs the token, which permits billed synthesis, playback, mute and shutdown, so the service is for trusted networks or behind TLS. A request can choose a provider, a voice and a pan, but never an upstream URL, a credential, a local file or the fallback policy.
- Idempotent by ID. A repeated request ID with identical content attaches to the running job or returns its receipt; different content is an
id_conflict. A client timeout never cancels accepted work: ask for the job's status before retrying. - Snapshots without secrets. Health lists providers, availability, the default and fallback order, the queue and mute state. Receipts name the provider and redacted attempts. No snapshot carries speech, instructions, response bodies, keys or endpoint URLs.
- Capabilities, negotiated. Health advertises the operations and request features a daemon supports, and clients never send a gated field (cancel, pan) to a daemon that doesn't list it.
- Tokens, not keys. For Azure resources without keys, Entra tokens are fetched by a configured broker, shared by concurrent requests, refreshed before expiry and pre-warmed every 30 seconds, so the hourly refresh never lands on a request.
Cancelling and muting
Cancelling one job leaves every other job, and the global mute, alone, and a lost reply can be retried safely. What a cancelled receipt means is precise: if the job was already playing, its player has been killed and reaped before the reply was sent.
Mute is broader and blunter: active players stop, pending work is discarded, and results still in flight are suppressed, even across a complete mute and unmute. Unmuting never replays old speech. A Ctrl-C on tts --remote cancels its own job on the daemon and exits 130.
Panning
--pan places daemon playback in the stereo field, so two agents, or two machines, can speak from different sides of the room. It is a playback property: never sent upstream, part of the request's identity, and rejected for raw output, which stays mono.
Running it
Local speech is driven by flags and environment only. Formats are 16-bit 24 kHz mono PCM (the default), WAV and MP3; --output replaces a file atomically, and a failed attempt leaves the old one untouched. Input is bounded to 1 MiB and audio to 64 MiB.
The daemon is a NixOS module, tts.*, running a user unit with keys loaded as systemd credentials; startup always requires the managed token, so a missing secret is never silently replaced.
imports = [ inputs.tools.nixosModules.tts ];
tts = {
enable = true;
bind = "127.0.0.1:7633";
# provider = "azure"; # omit for fallback
openai.apiKeyFile = "/run/secrets/openai-key";
azure.apiKeyFile = "/run/secrets/azure-key"; # or azure.auth = "entra"
};
Exit codes: 0 success or a closed pipe, 1 runtime failure, 2 invalid input or configuration.