<?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: Luiz Mueller</title>
    <description>The latest articles on DEV Community by Luiz Mueller (@luizpixthru).</description>
    <link>https://dev.to/luizpixthru</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%2F4206663%2Fad06c34a-4303-4d95-9739-f837729d228c.png</url>
      <title>DEV Community: Luiz Mueller</title>
      <link>https://dev.to/luizpixthru</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/luizpixthru"/>
    <language>en</language>
    <item>
      <title>Running Whisper in the browser: what broke and how I fixed it</title>
      <dc:creator>Luiz Mueller</dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:40:37 +0000</pubDate>
      <link>https://dev.to/luizpixthru/running-whisper-in-the-browser-what-broke-and-how-i-fixed-it-j90</link>
      <guid>https://dev.to/luizpixthru/running-whisper-in-the-browser-what-broke-and-how-i-fixed-it-j90</guid>
      <description>&lt;p&gt;I run a small site of free browser tools, and the one people use most is a transcriber: you drop in an audio or video file and get text or SRT/VTT subtitles back. By default nothing is uploaded. OpenAI's Whisper runs on your own device through &lt;a href="https://github.com/huggingface/transformers.js" rel="noopener noreferrer"&gt;transformers.js&lt;/a&gt;, and ffmpeg.wasm pulls the audio out of the file first. (There's also an optional Instant mode that uses the browser's Web Speech API, which sends the audio to Google or Microsoft. It's off by default, and this post is about the on-device mode.)&lt;/p&gt;

&lt;p&gt;It worked on my laptop on day one. Making it work on other people's devices took a lot longer. These are the problems that showed up in real use, in the order they hurt, and what I changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audio:&lt;/strong&gt; ffmpeg.wasm (about 31 MB, downloaded once) converts any file to 16 kHz mono float32 PCM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speech:&lt;/strong&gt; transformers.js 4.3 runs Whisper in a Web Worker. I offer three sizes: tiny ("Fast"), base ("Accurate") and small ("Best").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; WebGPU when it helps, WASM otherwise. The site doesn't send COOP/COEP headers, so WASM runs single-threaded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Download sizes, measured from the actual &lt;code&gt;.onnx&lt;/code&gt; files on the CDN:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;WASM (q8)&lt;/th&gt;
&lt;th&gt;WebGPU (fp16)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;tiny&lt;/td&gt;
&lt;td&gt;39 MB&lt;/td&gt;
&lt;td&gt;73 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;base&lt;/td&gt;
&lt;td&gt;73 MB&lt;/td&gt;
&lt;td&gt;139 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;small&lt;/td&gt;
&lt;td&gt;238 MB&lt;/td&gt;
&lt;td&gt;463 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. Big videos ran out of memory before transcription even started
&lt;/h2&gt;

&lt;p&gt;The first version did what most ffmpeg.wasm examples do: read the whole file into JavaScript, then write it into ffmpeg's in-memory filesystem. That's two full copies. A 300 MB phone video went past 1 GB of memory just to extract its audio, and phones gave up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; mount the file with &lt;code&gt;WORKERFS&lt;/code&gt; instead of copying it. ffmpeg then reads straight from the &lt;code&gt;File&lt;/code&gt; object, chunk by chunk, so the video's size barely matters. Only the extracted audio lives in memory, about 230 MB per hour of float32 PCM. I've since tested a 2.1 GB file. If the mount fails on an old browser, the code falls back to the copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Always using WebGPU was the wrong default, twice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On phones.&lt;/strong&gt; Android Chrome exposes &lt;code&gt;navigator.gpu&lt;/code&gt;, and the adapter looks fine. But the worker kept dying in the middle of a transcription, probably because of shared memory or unstable drivers: one user in India hit seven failures in a row on the smallest model. Phones now always use WASM with the q8 weights.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On laptops.&lt;/strong&gt; I assumed the GPU path was faster everywhere. Then I measured on an ordinary 2021 laptop (Core i5-11300H, Intel Iris Xe graphics) with 60 seconds of speech, timing only the transcription step. This was headless Chrome with the Vulkan backend, one run per model, in minutes per 10 minutes of audio:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;WASM (CPU)&lt;/th&gt;
&lt;th&gt;WebGPU (Iris Xe)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;tiny&lt;/td&gt;
&lt;td&gt;2.0&lt;/td&gt;
&lt;td&gt;3.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;base&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;td&gt;6.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;small&lt;/td&gt;
&lt;td&gt;24.6&lt;/td&gt;
&lt;td&gt;86.9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A second run, in headless Chrome with default settings, told the same story for the two smaller models: tiny 2.4 on the CPU against 3.4 on the GPU, base 4.7 against 8.2. I didn't repeat the small model. On that integrated GPU, the processor won every time, by 3.5x on the small model in the first run. Older Intel integrated graphics now go to WASM. The rule checks &lt;code&gt;adapter.info.vendor&lt;/code&gt; for &lt;code&gt;intel&lt;/code&gt; and an architecture starting with &lt;code&gt;gen-&lt;/code&gt;. Newer Intel chips, which I haven't measured, and dedicated GPUs stay on WebGPU.&lt;/p&gt;

