HomeWorkWhat I doContact
All projects

VieSpeak case study

Making an AI tutor feel like a conversation

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
VieSpeak App Store preview: speak out loud and the AI replies
VieSpeak App Store preview: corrections inside the conversation
VieSpeak App Store preview: CEFR level scored on six speaking criteria
VieSpeak App Store preview: word-by-word pronunciation score

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.

  1. App

    Flutter

    16 kHz mic audio, streamed as the learner talks

  2. Session

    NestJS

    One WebSocket, with turn state on the server

  3. Speech to text

    Deepgram Nova-3

    Interim and final transcripts

  4. Language model

    DeepSeek or Gemini

    Streamed tokens behind one interface

  5. 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.
matches(utterance: string, historyLength: number): boolean {
  return (
    !this.cancelled &&
    !this.failed &&
    historyLength === this.historyLength &&
    normalizeUtterance(utterance) === this.normalized
  );
}

02

Letting the learner interrupt

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.

Let's build
something
together.

Open to senior roles: remote, hybrid or on-site in Ho Chi Minh City. The quickest way to reach me is email or LinkedIn.