<?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: Theo Slater</title>
    <description>The latest articles on DEV Community by Theo Slater (@theoslater).</description>
    <link>https://dev.to/theoslater</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%2F3873857%2Fab0e0846-ac43-45ff-969e-bf672db0db09.jpeg</url>
      <title>DEV Community: Theo Slater</title>
      <link>https://dev.to/theoslater</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/theoslater"/>
    <language>en</language>
    <item>
      <title>How I Implemented Supertonic TTS into My Desktop App, OpenBench AI</title>
      <dc:creator>Theo Slater</dc:creator>
      <pubDate>Mon, 25 May 2026 13:49:08 +0000</pubDate>
      <link>https://dev.to/theoslater/how-i-implemented-supertonic-tts-into-my-desktop-app-openbench-ai-4abg</link>
      <guid>https://dev.to/theoslater/how-i-implemented-supertonic-tts-into-my-desktop-app-openbench-ai-4abg</guid>
      <description>&lt;p&gt;I recently added text-to-speech (TTS) capabilities to OpenBench AI, my local-first Tauri desktop chat app. Rather than relying on a single TTS service, I built a dual-engine system that lets users choose between browser-native speech synthesis or a high-quality on-device neural model. Here's how I did it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Goal
&lt;/h2&gt;

&lt;p&gt;Users should be able to click a speaker icon on any assistant message and hear it read aloud. They should have options: lightweight and built-in, or more natural-sounding but requiring a larger model download. All processing should happen localy with no API calls, no privacy concerns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: Dual Engines
&lt;/h2&gt;

&lt;p&gt;I implemented two TTS engines with a simple interface:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supertonic (ST-TTS)&lt;/strong&gt; — On-device neural TTS. Produces natural, high-quality audio. Requires downloading a ~100MB ONNX model on first use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser SpeechSynthesis&lt;/strong&gt; — The Web Speech API. No downloads, instant playback, slightly less natural, voice availability varies by OS.&lt;/p&gt;

&lt;p&gt;Both engines are exposed via a speaker icon on each message and configured in Settings &amp;gt; Speech.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Backend: tauri-plugin-supertonic
&lt;/h3&gt;

&lt;p&gt;A Rust Tauri 2 plugin (&lt;code&gt;tauri-plugin-supertonic@0.1&lt;/code&gt;) wrapping &lt;code&gt;supertonic-core&lt;/code&gt; and ONNX Runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a plugin?&lt;/strong&gt; No custom Rust commands needed. The plugin handles everything at the Rust layer: model download, voice selection, and WAV synthesis. The frontend only speaks through the JavaScript API.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frontend: tauri-plugin-supertonic-api
&lt;/h3&gt;

&lt;p&gt;An npm package (&lt;code&gt;tauri-plugin-supertonic-api@0.1&lt;/code&gt;) exposing a clean JavaScript interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;loadModel&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nf"&gt;synthesize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;// Returns base64 WAV&lt;/span&gt;
&lt;span class="nf"&gt;listVoices&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Voice&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nf"&gt;selectVoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;voiceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  State Management: Two Zustand Stores
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;settingsStore.ts&lt;/strong&gt; — Holds TTS configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;TtsSettings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;browser&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stTts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&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="kr"&gt;string&lt;/span&gt;
    &lt;span class="na"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="c1"&gt;// 0.5–2.0&lt;/span&gt;
    &lt;span class="na"&gt;pitch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="c1"&gt;// 0–2.0&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nl"&gt;stTts&lt;/span&gt;&lt;span class="p"&gt;:&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="kr"&gt;string&lt;/span&gt;
    &lt;span class="na"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;Settings persist to localStorage with version migration — if you ever add a new setting, users don't break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ttsStore.ts&lt;/strong&gt; — Manages playback state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;TtsState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;activeMessageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
  &lt;span class="na"&gt;currentAudio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLAudioElement&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="na"&gt;play&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-message tracking means each message bubble can independently show play/stop/loading state.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works End-to-End
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. User Clicks Speaker Icon
&lt;/h3&gt;

