<?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: Dodly Forbi</title>
    <description>The latest articles on DEV Community by Dodly Forbi (@dodly-jr).</description>
    <link>https://dev.to/dodly-jr</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%2F4026794%2F8ace9046-7393-4ce7-bae3-2a0c37c984cc.png</url>
      <title>DEV Community: Dodly Forbi</title>
      <link>https://dev.to/dodly-jr</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dodly-jr"/>
    <language>en</language>
    <item>
      <title>I shipped three AI tools that run entirely in the browser — here's everything that broke</title>
      <dc:creator>Dodly Forbi</dc:creator>
      <pubDate>Fri, 24 Jul 2026 20:04:12 +0000</pubDate>
      <link>https://dev.to/dodly-jr/i-shipped-three-ai-tools-that-run-entirely-in-the-browser-heres-everything-that-broke-1o0e</link>
      <guid>https://dev.to/dodly-jr/i-shipped-three-ai-tools-that-run-entirely-in-the-browser-heres-everything-that-broke-1o0e</guid>
      <description>&lt;p&gt;I set myself one rule for a side project: every tool has to run entirely in the user's browser. No file ever gets uploaded to a server. If I couldn't do something client-side, it didn't ship.&lt;/p&gt;

&lt;p&gt;That rule turned out to be a great filter for &lt;em&gt;what&lt;/em&gt; to build, and a brutal filter for &lt;em&gt;how&lt;/em&gt;. Here's what actually broke along the way — a broken model file caught by physically-impossible output, an upstream ONNX Runtime bug, and a memory ceiling that only shows up on long files. If you're building AI features that run on-device, some of this will save you a week.&lt;/p&gt;

&lt;p&gt;The site is WeConvertIt — image, PDF and audio tools that all run locally. Three of them use real models in the browser: background removal, music/vocal stem separation, and speech-to-text.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;1. The model file was broken, and it took a physics violation to prove it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The stem splitter (split a song into vocals and instrumental) uses Meta's HTDemucs, which is MIT-licensed and genuinely state of the art. I grabbed a third-party ONNX export of it, wired it up with onnxruntime-web, and the output was garbage — robotic artifacts, vocals still sitting in the "instrumental" track at nearly full volume.&lt;/p&gt;

&lt;p&gt;The obvious suspects were all mine. I'd converted the model to fp16 to halve the download; transformers are famously sensitive to precision loss, so that was suspect number one. I ran the same song through fp16 and fp32 natively: &lt;strong&gt;numerically identical to four significant figures.&lt;/strong&gt; Not it.&lt;/p&gt;

&lt;p&gt;Sample rate? Demucs wants 44.1kHz stereo; I logged what actually reached the model. Correct. Not it.&lt;/p&gt;

&lt;p&gt;So I did the thing I should have done first: &lt;strong&gt;diffed against the reference implementation.&lt;/strong&gt; I reimplemented HTDemucs's own &lt;code&gt;_spec()&lt;/code&gt;/&lt;code&gt;_ispec()&lt;/code&gt; (the STFT and inverse STFT) from the PyTorch source, and compared them against the JavaScript versions in my pipeline on identical input. Every probed value matched &lt;strong&gt;to eight decimal places&lt;/strong&gt;, in both directions. My code was innocent.&lt;/p&gt;

&lt;p&gt;Then I installed the real PyTorch demucs with the official checkpoint and ran the same full-band song through both. That's where it fell out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Reference (PyTorch)&lt;/th&gt;
&lt;th&gt;My ONNX model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vocals @ 20–60Hz&lt;/td&gt;
&lt;td&gt;4.8% of mix&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;69.3%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vocals @ 2–6kHz&lt;/td&gt;
&lt;td&gt;0.6% of mix&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;75.4%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bass @ sub-bass&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;145.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;other @ sub-bass&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;174.7%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the bottom rows. Stems coming out at 145% and 174% of the original mix's own magnitude. That isn't "lower quality" — it's &lt;strong&gt;impossible&lt;/strong&gt;. You cannot extract more energy from a signal than went into it. That single number told me the model file itself was defective, not my pipeline, not the architecture.&lt;/p&gt;

&lt;p&gt;The fix was to stop trusting someone else's conversion and export my own ONNX from the official pretrained checkpoint (this export is documented as finicky — it needs custom handling for the STFT ops, which is presumably how the broken one got broken). Then I validated the new export against the reference before shipping it: 0.987/0.999 correlation, no stem exceeding mix magnitude. The tool went from unusable to genuinely good.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; when a well-regarded model produces garbage, suspect the &lt;em&gt;file&lt;/em&gt; before the architecture — and find a number that's physically impossible if things were working. That's a much faster signal than "sounds bad."&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;2. An upstream ONNX Runtime bug that only fires on tied embeddings&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Speech-to-text uses Whisper via transformers.js. Session creation failed outright:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;TransposeDQWeightsForMatMulNBits Missing required scale ... model.decoder.embed_tokens.weight_transposed_DequantizeLinear&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My first assumption was that I'd downloaded a 4-bit quantized variant by mistake — &lt;code&gt;MatMulNBits&lt;/code&gt; is a 4-bit op. Wrong. The committed file was the standard uint8 build, and the binary contained zero &lt;code&gt;MatMulNBits&lt;/code&gt; strings.&lt;/p&gt;

