Skip to content

Voice AI · Voice Agent

Bilingual Voice Agent on LiveKit

Real-time bilingual voice agent for a datacenter reception kiosk, running LLM and TTS inference on CPU‑only compute under live event conditions.

7.47s‑12.19s

pre-fix TTS-first-audio delay, eliminated via sentence-level streaming

2

languages served live — English and Urdu, each on its own TTS pipeline

CPU‑only

entire STT, LLM, and TTS stack, no GPU inference available

LiveKit AgentsPythonFastAPIWhisper Large-v3DeepgramSilero VADQwen 3.5 35B (vLLM)Kokoro TTSMMS TTSQdrantall-MiniLM-L6-v2LangfuseOpenTelemetry

Voice AI · Sole Developer · Full-time employment · 1.5 months from takeover to live deployment, following roughly one week of prior scaffolding by another developer · Deployed and operated as the reception kiosk at a datacenter and telecom infrastructure company's facility inauguration

01
01 · Context

Context

The client, a datacenter and telecom infrastructure company, needed a bilingual voice kiosk for its facility inauguration: a reception-desk agent that could answer questions in English or Urdu using one internal reference document. The engineering reality was CPU‑only inference. No GPU was available for STT, LLM, or TTS, and no single TTS engine handled both English and Urdu well, which meant two separate synthesis pipelines had to run inside one real-time conversation loop. Every latency problem in the system traced back to CPU cycles being scarce, not to model quality. That ruled out the usual fix for a slow-feeling voice agent, which is to throw more parallel compute at synthesis.

02
02 · The Problem

The Hard Problem

The first implementation waited for the complete LLM response before handing it to TTS. Langfuse traces showed tts_first_frame latencies of 7.47 and 12.19 seconds across two conversation turns, meaning the kiosk sat silent for over ten seconds in the worst case before saying anything back.

Turn detection carried a separate risk. With min_endpointing_delay tuned to 0.1 seconds, a user's natural mid-sentence pause could register as the end of their turn, and the kiosk would start responding before they had finished speaking. A false positive on turn detection would interrupt a user mid-sentence in front of live event guests, with no graceful way to recover. That made real speech-boundary detection, not a shorter timeout, the hard requirement.

03
03 · How It Works

How It Works

Ingestion

Parse

Source document converted to text. One relevant document existed, so the ingestion path was kept deliberately simple rather than built for scale it didn't need.

Chunk

Semantic chunking, not fixed-size windows, to keep related information together.

Embed

Chunks embedded with all-MiniLM-L6-v2.

Index

Embedded chunks stored for semantic retrieval at query time.

Query

Capture

Audio streamed through LiveKit Agents, which handled room management and dispatch without custom WebRTC work.

Detect

Silero VAD determines actual speech boundaries instead of a fixed silence timeout.

Transcribe

Whisper Large-v3, fine-tuned on domain vocabulary; Deepgram available as a lower-latency English-only fallback.

Retrieve

Query embedded with all-MiniLM-L6-v2, matched against the indexed chunks.

Generate

Retrieved context injected alongside the query; Qwen 3.5 35B generates the response over vLLM.

Synthesize

Response routed to Kokoro for English or MMS-TTS for Urdu, streamed sentence by sentence rather than as one buffered block.

Return

Audio streamed back through LiveKit to the kiosk output.

Trace

Langfuse captures per-stage spans, including tts_first_frame, so a slow turn can be attributed to a specific stage instead of treated as one opaque delay.

04
04 · Evaluation

Evaluation

Evaluation methodology

Method
Testing combined scripted conversations, targeted query checks, and live rehearsal under kiosk conditions.
Query types
English and Urdu questions, domain-specific terminology, multiple phrasings of the same question, and natural speech with variable pacing and pauses. Interruption and mid-sentence barge-in were not explicitly tested and are not claimed here.
Pass/fail
Required transcription accurate enough to preserve intent, retrieval that surfaced the relevant chunk, a response grounded in that chunk, correct language routing to TTS, and a response that started quickly enough to feel conversational rather than stalled.
Tooling
Langfuse tracing was the primary diagnostic tool: it was how the TTS buffering problem was found in the first place, not a benchmark run after the fact.
05
05 · Key Decisions

Key Decisions

Decision

Shared asyncio execution vs a dedicated single-worker executor

