cd ~/blog

How to build a local AI assistant that answers in two seconds

A build guide for an always-on voice assistant running on one laptop: the latency budget stage by stage, two-layer turn detection, barge-in, and why the runtime matters more than the model for voice cloning.

12 Sept 2026·8 min read· 47·local aivoice assistantlatencyapple siliconmlx

An assistant you talk to has one hard requirement: it has to answer fast enough that you don't feel the gap. Everything else is negotiable. This is how to build one that runs on a single laptop, hits about two seconds a turn, and keeps running after you close the window.

All the numbers here come from an M4 MacBook Air with 16 GB, measured, not estimated, so you can size your own expectations against real hardware.

The budget is the architecture

Start by writing down what you can spend, because every component choice follows from it. Two seconds is roughly the limit before a spoken reply feels broken. Here's where mine goes:

Where two seconds per turn is spentyou stop talkingSilero VAD512-sample frames~1 mslocalwhisper-base.enMLX on Metal0.30 slocalministral-3bvia OpenRouter0.67 sremoteKokoro-82M13.2× realtime0.20 slocalendpointing0.30–0.95 sno model in it, and the second most expensive stage in the loopend of your sentence to start of hers≈ 2 seconds
Endpointing has no model in it and costs more than transcription.
StageChoiceWhereCost
Voice activitySilero v5, 512-sample frames @16ktorch, CPUnegligible
Endpointingacoustic + linguistic policyno model300 to 950 ms
Transcriptionwhisper-base.enMLX / Metal~0.3 s
Brainministral-3bremote0.67 s
SpeechKokoro-82Mtorch / MPS13.2× realtime

The thing to notice is that endpointing, which contains no model at all, is the second most expensive stage in the loop. If you're trying to get a voice agent under two seconds, that's where to look first — not at a smaller transcription model. Which model did you expect to be the bottleneck?

Turn detection needs two layers

Your VAD tells you a pause happened. It doesn't tell you the turn ended, and treating those as the same thing builds something that interrupts people constantly.

Put a linguistic layer on top of the acoustic one:

  • utterance sounds finished, commit after 300 ms
  • it trails off on "and", "so", "because", "um", wait 950 ms for more
  • more speech arrives, append it and re-evaluate

Two smaller constants matter as much as those. A redemption window of ~320 ms stops a breath from ending someone's turn. A pre-speech pad of ~220 ms keeps the onset consonant your VAD would otherwise clip — without it, "sixty" arrives as "ixty" often enough to be a real problem.

Plan for the microphone hearing the speaker

Design for this up front, because every component behaves correctly while the system as a whole fails.

Speakers leak into the mic. So: the assistant speaks, the leak reaches the mic, VAD fires on her own voice, barge-in kills playback about 300 ms in, transcription runs on the leak, and the model answers it. Now she's talking again. That loop has no exit, and every individual piece did exactly what you asked.

(tts 1.97s for 2.4s of audio)
(cut off)
(cut off after 0.3s)
you: ONE            <- her own voice
vela: One.

The fix is to ignore the mic while speaking, plus a tail of around 350 ms for the room to stop ringing. Re-enable barge-in when the user is on headphones, because then the premise is gone. The general lesson is that this class of bug only exists at the orchestrator level, so that's the layer you need to be able to reason about — it's also the layer with no paper to copy.

Match commands before you reach for the model

Opening an app, pausing music, setting a timer, asking the time — match all of these with regular expressions before any model call. It looks primitive next to routing everything through an LLM. Is it? I think so, for three reasons: they answer instantly, they answer identically every time, and nothing can talk them out of it. Anything unmatched falls through to conversation.

The general version: wherever correct behaviour is fully specified, a model converts a guarantee into a probability. Sometimes that's a good trade. Make it deliberately.

Tell the model what it can't do

Small models fail in a specific way that will undermine trust in your assistant faster than anything else: ask about a capability they don't have and they answer anyway. Ask a 3B model what's in your calendar and it'll invent a meeting, with a time.

Put the absences in the prompt explicitly — no calendar, no email, no files, no battery, no location, no browser history, no screen access. That single addition moved my small models from 1 out of 4 to 4 out of 5 on a set of hallucination traps. They probably weren't lying so much as under-instructed: nothing in the context said those capabilities were absent, so the likeliest continuation was the world where they exist.

Memory: read it fresh every turn

