NAOMS Devlog

Building a sovereign, local-first memory & identity system โ€” in the open, honestly.

Live Captions from a Model That Can't Stream

The on-device speech engine we use has no streaming mode โ€” every call decodes one fixed window of audio and wipes its own memory afterward. Here is how we're building live, running captions on top of it anyway, by owning the sliding window ourselves and keeping a hard rule that the raw audio is gone the instant it's used. In progress, not finished.

Technology Architect free July 3, 2026ยท7 min readยทvoice
TL;DR We're building live, on-device speech captions โ€” the kind that scroll under you as you talk โ€” and the hard part is that the speech model we run has no streaming mode at all. It decodes one fixed chunk of audio and forgets everything between calls. This is a builder's walkthrough of how we're turning that batch, stateless model into a running caption stream: a sliding window we own on our side, overlapping decodes stitched together, and a strict rule that the raw microphone audio is wiped the moment it's consumed. This path is in progress, not a finished feature โ€” only the early rung, audio actually arriving at the tap, is owner-accepted. We'll be plain about what is real today and what is still being wired.

Most speech-to-text you've used works like a stream: you talk, words appear under you, and it feels like the model is listening continuously. That feeling is a construction. The on-device engine we run to keep transcription private has no streaming mode. None. And the way we're building live captions on top of it is the interesting part โ€” so this is a walkthrough of the hard part, honestly labelled: it's in progress, not a shipped feature.

The problem: a model that only knows how to answer once

The engine is a small, quantized Whisper model running through whisper.cpp โ€” a lean C++ port of a well-known open speech model, chosen precisely because it runs entirely on your machine with nothing phoning home. The catch is in its shape. Its decode call takes a fixed window of audio, transcribes it, and returns. That call is stateless: the internal working memory it builds up โ€” the attention cache a transformer accumulates as it reads โ€” is reset on every invocation. There is no "keep listening" entry point. There is no handle you hold across calls. Each call is a fresh, sealed question: here are N seconds of sound, what were the words? โ€” and then it forgets you were ever there.

So the naive approach โ€” "feed it audio continuously and read the captions off the other end" โ€” is not a thing that exists. The API you'd want was never written, because the model wasn't built to work that way. It's a batch tool. We wanted a live one.

The move is to stop waiting for a streaming API and build the streaming ourselves, around a batch core we call over and over.

The trick: own the window, overlap the decodes, keep the tail hot

The component that does this is a small Rust piece that holds three things: the loaded model, a ring buffer, and a resampler. Together they turn a one-shot decoder into something that produces running text.

Resample first. Microphones on these machines typically capture at 48kHz. Whisper wants 16kHz. So the first thing that happens to incoming audio is a high-quality 3:1 downsample โ€” a proper sinc resampler, not a crude every-third-sample throwaway, because feeding the model dirty audio costs you accuracy you can't get back.

Then a sliding ring buffer. The resampled audio flows into a fixed-size ring buffer โ€” 30 seconds of it, 480,000 samples at 16kHz โ€” a circular chunk of memory that always holds the most recent slice of sound and quietly overwrites the oldest as new audio arrives. It never grows. It's a moving window onto "the recent past," and it's the surface we decode from.

Then overlapping decodes on a heartbeat. Roughly once a second, we take a 7-second window out of that buffer and run the batch decoder on it. Seven seconds for a caption that updates every one second means each decode overlaps its neighbors heavily โ€” the same words get transcribed several times across successive windows. That overlap is not waste; it's the whole mechanism. It's how we paper over the model's amnesia. Because the decoder has no memory of the last call, the audio itself has to carry the context forward, and the only way to do that is to let windows overlap so each new decode re-hears the recent past.

Then split the window: hot tail, cold body. Here's the part that makes it read as live rather than jumpy. Of that 7-second window, we treat the last 2 seconds as a context tail โ€” provisional, still changing, likely to be revised as more audio arrives to disambiguate it. The 5 seconds before it are "hot" and settling. Text that has scrolled out past that trailing 2-second tail is considered stable: we emit it as final and it won't move again. Text still inside the tail stays provisional โ€” shown, but subject to revision on the next pass. It's the same instinct as a person catching the tail end of a half-heard sentence and mentally rewriting it a beat later once the next words land.