The choice was between running TTS synthesis on the default shared asyncio path or isolating it in its own executor. Synthesis was CPU-bound and, left on the shared path, could block other async I/O in the pipeline in unpredictable ways. I moved ONNX synthesis onto a dedicated single-worker executor so its execution behavior was isolated and predictable.

Tradeoff

Reduced parallelism. A single worker caps throughput, but the kiosk served one conversation at a time, so isolation mattered more than throughput headroom.

Decision

Fixed silence timeout vs Silero VAD

The choice was between a fixed silence threshold and actual voice activity detection. A short fixed timeout risks cutting a user off mid-thought; a long one makes every turn feel sluggish regardless of whether the user has actually finished. I used Silero VAD so turn-taking was based on whether the user was still speaking, not on elapsed time.

Tradeoff

More tuning surface. min_endpointing_delay and related thresholds needed real calibration against natural speech, not just a default value, and getting that wrong (as the 0.1s setting showed) reintroduces the exact interruption problem VAD was meant to solve.

Decision

Full-response TTS vs sentence-level streaming

The choice was between waiting for the complete LLM response before synthesis, or starting synthesis on the first sentence as soon as it was available. The full-response approach was what produced the 7.47s and 12.19s tts_first_frame delays. I switched to sentence-boundary streaming, paired with the dedicated executor and a reduction in TTS frame size from 200ms to 20ms to cut buffering overhead further.

Tradeoff

Streaming partial LLM output into TTS adds orchestration complexity, sentence boundaries have to be detected correctly mid-stream, and it forecloses any synthesis strategy that needs the full response text up front.

06
06 · Implementation

Implementation Detail

The pipeline was originally instrumented as a single end-to-end latency number, which told us users were waiting but not why. I restructured observability around stage-level spans, so VAD, STT, retrieval, generation, and TTS each produced their own timing data in Langfuse rather than being folded into one aggregate figure. That's what made tts_first_frame visible as a distinct metric in the first place and what turned "the kiosk feels slow" into "synthesis isn't starting until the full response is generated." The instrumentation was then separated from the core pipeline logic so it didn't stay scattered across the agent implementation, keeping the pipeline readable while preserving that visibility. An end-to-end number tells you a user is waiting. Stage-level spans tell you where the wait is coming from.

07
07 · Results

Results

7.47s‑12.19s

tts_first_frame latency, pre-fix

Full-buffer TTS implementation, captured via Langfuse tracing across two conversation turns.

200ms → 20ms

TTS frame size reduction

Cut buffering overhead after moving to sentence-level streaming synthesis and a dedicated executor.

720–1120ms

estimated post-fix latency budget

Architectural estimate only; never validated against a live measurement.

CPU‑only

full inference stack

STT, LLM, and TTS all ran on shared CPU compute for the live deployment, no GPU available.

tts_first_frame latency measured 7.47 and 12.19 seconds in the initial full-buffer implementation, captured through Langfuse tracing across two conversation turns. Switching to sentence-level streaming synthesis, a dedicated executor for TTS, and a reduced frame size (200ms to 20ms) removed the full-response wait that caused those delays. No confirmed post-fix tts_first_frame measurement exists; an architectural estimate put the budget at 720 to 1120ms, but that figure was never validated against a live measurement and isn't presented here as an achieved result.

The kiosk was deployed and operated as the reception agent at the client's facility inauguration, handling English and Urdu conversations grounded in the client's reference document. No data exists on incidents during the event or usage beyond it.

08
08 · Reflection

Reflection

The system was built entirely around the absence of a GPU: sentence-level streaming was the ceiling CPU-bound synthesis allowed, and the dedicated executor existed to keep that synthesis from starving the rest of the event loop. With dedicated GPU inference, sample-level TTS streaming becomes possible instead of sentence-level, and the roughly 600 to 1000ms round trip that remote Whisper STT added, the single largest latency contributor in the pipeline, mostly disappears. Most of the tuning work in this project, frame size, executor isolation, buffering strategy, existed to work around scarce CPU cycles rather than to solve a modeling problem. Given the choice again, I would push for dedicated inference endpoints per stage before accepting a shared CPU host as the deployment target, rather than optimizing around that constraint after the fact.

Work with me

Building something similar?

Builds run with a delivery team assembled per engagement, and start with a two-week scoping phase that ends with a fixed price. If you are building a production AI system for a regulated industry, book a 15-minute intro call.

Book a call