<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[FlashKit Tools Engineering]]></title><description><![CDATA[FlashKit Tools Engineering]]></description><link>https://flashkittools.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 01 Nov 2026 11:54:33 GMT</lastBuildDate><atom:link href="https://flashkittools.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why our in-browser Whisper repeated words, and other bugs we fixed in FlashKit's audio and video tools]]></title><description><![CDATA[Language selection, a repetition guard, SRT/VTT export, muting video with ffmpeg.wasm, and why translation ended up behind a strict server function]]></description><link>https://flashkittools.hashnode.dev/why-our-in-browser-whisper-repeated-words-and-other-bugs-we-fixed-in-flashkit-s-audio-and-video-tools</link><guid isPermaLink="true">https://flashkittools.hashnode.dev/why-our-in-browser-whisper-repeated-words-and-other-bugs-we-fixed-in-flashkit-s-audio-and-video-tools</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[whisper]]></category><category><![CDATA[FFmpeg]]></category><dc:creator><![CDATA[FlashKit]]></dc:creator><pubDate>Fri, 30 Oct 2026 13:08:18 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: FlashKit is our product. This post covers bugs in our own tools and the changes we made to fix them. All the code below comes from the PR that shipped them (<a href="https://github.com/Tausif-wp/Flashkit-tools/pull/27">#27</a>, merged on 30 October 2026).</em></p>
<p>On 30 October we tested the audio and video tools on <a href="http://www.flashkit.tools">www.flashkit.tools</a> in production. We used test media generated with ffmpeg, headless Chrome driven by Puppeteer, and ffprobe to check the output files. Two of the core flows were broken. The transcriber sometimes got stuck repeating a word, and "Remove audio from video" never got past "Processing 0%". The subtitle translator had a quieter problem: when translation failed, it kept the English text without telling anyone. Here's what was going on and what we changed.</p>
<h2>Symptom 1: "What What What…"</h2>
<p>The transcriber runs Whisper in a Web Worker with transformers.js 2.17.2 and the quantized <code>Xenova/whisper-base</code> model. Your audio stays in the tab. On some clips the output fell into loops: "Ask What Your Country What What What…" or "to to to to…".</p>
<p>The worker could already accept a language:</p>
<pre><code class="language-js">// public/worker.js
const output = await transcriber(data.audio, {
    chunk_length_s: 30,
    stride_length_s: 5,
    language: data.language || null, // Auto-detect if null
    task: 'transcribe',
    return_timestamps: true
});
</code></pre>
<p>But the page had no language picker, so <code>data.language</code> was always undefined and every run used auto-detect. The subtitle translator uses the same model, and it fixes the source language to English. When we ran the same clip through it, the sentence came out clean. That was one comparison, not a controlled study, but it pointed straight at auto-detect.</p>
<h3>Fix 1: let people pick the language and default to the page locale</h3>
<p>The tool now has a Source Language dropdown with Auto Detect plus 19 languages. By default it follows the page's locale:</p>
<pre><code class="language-tsx">useEffect(() =&gt; {
  const browserLang = lang || navigator.language.split('-')[0];
  const langMatch = TRANSCRIPTION_LANGUAGES.find(l =&gt; l.code === browserLang);
  if (langMatch &amp;&amp; langMatch.code !== 'auto') {
    setSourceLang(langMatch.code);
  }
}, [lang]);

