← All notes

Running Whisper + Kokoro on a laptop for a real-time sales coach

2026-06-12 · 4 min read

A voice role-play app lives or dies on the gap between you finishing a sentence and the AI prospect answering. Round-trip a cloud STT service, then an LLM, then a cloud TTS service, and you're at three network hops of latency plus three vendors watching a founder practice their pitch. For ToCoach I wanted two of those three hops gone: speech recognition and speech synthesis run on the machine, and only the LLM call leaves it.

The models are smaller than people assume. Whisper base.en is ~150MB; Kokoro-82M is ~80MB. Both download automatically on first run and load through ONNX Runtime — Whisper via Transformers.js, Kokoro directly. The whole speech stack fits in less disk than a single node_modules folder, runs offline on a plane, and your fumbled discovery calls never leave the laptop.

The pipeline

MediaRecorder (mic)
  → Whisper base.en (ONNX, on-device)   ~transcript
  → Claude (the prospect persona)        ~reply text
  → Kokoro-82M (ONNX, on-device)         ~speech
  → Web Audio playback

Two implementation details do most of the latency work. First, sentence-level TTS: don't wait for the full LLM reply — synthesize and start playing the first sentence while the rest is still generating. That's what gets speech onset under a second; synthesizing the whole reply first would double or triple perceived latency for long answers. Second, tiered Whisper: tiny.en is available for weaker machines, base.en where the accuracy is worth the extra inference time.

The day I lost to a header

Transformers.js wants multithreaded ONNX inference, and multithreading in a browser context means SharedArrayBuffer. What nobody tells you: since Spectre, SharedArrayBuffer only exists on pages that are cross-origin isolated — served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. My symptom was nothing so helpful as an error saying that. Inference just ran single-threaded and slow, and one library path threw a SharedArrayBuffer is not defined deep inside a worker where the stack trace pointed everywhere except the actual cause.

A full day of profiling model quantization levels later, the fix was two lines — in Electron, headers on the session:

session.defaultSession.webRequest.onHeadersReceived((d, cb) => cb({
  responseHeaders: { ...d.responseHeaders,
    "Cross-Origin-Opener-Policy": ["same-origin"],
    "Cross-Origin-Embedder-Policy": ["require-corp"] }
}));

If your in-browser inference is mysteriously slow, check crossOriginIsolated in the console before you touch anything else. It's a boolean. It would have saved me a day.

What I'd do differently

I'd budget for the audio plumbing, not the models. The models were the easy part — they're good now, and they're free. The time went into VAD thresholds, barge-in behavior, MediaRecorder chunk sizes, and the never-brick rule: every AI surface has a deterministic fallback, so a hung model call degrades to an error string, never a frozen session.

On-device speech is no longer the hard mode. The hard mode is browser security headers, audio buffers, and designing for the call that never returns.
More notes →