Skip to content
C.W.K.
Stream
Lesson 04 of 05 · published

Faking Real-Time From a Batch Model

~12 min · batch-vs-streaming, localagreement, real-time, preview

Level 0Unlit
0 XP0/48 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"You can't insert a guess and take it back. So only insert the part that stopped being a guess."

The Mismatch

Whisper-family models are fundamentally batch: give them a chunk of audio, get a transcript. But good dictation feels streaming — words appear as you speak. The naive bridge ("record 30 seconds, transcribe once, paste") technically works but feels dead: no feedback while you talk, a long pause at the end. To feel live, you have to fake streaming on top of a batch model — carefully, because the batch model keeps changing its mind about earlier words as it hears more.

LocalAgreement

The trick is a streaming controller that sits above the engine. It keeps a rolling buffer, runs the batch model repeatedly as audio grows, and compares each new hypothesis to the previous one. Where two consecutive passes agree on a prefix, that prefix is committed — it's stable enough to show as final. The still-disagreeing tail stays visible as preview but is never treated as final. Once committed, text is settled. A new pass commits only if it starts with the full committed text; then what it says next is compared with what the previous pass said next, and the agreed part is committed. A pass that rewrote any committed word commits nothing: from words alone you cannot tell where the committed text ends inside it, and guessing is how a sketch commits the same words twice. That pass still previews its words after the longest prefix it shares with the committed text, so nothing new is dropped and the preview never goes blank, and commits resume once passes again start with the committed text and agree. Real implementations align by word timestamps, which is why they can keep committing through a rewrite; this sample stays conservative because words are all it has. That's the LocalAgreement idea: confirm text only after consecutive passes agree, keep the unconfirmed tail visible, and trim the buffer at sentence boundaries when it's safe.

pass N:    "send the report to"
pass N+1:  "send the report to Tuesday"     agreement on prefix ->
           ^^^^^^^^^^^^^^^^^^^  COMMITTED    "send the report to"
                               ^^^^^^^^  still tail -> preview only

# Commit only the agreed prefix. Never insert the volatile tail.

The MVP Discipline

Live-typing while you speak is the hard version, and it's a trap to start there. The honest MVP shows the partial transcript in the overlay as preview but only inserts once, at release — the text of one final pass over the whole clip, its last unconfirmed tail included, so nothing you said is dropped. Crucially, you still separate 'committed' from 'uncommitted' internally even in the MVP — so the day you add true live-type insertion, the machinery is already there and you're just changing where committed text goes (into the app instead of the overlay). Build the data model for the hard version; ship the easy version first.

Never insert text you might retract. Inserting a partial hypothesis and then correcting it in a live app means deleting characters out from under the user — janky at best, destructive at worst. Commit only what's stable (agreed across passes); keep the volatile tail as preview. The user sees motion without suffering corrections.
Model the hard version, ship the easy one. Even when the MVP only inserts once at the end, track committed-vs-uncommitted state internally. It costs almost nothing now and means live-type is a small change later, not a rewrite. Cheap forward-compatibility beats a second-system rebuild.

Code

Commit the agreed prefix, keep the tail as preview·swift
struct StreamingState {
    var committed: [String] = []     // settled words: never revised, only extended
    var previousRest: [String] = []  // what the last pass said after ALL committed words
    var tail: [String] = []          // volatile: preview only, never inserted from here
}

func commonPrefixCount(_ a: [String], _ b: [String]) -> Int {
    zip(a, b).prefix(while: { $0.0 == $0.1 }).count
}

func onNewHypothesis(_ hypo: [String], _ s: inout StreamingState) {
    let k = commonPrefixCount(s.committed, hypo)
    if k == s.committed.count {
        // The pass starts with the full committed text: compare what it says
        // next with what the previous pass said next, and commit the agreement.
        let rest = Array(hypo[k...])
        let agreed = commonPrefixCount(s.previousRest, rest)
        s.committed += rest[..<agreed]              // committed text only ever grows
        s.previousRest = Array(rest[agreed...])
        s.tail = s.previousRest
    } else {
        // The pass rewrote a committed word. Words alone cannot say where the
        // committed text ends in it, so commit nothing; preview its words after
        // the longest common prefix. Commits resume once two passes again start
        // with the full committed text and agree. (Real implementations align by
        // word timestamps, which is why they can keep committing here.)
        s.previousRest = []
        s.tail = Array(hypo[k...])
    }
    overlay.show(committed: s.committed, tail: s.tail)
}

func onRelease(finalPass: String) -> String {
    // At release one last pass runs over the whole clip; its text is final.
    // Insert ALL of it, the never-agreed last tail included, so nothing is dropped.
    return finalPass
}
// Live-type later: insert committed deltas as they stabilize, the final tail at release.

External links

Exercise

Describe what a user sees if you insert every partial hypothesis directly into their document as the model changes its mind. Then describe the LocalAgreement version. Which one could delete a word the user was mid-way through reading, and why is 'preview' the safe home for volatile text?
Hint
Direct-insert means the app rewrites already-placed words as the model revises — characters vanish and reappear under the cursor. LocalAgreement keeps volatile text in a preview overlay the user knows is provisional, and only writes into the real document what has stopped changing. The overlay is where guesses are allowed to be wrong.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.