13 min read

Locking a voice to your eyes

How Scroll reads the screen, figures out where you're looking, decides when to talk, and stops cleanly the instant your eyes move on — the methodology behind the core loop.

The pitch is one sentence — “it reads your screen as fast as you scroll.” The engineering underneath it is mostly about timing: what to read, how to know where you’re looking, when to speak, and how to stop the instant your eyes move on.

In the first post we set the law: stale narration is worse than sparse narration. This post is how the loop is built to honor it. We’ll walk it in order — capture, attention, pacing, voice, and the ledger — and explain the methodology at each stage. We’ll keep the exact tuning to ourselves; the approach is the interesting part anyway.

Stage 1 — Read the screen, not the pixels

The first real decision was to extract text on-device and never stream video frames to the model. Feeding a screenshot on every scroll would add latency, cost more, and — worst of all — be unreliable exactly where it matters: dense legal text and code. It would also push images off your machine continuously, which is a privacy non-starter for the documents Scroll is meant to read.

So capture is a router with two providers and a referee between them:

Finding the one column worth reading

A real page is mostly chrome: nav bars, sidebars, comment rails, related links. Reading all of it aloud would be worse than useless. So once we have the visible blocks, a selector scores each one for how “body text” it looks — length, prose-like sentence shape, whether it’s a heading, how central it is in the viewport — and penalizes the narrow, tiny, top-of-window fragments that tend to be toolbars. The highest-scoring block anchors a reading lane, and we pull in the blocks that share its column. That one selector is the difference between “Scroll read me the article” and “Scroll read me the sidebar.”

One normalized unit, hashed for memory

Everything past capture speaks one vocabulary: a normalized content block with a type, its text, its vertical position, optional per-line frames, and a content hash. That hash — computed after normalizing whitespace and case — is quietly load-bearing. It’s how Scroll dedupes blocks across captures, remembers what it has already read so it doesn’t repeat itself when you scroll back, and even resumes a document across sessions without re-reading the parts you already heard. A small idea doing a lot of work.

Stage 2 — Model the attention, not just the scroll

“Where are your eyes” is unknowable, so we estimate it from the things we can see: a reading line, your dwell, and your pointer.

Together these turn a raw scroll offset into something closer to a guess about what you’re actually trying to read — which is what the next stage needs.

Stage 3 — Decide when to talk (the pacing controller)

This is the part that earns the product, and the part the original blueprint flatly labeled “the secret sauce,” so we’ll describe the method and keep the dials in the drawer. The pacing controller is a small, pure, heavily-tested state machine. It watches a smoothed estimate of your scroll velocity and direction and classifies the moment:

What you doWhat it infersWhat you hear
Stop / settle“Explain this”The fullest narration your settings allow, on what’s in view
Steady reading pace“Keep me oriented”A lighter pass, one step coarser than your base detail
Fast scroll“Just the landmarks”Terse signposts — “…now the indemnification clause…”
Fling / scrollbar jump“I’m relocating”Silence, then it narrates wherever you actually land

Three principles keep that from feeling laggy or chatty:

The net effect is intentional: Scroll is not a continuous reader. It waits for meaningful moments, uses the audio still in your speaker as backpressure so it never talks over itself, and would rather say nothing than say something stale.

Stage 4 — Speak it, over a raw socket

For narration we drive a realtime speech model directly. The transport choice is worth a paragraph because it looks wrong until you see why.

The API can be driven over WebRTC or a plain WebSocket. WebRTC is the easy default for voice apps because it solves microphone capture — echo cancellation, jitter buffering, packet loss. But narration has no microphone input: it’s text in, audio out. WebRTC’s benefits buy us nothing and cost us a heavy native dependency.

The decision

Drive the model over a plain WebSocket and own the hard parts ourselves — buffering, playback scheduling, reconnects, jitter. For a text-in / audio-out native app that’s the right trade. (We’d revisit WebRTC only if Scroll ever took live voice commands.)

That choice comes with sharp edges worth documenting. The realtime session is configured audio-only — asking for text and audio together disables the audio — so captions are read from the audio transcript the model streams alongside the speech, not from a second channel. Output is raw PCM audio at 24 kHz, which we schedule into our own playback queue. Get one of those details wrong and you get silence with no error, which is exactly the kind of thing you only learn by shipping.

Stage 5 — The ledger that makes interruption honest

Here’s the piece that never demos but decides everything. When you scroll away and the voice has to cut off, we have to tell the model exactly how much it actually spoke — not how much it generated. Those differ, because audio lives in a local queue between “generated by the model” and “heard by you.” Truncate at the wrong number and the model’s memory of what it said drifts from reality, and the next thing it says makes no sense.

So Scroll keeps an audio playback ledger: per response, it tracks what was generated, what has actually played, and where the local queue sits. A clean barge-in then becomes a precise sequence: invalidate the stale audio, cancel the in-flight response, ask the server to truncate the conversation to the milliseconds you actually heard, flush the local queue ourselves, and return the HUD to listening. (Clearing the local queue is on us — the server-side clear is a WebRTC-only call that does nothing over a socket, another lesson best learned once.)

The metric that actually decides the product

It’s tempting to optimize first-audio latency. But that’s a means, not the end. The number that decides whether Scroll feels magic or broken is stale-audio duration: how many milliseconds of narration keep playing after you’ve visually moved on. We built a meter for it — it counts exactly that, off the ledger — and we watch it like a north star.

First-audio latency is what you measure. Stale-audio is what you feel.

With read-ahead and a model tuned for low think-time, continuous scrolling felt attached to the eye. The plan, at this point, was to drive stale-audio to zero by interrupting the voice the instant the page moved. That plan did not survive contact with real use — which is the next post.

Read less. Understand more.

Scroll explains papers, PRs, and docs out loud as fast as you skim. Try it free for 7 days with email only, or keep it forever for $49.

Download free trial ›

← All posts in the build journal