Cutting a game's voice loop from seven seconds to one and a half
- FastAPI
- vLLM
- Llama 3.1
- faster-whisper
- ElevenLabs
- Firebase
- Unity
The problem
The character is a spectator. You place a building, the weather turns, you say something out loud, and she reacts: curious about what you're doing, with no idea what a level or a gold count is. That premise puts two hard constraints on the backend.
The first is latency. A companion who takes seven seconds to respond isn't a companion, she's a loading screen. The first working version did exactly that, and it was unusable in a way no amount of prompt tuning would fix.
The second is cost. A game fires events constantly: UI updates, stat changes, weather, buildings appearing. If every event became an inference call and a text-to-speech call, the per-player bill would scale with how much the player fidgeted.
What I built
- A streaming voice endpoint. One FastAPI route takes the player's microphone audio plus the game's current context and returns a chunked audio stream, so Unity starts playing before the reply has finished being written.
- Local speech-to-text. faster-whisper on the GPU, quantised to int8 with a single beam and a voice-activity filter: accuracy that's good enough for conversational input, at a fraction of the time a larger configuration costs.
- Llama 3.1 8B on vLLM, co-located on the same GPU pod as the API so the model call never leaves the machine.
- WebSocket text-to-speech. Model tokens are fed into a persistent synthesis connection as they're generated, and audio comes back on the same socket while the model is still writing.
- An event pre-classifier that decides which game events are worth answering at all: the thing that removed 40–60% of inference calls.
- A separate silent-observation endpoint, so the game can tell the character what's happening without provoking a response.
- Per-world memory and rate limiting, keyed to a world id the client keeps, with per-tier request budgets and Firebase authentication in front.
Where the seven seconds went
The original design was sequential and file-based: upload the audio, transcribe it, call the model, wait for the whole reply, synthesise the whole reply, write an MP3, return a URL, let the client download it. Every stage waited for the one before it to finish completely, and the player waited for all of them.
Getting to ~1.5s took six changes, and only one of them was about making a component faster.
1. Stream instead of finishing
The endpoint returns a chunked stream rather than a finished file. Synthesis begins on the first sentence, so the player hears the start of the answer while the model is still producing the end of it. This is the single biggest win, and it's a change in the shape of the response, not in the speed of anything.
2. Give the player something to hear immediately
Even a fast pipeline has an unavoidable floor: transcription, plus the synthesis handshake. So the stream opens with a pre-loaded audio file (a soft breath, held in memory at startup) and the real reply follows behind it.
I like this one because it isn't an engineering fix at all. The measured pipeline is the same length; the perceived wait drops to nearly zero because the character audibly reacts the instant you stop talking. In a product where the character is meant to feel present, a breath is a better answer than a faster GPU. It also has to be content-neutral: it plays before the system knows whether you said hello or asked a question, so it can't be anything specific.
3. Do the blocking work off the event loop
Transcription is a blocking GPU call, and the naive version stalled the whole async service while it ran: including the filler audio that was supposed to be covering it. It runs in a thread now, kicked off as a task while context and memory are assembled, so the pieces that don't depend on each other stop pretending they do.
4. Split the prompt into a cached half and a cheap half
The character's personality, behavioural rules and examples were roughly 80% of the tokens on every single request, and they never change. They're now loaded once at startup and held as a static system prompt, while the per-turn layer carries only what actually varies: time of day, recent observations, recent conversation, the classified event, and what the player just said. Replies are capped at 80 tokens, which keeps her to one or two sentences: shorter is both cheaper and, for this character, better writing.
5. Compress the upload
This one was invisible until I measured it. Time-to-first-byte includes the player's own upload, and a WAV from a phone microphone is roughly ten times the size of an equivalent MP3. I wrote a small benchmark that runs the same request in both formats and reports the difference, then changed the client recommendation. No server-side optimisation would have found this, because the time wasn't being spent on the server.
6. Stop the proxies buffering it
A streaming response that a reverse proxy decides to buffer is just a slow non-streaming response, and it looks identical to a backend problem from the client side. Explicitly disabling buffering on the response is a one-line fix for a bug that will otherwise waste an afternoon.
Not calling the model is the best optimisation
The game wants to tell the backend everything that happens. Most of it doesn't deserve a spoken response: a stat change or a UI toggle isn't something a character should comment on, and answering it is both expensive and annoying.
So events are classified before anything expensive happens. Buildings appearing or being destroyed, weather changing, world events, direct conversation and greetings get a reply; minor updates and idle states are absorbed silently. That removed 40–60% of inference calls, and it made the character better rather than worse: she comments when something worth commenting on happens.
Two smaller versions of the same idea: a dedicated endpoint lets the game feed observations in with no response at all, and synthesised audio is cached by a hash of the text and voice so repeated lines cost nothing the second time.
Keeping the character honest
A character who "sees" your world will happily invent things she has never been told about. Early on she'd congratulate players on buildings that didn't exist, which is worse than saying nothing: it breaks the illusion that she's actually watching.
- An explicit grounding rule. The system prompt forbids mentioning any physical change (buildings, structures, weather) unless it appears in the observations passed in for that turn. If nothing happened, she is told to talk about the player instead of inventing scenery. It's the same principle as grounding an answer in retrieved documents, applied to a game world.
- A bounded observation buffer. The last handful of visual facts per world, persisted so they survive a restart, and only the most recent few are put in the prompt. Bounded context is a quality decision as much as a cost one.
- A deliberate blind spot. She perceives visual change but not numbers: no levels, no gold, no XP. Telling the model what it shouldn't understand turned out to be as important as telling it what it should.
- Two versions of every reply. The model writes roleplay markers like *yawns*, which are nice on screen and absurd read aloud. The text sent to synthesis is stripped of them; the text sent to the UI keeps them.
- In-character failure. When the model call fails, the fallback isn't an error: it's a line about the wind being too loud to hear clearly. Players experience a bad moment in the fiction instead of a broken game.
The client integration shaped the API
Two decisions came out of what the Unity side could actually do. The audio stream carries only audio, so the text for the on-screen bubble is fetched separately by a session id returned in the response headers: the alternative was multiplexing text and audio in one stream, which is more elegant and much harder to consume in a game engine.
And the world id lives on the client. The game generates one, stores it, and sends it with every request; the backend keys memory off it. That kept the whole first version free of a user-to-world mapping problem, at the cost of trusting the client with an identifier: the right trade at that stage, and the first thing I'd revisit for a real launch.
What I'd do differently
- Per-world JSON files don't survive success. Memory is a file per world, which is genuinely fine for a prototype and wrong the moment there are two server processes. It wants a real store.
- Hosted synthesis was the right call and the obvious limit. Using a managed voice API got a good voice into the game immediately, but it puts a network hop and someone else's rate limit in the middle of the latency budget. On my next voice project I served synthesis myself, and got both the latency and the voice cloning that a hosted API wasn't going to give me.
- No barge-in. The player can't interrupt her mid-sentence. It wasn't required here, but building it later taught me that retrofitting interruption is much harder than designing the pipeline to be cancellable from the start.
- The rate limiter fails open. A deliberate choice: a limiter bug shouldn't take the game down, but it means the cost ceiling is softer than it looks. Worth being honest about rather than filed under "resilience".
Notes
Client work, so the repository isn't public and the game isn't named. The numbers above are from my own instrumentation and benchmark harness: every stage of the request logs its own timing, which is the only reason it was possible to find where the seven seconds actually went.
Related
- Voice AI engineeringThe general shape of a real-time speech loop and its latency budget.
- LLM infrastructureServing models with vLLM and treating one as an upstream dependency.
- Voice AI agentsTurn-taking, interruption and the state around a live conversation.
- Real-time voice agent platformThe larger system I built later: retrieval, cloning and a video avatar.