// ...and when transcription starts:
workerRef.current.postMessage({
  type: 'transcribe',
  data: { audio: channelData, language: sourceLang === 'auto' ? null : sourceLang }
}, [channelData.buffer]);
</code></pre>
<p>Auto Detect is still in the list, but on the English page the default is now <code>en</code>.</p>
<h3>Fix 2: a repetition guard on the output</h3>
<p>A language hint makes loops less likely but doesn't rule them out, so we also clean up the text after decoding. Our backlog suggested collapsing repeated n-grams. What actually shipped is simpler: it collapses runs of the same word.</p>
<pre><code class="language-ts">const suppressRepetitions = (text: string): string =&gt; {
  const words = text.split(/\s+/);
  const result: string[] = [];
  let repeatCount = 0;
  let lastWord = '';
  for (const word of words) {
    const w = word.toLowerCase().replace(/[.,!?;:]/g, '');
    const last = lastWord.toLowerCase().replace(/[.,!?;:]/g, '');
    if (w === last &amp;&amp; w.length &gt; 1) {
      repeatCount++;
      if (repeatCount &lt; 2) result.push(word);
    } else {
      repeatCount = 0;
      result.push(word);
    }
    lastWord = word;
  }
  return result.join(' ');
};
</code></pre>
<p>The guard runs on each timestamped chunk and again on the joined text. It keeps at most two copies in a row, so "What What What What" becomes "What What". Single-character words like "a" are left alone. It does <strong>not</strong> catch a repeated phrase like "to the to the to the". That would need real n-gram detection, which is still on our list.</p>
<p>We didn't change the audio chunking. The worker already used Whisper's 30-second windows with a 5-second stride, and the page still decodes and resamples to 16 kHz mono with an <code>OfflineAudioContext</code> before handing the audio to the worker.</p>
<h2>SRT, VTT and TXT export</h2>
<p>The pipeline already returned timestamped chunks (<code>return_timestamps: true</code>), so subtitle export was mostly formatting. SRT uses a comma before the milliseconds and VTT uses a dot:</p>
<pre><code class="language-ts">const formatTimeSRT = (seconds: number): string =&gt; {
  const hrs = Math.floor(seconds / 3600);
  const mins = Math.floor((seconds % 3600) / 60);
  const secs = Math.floor(seconds % 60);
  const ms = Math.round((seconds % 1) * 1000);
  return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
};
</code></pre>
<p>Whisper sometimes returns <code>null</code> as the end time of the last chunk. In that case the exporter uses the start time plus five seconds (<code>chunk.timestamp[1] || chunk.timestamp[0] + 5</code>). Here is the start of the SRT we got from our 47-second test recording:</p>
<pre><code>1
00:00:00,000 --&gt; 00:00:06,480
Welcome to this short presentation about renewable energy. Solar panels convert sunlight into

