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.

Choosing a provider How tts chooses a provider for one request. A provider named in the request wins; otherwise the daemon's own provider; otherwise the ordered fallback list, which defaults to the registry order, Azure then OpenAI. An explicitly chosen provider never falls back. Each candidate is tried at most once, with a share of the remaining total timeout; unconfigured candidates and unsupported options are skipped. A provider's HTTP, transport or invalid-audio failure moves to the next candidate only while no audio has been delivered. After output, decoding or playback has started, or after a mute, cancellation or shutdown, nothing falls back. A transport failure may already have been billed upstream, so fallback can cost at more than one provider. requesttext + options provider named? that provider onlynever falls back ordered listdaemon provider, thenazure → openai try the next candidateshare of the remaining timeout audiodeliver · play fails before any audio?HTTP · transport · invalid audio stopno fallback after output,mute or cancel yes no yes: next no
Fallback is bounded twice. Each candidate once, within one total deadline, and never after audio, mute or cancellation.

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.

The shared daemon The tts daemon. Clients (tts, aztts and oaitts with --remote) send authenticated commands over the remote-cli protocol with an owner-private bearer token. Admission deduplicates request IDs: an identical repeat attaches to the existing job and a conflicting one is rejected. Synthesis runs two requests at a time by default through the chosen provider adapter, holding the provider keys. Non-raw jobs enter FIFO playback lanes (one at a time by default, overlapping by up to two seconds, at most 32 queued) and play through the sound server; raw requests return their bytes instead. Receipts are retained for 128 jobs or 300 seconds, and the mute state persists on disk. clientstts · aztts · oaitts--remote POST /commandbearer token admissionrequest_id dedupeid_conflict synthesis2 at a timeprovider adapters FIFO playback lanes1 parallel · 2 s overlapcapacity 32 sound serverpacat · optional pan raw bytes statereceipts: 128 jobs, 300 smute, persisted raw: true
Speech from many machines, one queue. Overlapping requests play in arrival order instead of talking over each other.

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.

A job's life States of one tts job on the daemon. A job is queued, then synthesizing, then ready, playing and completed; it can also fail. Cancelling a queued or synthesizing job marks it cancelled and discards any late provider result; cancelling a ready or playing job frees its audio and kills and reaps only its own player before replying, so a cancelled receipt means its audio has stopped. Cancelling a finished job returns its receipt unchanged. A global mute stops active players, discards pending work and suppresses in-flight results; unmute never replays anything. queued synthesizing ready playing completed cancellednothing replays failedor muted player reaped first
Every state has an answer. Cancelling a finished job returns its receipt unchanged; an unknown ID is remembered, so a request that races its own cancellation is cancelled on arrival.

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.

Constant-power panning The pan law tts uses for daemon playback: the left gain is cos((p + 1) · π/4) and the right gain is sin((p + 1) · π/4) for a pan position p from −1 (left) to +1 (right). The two curves cross at 0.707 in the centre, so the summed power stays constant and neither gain ever exceeds one, which means panning can never clip. Raw and file output stay mono. 0 0.5 0.707 1 -1 -0.5 0 +0.5 +1 left = cos((p+1)·π/4) right = sin((p+1)·π/4) 0.707 each at centre pan position p (--pan)
Constant power, never above unity. Moving a voice across the room never makes it louder or clips it.

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.