How the real-time voice loop behind VieSpeak replies quickly, stops when it is interrupted and keeps going when a provider fails.
Role
Flutter app, NestJS backend, voice AI
Platforms
iOS, Android
Stack
Flutter, NestJS, PostgreSQL, Redis, WebSocket
Try it
The problem
Every delay is audible
VieSpeak is where Vietnamese learners practise speaking English out loud with an AI tutor. Nobody keeps practising with a partner who answers late, talks over them or goes silent.
Every reply crosses three outside services: speech-to-text, a language model and text-to-speech. Any of them can be slow or refuse a request, and the learner hears every delay.
Architecture
From one request to a live stream
The first version was one HTTP request per turn: record the learner, upload the clip, transcribe it, generate the text, synthesize the whole reply and send back a single audio file. Preloaded filler clips hid part of the wait, but the wait was still there.
I replaced it with a streaming pipeline over one WebSocket, so each stage starts as soon as the one before it has something to give.
App
Flutter
16 kHz mic audio, streamed as the learner talks
Session
NestJS
One WebSocket, with turn state on the server
Speech to text
Deepgram Nova-3
Interim and final transcripts
Language model
DeepSeek or Gemini
Streamed tokens behind one interface
Text to speech
Cartesia Sonic
Audio streamed back as text arrives
The bilingual tutor mode uses ElevenLabs in both directions, because it has to understand and speak Vietnamese.
Key decisions
What makes it feel live
01
Replies that start at the pause
Waiting for speech-to-text to confirm the end of a turn leaves all of the model's start-up time for after the learner has stopped talking.
A small energy-based detector watches the mic stream. After 340 ms of quiet, the session starts generating a reply from the transcript so far and only buffers it. If the final transcript matches, the turn adopts that reply. If the learner carries on, the call is aborted and nothing was ever sent.
Waiting for the final transcript
Learner
Confirm
Model
AI voice
Starting at the pause
Learner
Model
AI voice
Schematic, not to scale.
The detector uses 20 ms frames, an adaptive noise floor and hysteresis, so a soft voice in a noisy room does not read as a pause.
A wrong guess costs one discarded model call, never a cut-off learner.
At most three attempts per turn keep the cost bounded.
People cut in when they talk. A naive interrupt, though, would fire on a cough or on the AI's own voice coming back through the phone speaker.
Interruptions use a candidate and confirm model. Voice activity opens a candidate, and real words in the transcript within 700 ms confirm it. The server then aborts the model, closes the speech stream and tells the app to drop the audio it has queued.
Signals in the first 400 ms of AI speech are ignored, because they are far more likely to be noise or echo.
If no words arrive in time, the candidate expires silently and the AI keeps talking.
Exam mode is left out on purpose: a real examiner does not get interrupted.
03
Surviving a provider failure
When a speech vendor refuses a turn halfway through, because of a quota or concurrency limit, everything already sent to it is gone.
Each turn keeps its own text, so the session can open a second vendor and replay the turn there. The learner hears the reply a moment late instead of not at all.
One fallback per turn, so two refusing vendors can never loop.
The fallback voice only speaks English, so Vietnamese sentences stay in the transcript and are not read aloud as noise.
Grading calls retry with jittered backoff, then switch provider behind a circuit breaker.
A speech-to-text socket that drops mid-session reconnects with backoff.
Measuring
Numbers that add up
Every turn writes a structured log with its latency broken into stages, rolled up in Redis.
One metric looked far too slow and read like a hidden problem in the pipeline. It was not: the timer started when the learner began speaking, so it included their whole sentence. I split it so the stages add up exactly, and pinned that arithmetic in tests.
first audio = learner speech + final transcript + first token + first audio byte
Voice behaviour has its own specs for barge-in, speculation, provider fallback, reconnects and the latency arithmetic.