<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: XTSoft</title>
    <description>The latest articles on DEV Community by XTSoft (@xtsoft_0c4a5615ffbefe25de).</description>
    <link>https://dev.to/xtsoft_0c4a5615ffbefe25de</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4116605%2F07f46230-c3f6-486a-b546-e57befe4729a.png</url>
      <title>DEV Community: XTSoft</title>
      <link>https://dev.to/xtsoft_0c4a5615ffbefe25de</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/xtsoft_0c4a5615ffbefe25de"/>
    <language>en</language>
    <item>
      <title>I kept pausing Japanese streams — so I built tab-audio captions on Cloudflare Workers</title>
      <dc:creator>XTSoft</dc:creator>
      <pubDate>Wed, 09 Sep 2026 01:49:53 +0000</pubDate>
      <link>https://dev.to/xtsoft_0c4a5615ffbefe25de/i-kept-pausing-japanese-streams-so-i-built-tab-audio-captions-on-cloudflare-workers-2cdb</link>
      <guid>https://dev.to/xtsoft_0c4a5615ffbefe25de/i-kept-pausing-japanese-streams-so-i-built-tab-audio-captions-on-cloudflare-workers-2cdb</guid>
      <description>&lt;p&gt;I'm a Chinese speaker living in the US. Weekdays are mostly English: standup on Zoom, design docs, Slack threads that somehow always explode at 5pm. Nights and weekends are Japanese — VTuber streams, cooking livestreams, the occasional anime episode I refuse to watch with English-only subs.&lt;/p&gt;

&lt;p&gt;For months my "language practice" looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Twitch / YouTube open in Chrome
&lt;/li&gt;
&lt;li&gt;Host says something fast
&lt;/li&gt;
&lt;li&gt;I miss it
&lt;/li&gt;
&lt;li&gt;Pause
&lt;/li&gt;
&lt;li&gt;Scrub back 8–12 seconds
&lt;/li&gt;
&lt;li&gt;Still not sure → open a dictionary tab
&lt;/li&gt;
&lt;li&gt;Stream moves on without me
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That loop is survivable for a recorded course. It is miserable for a live stream where chat is already three jokes ahead.&lt;/p&gt;

&lt;p&gt;I didn't want another upload-a-video translator. I wanted: stay on the same tab, see the original line + a Chinese (or English) translation almost immediately, keep watching.&lt;/p&gt;

&lt;p&gt;So I built a Chrome/Edge extension that captions &lt;strong&gt;whatever audio is playing in the tab&lt;/strong&gt;, and put the realtime path on &lt;strong&gt;Cloudflare Workers + Durable Objects&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The product constraint (before the stack)
&lt;/h2&gt;

&lt;p&gt;Hard requirements from day one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Works on &lt;strong&gt;any tab with audio&lt;/strong&gt; — YouTube, Twitch, Netflix-in-browser, Zoom/Meet in the browser, random course sites
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No file upload&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bilingual&lt;/strong&gt; overlay (source + target)
&lt;/li&gt;
&lt;li&gt;Latency low enough that a live stream still feels watchable (~0.5s ballpark for me)
&lt;/li&gt;
&lt;li&gt;Prefer keeping &lt;strong&gt;raw audio in the tab&lt;/strong&gt;; only ship text / small chunks for processing
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That ruled out "export MP4 → wait → download SRT." The extension had to own capture; the edge had to own the session.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the pieces fit (CF-shaped)
&lt;/h2&gt;

&lt;p&gt;High level, nothing fancy:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extension (Chrome MV3)&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Uses &lt;code&gt;tabCapture&lt;/code&gt; / offscreen document patterns to grab the tab's audio stream, run VAD-ish chunking so I'm not uploading silence, and render a floating bilingual overlay. Settings and history live locally first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worker (stateless edge)&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Auth, plan checks, routing. A request comes in: "here's a short audio/text segment for session &lt;code&gt;X&lt;/code&gt;." The Worker doesn't hold long-lived state — it just verifies the user and hands the work to the right place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Durable Object (one per caption session)&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
This is the part that made the architecture click for me.&lt;/p&gt;

&lt;p&gt;Each Start click maps to a Durable Object keyed by session id. That object:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Owns the &lt;strong&gt;WebSocket&lt;/strong&gt; to the extension (hibernation-friendly)
&lt;/li&gt;
&lt;li&gt;Queues / serializes ASR + translation steps so chunks don't arrive out of order on screen
&lt;/li&gt;
&lt;li&gt;Tracks &lt;strong&gt;minute usage&lt;/strong&gt; for the free tier without racing a shared Redis
&lt;/li&gt;
&lt;li&gt;Keeps a short rolling buffer of recent caption lines (enough to recover a reconnect without rebuilding history from scratch)
&lt;/li&gt;
&lt;li&gt;Tears down cleanly when the user hits Stop or the tab dies
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tried the "just shove everything through a single Worker + external WebSocket server" version first. It worked until two tabs and a flaky Wi‑Fi night. Per-session Durable Objects made the failure domain obvious: one stuck session doesn't take down everyone else's captions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Cloudflare specifically&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
I didn't want to babysit a caption server in us-west-2 while I'm watching a stream at 1am PT. Workers put the handshake close to me; Durable Objects give me single-threaded session semantics without standing up Redis + a sticky-session LB for a side project that started as "please stop pausing."&lt;/p&gt;

