[{"data":1,"prerenderedAt":4},["ShallowReactive",2],{"article-doc:cookbook\u002Fstable-captions-no-jitter":3},"---\ntitle: \"How to build realtime captions that don't jitter\"\ndescription: \"Live captions that flicker and rewrite themselves destroy trust. Here's the StableStream model: the API only emits stable text, so your client is append-only — no partial→final replacement, no rollback, no debounce.\"\nseries: \"A\"\ndifficulty: \"beginner\"\nreading_time: \"8 min\"\nstatus: \"draft\"\nlang: \"en\"\nrelated_docs:\n  - \"\u002Fconcepts\u002Fstable-vs-partial-text\"\n  - \"\u002Fconcepts\u002Fstablestream\"\n  - \"\u002Fguides\u002Fstable-caption-ui\"\n  - \"\u002Frealtime\u002Fquickstart\"\nrelated_cookbook:\n  - \"stream-mic-audio-over-websocket\"\n  - \"add-realtime-captions-to-a-web-app\"\nsource_urls:\n  - \"https:\u002F\u002Fdocs.lansonai.com\u002Fconcepts\u002Fstablestream\"\n  - \"https:\u002F\u002Fdocs.lansonai.com\u002Fguides\u002Fstable-caption-ui\"\n  - \"https:\u002F\u002Fdocs.lansonai.com\u002Frealtime\u002Fquickstart\"\nclaim_sources:\n  - claim: \"The current realtime API only pushes final\u002Fstable transcription events; there is no partial-text event stream.\"\n    source: \"lanson-audio-docs\u002Fcontent\u002F4.concepts\u002F2.stable-vs-partial-text.md (callout: Current API behavior)\"\n    type: \"fact\"\n  - claim: \"Each conversation.item.input_audio_transcription.completed event contains final, stable text.\"\n    source: \"lanson-audio-docs\u002Fcontent\u002F5.guides\u002F3.stable-caption-ui.md\"\n    type: \"fact\"\n  - claim: \"High accuracy alone does not make live captions usable; stability is what users actually feel.\"\n    source: \"lanson-offical-react\u002Fpublic\u002Fblog\u002Fen\u002Freadable-captions-beyond-accuracy.md\"\n    type: \"opinion\"\n  - claim: \"In a 6-client flood test, utterance completion stayed around 500-650ms p50, max under 800ms, overSlaCount 0.\"\n    source: \"founder manual source LASN-595 (lanson-admin-nuxt\u002Ffounder-influence\u002Fintel\u002F2026-06-19.md) + workflows\u002Fopenai-partner-network\u002Flanson-live-technical-architecture.md (Latency Budget)\"\n    type: \"metric\"\n  - claim: \"Reflow is a comprehension cost: when a line moves, the reader must relocate it and re-verify what they already read, competing with listening.\"\n    source: \"lanson-offical-react\u002Fpublic\u002Fblog\u002Fen\u002Fevaluating-real-time-captions-readability-vs-accuracy.md (Stability: reflow is a comprehension cost)\"\n    type: \"opinion\"\n  - claim: \"Production realtime traffic runs a heavier latency tail than the controlled flood test.\"\n    source: \"Axiom dataset lanson.backend.api, event realtime_pipeline_completed, query p0_realtime_handler_wall_ms_by_stt_vendor (lanson-dashboard-server\u002Fsrc\u002Flib\u002Fguardian-apl.ts)\"\n    type: \"metric\"\ncreated_at: \"2026-08-27\"\nupdated_at: \"2026-08-27\"\n---\n\n# How to build realtime captions that don't jitter\n\n> **TL;DR** — Most live-caption tutorials assume you receive a stream of *partial* text that you must keep replacing until it \"finalizes.\" That model is what causes the flicker. LansonAI's realtime API takes the opposite approach: it only emits **stable** text. Your client becomes **append-only** — no replacement mapping, no rollback, no debounce. This post shows the model and the minimal code.\n\n## Problem\n\nYou build a live caption UI. The first demo looks fine. Then a real speaker talks at normal speed, and the captions start to misbehave:\n\n- A word appears, gets replaced by a different word, then changes again.\n- A whole line rewrites itself mid-sentence.\n- The text \"jitters\" — characters shift left and right as the model revises its guess.\n\nThe result is worse than no captions at all. Users stop trusting what they're reading, because they can't tell what's final and what's still being decided.\n\nThe usual fix is to build a **partial → final state machine**: keep the latest partial in a \"working\" slot, and only commit it to the transcript when the API tells you it's final. You add replacement logic, rollback logic, and debounce to hide the churn. It works, but it's a lot of client complexity for a problem that's really a *protocol* problem.\n\n## Architecture\n\nThe root cause is a design choice about **what the server sends**.\n\nThere are two models:\n\n**Model 1 — Partial stream (the common one).** The server sends a best-guess on every audio chunk. The client renders the guess, then keeps overwriting it as better guesses arrive, until a \"final\" event locks it in. The client owns the stability logic.\n\n```text\nserver:  \"hel\"  →  \"hello\"  →  \"hello w\"  →  \"hello wo\"  →  FINAL \"hello world\"\nclient:  renders each, replaces the previous, commits on FINAL\n```\n\n**Model 2 — Stable stream (LansonAI's StableStream).** The server does the stabilization work. It only sends an event when an utterance has reached a stable boundary. What it sends is final. The client never sees a partial, so there's nothing to replace.\n\n```text\nserver:  (silence while the utterance is still forming)\n         →  FINAL \"hello world\"\nclient:  appends. done.\n```\n\nThe key reframe: stability is not a binary `wrong → correct`. It's a lifecycle — `mutable → increasingly reliable`. Model 1 pushes the whole lifecycle to the client. Model 2 collapses it server-side and hands the client only the stable end.\n\n> **Why we built it this way** — In live speech, the system doesn't have all the information until the utterance is over. Sending partials is cheap, but it exports the hardest part of the problem (deciding what's stable) to every client, in every language, in every framework. Doing it once, server-side, means every consumer gets the same stable contract. This is the same insight behind our \"accuracy is not the same as readable\" position: a caption that is 99% accurate but keeps rewriting itself is *less* usable than one that is 97% accurate and never moves.\n\n## Code\n\nThe full audio pipeline (mic capture → 16kHz mono PCM16LE → 100ms frames → WebSocket) is covered in [How to stream microphone audio over WebSocket](\u002Fcookbook\u002Fstream-mic-audio-over-websocket). Here we focus on the **render side**, which is where jitter is born and killed.\n\n### 1. Connect (browser)\n\nBrowser connections use a short-lived session token from your backend (never put your secret key in the browser):\n\n```javascript\n\u002F\u002F 1. Get a session token from your backend\nconst { token } = await fetch(\"\u002Fyour-backend\u002Fsession-token\", {\n  method: \"POST\",\n}).then((r) => r.json());\n\n\u002F\u002F 2. Open the realtime stream\nconst ws = new WebSocket(\n  `wss:\u002F\u002Faudio.lansonai.com\u002Fv1\u002Faudio\u002Ftranscriptions\u002Fstream?access_token=${token}`\n);\n```\n\n### 2. Append-only render (the whole trick)\n\nBecause every `completed` event is already final, the render path is a plain append. No working slot, no replacement, no rollback:\n\n```javascript\nconst captions = document.getElementById(\"captions\");\n\nfunction appendCaption(utteranceIndex, text) {\n  const p = document.createElement(\"p\");\n  p.dataset.utterance = utteranceIndex;\n  p.textContent = text;\n\n  \u002F\u002F Auto-scroll only if the user is already near the bottom\n  const nearBottom =\n    captions.scrollHeight - captions.scrollTop - captions.clientHeight \u003C 100;\n\n  captions.appendChild(p);\n  if (nearBottom) captions.scrollTop = captions.scrollHeight;\n}\n\nws.onmessage = (event) => {\n  const data = JSON.parse(event.data);\n\n  \u002F\u002F Optional: lightweight status indicator while an utterance is in flight\n  if (data.type === \"input_audio_buffer.speech_started\") {\n    showIndicator(data.utterance_index, \"listening\");\n  }\n  if (data.type === \"input_audio_buffer.speech_stopped\") {\n    showIndicator(data.utterance_index, \"processing\");\n  }\n\n  \u002F\u002F The only event that changes the transcript:\n  if (data.type === \"conversation.item.input_audio_transcription.completed\") {\n    hideIndicator(data.utterance_index);\n    appendCaption(data.utterance_index, data.text); \u002F\u002F final, stable text\n  }\n};\n```\n\n### 3. What you do NOT need to write\n\nThis is the part that surprises people coming from a partial-stream API:\n\n- ❌ No partial → final replacement mapping\n- ❌ No \"working text\" buffer or slot\n- ❌ No rollback \u002F undo of previously shown text\n- ❌ No debounce to hide churn\n- ❌ No re-rendering of already-committed lines\n\nThe transcript is a monotonically growing list. Each `completed` event appends one immutable line.\n\n## Result\n\n- **No visible jitter.** Lines appear once and never move.\n- **Less client code.** The stability state machine simply doesn't exist on the client.\n- **Consistent behavior across clients.** Web, mobile, and server-side consumers all get the same stable contract, because stabilization happens once, server-side.\n\nThe trade you're making — waiting for stable instead of rendering partials — is bounded, not open-ended. In our own flood testing, utterance completion landed around 500–650ms p50; production traffic runs a heavier tail. Notice what that variance does *not* affect: your client code. Because committed text never moves, latency only changes *when* a line appears — never *whether* what the reader already saw is still true.\n\n## Why it matters\n\n1. **Stability is a protocol decision, not a UI trick.** You can paper over jitter with debounce and replacement logic, but you're re-implementing, per client, a decision that belongs in one place. Push it server-side and every consumer inherits it for free.\n\n2. **Accuracy and readability are different axes.** A high-accuracy caption that keeps rewriting itself reads as *broken*, not as *smart*. Reflow is a comprehension cost: when a line moves, the reader has to relocate it and re-decide whether what they already read still means the same thing — effort that competes directly with listening. Users calibrate trust on stability, not on WER. (This is the engineering version of our \"readable captions beyond accuracy\" argument.)\n\n3. **The transferable lesson.** Whenever you stream a provisional result (transcription, translation, LLM output, search), decide *explicitly* where stabilization happens. If you send partials, you've handed your hardest problem to every client. If you stabilize once and emit only the stable result, your clients get simpler and your contract gets stronger.\n\n---\n\n### Further reading (Docs)\n\n- [Stable vs. Partial Text](https:\u002F\u002Fdocs.lansonai.com\u002Fconcepts\u002Fstable-vs-partial-text) — 概念\n- [StableStream](https:\u002F\u002Fdocs.lansonai.com\u002Fconcepts\u002Fstablestream) — 稳定性契约\n- [Build a Stable Caption UI](https:\u002F\u002Fdocs.lansonai.com\u002Fguides\u002Fstable-caption-ui) — API 操作手册版\n- [Realtime Quickstart](https:\u002F\u002Fdocs.lansonai.com\u002Frealtime\u002Fquickstart) — 最小可跑示例\n\n### Related cookbook recipes\n\n- [How to stream microphone audio over WebSocket](\u002Fcookbook\u002Fstream-mic-audio-over-websocket) — 音频管线\n- [How to add realtime captions to a web app](\u002Fcookbook\u002Fadd-realtime-captions-to-a-web-app) — 完整浏览器集成\n",1790059118939]