Then de-duplicate at the seams. Because windows overlap, the same word gets decoded in consecutive passes, and a dumb concatenation would stutter โ€” "the the quick quick brown." A language-aware layer above the decoder detects and removes those cross-window duplicates, so the stitched output reads as one clean running transcript instead of a pile of overlapping fragments.

flowchart TD
  A[Microphone audio ยท 48kHz] --> B[Sinc resampler ยท 3 to 1 downsample to 16kHz]
  B --> C[(Sliding ring buffer ยท 30s / 480,000 samples ยท oldest overwritten)]
  C -->|about once per second| D[Take a 7-second window ยท 5s hot + 2s context tail]
  D --> E[Batch decoder ยท stateless ยท KV cache reset every call]
  E --> F[Stitch overlapping decode with previous ยท drop duplicate words]
  F --> G{Is this text outside the trailing 2 seconds?}
  G -- yes --> H[Emit as FINAL ยท settled, won't change]
  G -- no --> I[Keep PROVISIONAL ยท revise on next pass]

The invariant we won't trade away: the audio is gone the moment it's used

There's a rule threaded through this that a cloud transcription service structurally cannot make. When raw audio samples cross the boundary into the speech component, that component copies them on entry and never holds onto the caller's buffer past the return. The samples it works from are its own; the originals aren't retained. Audio doesn't accumulate somewhere waiting to be mishandled โ€” it's consumed and released as it flows through.

This matters because the sliding-window design is, by nature, holding a rolling buffer of your recent speech in memory. That's unavoidable โ€” you can't decode sound you've already discarded. So the discipline is to make that window the only place raw audio lives, keep it bounded to those 30 seconds, and guarantee nothing leaks out the sides into some long-lived copy. A remote service, by contrast, has to receive your audio over the wire and hold it long enough to process on hardware you don't control. The privacy property here isn't a promise in a policy document; it's a shape in the code โ€” bounded buffer in, transcript out, raw bytes gone. That's the Honesty axiom applied to sound.

One more tell that this path is meant to be non-optional: the release build literally refuses to compile unless the speech feature is switched on. Voice isn't a bolt-on that might or might not be present โ€” the binary won't exist without it.

What's real today, and what isn't

Now the honest part, because the honest part is the point.

This live-transcription path is in progress, not finished, not celebrated. The broader voice-and-video effort it belongs to had its overall celebration retracted earlier this year, and it's back in implementation. The one rung that is owner-accepted is narrow: audio actually arriving at the tap โ€” the microphone-to-buffer plumbing genuinely working end to end. The transcription mechanism this article describes is wired up and lives on the main line, but it has not been rung-celebrated as a finished, user-facing feature. Recent work around it has been plumbing and gap-closing โ€” for instance, making sure the small model file ships to the right place so the engine can find it โ€” not the epic's completion.

Two more limits worth stating plainly. On Apple platforms, the live path uses a different speech engine entirely, tuned for that hardware, rather than the whisper.cpp path described here. And on Android, this Rust whisper path isn't present yet at all. So "we do live on-device captions" is not a flat, everywhere claim โ€” it's a mechanism that is real on one path, being built out, with the edges honestly ragged.

What's genuinely settled is the idea, and it's a good one: you don't need a model with a streaming API to build a live caption stream. You need to own the window yourself โ€” resample clean, keep a bounded rolling buffer, decode overlapping slices on a heartbeat, hold the tail provisional and let the body settle, stitch and de-duplicate the seams โ€” and trade a little latency and some recompute for the feeling of the words keeping up with the voice. It's a batch model wearing a real-time coat we tailored by hand. We're still hemming it. But the clever part โ€” the part that makes a stateless, one-shot decoder feel like it's listening โ€” is real, and it's on the main line today.

For the wider reasoning behind keeping the voice on your own machine in the first place, see giving the system a voice that stays home.

Written by AI agents from real project logs; owned and edited by Mujo.


Written by AI agents from real project logs; owned and edited by Mujo.

โ† more in Technology   home โœฆ   all โ†’