LiveKit in Production
LiveKit in Production
A tuning and operations guide for teams shipping cascading voice agents (Sarvam STT, then an LLM, then Sarvam TTS) on the LiveKit Agents SDK.
Everything here is derived from the current livekit-plugins-sarvam plugin source and the livekit-agents runtime. New to LiveKit and Sarvam? Start with Build Your First Voice Agent using LiveKit first; come back here before you ship.
The LiveKit defaults are tuned for English, LiveKit Cloud hosted, Silero VAD driven pipelines. A Sarvam pipeline is none of those three. At minimum, change vad, turn_detection, stream_type, and the endpointing delays. Shipping on defaults is the single most common cause of “the agent feels slow” or “it cuts me off.”
Which STT class to use
saaras:v3-realtime gives you true partial transcripts (barge-in no longer waits for a final), a simpler VAD (three millisecond-valued parameters instead of ten frame counters), live reconfiguration without a reconnect, and a structured event and close-code protocol. This guide documents STTStreaming throughout; the legacy class is covered only in Section 11.
sarvam.STT’s default model is not saaras:v3. If you use the legacy class, pass model="saaras:v3" explicitly.
Contents
- Reference config
- Where latency hides
- Skip LiveKit’s Silero VAD
- STT configuration
- Tuning Sarvam’s VAD
- Turn handling and endpointing
- Barge-in and interruptions
- TTS configuration
- Measuring latency
- Capturing request IDs
- The legacy class and migration
- Reliability and failure modes
- Tuning playbook
- Copy-paste configs
1. Reference config
Every override above is explained below. If you change nothing else, change vad, turn_detection, stream_type, and endpointing.min_delay.
2. Where latency hides
The user-perceived latency is the sum of the whole chain, not any one model:
Three things worth knowing:
- The first hop is a hard floor most people never look at. The plugin buffers audio into fixed-size chunks before sending: 1000 ms on the default
stream_type="balanced", 500 ms on"fast". Sarvam’s VAD can’t detect end-of-speech until the chunk containing that silence arrives, sobalancedadds up to a full second in front of every turn. Setstream_type="fast". vad_min_silence_msandmin_delaystack, they don’t overlap. 500 ms of Sarvam silence plus a 0.5 smin_delayis a full second of dead air. Tune both together.- TTS time-to-first-byte is dominated by connection state, not model speed. A cold Sarvam TTS websocket costs a full TLS handshake on the critical path. Call
prewarm()(Section 8).
Measure this instrumented, don’t guess: Section 9.
3. Skip LiveKit’s Silero VAD
Pass vad=None and let Sarvam’s server-side VAD drive turn-taking.
STTStreaming runs with endpointing="vad" by default, so the server emits vad.speech_start and vad.speech_end on every session, and the plugin maps those onto the framework’s start/end-of-speech events. That VAD already runs server-side on the exact audio the recognizer sees, is tunable through three explicit parameters trained on Indic speech (Section 5), and adds no local inference cost. Running Silero alongside it means two VADs disagreeing about turn boundaries: double-triggered interruptions, warnings in your logs, and endpointing metrics anchored on the wrong timestamp.
If you omit vad, AgentSession silently installs a LiveKit Inference-hosted Silero VAD, which needs LiveKit Cloud credentials and adds a network hop. Pass vad=None explicitly to opt out.
Passing vad=None does disable four framework features gated on a VAD instance. Be deliberate about the trade:
The trade is a good one on saaras:v3-realtime specifically: because it emits real partial transcripts and signals end-of-speech before the final transcript, barge-in fires on partials (Section 7) and preemptive generation actually engages (Section 6), neither of which worked without a VAD on the legacy plugin.
The semantic turn detector is a weak fit for Sarvam regardless: outside LiveKit Cloud it resolves to a local model whose per-language thresholds cover 14 languages (Hindi included), falling back to an English-calibrated threshold for everything else. Sarvam’s own VAD plus a tuned min_delay is the more predictable choice for Indic languages.
If you do keep a VAD (for example, on LiveKit Cloud, to get the EOT model for Hindi), its min_silence_duration must be at least 0.25 s, or AgentSession raises a ValueError.
4. STT configuration
Endpoint: wss://api.sarvam.ai/speech-to-text-realtime/ws. Model is fixed at saaras:v3-realtime; any invalid parameter raises ValueError at construction.
stream_type is the single biggest latency knob. fast buffers audio in 500 ms chunks before sending; balanced (the default) and simulated use 1000 ms. Set "fast" for any interactive agent; the default is the single most impactful bad setting for voice.
endpointing: vad (default) has the server emit speech-start and speech-end events, and pairs with turn_detection="stt". manual has the plugin send events itself on the first audio frame and on flush; pair with the framework’s turn_detection="manual" for push-to-talk or IVR flows. In manual mode the VAD tuning parameters aren’t sent at all.
mode:
codemix is worth A/B-testing against transcribe for consumer-facing Indian deployments.
prompt carries domain vocabulary: product names, city names, SKUs, agent names. It’s the cheapest accuracy win available and frequently left empty. Unlike the legacy class, the realtime plugin sends it as a URL query parameter on the websocket handshake, so keep it short (a compact term list) or a long prompt risks a rejected handshake; use a TTS pronunciation dictionary (dict_id) for output-side fixes instead.
Live reconfiguration. STTStreaming.update_options(...) sends a config update over the live websocket, no reconnect, no dropped audio, and the plugin defers any endpointing switch until the current utterance completes. Updatable in place: language, stream_type, mode, prompt, endpointing, and all three VAD parameters. sample_rate and return_timestamps are connection-only: changing them on a live stream is logged and ignored, so set them at construction.
sample_rate and encoding: 8000 or 16000 only, and linear16 only. Use 16000 for WebRTC; 8000 matches PSTN, but upsampling to 16 kHz is usually cheaper than degrading the recognizer’s input.
Event mapping:
END_OF_SPEECH precedes FINAL_TRANSCRIPT, the opposite of the legacy plugin, and it’s why preemptive generation works here (Section 6): the framework marks the turn committed at end-of-speech, then the arriving final triggers speculative LLM work and starts the endpointing hold. If the final never arrives, the plugin emits END_OF_SPEECH anyway after a 2.0 s fallback.
5. Tuning Sarvam’s VAD
Three parameters, sent only when endpointing="vad". All default to None (Sarvam’s server-side default applies); set them explicitly whenever you need reproducible behavior across environments.
Lowering vad_min_silence_ms ends turns faster (snappier, more mid-sentence cutoffs); raising it holds through natural pauses (no cutoffs, more dead air). It stacks with endpointing.min_delay (Section 6): budget them together, since tuning one down while leaving the other conservative won’t move the needle.
Raise vad_sot_threshold first on noisy telephony, before touching anything else; it’s the cleanest defense against a VAD that keeps triggering on ambient audio. Raise vad_min_speech_ms to ignore clicks, coughs, keyboard noise, and DTMF beeps; lower it to catch very short words.
Recommended starting points by channel:
Tune the VAD before you tune min_delay. They compensate for each other, so changing both at once means you can’t attribute a regression.
6. Turn handling and endpointing
min_endpointing_delay, allow_interruptions, turn_detection, preemptive_generation, and similar flat AgentSession kwargs are deprecated in favor of a nested turn_handling dict:
turn_detection: set it to "stt". Omitting it defaults to a semantic EOT model that needs a VAD and is English-calibrated outside 14 languages; "vad" needs a LiveKit VAD; "stt" trusts Sarvam’s own vad.speech_end and is the right choice here.
Endpointing defaults, and why to change them:
min_delay is a hard sleep after the turn is detected and before it’s committed: sleep(min_delay - time_since_last_speech). It’s a grace period, not pure waste; a new vad.speech_start during the hold cancels the pending commit. Because Sarvam’s VAD already confirmed silence server-side, the default 0.5 s is redundant margin stacked on margin. Dropping it to 0.25 s typically removes about 250 ms of dead air per turn with no increase in cutoffs, provided vad_min_silence_ms isn’t itself set aggressively low.
max_delay only applies when a semantic EOT model returns a low probability. With turn_detection="stt" or vad=None, no prediction is ever made, so the hold is always exactly min_delay. Keep max_delay as a documented ceiling, but don’t tune it expecting an effect.
Preemptive generation should stay enabled. Because saaras:v3-realtime emits end-of-speech before the final transcript, the framework has already marked the turn committed by the time the final arrives, which is the condition it needs to start speculative LLM work: a genuine improvement over the legacy plugin, where the reverse event order kept speculation inert in "stt" mode.
Interim transcripts don’t trigger speculation; the framework only speculates on finals. Interims buy you barge-in and live UI, not an earlier LLM start.
mode: "dynamic" learns min_delay per session from the user’s own pausing rhythm, clamped so your configured value is a floor. Worth trying for long consultative calls with a stable speaker; skip for short transactional calls, which never accumulate enough samples to learn anything. With turn_detection="stt" and vad=None there are fewer observations available, so expect it to stay near the floor; "fixed" is the honest default.
Other defaults worth revisiting:
7. Barge-in and interruptions
Interruption is triggered from three places in the runtime; only two are reachable with vad=None: an interim-transcript hook, since Sarvam’s transcript.partial lands there, and a final-transcript hook as backstop. VAD-driven interruption never fires. So barge-in latency equals the time for Sarvam to return the first partial of the interrupting speech, a single 500 ms chunk plus model and network time on stream_type="fast", not a completed utterance or local VAD energy detection. It’s also more robust than local-VAD barge-in: a cough, a door slam, or TV audio can’t interrupt the agent, because none of those produce a transcript.
Since min_duration is inert, min_words is your noise filter:
0 (default) lets any transcript interrupt; occasionally over-eager on ASR artifacts. 1 is a good default, filtering empty or whitespace-only artifacts. 2 suits noisy channels, but while the agent is speaking, a user turn shorter than min_words is dropped entirely rather than merely failing to interrupt, so single-word acknowledgements get discarded mid-agent-speech. Don’t exceed 2, and don’t go above 1 at all if single-word confirmations matter in your flow. Word counting is whitespace-delimited, so Devanagari, Tamil, Telugu, Bengali, and similar scripts are counted correctly with no adjustment needed.
False-interruption recovery. When something interrupts the agent and no real user turn materializes, the agent’s speech is paused, not cancelled; after false_interruption_timeout seconds of silence it resumes and fires an agent_false_interruption event. Lower the timeout to 1.2 to 1.5 s (the 2.0 s default is a long, awkward silence on a call). Instrument the event; a high false-interruption rate means the VAD is too eager, and the fix is at the VAD (raise vad_sot_threshold or vad_min_speech_ms), not here.
For confirmations, legal disclaimers, or OTP readback, disable interruptions per turn rather than for the whole session (which makes the agent feel like an IVR):
8. TTS configuration
Use bulbul:v3; temperature and dict_id are only supported on this model.
Validation ranges (enforced client-side, ValueError unless noted): pace 0.3 to 3.0, loudness 0.5 to 2.0, temperature 0.01 to 2.0, min_buffer_size 30 to 200, max_chunk_length 50 to 500, output_audio_bitrate one of 32k/64k/96k/128k/192k, speech_sample_rate one of 8000/16000/22050/24000/32000/44100/48000, output_audio_codec one of mp3/opus/flac/aac/wav/linear16/mulaw/alaw. pitch (-0.75 to 0.75) is the one exception: out-of-range values are clamped with a warning instead of raising. Speaker and model compatibility is validated at construction, so an unsupported voice fails at startup rather than mid-call.
Latency levers, in order of impact:
prewarm() in practice:
Verify it worked: TTSMetrics.connection_reused should read True and acquire_time near zero from the second turn on. If it’s False every turn, you’re likely constructing a new sarvam.TTS() per session instead of reusing one.
Leave send_completion_event=True; setting it False means the receive loop never sees a completion signal and blocks until it times out as APITimeoutError.
Voice quality. pace at 1.0 often reads slightly fast for support flows; 0.9 to 0.95 measurably helps comprehension, and intelligibility degrades above 1.15. temperature at 0.3 to 0.5 gives consistent delivery for regulated content (disclaimers, amounts, OTPs); 0.8 and up is more expressive, at the cost of occasional mispronunciation. dict_id (v3 only) is worth setting for brand names and place names that generic TTS mangles. AgentSession filters markdown and emoji from TTS input by default; keep both on, and instruct your LLM to produce properly punctuated sentences, since the plugin tokenizes on sentence boundaries.
9. Measuring latency
Track end-to-end latency first, then decompose. ChatMessage.metrics is the primary surface; every committed turn carries these fields:
Report the p50 and p95, not just the average. (p50 is the median: half your turns are faster than this. p95 is the value 95% of turns beat, so it captures the slow tail that an average hides.) A p50 of 800 ms with a p95 of 4 s feels broken to users even though the average looks fine.
metrics_collected remains the per-request latency and request-ID feed (usage and cost tracking has moved to session_usage_updated). TTSMetrics.cancelled=True counts barge-ins that killed a synthesis in flight, a good proxy for interruption health. For realtime STT, billed audio duration comes from the server’s session.end when available, falling back to a local estimate if the session dies first; reconcile against Sarvam’s dashboard rather than assuming either source is complete.
The SDK also emits OpenTelemetry spans (user_turn, eou_detection, stt_node, llm_node, tts_node, tts_request_run). Wire up an OTel exporter in production; it’s the only view that shows where a slow turn was actually slow, and it’s where Sarvam’s TTS request IDs land (Section 10).
Suggested SLOs for a cascading Sarvam pipeline:
10. Capturing request IDs
Sarvam’s server-side request_id is what their support team needs to investigate a bad transcript or mispronunciation. Coverage differs by surface:
STTMetrics.request_id is the real Sarvam ID; log it and you’re done for STT. On the streaming TTS path, TTSMetrics.request_id is a locally generated ID assigned before Sarvam replies, so Sarvam’s actual ID only reaches the OTel span attribute lk.provider_request_ids; enable tracing if you need it there. ChatMessage.metrics["provider_request_ids"] is populated only for realtime (speech-to-speech) models, so it stays empty in any cascading STT-LLM-TTS pipeline.
Events emitted before Sarvam’s first message with an ID (a START_OF_SPEECH at session start, for instance) can carry an empty request_id. Treat "" as “not yet known,” not as an error.
A minimal production log line should carry session_id, turn_id, speech_id, the Sarvam STT request ID, the Sarvam TTS request IDs, and e2e_latency. With those six fields you can escalate any complaint to Sarvam support with evidence. speech_id ties one utterance’s LLM and TTS calls together; segment_id identifies an individual synthesis segment within an utterance. Keep the IDs logged for as long as your support SLA requires, and don’t store transcript text alongside them unless that store meets your PII policy: Indian consumer call transcripts routinely contain names, addresses, and financial details.
11. The legacy class and migration
sarvam.STT with model="saaras:v3" is the only path that supports batch recognize() (STTStreaming raises NotImplementedError for it). If you’re maintaining an existing deployment on it, the differences that matter:
New builds should use STTStreaming. Migrating is mostly a constructor swap plus replacing the VAD parameters per Section 5, but re-tune min_delay afterward, since the event ordering and chunking both change your latency profile.
12. Reliability and failure modes
Retries. STTStreaming uses the framework default: 3 retries, 2.0 s interval, 10.0 s timeout. That timeout is too long for voice; a caller will have hung up. Tighten it:
For calls you can’t afford to drop, wrap STT and TTS in a FallbackAdapter; a failed TTS is a silent agent, which is worse than a wrong one.
Websocket close codes are the most useful operational signal available:
Non-fatal error events (is_fatal: false) log a warning and let the session continue; watch their rate as an early degradation signal, since it’s otherwise invisible. Fatal errors raise APIStatusError. ValueError on any parameter fires at construction time, so instantiate sarvam.STTStreaming and sarvam.TTS in setup_fnc, not the entrypoint, so config errors fail the worker at startup rather than the caller’s session.
Sarvam closes idle TTS websockets after 60 s. The plugin defends on two layers, a transport-level ping every 20 s and an application-level ping every 30 s, so stale pooled connections are detected and replaced transparently. Size your key’s rate limits against peak concurrent calls times two (one STT socket plus one TTS socket per call), not average volume, and alert on close code 1003.
13. Tuning playbook
Change one thing at a time and measure between steps.
- Instrument first. Wire up Section 9 and Section 10 before touching any parameter, and collect a baseline over at least 30 real calls.
- Get the architecture right.
stream_type="fast",vad=None,turn_detection="stt",model="bulbul:v3". Confirm there’s noTurnDetector requires a VAD modelwarning at startup; if you see it,vad=Nonedidn’t take effect. - Tune Sarvam’s VAD. Raise
vad_min_silence_msif users get cut off mid-sentence; lower it if there’s dead air after they finish. Raisevad_sot_threshold, thenvad_min_speech_ms, if noise or crosstalk is triggering turns. - Trim
endpointing.min_delay. Step down from 0.5 in 0.1 s increments (0.4, 0.3, 0.25, 0.2), stopping one step before cutoffs reappear. Most Sarvam pipelines land at 0.2 to 0.3 s. - Fix barge-in. Raise
interruption.min_words(0, then 1, then 2) until noise-triggered interruptions stop, watching that real single-word replies survive. Lowerfalse_interruption_timeoutto 1.2 to 1.5 s. For telephony, setaec_warmup_duration=None. - Cut TTS time-to-first-byte.
min_buffer_size=30,output_audio_codec="linear16"(ormulawfor 8 kHz), andprewarm(). - Cut LLM time-to-first-token. Shrink the system prompt, scope the tool list to the current conversation phase, cap conversation history, and keep
max_tool_stepslow. - Polish the voice.
pace0.9 to 0.95 for support flows, lowertemperaturefor regulated content, and adict_iddictionary for brand and place names. - Re-baseline. Re-measure over 30-plus calls and set alerts on p95
e2e_latency, false-interruption rate, close code 1003, and non-fatal STT error rate.
14. Copy-paste configs
WebRTC or app: lowest latency, clean audio
Telephony or SIP: noisy, echo handled by the carrier
For compliance and audit flows, use mode="verbatim", return_timestamps=True, a higher vad_min_silence_ms (around 900), a lower temperature (around 0.3), and disable preemptive_generation to avoid speculative spend; disable interruptions per-turn for disclosures. For push-to-talk or IVR, set endpointing="manual" on the STT and turn_detection="manual" on the session, then call session.commit_user_turn() yourself on a DTMF key or button release.