OmniVoice is theLAB's text-to-speech service — a VibeVoice / Qwen3-TTS-family model running on the host apexlabs-voicebox on our private Tailscale net. It's the final stage of ADI's voice loop: an LLM produces a reply, and OmniVoice turns that text into the audio the user actually hears. The whole pipeline upstream of it lives in the fully-local voice stack.
The nice part is that OmniVoice speaks the same dialect as everything else in the fleet: an OpenAI-compatible speech endpoint. If you've called OpenAI's /v1/audio/speech before, you already know the shape — point it at our host and you get audio bytes back.
01Hit the speech endpoint
The endpoint is POST /v1/audio/speech. On ADI the base is http://<host>:8881, so the full URL is http://<host>:8881/v1/audio/speech where <host> is apexlabs-voicebox on the tailnet. The request body is plain JSON; the response is raw audio bytes.
curl -s http://<host>:8881/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "omnivoice",
"voice": "adi_ref",
"input": "Welcome back. Your garage box is online.",
"format": "wav"
}' \
-o out.wav # response body is the audio itself
Four fields carry the request. model is omnivoice; voice is adi_ref, our reference/cloned voice; input is the text to speak; and format defaults to wav. Swap format for mp3, ogg, or webm when you want a compressed stream, and swap voice if you register another reference voice later.
Authorization: Bearer <key> header for providers that require one — but OmniVoice running locally on the tailnet does not. Leave it off. Content-Type: application/json is the only header that matters here.
02Go through ADI's tts.php instead
In production we rarely call the raw endpoint. ADI ships a PHP provider abstraction, tts.php, that fronts several backends — omnivoice, cosyvoice2, kokoro, groq, google_cloud, and browser — behind one interface. For omnivoice it defaults model to "omnivoice" and voice to the OMNIVOICE_VOICE constant ("adi_ref"), then posts {model, voice, input, ...} to {base}/v1/audio/speech — exactly the call from step 01, just centralized.
# conceptually, what tts.php does for the omnivoice provider
$payload = [
"model" => "omnivoice",
"voice" => OMNIVOICE_VOICE, # "adi_ref"
"input" => $text,
"format" => $format, # wav by default
];
$audio = http_post_json("{$base}/v1/audio/speech", $payload);
Routing through tts.php means a single call site can fall back to another provider, or switch a user from OmniVoice to a cloud voice, without the caller changing a line.
03Lean on the disk cache
TTS is slow and our phrasing repeats — "online", confirmations, error chimes. So tts.php caches every response on disk, keyed by sha256(provider|base|model|voice|format|language|text). A repeated phrase is a cache hit: the audio comes back instantly and the response carries an X-TTS-Cache: hit header. This is the single biggest latency win in the whole voice loop, since synthesis cost drops to a disk read for anything ADI has said before.
model, voice, format, and language alongside the text, changing any one of them — e.g. asking for mp3 instead of wav, or a different reference voice — is a different key and a fresh miss. Keep your defaults stable to keep the hit rate high.
Verify it works
out.wavplays back clean speech in theadi_refvoice — not silence or a JSON error body.- A second identical request returns near-instantly and reports
X-TTS-Cache: hit. - Through
tts.php, a phrase the LLM just generated is audible end-to-end with no per-request key or auth.