<?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: Fawwaaz Sheik</title>
    <description>The latest articles on DEV Community by Fawwaaz Sheik (@fzsheik).</description>
    <link>https://dev.to/fzsheik</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3920379%2F2defd7f8-10e8-49bd-97af-20ac0cbeb110.png</url>
      <title>DEV Community: Fawwaaz Sheik</title>
      <link>https://dev.to/fzsheik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fzsheik"/>
    <language>en</language>
    <item>
      <title>How I built a private audio transcription tool in browser using Transformers.js</title>
      <dc:creator>Fawwaaz Sheik</dc:creator>
      <pubDate>Fri, 08 May 2026 15:59:34 +0000</pubDate>
      <link>https://dev.to/fzsheik/how-i-built-a-private-audio-transcription-tool-in-browser-using-transformersjs-1fl0</link>
      <guid>https://dev.to/fzsheik/how-i-built-a-private-audio-transcription-tool-in-browser-using-transformersjs-1fl0</guid>
      <description>&lt;p&gt;So my dad needed to transcribe an interview. Simple enough right? Except he refused to upload his voice to any cloud service which honestly makes total sense. I went looking for local options and everything required installing Python, managing dependencies, running terminal commands. An hour of setup minimum. Not happening.&lt;/p&gt;

&lt;p&gt;So instead of doing the setup I just built it. Took about 5 hours. Here's how the whole thing works under the hood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The core architecture&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fundamental insight is that you can run Whisper which is the same model powering most cloud transcription services directly in the browser using WebAssembly. No server needed. Transformers.js by Hugging Face handles all the heavy lifting: model downloading, caching, ONNX inference, and audio chunking.&lt;/p&gt;

&lt;p&gt;Here's the high level flow:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Freazy7zye80s6nqxicff.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Freazy7zye80s6nqxicff.png" alt=" " width="800" height="331"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a Web Worker is non-negotiable&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the most important architectural decision. Whisper is computationally heavy. If you run it on the main thread your entire UI freezes so then theres no progress bar updates, no animations, nothing. The tab locks until transcription finishes.&lt;/p&gt;

&lt;p&gt;A Web Worker runs on a completely separate thread. Your React UI stays alive and responsive while Whisper churns through the audio in the background. They communicate via &lt;code&gt;postMessage&lt;/code&gt; where the worker fires updates after each 30 second chunk and React renders them as they arrive.&lt;/p&gt;

&lt;p&gt;The two-thread model looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxkomnezw9uvg9f1m3itj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxkomnezw9uvg9f1m3itj.png" alt=" " width="800" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gotcha nobody tells you about: audio decoding&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Whisper doesn't understand MP3 or WAV files directly. It needs raw audio samples, specifically 16,000 samples per second (16kHz mono) as a &lt;code&gt;Float32Array&lt;/code&gt;. So before you even touch Transformers.js you have to decode the file using the browser's &lt;code&gt;AudioContext&lt;/code&gt; API and resample it.&lt;/p&gt;

&lt;p&gt;This is the step that trips everyone up. The worker receives the raw file, not decoded audio. You decode it in the main thread using &lt;code&gt;AudioContext&lt;/code&gt;, resample to 16kHz, then pass the &lt;code&gt;Float32Array&lt;/code&gt; to the worker. Miss this step and Whisper just produces garbage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebGPU: promising but not ready to hardcode&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I spent a while trying to get WebGPU acceleration working. The results were all over the place — Chrome gave me a speedup, Firefox hung for 200 seconds, my own Zen Browser (firefox-based) silently fell back to WASM.&lt;/p&gt;

&lt;p&gt;My conclusion: let Transformers.js auto-detect. Don't hardcode &lt;code&gt;device: 'webgpu'&lt;/code&gt;. On Apple Silicon Macs the unified memory architecture means CPU and GPU share the same pool, so the automatic mode actually balances work across both and ends up faster than forcing either one explicitly. Hardcoding loses you that optimization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real world speed numbers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On a Mac M3 via WASM with the Xenova models, for a 24 second test clip:&lt;/p&gt;

&lt;p&gt;Tiny model: ~2700ms — roughly 7 seconds per minute of audio&lt;br&gt;
Base model: ~8000ms — roughly 20 seconds per minute of audio&lt;br&gt;&lt;br&gt;
Small model: ~35000ms — roughly 90 seconds per minute, slower than realtime&lt;/p&gt;

&lt;p&gt;Tiny is the right default. Most people transcribing interviews or lectures don't need the accuracy difference between tiny and base. Let them upgrade if they want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model download UX problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The tiny model is ~40MB on first load. On slow connections that can take a minute. Without clear feedback users think it's broken and close the tab. I added a progress bar with the exact MB count and fun facts that rotate every few seconds during the download. The fun facts are dumb but they work so people stay.&lt;/p&gt;

&lt;p&gt;The key message to show: "This only downloads once future visits are instant." That reframes a one-time annoyance as a feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd do differently&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Skip building the UI before proving the worker works. I spent time on design before the core was solid. The right order is: get the worker transcribing a test file in the console, build the hook that bridges React and the worker, then build UI around that working foundation.&lt;/p&gt;

&lt;p&gt;Also test on Windows Chrome early. My dev setup is Mac with Zen Browser and I hit WebGPU issues I wouldn't have caught without testing cross-platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's next&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Speaker diarization is the most requested feature by far. I noticed every competitor comment thread is full of people wishing for "Speaker A / Speaker B" labels. It's not possible cleanly in the browser yet since pyannote (the standard diarization library) hasn't been ported to Transformers.js, but that's coming but maybe theres an alternative stack we can apply right now.&lt;/p&gt;

&lt;p&gt;Language selection is a quick win, Whisper supports 99 languages natively, just needs a UI selector exposed.&lt;/p&gt;

&lt;p&gt;AI post-processing is the monetization path sending the transcript text (not audio) to an LLM for cleanup, filler word removal, and summaries. Privacy story stays intact since it's just text.&lt;/p&gt;

&lt;p&gt;You can try it at &lt;a href="https://usewhispy.com" rel="noopener noreferrer"&gt;usewhispy.com&lt;/a&gt;. Free, no account, works offline after first load.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>audio</category>
      <category>webgpu</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
