Live calls
One websocket per call. You push caller audio and play what comes back. Voice activity detection, turn taking, barge-in, transcription, the LLM and text-to-speech all run server side. You do not orchestrate anything.
Connect
wss://api.latency.cam/ws/stream?api_key=sk-lat-...
The key goes in the query string, not a header. Browsers cannot set headers on a WebSocket, so a header-only scheme would rule out browser clients entirely. A bad key gets a JSON error frame with code: "unauthorized" and then close code 1008, so you see a reason instead of an opaque handshake failure.
1. Configure, first frame
Audio sent before this is refused.
{
"type": "configure",
"language": "hi",
"script": "roman",
"audio_format": "mulaw_8000",
"voice": "Hindi (Female)",
"llm": "nova-lite",
"system_prompt": "You are a delivery helpdesk. Reply in Hindi, under 12 words.",
"silence_ms": 500,
"interrupt_min_words": 3,
"use_fillers": true,
"greeting": true
}You get back {"type":"configured","config":{...}} echoing everything that was applied, so you can assert on it.
| Field | Default | Notes |
|---|---|---|
language | hi | Set it from your CRM. Beats auto-detect on both speed and accuracy. |
script | roman | For Hindi, Roman text sounds noticeably better than Devanagari. Other languages use native. |
voice | female for the language | Exact string from /v1/models, e.g. Tamil (Male). 38 voices, all verified. |
audio_format | mulaw_8000 | Applies in both directions. This is what telephony gives you. |
silence_ms | 500 | Silence that ends a turn. Lower feels snappier but clips people who pause mid-sentence. |
interrupt_min_words | 3 | How much speech is needed to cut the bot off. Keep it at 3 or more: Indian callers backchannel with "haan" and "ji" constantly and those must not interrupt. 0 means any sound does. |
use_fillers | true | Leave it on. This is the single biggest factor in how fast the agent feels. |
auto_language_detect | false | Safety net for mid-call switches. Costs about 200ms on the turn it runs. |
normalize_numbers | true | Leave it on, or "15250" gets read as digits instead of "pandrah hazaar do sau pachaas". |
2. Stream caller audio
Binary frames, 20 milliseconds each — 160 bytes for 8kHz mulaw. Send them roughly one every 20ms, in real time. Do not dump a whole file at once: turn detection would see it as one enormous utterance and never fire.
3. Read events
A normal turn produces these, in order:
speech_started caller began talking
turn_end 500ms of silence, turn closed { turn, audio_ms }
filler_played cached audio already sent to you { id, category }
transcript.final { text, turn, language, stt_ms, model }
llm.reply { text, turn, llm_ms }
bot_audio_started { turn, ttfa_ms, text } binary frames start now
bot_audio_done synthesis for this turn finished
turn_complete { turn, total_ms, stt_ms, llm_ms, tts_ms }Binary frames arriving between bot_audio_started and bot_audio_done are raw audio in your configured format, with no header. Write them straight to the call.
Barge-in
interrupted { turn, reason } the caller has taken the turn
speech_abandoned { turn, reason: "barge_in" } old synthesis stoppedBarge-in fires about 200ms after the caller starts speaking, not after they finish. That distinction matters: waiting for end-of-turn means the bot talks over the caller for their whole sentence plus the silence timeout, which is the most obvious way a voice agent feels broken.
Every event carries a turn number. When you see interrupted, drop any audio you have buffered for the previous turn — that is what the number is for.
Other events
| Event | Meaning |
|---|---|
turn_empty | No speech recognised. Nothing was sent to the LLM, so no reply is coming. |
language_detected | Only with auto_language_detect. Carries confidence and the language it switched from. |
error | Carries stage of stt, llm or tts plus the real exception. The call stays open and the turn is abandoned — play a fallback line. |
goodbye, pong | Replies to hangup and ping. |
Frames you can send
| Frame | What it does |
|---|---|
configure | Once, first. |
say | Make the bot speak arbitrary text. This is how you script an opening line or read out a CRM lookup result. |
reset | Clear the conversation history without dropping the connection. |
ping / hangup | Keepalive, and a clean end. |
The turn budget, measured
From the caller finishing their sentence, measured over wss from inside AWS, so excluding the ~238ms India round trip:
1ms filler starts playing <- what the caller actually perceives 166ms transcription done faster-whisper large-v3-turbo int8 553ms LLM reply ready nova-lite via Bedrock, ~387ms 1346ms first bot audio out svara-TTS via vLLM, TTFA ~793ms
Measured wait about 1.5 seconds. Perceived wait about 1 millisecond, because the filler is already playing. That gap is the entire trick, and it is why use_fillers defaults to on.
A complete client
import asyncio, json, os, websockets
KEY = os.environ["LATENCY_API_KEY"]
URL = f"wss://api.latency.cam/ws/stream?api_key={KEY}"
FRAME = 160 # 20ms of 8kHz mulaw
async def main():
async with websockets.connect(URL, max_size=None) as ws:
await ws.send(json.dumps({
"type": "configure",
"language": "hi",
"script": "roman",
"audio_format": "mulaw_8000",
"system_prompt": "You are a delivery helpdesk. Reply in Hindi, under 12 words.",
}))
current_turn, abandoned = 0, set()
async def pump():
# In production this is your telephony provider's media stream.
with open("caller.mulaw", "rb") as fh:
while chunk := fh.read(FRAME):
await ws.send(chunk)
await asyncio.sleep(0.02) # real-time pacing matters
asyncio.create_task(pump())
async for msg in ws:
if isinstance(msg, bytes):
if current_turn not in abandoned:
play_to_caller(msg) # raw audio, no header
continue
ev = json.loads(msg)
if ev["type"] == "bot_audio_started":
current_turn = ev["turn"]
elif ev["type"] == "interrupted":
abandoned.add(current_turn) # stop the old reply
stop_playback()
elif ev["type"] == "transcript.final":
print("caller:", ev["text"])
elif ev["type"] == "error":
say_fallback_line()
asyncio.run(main())Known limitations
- No streaming partial transcripts. Whisper transcribes complete utterances, so
transcript.finalarrives once per turn. Partials would save roughly 70ms of a 1500ms turn, which is why they are not built yet. - Voice activity detection is energy based, calibrated on the first ~300ms of the call. It handles turn taking well and does not reliably tell speech from sustained background noise. Silero VAD would be better and is a known gap.
- The server decides when a turn ended. That keeps the protocol simple and means a naive client cannot break turn taking, but you cannot override it.