Text to speech
Text to speech (TTS) turns the bot's reply into the audio the caller hears. This chapter lists every TTS provider the gateway supports, the variables each one reads, how to override the voice for a single call, and the cache and playback settings that shape what callers hear. Pick a provider, set its required key, and restart the gateway. Ready-made combinations are in Configuration recipes; the recogniser side is in Speech recognition.
Choosing
Set TTS_PROVIDER to one of the values below. The default is sarvam.
TTS_PROVIDER=sarvam
| Provider (TTS_PROVIDER) | Mode | Best for |
|---|---|---|
| sarvam | Streaming (WebSocket or HTTP) | Indian languages, low latency with the v4-flash model |
| Streaming with Chirp3-HD voices | Multi-language production, broad language coverage | |
| gemini | Streaming on Vertex AI, batch on Cloud TTS | Expressive voices, designed or replicated custom voices |
| elevenlabs | Streaming over a persistent WebSocket | Natural, expressive customer-facing voices |
| smallest | Streaming (WebSocket) with native 8 kHz output | Indian languages incl. Malayalam, lowest cost of those |
| deepgram | Batch | Speed on English and a few European and Asian languages |
| azure | Streaming (chunked) | Neural voices with speaking styles |
| murf | Batch | Human-like brand voices, English first |
| vexyl | Batch over a WebSocket, self-hosted | On-premise, 22 Indian languages |
| openrouter | Batch only | Trying many vendors behind one key |
Streaming providers start playing while the sentence is still being rendered, so the caller hears audio sooner. Batch providers render the whole sentence first, so expect a longer pause before each reply. When a provider fails on a turn, the gateway falls back to another configured provider so the caller is not left in silence.
Costs are billed per provider and model with built-in rates. To change a rate, create config/pricing.json from the example in Webhooks and billing and edit it.
Sarvam
| Variable | Default | Set it when |
|---|---|---|
SARVAM_API_KEY (required) |
unset | Always, for this provider |
SARVAM_TTS_MODEL |
bulbul:v3 | You want bulbul:v4-flash, the current fast model. bulbul:v2 is legacy |
SARVAM_TTS_SPEAKER |
built-in: kavya (bulbul:v3), kavya_hi_conversational (bulbul:v4-flash) | Choosing the voice. v3 takes a bare name such as kavya; v4-flash takes a persona id such as kavya_hi_conversational |
SARVAM_TTS_SPEAKER_<LANG> |
unset | One language needs a different speaker, for example SARVAM_TTS_SPEAKER_ML_IN. Beats the global speaker |
SARVAM_TTS_PACE |
1.0 | Speaking speed. Clamped to 0.5–2.0 (v2 allows 0.3–3.0) |
SARVAM_TTS_TEMPERATURE |
unset | Variation in delivery, 0.01–1.0, v3 and v4-flash only. Unset uses Sarvam's own default |
SARVAM_TTS_STREAMING |
true | Streams each sentence as it is synthesised. Set false to use REST batch only |
SARVAM_TTS_STREAM_TRANSPORT |
ws | Set http when the model is v4-flash (see below) |
SARVAM_TTS_WS_REUSE |
true | Pooled sockets stay open between sentences, so the first chunk of each warm sentence arrives faster. Set false for a fresh socket per sentence |
SARVAM_TTS_WS_IDLE_MAX |
unset | Idle pooled sockets kept per configuration |
SARVAM_TTS_WS_IDLE_TTL_MS |
unset | How long an idle pooled socket lives |
SARVAM_TTS_WS_PING_MS |
unset | Keep-alive interval for pooled sockets |
SARVAM_TTS_WS_TIMEOUT |
unset | Cap on a streaming request, both transports |
SARVAM_TTS_WS_CONNECT_TIMEOUT_MS |
unset | Cap on opening a socket |
SARVAM_TTS_MAX_CONCURRENT |
unset | Limit on simultaneous Sarvam requests |
SARVAM_TTS_QUEUE_TIMEOUT |
unset | How long a request waits in that limit before failing |
SARVAM_TTS_PREPROCESSING |
true | Text normalisation of numbers and abbreviations; always on server-side for v3 and v4-flash. Set false to disable |
SARVAM_TTS_STREAM_URL |
https://api.sarvam.ai/text-to-speech/stream |
You route through your own proxy |
SARVAM_TTS_WS_URL |
wss://api.sarvam.ai/text-to-speech/ws |
You route through your own proxy |
Model and speaker must match. v4-flash rejects a bare v3 name and v3 rejects a persona id. The gateway maps one to the other automatically (kavya becomes kavya_hi_conversational and back) and logs a warning once, but set the right form to keep logs quiet. v4-flash has no Malayalam or Odia persona, but it is multilingual: Malayalam text spoken through a Hindi persona is correct Malayalam. Speaker ids keep their own language tag, so a Hindi-tagged persona such as shilpa_hi_narration works for Malayalam.
With v4-flash set SARVAM_TTS_STREAM_TRANSPORT=http. The WebSocket transport cuts the tail off many sentences on that model; HTTP streaming has the same first-audio speed without the cut. Cached audio made over WebSocket carries the cut, so purge cached v4-flash audio after switching (see The TTS cache). If a stream fails, the gateway tries the other transport, then REST batch, then another provider.
Language codes are mapped to Sarvam's list before the request: en-US and en-GB become en-IN, and or-IN becomes od-IN. Cache keys and SARVAM_TTS_SPEAKER_<LANG> still use the code your session carries.
| Variable | Default | Set it when |
|---|---|---|
GOOGLE_APPLICATION_CREDENTIALS (required) |
unset | Always. Path to the service-account JSON file; also used by Gemini TTS and Google STT |
GOOGLE_VOICE_<LANG> |
built-in Chirp3-HD voice per language | Choosing the voice for one language, for example GOOGLE_VOICE_ML_IN. Beats the global name |
GOOGLE_TTS_VOICE_NAME |
unset | Single-language bots only (see below) |
GOOGLE_TTS_REGION |
unset (global endpoint) | Data residency or latency: asia, us or eu |
GOOGLE_TTS_STREAMING |
true |
On. Set false to use REST batch only |
GOOGLE_TTS_TIMEOUT |
unset | A stalled request should fail over sooner |
Set voices per language, not globally. Google rejects a voice whose language does not match the request, and the turn then plays silence. A single GOOGLE_TTS_VOICE_NAME applies to every language and silently breaks a multi-language bot, so use it only on a bot that speaks one language.
The request language is derived from the voice name: zh-CN uses cmn-CN voices and ar-AE uses ar-XA voices. A name that does not match its language is rejected.
Keep Chirp3-HD voice names. Other voice types drop back to REST batch and lose progressive streaming. All configured languages default to Chirp3-HD except Odia: Google publishes no Odia voice, so an Odia turn on Google falls to another configured provider.
Gemini
| Variable | Default | Set it when |
|---|---|---|
GOOGLE_APPLICATION_CREDENTIALS (required) |
unset | Always. Same service account as Google TTS |
GEMINI_TTS_MODEL |
gemini-2.5-flash-tts | You want gemini-3.8-flash-tts or gemini-3.8-flash-lite-tts. Introductory pricing applies to the 3.8 models; check your provider console |
GEMINI_TTS_BACKEND |
auto | Force cloud-tts or vertex. Auto sends 3.8 models to Vertex AI and everything else to Cloud TTS |
GEMINI_TTS_VERTEX_MODELS |
unset | Change which model names auto routes to Vertex |
GEMINI_TTS_VERTEX_LOCATION |
global | Never; only global has the 3.8 models |
GEMINI_TTS_VERTEX_PROJECT |
project of the credentials | The Vertex project differs from the credentials' project |
GEMINI_TTS_STREAMING |
true | On Vertex the sentence streams, so first audio arrives before rendering finishes. Set false to use batch on Vertex. Cloud TTS models are never streamed |
GEMINI_TTS_REGION |
unset (global) | Cloud TTS backend only: us, eu or northamerica-northeast1. asia is not supported |
GEMINI_TTS_VOICE_NAME |
Kore | Global voice (Kore, Puck, Zephyr, Charon and others), or a custom voice_<id> |
GEMINI_TTS_VOICE_<LANG> |
unset | Voice for one language, for example GEMINI_TTS_VOICE_ML_IN |
GEMINI_TTS_PROMPT |
unset | A style prompt on the Cloud TTS backend. Ignored on Vertex |
GEMINI_TTS_VERTEX_PROMPT |
false | Set true to prepend the prompt on Vertex anyway. The 3.8 models read it aloud, so leave it false |
GEMINI_TTS_TEMPERATURE |
1.0 | Variation in delivery |
The 3.8 models run on Vertex AI, in location global only. The same service account is used. Cloud TTS and Vertex are separate backends, so a Cloud TTS-only model keeps its original request while a 3.8 model goes to Vertex. A per-call tts_model can send one call to Vertex while the rest of the host stays on Cloud TTS.
ElevenLabs
| Variable | Default | Set it when |
|---|---|---|
ELEVENLABS_API_KEY (required) |
unset | Always, for this provider |
ELEVENLABS_MODEL |
eleven_flash_v2_5 | You want a different realtime model |
ELEVENLABS_VOICE_ID |
built-in voice | Choosing the global voice |
ELEVENLABS_VOICE_<LANG> |
unset | One language needs its own voice, for example ELEVENLABS_VOICE_ML_IN |
ELEVENLABS_V3_ENABLED |
true | Languages the flash model cannot speak route to v3 automatically. Set false to throw instead and fall back to another provider |
ELEVENLABS_V3_MODEL |
eleven_v3 | You want a different model for those languages |
ELEVENLABS_FORCE_V3 |
unset | Set true to send every language to v3: best quality, slower first audio |
ELEVENLABS_AUTO_MODE |
true | The socket skips chunk-schedule buffering because input is whole sentences. Set false to restore buffering |
ELEVENLABS_WS_REUSE |
true | Persistent socket, so warm turns skip the handshake. Set false for a socket per request |
ELEVENLABS_STREAMING |
true |
On. Set false to use REST batch only |
ELEVENLABS_STABILITY |
0.5 | Consistency of delivery, 0.0–1.0 |
ELEVENLABS_SIMILARITY_BOOST |
0.75 | Clarity, 0.0–1.0 |
ELEVENLABS_STYLE |
0.0 | Style exaggeration, 0.0–1.0 |
ELEVENLABS_USE_SPEAKER_BOOST |
true | Disable speaker boost |
ELEVENLABS_OPTIMIZE_LATENCY |
unset | Realtime socket only; v3 rejects it |
ELEVENLABS_INACTIVITY_TIMEOUT |
unset | Idle timeout of the socket, in seconds (the API caps it) |
ELEVENLABS_STREAM_FORMAT |
ulaw_8000 | Streaming output format; decoded locally to 8 kHz |
ELEVENLABS_OUTPUT_FORMAT |
pcm_22050 | REST fallback format |
ELEVENLABS_TIMEOUT |
unset | Cap on a request |
ELEVENLABS_API_URL |
https://api.elevenlabs.io |
You route through a proxy |
ELEVENLABS_WSS_URL |
wss://api.elevenlabs.io |
You route through a proxy |
The first call after an idle period pays a socket handshake; warm turns do not. v3 is slower to first audio than flash, so use it only for languages flash cannot speak. v3 rejects the latency optimisation parameter, which the gateway leaves out for it.
Smallest
| Variable | Default | Set it when |
|---|---|---|
SMALLEST_API_KEY (required) |
unset | Always, for this provider |
SMALLEST_TTS_VOICE_ID |
avni | Choosing the global voice. Avoid divya: it truncates long text |
SMALLEST_VOICE_<LANG> |
unset | One language needs its own voice, for example SMALLEST_VOICE_ML_IN. Beats the global voice |
SMALLEST_TTS_MODEL |
lightning_v3.1 | You want another model such as lightning_v3.1_pro |
SMALLEST_TTS_SAMPLE_RATE |
8000 | Never for telephony: 8 kHz is native, so no resampling is in the path |
SMALLEST_TTS_SPEED |
1.0 | Speaking speed |
SMALLEST_TTS_STREAMING |
true | WebSocket streaming. Set false to use REST batch |
SMALLEST_TTS_COMPLETE_BACKOFF_MS |
0 | Keep 0. The API's own default of 4000 adds a multi-second delay at the end of every sentence |
SMALLEST_TTS_TIMEOUT |
30000 | Cap on a request, both transports |
SMALLEST_TTS_MAX_CHARS |
5000 | Longer text is truncated with a warning |
SMALLEST_TTS_WS_URL |
wss://api.smallest.ai/waves/v1/tts/live |
You route through a proxy |
SMALLEST_TTS_REST_URL |
https://api.smallest.ai/waves/v1/tts |
You route through a proxy |
Most catalogue voices are multilingual, so one voice usually covers every language you serve. Streaming and batch return identical audio, so cached entries are interchangeable between them.
OpenRouter
One key reaches many voice vendors. Choose the vendor and model with OPENROUTER_TTS_MODEL. It is a batch path with no streaming, so replies start after synthesis finishes.
| Variable | Default | Set it when |
|---|---|---|
OPENROUTER_API_KEY (required) |
unset | Always. Use a normal key; a management key fails with "User not found" |
OPENROUTER_TTS_MODEL |
mistralai/voxtral-mini-tts-3-0 | Choosing another model, written vendor/name |
OPENROUTER_TTS_VOICE |
unset | Choosing the voice. Ids are model-specific (Gemini models use names such as Kore) |
OPENROUTER_TTS_VOICE_<LANG> |
unset | One language needs its own voice, for example OPENROUTER_TTS_VOICE_ML_IN |
OPENROUTER_TTS_INSTRUCTIONS |
unset | Delivery directions. Some models honour them, others ignore them |
OPENROUTER_TTS_SPEED |
unset | Speaking speed. Only sent when set, because some models reject a non-default value |
OPENROUTER_TTS_TIMEOUT |
30000 | Cap on a request |
OPENROUTER_TTS_MAX_CHARS |
3000 | Longer text is truncated with a warning; one model caps at 3000 |
OPENROUTER_TTS_URL |
unset | Override the endpoint, for example a regional one |
Audio arrives at a model-specific rate; the gateway reads it and resamples as needed. Listen to a voice before committing: a round trip through speech recognition proves the words are intelligible, not that they sound natural.
Deepgram
| Variable | Default | Set it when |
|---|---|---|
DEEPGRAM_API_KEY (required) |
unset | Always, for this provider |
DEEPGRAM_MODEL |
aura-asteria-en | Choosing the global voice |
DEEPGRAM_VOICE_<LANG> |
per-language built-ins | One language needs another voice, for example DEEPGRAM_VOICE_EN_IN |
DEEPGRAM_API_URL |
https://api.deepgram.com |
You route through a proxy |
DEEPGRAM_TIMEOUT |
unset | Cap on a request |
Built-in voices cover English (US, GB, India), German, Spanish, French, Italian, Japanese, Korean, Dutch, Polish, Portuguese (Brazil), Russian and Chinese. Indian-language callers are better served by another provider.
Azure
| Variable | Default | Set it when |
|---|---|---|
AZURE_TTS_API_KEY (required) |
unset | Always, for this provider |
AZURE_TTS_REGION |
centralindia | Your Speech resource lives in another region |
AZURE_TTS_VOICE_NAME |
en-US-JennyNeural | Choosing the global voice |
AZURE_TTS_VOICE_STYLE |
none | You want a speaking style such as friendly or cheerful |
AZURE_TTS_RATE |
1.2 | Speaking speed; 1.0 is normal |
AZURE_TTS_PITCH |
0% | Shifting pitch |
AZURE_TTS_STREAMING |
true |
On. Set false for REST batch, resampled from 24 kHz |
AZURE_TTS_TIMEOUT |
unset | Cap on a request |
AZURE_TTS_VOICE_<LANG> |
unset | One language needs its own voice, for example AZURE_TTS_VOICE_ML_IN. Beats the global voice |
AZURE_TTS_RATE_<LANG> |
unset | One language needs its own speed, for example AZURE_TTS_RATE_ML_IN |
AZURE_TTS_PITCH_<LANG> |
unset | One language needs its own pitch, for example AZURE_TTS_PITCH_ML_IN |
AZURE_TTS_STYLE_<LANG> |
unset | One language needs its own style, for example AZURE_TTS_STYLE_EN_US |
Murf
| Variable | Default | Set it when |
|---|---|---|
MURF_API_KEY (required) |
unset | Always, for this provider |
MURF_VOICE_ID |
en-US-natalie | Choosing the global voice |
MURF_MODEL |
GEN2 | Choosing another Murf model |
MURF_STYLE |
Conversational | Choosing another speaking style |
MURF_SAMPLE_RATE |
24000 | Choosing 8000; the audio is downsampled to the call's rate either way |
MURF_VOICE_<LANG> |
built-in per language | One language needs its own voice, for example MURF_VOICE_HI_IN. Beats the global voice |
Murf has no streaming path: the whole sentence is rendered before playback. Dedicated voices cover English, Hindi, Tamil and Bengali; other Indian languages go through a multilingual voice.
Vexyl
A self-hosted Indian-language engine reached over a WebSocket, with named speakers and style control.
| Variable | Default | Set it when |
|---|---|---|
VEXYL_TTS_URL (required) |
ws://localhost:8092 | The engine runs on another host or port |
VEXYL_TTS_API_KEY |
unset | The engine requires a key |
VEXYL_TTS_STYLE |
default | You want warm or formal. Default and warm pick a female speaker, formal a male one |
VEXYL_TTS_STYLE_<LANG> |
default | One language needs a different style, for example VEXYL_TTS_STYLE_ML_IN |
VEXYL_TTS_SPEAKER |
unset | Pinning a named speaker (for example Anjali or Harish). Beats style selection |
VEXYL_TTS_SPEAKER_<LANG> |
unset | One language needs its own speaker, for example VEXYL_TTS_SPEAKER_ML_IN. Beats the global speaker |
VEXYL_TTS_TIMEOUT |
unset | Cap on a request |
Per-call voice and model
A single call can use a different provider, model or voice from the host default. Set these keys in the session metadata before the call is answered (from the dialplan), as SIP headers on the INVITE, or in an outbound call request. Every accepted voice and model id is listed in Per-call settings reference. The metadata endpoints are in HTTP API.
| Metadata key | Meaning |
|---|---|
tts_provider |
The provider for this call. An unrecognised value is rejected with a log warning and the host default is used |
tts_model |
Honoured by openrouter (vendor/name) and gemini (a bare id such as gemini-3.8-flash-lite-tts; that call goes to Vertex) |
tts_voice |
openrouter: voice id. smallest: voice id, including cloned voices. sarvam: speaker. google: voice name, whose locale drives the language. gemini: a prebuilt name or a voice_<uuid> custom voice |
curl -X POST http://127.0.0.1:8081/session/UUID/metadata \
-H "Content-Type: application/json" \
-d '{"tts_provider": "smallest", "tts_voice": "avni"}'
Only the provider named by tts_provider reads tts_model and tts_voice, so a fallback to another provider never receives a voice id it cannot use. Values are sanitised on entry: a model is vendor/name or a bare id, and a voice is at most 160 characters of letters, digits, and _ . : -. Browser widget sessions cannot set these keys. The cache key and the billing record follow the resolved model and voice.
Custom voices
Vexyl designs a Gemini voice from a text description, or replicates a voice from a consent-recorded sample, on request, and hands you a voice_<id>. Set it as GEMINI_TTS_VOICE_NAME or GEMINI_TTS_VOICE_<LANG>, or pass it per call as tts_voice. The gateway routes such calls to Vertex automatically, whatever the model. A description sets persona and timbre, not a regional dialect; only a replicated voice captures a real accent.
The TTS cache
The cache stores rendered audio on disk so a repeated sentence costs no synthesis and plays at once. It is off unless you enable it. It suits greetings, prompts, menus and FAQ answers; it does little for replies that are unique every time.
| Variable | Default | Set it when |
|---|---|---|
TTS_CACHE_ENABLED |
false | Always, to turn caching on |
TTS_CACHE_DIR |
cache/tts under the gateway's working directory |
You want it elsewhere (/app/cache/tts in Docker). Mount the parent so the cache survives restarts |
TTS_CACHE_MAX_SIZE_MB |
5000 | The disk is smaller, or the phrase set larger |
TTS_CACHE_MAX_AGE_DAYS |
90 | Entries not read for this long are removed. Age counts from the last read, so a greeting played on every call never expires |
TTS_CACHE_CLEANUP_STRATEGY |
lru | Choose lru, size, ttl or none for what is deleted when the cache is full |
TTS_CACHE_STATS_LOGGING |
unset | Set true to log hit rate periodically |
TTS_CACHE_WARMUP_ON_START |
unset | Set true to pre-render a phrase file at boot |
TTS_CACHE_WARMUP_FILE |
unset | Path to the phrase file for the boot warm-up |
The cache key includes the text, language, provider, model and voice settings, so a changed voice makes new entries instead of serving the wrong audio. The old entries still sit on disk, though, and cached audio keeps the voice it was made with. Purge the cache after you change a voice, speaker, model or transport (for example moving Sarvam v4-flash from WebSocket to HTTP). Stop the gateway first, and start it again afterwards.
- Binary install: stop the service, run
rm -rf cache/tts/*in the gateway's working directory, start the service. - Docker with a bind mount: stop the container, run
rm -rf ./cache/tts/*on the host, start it. - Docker with a named volume: run the commands below.
docker compose down
docker run --rm -v PROJECT_vexyl-cache:/c busybox sh -c 'rm -rf /c/tts/*'
docker compose up -d
Check the hit rate:
curl -s http://127.0.0.1:8081/cache/stats
The response covers the time since the process started. hitRateChars is the figure that tracks your bill, because a hit on a three-character "Yes" and a miss on a long sentence are not an even trade. hitRateRequests counts requests instead. byProvider gives hits, misses, hit characters and miss characters per provider, and cache gives disk entries, size and the last cleanup. Per-call figures are in the billing record.
Pre-render phrases before callers need them:
curl -X POST http://127.0.0.1:8081/cache/warmup \
-H "Content-Type: application/json" \
-d '{"phrases": [{"text": "Hello, how can I help you?", "languageCode": "en-IN"}]}'
Warm-up renders through the same path live sentences use, so entries land under the key calls will read. The response reports cached (newly rendered), skipped (already cached), errors and total. When two calls need the same uncached sentence at once, they share one synthesis.
| Symptom | Fix |
|---|---|
| No hits at all | Check TTS_CACHE_ENABLED=true and that the cache directory is writable |
| Old voice after a change | Purge the cache directory and restart |
| Disk filling | Lower TTS_CACHE_MAX_SIZE_MB or TTS_CACHE_MAX_AGE_DAYS |
| Low hit rate | Replies are unique; keep dynamic parts short and warm up the fixed phrases |
Each miss on the sentence-streaming path is logged as CACHE MISS (streaming) with the text, which is the list to mine when choosing what to warm up. Cache behaviour during outages is covered in Troubleshooting.
Sentence streaming and seams
When the bot streams its reply, the gateway cuts it into sentences, renders them in order and plays them back to back. These settings control how smooth that is.
| Variable | Default | Set it when |
|---|---|---|
LITEBOT_SENTENCE_STREAMING |
true |
On. Set false to stop sentence-by-sentence playback and render each reply whole |
LITEBOT_STREAM_MIN_SENTENCE_CHARS |
12 | Short pieces are merged into the next sentence so providers never get a tiny request, unless the short piece is already cached |
LITEBOT_SENTENCE_PREBUFFER_MS |
600 | Audio buffered before playback starts. Raise it if slow providers leave gaps; half of it is rebuffered after a starvation |
LITEBOT_TTS_SYNTH_CONCURRENCY |
1 | Keep 1. Parallel synthesis starves the sentence being played on providers that render near real time |
TTS_SENTENCE_MIN_GAP_MS |
200 | Silence padded between sentences when the audio itself has too little. Providers that already pad get none. 0 disables |
TTS_SENTENCE_EDGE_FADE_MS |
5 | A short fade where a sentence starts or ends mid-waveform, to remove clicks. 0 disables |
TTS_TAIL_DELAY_MS |
40 | Delay after playback ends so the last audio drains before the line is treated as quiet |
PROCESSING_SOUND_HARD_CUT |
true | The thinking sound is cut the moment speech is ready. Set false to wait for its phrase to end |
Barge-in, interruptions and the thinking sound are described in Conversation behaviour; day-to-day checks are in Operations.