&lt;p&gt;Calls &lt;code&gt;ttsStore.play(messageId, text)&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Text Cleaning
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;cleanTextForSpeech()&lt;/code&gt; function strips markdown, HTML, code blocks, and math notation. You don't want the TTS engine trying to pronounce &lt;code&gt;**bold**&lt;/code&gt; or &lt;code&gt;$\LaTeX{}$&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Strip markdown bold/italic&lt;/span&gt;
&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\*\*(&lt;/span&gt;&lt;span class="sr"&gt;.+&lt;/span&gt;&lt;span class="se"&gt;?)\*\*&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Strip code blocks and inline code&lt;/span&gt;
&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/``&lt;/span&gt;&lt;span class="err"&gt;`
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;endraw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="s2"&gt;```/g, "")
text = text.replace(/`&lt;/span&gt;&lt;span class="p"&gt;(.&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;?)&lt;/span&gt;&lt;span class="s2"&gt;`/g, "$1")

// Strip HTML tags
text = text.replace(/&amp;lt;[^&amp;gt;]*&amp;gt;/g, "")

// etc.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Engine Dispatch
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Browser SpeechSynthesis
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Split text into sentences (simple regex split on &lt;code&gt;.&lt;/code&gt; or &lt;code&gt;!&lt;/code&gt; or &lt;code&gt;?&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;For each sentence, create a &lt;code&gt;SpeechSynthesisUtterance&lt;/code&gt; with the configured voice, rate, and pitch.&lt;/li&gt;
&lt;li&gt;Queue them with &lt;code&gt;window.speechSynthesis.speak()&lt;/code&gt; — the browser queues them automatically.&lt;/li&gt;
&lt;li&gt;Stop via &lt;code&gt;window.speechSynthesis.cancel()&lt;/code&gt;.
Pros: instant, no downloads, works everywhere.
Cons: less natural, voice quality varies by OS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Supertonic (ST-TTS)
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Check if the model is loaded. If not, show a "Downloading TTS model (~100MB)" toast and call &lt;code&gt;loadModel()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Once ready, call &lt;code&gt;synthesize(text, "en")&lt;/code&gt; from the supertonic API.&lt;/li&gt;
&lt;li&gt;Get back a WAV file as base64.&lt;/li&gt;
&lt;li&gt;Play via &lt;code&gt;new Audio("data:audio/wav;base64,...").play()&lt;/code&gt;.
Pros: high-quality, consistent across platforms, on-device.
Cons: ~100MB download, slower synthesis.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;play&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;activeMessageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;settingsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleanText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cleanTextForSpeech&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;try&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;engine&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;browser&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;playBrowserTts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cleanText&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="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Ensure model is loaded&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;modelLoaded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;showToast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Downloading TTS model (~100MB)...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;supertonic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadModel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nx"&gt;modelLoaded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;wavBase64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;supertonic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;synthesize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;cleanText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&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;audio&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;Audio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`data:audio/wav;base64,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;wavBase64&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;audio&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="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;currentAudio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;span class="k"&gt;catch &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="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;showToast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TTS failed&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;h3&gt;
  
  
  4. Stopping Playback
&lt;/h3&gt;

&lt;p&gt;The stop function pauses the current &lt;code&gt;HTMLAudioElement&lt;/code&gt; or cancels &lt;code&gt;window.speechSynthesis&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also, when the user switches conversations, auto-stop fires — no audio bleeding into the next chat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Settings UI (SpeechTab.tsx)
&lt;/h2&gt;

&lt;p&gt;The Settings modal has a Speech tab with two sections:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Engine Selector&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;○ Browser (lightweight, instant)
◉ On-device (natural, ~100MB download)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If Browser Selected&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Voice dropdown (sourced from &lt;code&gt;speechSynthesis.getVoices()&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Speed slider (0.5–2.0)&lt;/li&gt;
&lt;li&gt;Pitch slider (0–2.0)&lt;/li&gt;
&lt;li&gt;Test Voice button
&lt;strong&gt;If ST-TTS Selected&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Load Model button (shows progress; disabled if model is loading or loaded)&lt;/li&gt;
&lt;li&gt;Voice style selector (sourced from &lt;code&gt;listVoices()&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Speed slider (if supported)&lt;/li&gt;
&lt;li&gt;Test Voice button
Both tabs show a live playback preview so users hear the difference before committing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key Design Decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  No Custom Rust Commands
&lt;/h3&gt;

&lt;p&gt;The plugin abstraction meant I could avoid writing custom Tauri command handlers. The plugin exposes everything through a clean npm package. If I ever need to add a new TTS feature (e.g., model switching, streaming), I just extend the plugin — the frontend doesn't need to change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Web-Standard Audio Playback
&lt;/h3&gt;

&lt;p&gt;I didn't reach for a Tauri audio plugin. &lt;code&gt;HTMLAudioElement&lt;/code&gt; handles the WAV file from supertonic perfectly. &lt;code&gt;window.speechSynthesis&lt;/code&gt; is built-in. Both are reliable and require zero extra dependencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-Message Tracking
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;activeMessageId&lt;/code&gt; in the TTS store lets each message bubble track its own playback state independently. One message can be playing while another shows a "Play" button. This feels more natural than a global play/stop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lazy Model Loading
&lt;/h3&gt;

&lt;p&gt;The ~100MB supertonic model only downloads when the user first clicks play with ST-TTS selected — not on app startup. A toast notifies them what's happening. Once loaded, subsequent plays are instant. This keeps the app lightweight until the feature is actually needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently (or Next)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streaming synthesis&lt;/strong&gt; — For long messages, synthesizing the entire text at once can feel slow. Streaming chunks as they're generated would improve perceived performance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice caching&lt;/strong&gt; — Cache synthesized audio so replaying the same message is instant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt; — If ST-TTS synthesis is slow, queue requests or show a "Synthesizing..." state more explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform voice testing&lt;/strong&gt; — Browser voice availability varies wildly (Windows, Mac, Linux). Testing coverage is key.
## Wrapping Up&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dual-engine TTS isn't complicated, but it requires thoughtful state management and a clean architecture. By separating concerns (settings, playback, engine logic) and leaning on the plugin system, I ended up with a feature that's flexible, performant, and maintainable.&lt;/p&gt;

&lt;p&gt;If you're building a Tauri app and want to add TTS, this pattern should translate directly. The key takeaway: abstract your engine behind a simple interface, lazy-load heavy resources, and let the user choose.&lt;/p&gt;

&lt;p&gt;Feel free to check it out at: &lt;a href="https://github.com/monolabsdev/openbench-ai" rel="noopener noreferrer"&gt;https://github.com/monolabsdev/openbench-ai&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>react</category>
      <category>rust</category>
    </item>
    <item>
      <title>I Built a Desktop Chat App for Running Local LLMs Offline</title>
      <dc:creator>Theo Slater</dc:creator>
      <pubDate>Wed, 20 May 2026 15:16:48 +0000</pubDate>
      <link>https://dev.to/theoslater/i-built-a-desktop-chat-app-for-running-local-llms-offline-6im</link>
      <guid>https://dev.to/theoslater/i-built-a-desktop-chat-app-for-running-local-llms-offline-6im</guid>
      <description>&lt;p&gt;A few months ago I got tired of bouncing between ChatGPT, Claude, and a dozen other AI chat UIs every time I wanted to test a different model. It felt like productivity, if productivity involved tab overload and constant context switching.&lt;/p&gt;

&lt;p&gt;I also wanted to run everything locally without sending data off to someone else’s server just to ask a question about JSON formatting.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Openbench AI&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is
&lt;/h2&gt;

&lt;p&gt;OpenBench is a desktop chat app that connects to Ollama and lets you talk to local LLMs without the usual ritual of Docker, Python environments, or “why is this port not working again” debugging sessions.&lt;/p&gt;

&lt;p&gt;You install it. You open it. You chat.&lt;/p&gt;

&lt;p&gt;That’s it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key features
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Multi-model chat (side by side comparison)
&lt;/h3&gt;

&lt;p&gt;You can run multiple models at once and watch them respond in real time to the same prompt.&lt;/p&gt;

&lt;p&gt;Instead of guessing which model is best, you can just compare them directly under identical conditions like a mildly scientific experiment that occasionally exposes how inconsistent models can be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Markdown + LaTeX support
&lt;/h3&gt;

&lt;p&gt;Full rendering via KaTeX.&lt;/p&gt;

&lt;p&gt;So when a model writes equations or structured explanations, it actually looks like something a human might willingly read.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guest mode
&lt;/h3&gt;

&lt;p&gt;Temporary chats that disappear when you close the app.&lt;/p&gt;

&lt;p&gt;No storage. No accounts. No memory overhead.&lt;/p&gt;

&lt;p&gt;Just quick experiments without commitment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ollama model management
&lt;/h3&gt;

&lt;p&gt;You can install models directly inside the app instead of juggling terminal commands and hoping you typed everything correctly the first time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conversation tools
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Archive chats&lt;/li&gt;
&lt;li&gt;System prompt presets&lt;/li&gt;
&lt;li&gt;CUstom system prompt&lt;/li&gt;
&lt;li&gt;Clean interface without unnecessary clutter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Tauri&lt;/li&gt;
&lt;li&gt;Zustand&lt;/li&gt;
&lt;li&gt;MUI&lt;/li&gt;
&lt;li&gt;SQLite&lt;/li&gt;
&lt;li&gt;ollama-rs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What i would change
&lt;/h2&gt;

&lt;p&gt;If I rebuilt it, I’d add OpenAI-compatible API support from day one.&lt;/p&gt;

&lt;p&gt;Right now it only works with Ollama, though the architecture already supports additional providers. I just haven’t wired them in yet because priorities are a fictional concept.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source
&lt;/h2&gt;

&lt;p&gt;The project is open source, and release builds are available if you want to try it without compiling anything yourself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/monolabsdev/openbench-ai" rel="noopener noreferrer"&gt;Openbench GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>ollama</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Built a Desktop Chat App for Running Local LLMs Offline</title>
      <dc:creator>Theo Slater</dc:creator>
      <pubDate>Wed, 20 May 2026 15:16:48 +0000</pubDate>
      <link>https://dev.to/theoslater/i-built-a-desktop-chat-app-for-running-local-llms-offline-21cm</link>
      <guid>https://dev.to/theoslater/i-built-a-desktop-chat-app-for-running-local-llms-offline-21cm</guid>
      <description>&lt;p&gt;A few months ago I got tired of bouncing between ChatGPT, Claude, and a dozen other AI chat UIs every time I wanted to test a different model. It felt like productivity, if productivity involved tab overload and constant context switching.&lt;/p&gt;

&lt;p&gt;I also wanted to run everything locally without sending data off to someone else’s server just to ask a question about JSON formatting.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Openbench AI&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is
&lt;/h2&gt;

&lt;p&gt;OpenBench is a desktop chat app that connects to Ollama and lets you talk to local LLMs without the usual ritual of Docker, Python environments, or “why is this port not working again” debugging sessions.&lt;/p&gt;

&lt;p&gt;You install it. You open it. You chat.&lt;/p&gt;

&lt;p&gt;That’s it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key features
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Multi-model chat (side by side comparison)
&lt;/h3&gt;

&lt;p&gt;You can run multiple models at once and watch them respond in real time to the same prompt.&lt;/p&gt;

&lt;p&gt;Instead of guessing which model is best, you can just compare them directly under identical conditions like a mildly scientific experiment that occasionally exposes how inconsistent models can be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Markdown + LaTeX support
&lt;/h3&gt;

&lt;p&gt;Full rendering via KaTeX.&lt;/p&gt;

&lt;p&gt;So when a model writes equations or structured explanations, it actually looks like something a human might willingly read.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guest mode
&lt;/h3&gt;

&lt;p&gt;Temporary chats that disappear when you close the app.&lt;/p&gt;

&lt;p&gt;No storage. No accounts. No memory overhead.&lt;/p&gt;

&lt;p&gt;Just quick experiments without commitment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ollama model management
&lt;/h3&gt;

&lt;p&gt;You can install models directly inside the app instead of juggling terminal commands and hoping you typed everything correctly the first time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conversation tools
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Archive chats&lt;/li&gt;
&lt;li&gt;System prompt presets&lt;/li&gt;
&lt;li&gt;CUstom system prompt&lt;/li&gt;
&lt;li&gt;Clean interface without unnecessary clutter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Tauri&lt;/li&gt;
&lt;li&gt;Zustand&lt;/li&gt;
&lt;li&gt;MUI&lt;/li&gt;
&lt;li&gt;SQLite&lt;/li&gt;
&lt;li&gt;ollama-rs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What i would change
&lt;/h2&gt;

&lt;p&gt;If I rebuilt it, I’d add OpenAI-compatible API support from day one.&lt;/p&gt;

&lt;p&gt;Right now it only works with Ollama, though the architecture already supports additional providers. I just haven’t wired them in yet because priorities are a fictional concept.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source
&lt;/h2&gt;

&lt;p&gt;The project is open source, and release builds are available if you want to try it without compiling anything yourself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/monolabsdev/openbench-ai" rel="noopener noreferrer"&gt;Openbench GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing note
&lt;/h2&gt;

&lt;p&gt;Somewhere between building multi-model streaming and debugging Rust event bridges, I learned that the world did not strictly need another chat UI.&lt;/p&gt;

&lt;p&gt;So I made one anyway.&lt;/p&gt;

&lt;p&gt;For what it’s worth, it behaves better than most tools that have significantly more funding and optimism.&lt;/p&gt;

&lt;p&gt;And yes, it works just fine for a gay developer trying to wrangle multiple AI models without losing their mind to tool fragmentation.&lt;/p&gt;

&lt;p&gt;Which is probably more honesty than most software documentation is legally allowed to contain.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>ollama</category>
      <category>opensource</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Theo Slater</dc:creator>
      <pubDate>Sat, 11 Apr 2026 17:26:05 +0000</pubDate>
      <link>https://dev.to/theoslater/-3d73</link>
      <guid>https://dev.to/theoslater/-3d73</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/peixotomdb/i-got-tired-of-using-10-different-dev-tools-so-i-built-my-own-2gl4" class="crayons-story__hidden-navigation-link"&gt;I got tired of using 10 different dev tools, so I built my own&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/peixotomdb" class="crayons-avatar  crayons-avatar--l  "&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%2Fuser%2Fprofile_image%2F1019001%2Fac7ddfda-b0b5-4608-9f04-9a4155383488.jpeg" alt="peixotomdb profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/peixotomdb" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Peixoto
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Peixoto
                
              
              &lt;div id="story-author-preview-content-3487106" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/peixotomdb" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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%2Fuser%2Fprofile_image%2F1019001%2Fac7ddfda-b0b5-4608-9f04-9a4155383488.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Peixoto&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/peixotomdb/i-got-tired-of-using-10-different-dev-tools-so-i-built-my-own-2gl4" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Apr 11&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/peixotomdb/i-got-tired-of-using-10-different-dev-tools-so-i-built-my-own-2gl4" id="article-link-3487106"&gt;
          I got tired of using 10 different dev tools, so I built my own
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/showdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;showdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/productivity"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;productivity&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/programming"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;programming&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/peixotomdb/i-got-tired-of-using-10-different-dev-tools-so-i-built-my-own-2gl4" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;7&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/peixotomdb/i-got-tired-of-using-10-different-dev-tools-so-i-built-my-own-2gl4#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              2&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            5 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
  </channel>
</rss>