&lt;p&gt;One detail matters here. transformers.js asks for the &lt;code&gt;high-performance&lt;/code&gt; adapter, so the check has to ask for the same one: &lt;code&gt;requestAdapter({ powerPreference: 'high-performance' })&lt;/code&gt;. On a laptop with two GPUs, a plain &lt;code&gt;requestAdapter()&lt;/code&gt; can return the integrated one while inference runs on the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The dtype trap
&lt;/h2&gt;

&lt;p&gt;Without an explicit &lt;code&gt;dtype&lt;/code&gt;, the WebGPU path downloaded fp32 weights: 923 MB for the small model, without telling anyone. Picking dtypes by hand turned up three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;q8 on the GPU is slow,&lt;/strong&gt; probably because int8 isn't accelerated there. In an earlier test on the same laptop, with the same model in both runs, a 35-second clip took about 25 s with fp16 and 196 s with q8.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fp16 on the GPU&lt;/strong&gt; is twice the download of q8 but much faster, so it's the GPU default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;q4f16&lt;/strong&gt; is smaller than q8 for the small model, but it returned unreadable text in my tests. It's out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On WASM I use q8. Of the formats I tested, it's the smallest that gave readable text. The model repositories also have int8 files that are smaller still, which I haven't tried yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Failures that should never reach the user
&lt;/h2&gt;

&lt;p&gt;Some errors repeat often enough that showing them is the wrong answer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Out of memory on the larger models.&lt;/strong&gt; &lt;code&gt;failed to call OrtRun()&lt;/code&gt; with an allocation error means the device can't hold that model. The tool now continues by itself with the smallest model and says so on screen, instead of showing a raw ONNX error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model downloads that stall on slow connections.&lt;/strong&gt; The tool retries once, and files that finished downloading stay in the browser cache. If it stalls again, it steps down to the next smaller model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A GPU that dies mid-job.&lt;/strong&gt; The job restarts on the CPU in a fresh worker, and the GPU isn't used again for the rest of that session.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Timeouts that fired while work was still happening
&lt;/h2&gt;

&lt;p&gt;In one week, analytics showed four people getting "Reading the audio timed out". The timeout was a fixed 10 minutes, and it started before the 31 MB engine had even downloaded. My best guess is that on a slow connection, the download ate the budget, and files that were still being read failed anyway.&lt;/p&gt;

&lt;p&gt;Now the timeout measures lack of progress, not total time. Downloading the engine gets 15 minutes, since it reports no progress. Reading audio only gives up after 3 minutes with no progress event at all.&lt;/p&gt;

&lt;p&gt;Phones added one more trap. When you switch apps, the browser freezes the tab, and the timer can fire the moment you come back, before the page has noticed it was hidden. So if the timer fires more than 5 seconds late, or while the page is hidden, it re-arms instead of failing.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. People leaving halfway
&lt;/h2&gt;

&lt;p&gt;Almost as many people left halfway as hit an error. In one week, 26 people started a transcription: 9 hit an error, and 7 left without any error, before it finished. Several cancelled during the transcription step itself. Small things help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Screen Wake Lock&lt;/strong&gt; while processing, so a phone screen doesn't turn off and freeze the tab. It's requested again when the page becomes visible, because the browser releases it when the page is hidden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progress in the tab title&lt;/strong&gt;, like "42% · Transcribing…", and "✓ Transcript ready" if it finishes while you're on another tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;beforeunload&lt;/code&gt; prompt&lt;/strong&gt; while a job is running, so an accidental reload doesn't throw away twenty minutes of work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest estimates up front.&lt;/strong&gt; My page used to promise 5–15 minutes per 10 minutes of audio for the small model. The measurement above says 25. The page now shows the measured numbers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's still hard
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The small model on phones is slow.&lt;/strong&gt; Some languages, like Hindi, Tamil and Urdu, need it to come out right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No speaker labels.&lt;/strong&gt; Plain Whisper doesn't do diarization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first run means a download&lt;/strong&gt; of tens to hundreds of MB, depending on the model. After that, it starts from the cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try it, the tool is at &lt;a href="https://pixthru.com/video-to-text/" rel="noopener noreferrer"&gt;pixthru.com/video-to-text&lt;/a&gt;. It has a 12-second sample if you don't want to use your own file. I'd like to hear from anyone who has measured WebGPU against WASM on other GPUs, especially Apple Silicon and AMD integrated graphics.&lt;/p&gt;

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