&lt;p&gt;The op wasn't &lt;em&gt;in&lt;/em&gt; the file. ONNX Runtime's graph optimizer was &lt;strong&gt;creating it at session-creation time&lt;/strong&gt; — a fusion pass that turns &lt;code&gt;DequantizeLinear + MatMul&lt;/code&gt; into &lt;code&gt;MatMulNBits&lt;/code&gt;, which crashes when a weight initializer is shared by two consumers. Whisper's tied embedding/lm_head is exactly that pattern, so every quantized Whisper decoder trips it. It's a known regression (onnxruntime#28306).&lt;/p&gt;

&lt;p&gt;Two things made this worse to diagnose. First, no amount of re-downloading model files would ever have fixed it. Second, &lt;code&gt;@huggingface/transformers&lt;/code&gt; &lt;strong&gt;statically inlines its onnxruntime-web build at publish time&lt;/strong&gt; — so bumping onnxruntime-web in your own &lt;code&gt;package.json&lt;/code&gt; does nothing to that code path.&lt;/p&gt;

&lt;p&gt;The fix was one line: pass &lt;code&gt;session_options: { graphOptimizationLevel: 'basic' }&lt;/code&gt; to the pipeline, skipping the buggy aggressive fusion while keeping the cheap optimizations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if an ONNX error names an operator your model doesn't contain, the optimizer is generating it. Check the graph optimization level before you touch the weights.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;3. Long audio doesn't fail loudly, it fails quietly&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two separate traps here, both of which ship silently if you don't test long files.&lt;/p&gt;

&lt;p&gt;Whisper's context window is &lt;strong&gt;30 seconds&lt;/strong&gt;. Hand it a five-minute recording without setting &lt;code&gt;chunk_length_s&lt;/code&gt; and it doesn't throw — it transcribes the first 30 seconds and returns a tidy, complete-looking result. I reproduced this: 238 characters returned for a five-minute speech. Imagine a user transcribing an hour-long lecture and never noticing 98% is missing. Now the chunking constants are hardcoded in the worker where the UI can't reach them, plus a sanity check that flags a transcript implausibly short for the audio duration.&lt;/p&gt;

&lt;p&gt;Then the memory ceiling. The WASM heap caps around 1GB, and my first architecture handed the entire file's decoded PCM to one transcription call — so memory grew chunk after chunk until it blew, and when it blew it took the whole job with it. That produced the world's most confusing bug report: "transcript is shorter than the audio."&lt;/p&gt;

&lt;p&gt;The fix was architectural: process long files in &lt;strong&gt;5-minute segments&lt;/strong&gt;, decode each segment on demand rather than holding the whole file's PCM, and &lt;strong&gt;terminate and recreate the worker between segments&lt;/strong&gt; so the browser actually reclaims WASM memory. That bounds peak memory to one segment regardless of total length. A failing segment retries once, then gets marked in the transcript — never silently dropped. A 29-minute file now completes in about 7.5 minutes with no crash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; for browser ML, memory is the real duration limit, not the model. Terminating the worker is a blunt but very effective way to force reclamation.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;4. Sometimes the ceiling is real&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I ran the identical investigation on the background remover, expecting another broken conversion. It wasn't. The model file was byte-identical (MD5-verified) to what the official Python package downloads, and its output matched the reference at &lt;strong&gt;0.9999 correlation&lt;/strong&gt;. My own post-processing measured 0.00% erosion on three of four deliberately hard test images.&lt;/p&gt;

&lt;p&gt;The tool is just… at its ceiling. That model runs at &lt;strong&gt;320×320&lt;/strong&gt;. Individual hair strands are sub-pixel at that resolution; they physically cannot be resolved. The cloud tools beat it because they run far bigger models on their servers — which is exactly the trade I'm not willing to make.&lt;/p&gt;

&lt;p&gt;So the honest answer there was a manual erase/restore brush: let the model do 85% and let the user finish it in ten seconds. Every good cutout tool works this way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; run the same diagnostic even when you expect bad news. "Confirmed ceiling" is a genuinely useful answer — it stops you tuning knobs against a problem that doesn't exist.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What running locally actually buys you&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The thing I keep coming back to: for a stem splitter or a transcription tool, the file is someone's unreleased track, or a private interview, or a medical consult. Every mainstream alternative uploads it. Running on-device means the privacy claim is &lt;em&gt;verifiable&lt;/em&gt; rather than promised — open the Network tab, run a job, watch nothing leave. When I tested the stem splitter end to end, there were zero requests with a POST body in the entire session. The model comes down; the audio never goes up.&lt;/p&gt;

&lt;p&gt;The cost is real: bigger downloads, slower processing, and a hard ceiling on model size. But for this category, I'll take it.&lt;/p&gt;

&lt;p&gt;WeConvertIt is a client-side web application built with Next.js, utilizing ffmpeg.wasm for audio, pdf-lib/pdf.js for PDFs, qpdf-wasm for encryption, and Canvas for image processing. The application is live at weconvertit.com. — first real project, built solo, and I'd genuinely value feedback, especially anything that breaks.&lt;/p&gt;

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