SQLite is enough. Four tables carry different lifetimes: episodic (what happened), semantic (what's true), narrative (what it adds up to), and a ledger of predictions written down before they were tested.

The design rule that makes it work is that the situation summary gets read from the database on every turn instead of living in the context window. Ask mine what training step a model grokked at and you get 5600, because that number is a row in the ledger. If it isn't in the database, she says she doesn't know.

Adopt that constraint early. Every companion-style assistant I've seen dies the same way: it has nothing real to report, so it fabricates an inner life, and you feel the lie inside ninety seconds.

Voice cloning, and why it probably won't be fast

If you want the assistant to answer in your own voice, this is the part to understand before you spend a weekend on it.

Profile by stage, and don't skip that step. My clone pipeline took 34.9 seconds to produce 4.9 seconds of audio. The vocoder was 4.8 s of that and is fine. The token generator was 28.4 s — 81.3% of the time, one stage, 5.7× too slow. Every optimisation I'd been considering was aimed somewhere else.

Voice cloning latency broken down by stage34.9 seconds to speak 4.9 seconds of audioone voice-clone reply, profiled by stagetoken generator (T3)28.41 s81.3% · 4.4 tok/s where 25 is realtimeS3Gen, the vocoder4.83 s13.8% · this part is fineeverything else1.69 s4.8%Realtime needs 25 tokens per second. One stage is 5.7× too slow and everyoptimisation I had been considering was aimed somewhere else.
Profile by stage before optimising. The vocoder was never the problem.

Why that stage is slow is architectural, not a tuning problem. It generates speech autoregressively: a 0.5B transformer emitting discrete speech tokens at 25 per second of audio, one at a time, each conditioned on every token before it. A five second reply is ~125 sequential forward passes that can't be parallelised. Kokoro is fast because it isn't autoregressive — it predicts an entire mel spectrogram in essentially one shot. That difference is the entire 90× gap.

The runtime matters more than the model

Here's the comparison that should change how you pick components on Apple silicon, both measured on the same machine:

Token rate for two models on two runtimesSame laptop, same kind of workautoregressive decode is memory-bandwidth bound, not compute boundQwen2.5 · 1.5B · MLX~20 tok/sthree times the parametersChatterbox T3 · 0.5B · MPS4.4 tok/sand four and a half times slowerPer parameter that is MLX being roughly 13× more efficient. Check whatruntime a component ships for before you compare model sizes.
Three times the parameters, four and a half times the speed.

Three times the parameters, four and a half times the speed. Per parameter, that's MLX being roughly 13× more efficient.

Why? Because of what autoregressive decoding actually is: memory-bandwidth bound, not compute bound. Every token means reading the entire weight matrix out of memory, and the arithmetic done with it's trivial by comparison. MLX uses unified memory properly and avoids per-kernel launch overhead; MPS does neither well. If your model also runs classifier-free guidance that's two forward passes per token, so double the bandwidth again — dropping the guidance weight to 0.3 bought me about 2×.

So if you're choosing components for on-device inference on a Mac, check what runtime they ship for before you compare model sizes. It's a bigger lever.

Always discard the first run

A benchmark whose impossibility revealed warmupThe benchmark that could not be truerealtime factor, higher is fasterfirst run, short text0.05×first run, longer text0.29×warm0.59×longer input, less time?Longer text cannot take proportionally less time. That is what made theresult readable: warmup had landed entirely on the first call. Throw awaythe first run of everything.
An impossible result is a gift: it tells you the measurement is wrong before you act on it.

Practical notes

  • Report thermal state. On a fanless machine an hour into work the numbers move meaningfully. Say which run, how warm, how many.
  • Report thermal state. On a fanless machine an hour into work, numbers move meaningfully. Say which run, how warm, how many.
  • Keep a model warm in a separate process. Mine pays 11.5 s load and 5.3 s warmup once instead of per reply.
  • Degrade loudly, not silently. If the clone service is down, the assistant uses the fast voice and logs why, instead of going quiet.
  • Cache your common phrases. "I'm here", "Opening Chrome", "Paused", "It's 11:40 p.m." repeat constantly. Pre-synthesise the top fifty in the good voice, hash-key them, fall back to the fast model for novel sentences. Cheapest win available, and I'd do this before porting anything.

If you build one of these, the part that'll surprise you is how little of the work is model selection. It's a latency budget, a turn-taking policy, and a feedback loop between your speaker and your microphone.

Found this useful?

No sign-in — one like per reader, and you can take it back.