&lt;p&gt;ASR / translation providers sit behind the Worker as normal HTTPS calls. The interesting bit for me wasn't swapping models — it was keeping the &lt;strong&gt;session glue&lt;/strong&gt; boring and correct at the edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "good enough" latency felt like
&lt;/h2&gt;

&lt;p&gt;I didn't optimize for leaderboard numbers. I optimized for: can I leave this on for a 90-minute cooking stream without rage-pausing?&lt;/p&gt;

&lt;p&gt;For me, captions landing around &lt;strong&gt;half a second&lt;/strong&gt; after speech is the line between "tool" and "distraction." Faster is nicer; much slower and my thumb goes back to the spacebar.&lt;/p&gt;

&lt;p&gt;So the goal stayed: &lt;em&gt;watchable live captions&lt;/em&gt;, not &lt;em&gt;court-ready transcripts&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A normal evening with it
&lt;/h2&gt;

&lt;p&gt;Typical flow now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install the extension
&lt;/li&gt;
&lt;li&gt;Open the Japanese stream (or the English Zoom tab for work)
&lt;/li&gt;
&lt;li&gt;Pick target language — Chinese when I'm tired, English when I'm taking notes
&lt;/li&gt;
&lt;li&gt;Hit Start
&lt;/li&gt;
&lt;li&gt;Read along: original on top, translation under it
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Meetings: browser Meet/Zoom tab → Start → stop pretending I caught every acronym.&lt;br&gt;&lt;br&gt;
Language nights: dual subtitles on, dictionary tab closed unless I really want a deep dive later.&lt;/p&gt;

&lt;p&gt;I still export / copy history when I need searchable notes after a lecture. Live comprehension and archival transcripts turned out to be different jobs — which is why the Durable Object keeps only a short buffer, not a forever archive in SQLite by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy, said plainly
&lt;/h2&gt;

&lt;p&gt;"Listens to tab audio" sounds creepy if you care about privacy (I do — this runs on my work laptop too).&lt;/p&gt;

&lt;p&gt;Design intent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Capture stays in the &lt;strong&gt;extension / tab&lt;/strong&gt; path
&lt;/li&gt;
&lt;li&gt;The session DO sees &lt;strong&gt;segments for that session&lt;/strong&gt;, not a permanent dump of every stream I've ever watched
&lt;/li&gt;
&lt;li&gt;UI makes it obvious when captioning is running
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not a formal security audit. Just: I wouldn't ship something I refuse to enable myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I actually shipped
&lt;/h2&gt;

&lt;p&gt;All of that is packaged as &lt;strong&gt;SonicCaption&lt;/strong&gt; — Chrome/Edge extension, freemium minutes, bilingual overlay.&lt;/p&gt;

&lt;p&gt;Site: &lt;a href="https://soniccaption.com" rel="noopener noreferrer"&gt;soniccaption.com&lt;/a&gt;&lt;br&gt;&lt;br&gt;
Short demo: &lt;a href="https://www.youtube.com/watch?v=uYHMBQvShtI" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'm not doing a pricing pitch here. The interesting part was the constraint set and putting session state in Durable Objects instead of "yet another Node process."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself six months ago
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Fix the pause loop, not "AI translation" as a vibe.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
The job was staying present on a live Japanese stream while my brain is half in English from work Slack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. One Durable Object per session beats a clever global queue.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Ordering, reconnects, and usage counters got simpler when the unit of isolation matched the unit of user intent (Start → Stop).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Universal tab audio beats site-specific hacks for v1.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Special-casing YouTube forever is how you never ship Twitch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Dual subtitles change behavior.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Source + target together cut how often I alt-tabbed to a dictionary mid-stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open questions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Where do you split live captions vs archival transcripts in product &lt;em&gt;and&lt;/em&gt; in storage?
&lt;/li&gt;
&lt;li&gt;How do you explain &lt;code&gt;tabCapture&lt;/code&gt; permissions without sounding like malware?
&lt;/li&gt;
&lt;li&gt;For bilingual UI on a 13" laptop: stacked lines, side-by-side, or toggle — what feels least noisy?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've built realtime stuff on Workers / Durable Objects (or fought the same pause-rewind ritual on JP streams), I'd love to hear what you landed on in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built this because I needed it. Feedback: &lt;a href="mailto:support@soniccaption.com"&gt;support@soniccaption.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>chrome</category>
      <category>cloudflare</category>
    </item>
  </channel>
</rss>
