Gandr in LiveKit Agents.
1.6.7
verified against livekit-agents, 2026-07-31
One file, one constructor line. It implements the standard livekit-agents TTS interface, so your STT, LLM and turn handling do not change.
The whole integration
Drop the file next to your agent, change one line.
curl -O https://gandr.ai/integrations/livekit/gandr_tts.py
| What it needs | |
|---|---|
| One file | gandr_tts.py, next to your agent. Delete it to uninstall. |
| No new dependencies | aiohttp only, which livekit-agents already requires. |
| Python | 3.10 or newer, same floor as livekit-agents itself. |
| A key | GANDR_API_KEY in your environment, or pass api_key=. |
from gandr_tts import GandrTTS
session = AgentSession(
stt=inference.STT(model="<your-stt-model>"),
llm=inference.LLM(model="google/gemma-4-31b-it"),
tts=GandrTTS(voice="gandr-mia"), # key from GANDR_API_KEY
)
That is the integration. There is no package to install, transport is aiohttp, which livekit-agents already depends on. Python 3.10 or newer.
Telephony: do not ask for 8 kHz
The obvious move on a SIP call is to construct the plugin at the call’s native rate so nothing resamples. It is the wrong move, and we had it wrong in our own guide until we measured it.
The engine renders at 24 kHz. Asking for 8 kHz makes the server resample before it releases the first chunk, and that costs materially on every single utterance, the most expensive silence in a conversation, because it lands between the caller finishing and the agent starting.
LiveKit already resamples into the SIP leg, locally, for nothing. So take the default and let it.
Time to first audio by requested rate, production API, five runs per rate per voice
| Requested rate | Relative to the default |
|---|---|
| 24000 (default) | baseline |
| 22050 | no measurable cost |
| 16000 | no measurable cost |
| 8000 | materially slower, every utterance |
Measured 2026-07-31, gandr-mia and gandr-leo, five runs per rate per voice. This run answers exactly one question, what a requested rate costs against the other rates, so the column is relative on purpose. These rows are relative on purpose; the absolute figure is: first audio byte in 146 ms over the open internet, 116 ms p50 first audio, server side warm.
If your pipeline genuinely needs a narrowband source, 16000 costs nothing against 24000. 8000 is the only rate that does.
Constructor
Everything the plugin takes
| Argument | Default | What it does |
|---|---|---|
| api_key | GANDR_API_KEY | Your gnd_ key. Raises at construction if neither is set. |
| voice | gandr-mia | A stock id, or a gnd: clone id. |
| lang | en | Language of the input text. |
| sample_rate | 24000 | 8000, 16000, 22050 or 24000. Leave it at 24000, see below. |
| speed | unset | 0.6 to 1.5. Pitch preserving, applied after synthesis. |
| volume | unset | 0.5 to 2.0. Soft-ceiling mastered, never clips. |
| extra | unset | Merged into every request: pronunciation_dict, temperature, cfg_weight, seed. Send the first two, omit temperature and the door picks one from the voice, omit cfg_weight and nothing is sent. |
| base_url | tts.gandr.ai | Leave it. Requests route to the nearest healthy region. |
| timeout | 30.0 | Socket read timeout on the audio stream, seconds. |
| prewarm_on_start | True | Wakes a worker as soon as the plugin is built. |
Changing voice mid-call
The next utterance picks it up. Nothing is torn down.
session.tts.update_options(voice="gandr-leo")
session.tts.update_options(lang="es", speed=1.1)
The overflow lane
The fleet is always on, but overflow spills to a fallback lane that can take longer on its first request. On a healthcare line a silent first second is a dropped call, so the plugin does three things about it: it opens the path when it is constructed, it retries once through an overflow response instead of failing, and it exposes prewarm() so you can open it earlier still.
Fire it on the SIP invite and first audio is at full speed by the time your model has a sentence.
# on the invite, before the caller has finished saying hello
await session.tts.prewarm()
Failover
Errors map to livekit-agents’ own exception classes, so the framework’s retry applies with no extra code. A stream that ends early now raises rather than playing a half sentence, which is what you want when the sentence is a dose or an address.
For provider failover during an evaluation, wrap it in LiveKit’s stock adapter:
Server-side we already route across two regions with failover between them before your adapter would engage.
from livekit.agents.tts import FallbackAdapter
tts = FallbackAdapter([
GandrTTS(voice="gandr-mia"),
inference.TTS(model="..."), # any secondary
])
Voices
Six stock voices on every key, all multilingual, set lang per session or per update_options call. Or clone: five to thirty seconds of clean reference audio gives you a gnd: id that works anywhere a stock id does, at the same latency after first use.
Stock voices
| ID | Character |
|---|---|
| gandr-mia | easy, unhurried (f), the default |
| gandr-ava | warm, friendly (f) |
| gandr-jenny | steady, sincere (f) |
| gandr-dane | smooth, measured (m) |
| gandr-leo | clear, professional (m) |
| gandr-lewis | warm, even (m) |
What your model does not have to spell out
Times, money, codes and phone numbers are normalized server-side on English requests, with nothing to configure. It matters more than it sounds: this is the category of mistake a caller actually notices.
Your own wording always wins. When you need to force it:
- <spell>TKT4829XB</spell>character by character, for codes and serials
- <break time="800ms"/>a pause at exactly that point
- extra={"pronunciation_dict": [...]}sounds-like replacements for brand and domain words
Automatic readback
| Your model writes | The caller hears |
|---|---|
| 9:00 AM | nine A M |
| $42.50 | forty-two dollars and fifty cents |
| order OX49 | order O X, four nine |
| (415) 555-0142 | digit by digit, grouped |
| zip, tracking, account numbers | digit by digit |
Unlimited minutes on a flat stream, so agent talk time stops being a line item.
Get a keyFull API reference , gandr.ai/docs