2
00:00:06,480 --&gt; 00:00:13,040
electricity without producing any pollution. Wind turbines use the movement of air to generate
</code></pre>
<p>One weakness we noticed while writing this up: because the seconds are floored and the milliseconds rounded separately, a fraction of .9995 or higher would print as <code>,1000</code>. We haven't seen that in real output, but it's worth fixing.</p>
<h2>Muting video: from MediaRecorder to ffmpeg.wasm</h2>
<p>The old mute tool played the video and recorded only its video track with <code>captureStream()</code> and <code>MediaRecorder</code>, then waited for <code>ended</code>. The bug was in the render:</p>
<pre><code class="language-tsx">&lt;video ref={videoRef} src={URL.createObjectURL(file)} ... /&gt;
</code></pre>
<p><code>setIsProcessing(true)</code> triggered a re-render. Every render created a new object URL, so the <code>&lt;video&gt;</code> element reset, stayed paused at 0, and never fired <code>ended</code>. Even when it worked, this approach ran in real time and produced WebM.</p>
<p>The fix creates the object URL once with <code>useMemo</code> and revokes it on cleanup. More importantly, we stopped playing the video at all. ffmpeg.wasm now copies the video stream and drops the audio:</p>
<pre><code class="language-ts">await ffmpeg.writeFile(inputName, await fetchFile(file));
await ffmpeg.exec(['-i', inputName, '-c:v', 'copy', '-an', 'output.mp4']);
const data = await ffmpeg.readFile('output.mp4');
setDownloadUrl(URL.createObjectURL(new Blob([data], { type: 'video/mp4' })));
</code></pre>
<p>Since <code>-c:v copy</code> doesn't re-encode, the time it takes depends on file size, not on how long the video plays. In our test, a 5-second H.264/AAC MP4 came back about a second after the click: a 34,072-byte MP4 with a single H.264 stream, no audio stream, and a duration of exactly 5.000000 s according to ffprobe. That's one small file on one machine, so treat it as an observation, not a benchmark.</p>
<h2>Why translation moved to a server function</h2>
<p>Translation can't run in the tab. Before this PR, the browser called Google's <code>gtx</code> endpoint through <code>corsproxy.io</code>, and our test logged a 403 from that proxy. The subtitle translator also had a silent fallback, <code>translatedTexts[i] || text</code>, so a failed translation looked like a successful one in English, and the Spanish "dub" then read out English text.</p>
<p>Now both tools POST to <code>/api/translate</code>, a Netlify function. It accepts at most 100 texts of 500 characters each (20 KB body limit). It checks language codes against a regex, allows only flashkit.tools origins, and has an 8-second per-request timeout inside a 20-second total budget. It calls Google first and falls back to MyMemory. If Google returns a 429, it skips Google for the rest of that invocation. The client splits text at sentence boundaries into chunks of up to 500 characters. The subtitle translator caps each batch at 100 lines and 3,000 characters, and a failed line now shows up as <code>[ERROR: reason]</code> instead of quietly staying in English.</p>
<h3>MyMemory's translation memory returned the wrong sentence</h3>
<p>MyMemory mixes machine translation with translation-memory (TM) matches, and some TM matches are wrong in ways no error code will catch. Before the fix, we sent "The quick brown fox jumps over the lazy dog." to Hindi and got back "सारे जहाँ से अच्छा हिंदोस्ताँ हमारा", a famous patriotic song lyric. According to the PR, "Hello there." came back as "कृपया ध्यान दें" ("please pay attention"), a MateCat TM match with a 0.97 score.</p>
<p>The commit history shows we tried several policies. First we dropped TM matches below 0.85. Then we went MT-only, then briefly accepted the best available match, then preferred "reliable" sources like MateCat. The 0.97 MateCat match made it clear that no score threshold or source list would be safe, so we settled on accepting only results that MyMemory marks as machine translation:</p>
<pre><code class="language-ts">function extractMTOnlyTranslation(data: { matches?: MyMemoryMatch[] }): string | null {
  for (const match of data.matches || []) {
    if ((match['created-by'] || '') === 'MT!' &amp;&amp; match.translation?.trim()) {
      return match.translation;
    }
  }
  return null;
}
</code></pre>
<p>If no <code>MT!</code> match comes back, the function retries once with <code>mt=1&amp;onlyprivate=1</code>. If that also fails, it returns an error. Every result, from Google or MyMemory, then goes through some basic checks. The output can't be identical to the input. For Hindi, Arabic, Russian, CJK and a few other languages, it must contain characters from the target script. And every number in the input has to appear in the output. The number check would have caught a song lyric replacing "Bake it for 35 minutes."</p>
<p>In our final test run, a batch of 40 natural sentences came back with no errors in both Spanish and Hindi (40/40 each), and "Hello there." became "नमस्ते." in Hindi and "Hola." in Spanish. These checks reduce bad results; they don't guarantee good ones.</p>
<h2>Limits</h2>
<ul>
<li><strong>Speed depends on your CPU.</strong> The worker uses quantized whisper-base on transformers.js 2.x. In headless Chrome on our CPU-only test box, the cues for a 47-second WAV appeared about 35–40 seconds after we started. That timing came from a polling script and is not a benchmark. We haven't moved to transformers.js v3 or WebGPU yet.</li>
<li><strong>First run downloads a lot.</strong> In our test sessions, the response sizes on first load added up to about 80 MB. That total covers the model, the app and third-party scripts together, so it's an upper bound for the model. Browser caching makes later runs lighter.</li>
<li><strong>ffmpeg.wasm is loaded from unpkg</strong> (<code>@ffmpeg/core@0.12.6</code>). The tool needs to reach unpkg once per page load, and a large or long video uses a lot of memory in the tab. We tested end to end with H.264 MP4 input only.</li>
<li><strong>Translation needs a network request.</strong> Your audio and video stay in the browser, but any text you choose to translate goes to our function and from there to Google or MyMemory. The page asks for your consent first.</li>
<li><strong>The repetition guard is word-level only</strong>, as described above.</li>
</ul>
<h2>Try it</h2>
<ul>
<li>Transcribe audio or video to text (with SRT/VTT/TXT export): <a href="https://www.flashkit.tools/tool/transcribe-audio-video-text-private/">https://www.flashkit.tools/tool/transcribe-audio-video-text-private/</a></li>
<li>Remove audio from a video: <a href="https://www.flashkit.tools/tool/video-mute-tool-no-watermark-free/">https://www.flashkit.tools/tool/video-mute-tool-no-watermark-free/</a></li>
</ul>
<p>If you find a clip that still loops or a translation that's wrong, please tell us. Those reports are what led to the fixes in this post.</p>
]]></content:encoded></item></channel></rss>