<?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: FS03</title>
    <description>The latest articles on DEV Community by FS03 (@fs03_c7d3cbdc6bb2eee4d1f1).</description>
    <link>https://dev.to/fs03_c7d3cbdc6bb2eee4d1f1</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%2F4185668%2Ff17c82a5-c2f3-43cf-af42-f2186734d272.png</url>
      <title>DEV Community: FS03</title>
      <link>https://dev.to/fs03_c7d3cbdc6bb2eee4d1f1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fs03_c7d3cbdc6bb2eee4d1f1"/>
    <language>en</language>
    <item>
      <title>How I Built a 100% Free, Private In-Browser AI Text-to-Speech Tool Using Kokoro-82M &amp; WebGPU</title>
      <dc:creator>FS03</dc:creator>
      <pubDate>Thu, 15 Oct 2026 00:28:36 +0000</pubDate>
      <link>https://dev.to/fs03_c7d3cbdc6bb2eee4d1f1/how-i-built-a-100-free-private-in-browser-ai-text-to-speech-tool-using-kokoro-82m-webgpu-32ih</link>
      <guid>https://dev.to/fs03_c7d3cbdc6bb2eee4d1f1/how-i-built-a-100-free-private-in-browser-ai-text-to-speech-tool-using-kokoro-82m-webgpu-32ih</guid>
      <description>&lt;p&gt;Every time I needed to convert an article, chapter, or voiceover script into natural audio, I ran into the same frustrating wall:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Services demanding $22/month subscriptions.&lt;/li&gt;
&lt;li&gt;Strict 500-character limits before a paywall pops up.&lt;/li&gt;
&lt;li&gt;Mandatory account signups and credit card forms.&lt;/li&gt;
&lt;li&gt;Privacy concerns over uploading sensitive text to third-party cloud servers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I decided to see if modern browser standards (WebGPU, WebAssembly, and Web Workers) could handle state-of-the-art neural speech synthesis completely on the client side — with &lt;strong&gt;zero server bills, zero paywalls, and zero limits&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The result is &lt;strong&gt;&lt;a href="https://puretts.com" rel="noopener noreferrer"&gt;PureTTS (puretts.com)&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is how I built it and the engineering hurdles I had to solve.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Tech Stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Neural Model:&lt;/strong&gt; &lt;a href="https://huggingface.co/hexgrad/Kokoro-82M" rel="noopener noreferrer"&gt;Kokoro-82M&lt;/a&gt; (a lightweight 82M-parameter model delivering comparable quality to ElevenLabs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inference Engine:&lt;/strong&gt; &lt;code&gt;kokoro-js&lt;/code&gt; compiled to ONNX WebAssembly / WebGPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture:&lt;/strong&gt; Dedicated Web Worker (&lt;code&gt;tts.worker.js&lt;/code&gt;) + Vanilla JavaScript frontend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting:&lt;/strong&gt; GitHub Pages with a custom domain on Porkbun.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total Cloud Inference Cost:&lt;/strong&gt; &lt;strong&gt;$0.00 / month&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Challenge 1: The Main Thread Freeze (Offloading to a Web Worker)
&lt;/h3&gt;

&lt;p&gt;Running neural inference in JavaScript is computationally heavy. On initial prototypes, running the forward pass on the main browser thread caused the entire DOM to lock up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The text counter froze.&lt;/li&gt;
&lt;li&gt;Buttons became unresponsive.&lt;/li&gt;
&lt;li&gt;The browser tab warned: &lt;em&gt;"This page is slowing down your browser."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To keep the UI at a buttery 60 FPS, all model loading, phoneme tokenization, and ONNX execution were isolated inside a dedicated &lt;strong&gt;Web Worker&lt;/strong&gt;:&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;// main.js - Communicating asynchronously with the worker&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ttsWorker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tts.worker.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;module&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;ttsWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GENERATE_TTS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;selectedVoice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;ttsWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wavBlob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PROGRESS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;updateProgressBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMPLETE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;audioPlayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wavBlob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;audioPlayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This completely separated heavy tensor crunching from the UI.&lt;/p&gt;




&lt;h3&gt;
  
  
  Challenge 2: Bypassing the 29-Second Model Limit
&lt;/h3&gt;

&lt;p&gt;Kokoro-82M has an inherent context window of approximately 512 phonemes (roughly 240 characters or ~29 seconds of speech). If you pass a 2,000-word blog post or book chapter, the model simply truncates it.&lt;/p&gt;

&lt;p&gt;To provide truly &lt;strong&gt;unlimited continuous speech&lt;/strong&gt;, I built an intelligent chunking and audio concatenation pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Intelligent Text Chunker:&lt;/strong&gt; A regex engine splits text by sentence terminators (&lt;code&gt;.&lt;/code&gt;, &lt;code&gt;!&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;, &lt;code&gt;\n&lt;/code&gt;) while preserving natural phrase boundaries (target max chunk size ~200 characters).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sequential Inference:&lt;/strong&gt; Chunks are synthesized sequentially with lightweight progress reporting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuous Audio Stitching:&lt;/strong&gt; We insert a natural 220ms pause between sentences and concatenate the raw &lt;code&gt;Float32Array&lt;/code&gt; PCM audio buffers:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Merging sequential audio buffers with natural pauses&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;concatenatePcmBuffers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sampleRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24000&lt;/span&gt;&lt;span class="p"&gt;)&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;pauseSamples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sampleRate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.22&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 220ms natural pause&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buffers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pauseSamples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Float32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &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;buffer&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;buffers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pauseSamples&lt;/span&gt;&lt;span class="p"&gt;;&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;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;In-Memory WAV Encoder:&lt;/strong&gt; Instead of pulling in a heavy external audio library, the worker serializes the merged PCM stream directly into a clean 16-bit PCM WAV Blob with proper RIFF headers on the fly.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  Challenge 3: Client-Side Privacy
&lt;/h3&gt;

&lt;p&gt;In enterprise and academic workflows, users often need to read sensitive medical notes, legal documents, or unreleased writing. Because PureTTS downloads the model weights to the browser's local cache (IndexedDB) and computes locally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No text is ever transmitted across the network.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No audio is stored on a server.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;It works completely offline after the initial model load!&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  The Result
&lt;/h3&gt;

&lt;p&gt;You can try it live right in your browser here:&lt;br&gt;&lt;br&gt;
👉 &lt;strong&gt;&lt;a href="https://puretts.com" rel="noopener noreferrer"&gt;https://puretts.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero account creation or email logins.&lt;/li&gt;
&lt;li&gt;No character count limits.&lt;/li&gt;
&lt;li&gt;Natural voices with instant WAV download.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd love feedback from fellow developers! What features or voices should I add next?&lt;/p&gt;

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