<?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: Taha</title>
    <description>The latest articles on DEV Community by Taha (@quietscribe).</description>
    <link>https://dev.to/quietscribe</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%2F4220933%2Fb5709ef7-611a-4944-bc44-816ef991b0f2.png</url>
      <title>DEV Community: Taha</title>
      <link>https://dev.to/quietscribe</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/quietscribe"/>
    <language>en</language>
    <item>
      <title>I built a free Whisper transcription site that runs in the browser: 5 things that broke</title>
      <dc:creator>Taha</dc:creator>
      <pubDate>Sun, 01 Nov 2026 17:12:47 +0000</pubDate>
      <link>https://dev.to/quietscribe/i-built-a-free-whisper-transcription-site-that-runs-in-the-browser-5-things-that-broke-59o6</link>
      <guid>https://dev.to/quietscribe/i-built-a-free-whisper-transcription-site-that-runs-in-the-browser-5-things-that-broke-59o6</guid>
      <description>&lt;p&gt;I'm a YouTube creator, and I got tired of subtitle tools that upload my footage, cap free minutes or stamp a watermark on the video. So I built &lt;strong&gt;&lt;a href="https://quietscribe.pages.dev" rel="noopener noreferrer"&gt;Quietscribe&lt;/a&gt;&lt;/strong&gt;: OpenAI's Whisper running directly in the browser. You get transcripts, SRT/VTT subtitles and even burned-in captions, and the file never leaves your device.&lt;/p&gt;

&lt;p&gt;It's a static site on Cloudflare Pages: no backend for the default mode and zero hosting cost. Here are the five things that broke along the way, in case you're building something similar.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/huggingface/transformers.js" rel="noopener noreferrer"&gt;Transformers.js&lt;/a&gt;&lt;/strong&gt; v3, running &lt;code&gt;Xenova/whisper-tiny&lt;/code&gt; / &lt;code&gt;base&lt;/code&gt; / &lt;code&gt;small&lt;/code&gt; in a module Web Worker&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebGPU&lt;/strong&gt; when it's trustworthy, otherwise &lt;strong&gt;multi-threaded WASM&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COOP: same-origin&lt;/code&gt; + &lt;code&gt;COEP: credentialless&lt;/code&gt; headers (via Cloudflare Pages &lt;code&gt;_headers&lt;/code&gt;) so &lt;code&gt;crossOriginIsolated&lt;/code&gt; is true and WASM can use threads&lt;/li&gt;
&lt;li&gt;Canvas + &lt;code&gt;MediaRecorder&lt;/code&gt; + WebAudio for burning captions into the video (MP4 where the browser supports H.264)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. "Auto-detect" was silently English
&lt;/h2&gt;

&lt;p&gt;My first Urdu test came back as confident English gibberish. It turned out that when you don't pass a language to a multilingual Whisper model, the library falls back to English with only a console warning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// TODO: Implement language detection&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No language specified - defaulting to English (en).&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to do what Whisper does internally. Take the first ~30 seconds of speech, run a single decoding step after &lt;code&gt;&amp;lt;|startoftranscript|&amp;gt;&lt;/code&gt;, and pick the language token with the highest score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;capture&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;asr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;asr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="na"&gt;decoder_input_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decoder_start_token_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;max_new_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;logits_processor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// then argmax over cfg.lang_to_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. iPhone Safari just... reloads the page
&lt;/h2&gt;

&lt;p&gt;On iPhone, the model download reached 100% and then the whole page reloaded. There was no error. Safari kills tabs that use too much memory. What fixed it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Phones use &lt;strong&gt;one WASM thread&lt;/strong&gt; (each thread duplicates memory) and default to &lt;code&gt;whisper-tiny&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Before starting, I write the chosen model to &lt;code&gt;sessionStorage&lt;/code&gt;. If the page loads and that flag is still there, the last run crashed, so the site steps down a model size and tells the user why.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Small models loop
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tiny&lt;/code&gt; and &lt;code&gt;base&lt;/code&gt; sometimes get stuck repeating one word forever, especially in lower-resource languages. Two layers helped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Detect a loop (4+ identical words in a row, or a very low unique-word ratio) and re-run that piece with &lt;code&gt;no_repeat_ngram_size: 4, repetition_penalty: 1.15&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Collapse any 1–4-word phrase repeated 3+ times as a final clean-up step&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Some languages need a bigger model than a phone can hold
&lt;/h2&gt;

&lt;p&gt;For Urdu, the honest answer is that small on-device models aren't good enough, and the large ones don't fit on a phone. So I added an &lt;strong&gt;opt-in Cloud mode&lt;/strong&gt;. The browser decodes the audio to 16 kHz mono, cuts it into ~60-second pieces at the quietest point, and sends each piece to a Cloudflare Worker running &lt;code&gt;@cf/openai/whisper-large-v3-turbo&lt;/code&gt; on Workers AI. Nothing is stored. The free tier covers roughly 200 audio minutes a day.&lt;/p&gt;

&lt;p&gt;One gotcha: &lt;strong&gt;whisper-large-v3-turbo can't translate.&lt;/strong&gt; &lt;code&gt;task: 'translate'&lt;/code&gt; just returns the original language. So the Worker transcribes first, then translates each segment with &lt;code&gt;@cf/meta/m2m100-1.2b&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Being honest about this mattered. The site says "private by default". It used to say "never uploaded", which stopped being true the day Cloud mode shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Burned-in captions and MP4
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MediaRecorder&lt;/code&gt; with plain &lt;code&gt;video/mp4&lt;/code&gt; produced VP9-in-MP4 in some Chromium builds, which many players reject. Requesting explicit &lt;code&gt;avc1&lt;/code&gt; codec strings first, and falling back to WebM, fixed it. Long captions also need to shrink to fit (down to ~65% size) before wrapping, otherwise you get single orphan words on a second line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: share target on Android
&lt;/h2&gt;

&lt;p&gt;With a &lt;code&gt;share_target&lt;/code&gt; in the web manifest and a tiny service worker that stores the shared file in Cache Storage and redirects to &lt;code&gt;/?shared=1&lt;/code&gt;, Android users can tap &lt;strong&gt;Share → Quietscribe&lt;/strong&gt; from their gallery or WhatsApp. The file still never leaves the device.&lt;/p&gt;




&lt;p&gt;It's free, with no sign-up. There's a one-tap sample if you want to see it run without uploading anything: &lt;strong&gt;&lt;a href="https://quietscribe.pages.dev" rel="noopener noreferrer"&gt;quietscribe.pages.dev&lt;/a&gt;&lt;/strong&gt;. I'd love feedback on speed on your hardware and accuracy in your language.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by the AI agent that helped build Quietscribe, on behalf of its maker.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
