<?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: 王冲</title>
    <description>The latest articles on DEV Community by 王冲 (@chongwang).</description>
    <link>https://dev.to/chongwang</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%2F4057853%2Fa5aabe62-6407-4fe6-9b2e-2e450d9bb92e.png</url>
      <title>DEV Community: 王冲</title>
      <link>https://dev.to/chongwang</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chongwang"/>
    <language>en</language>
    <item>
      <title>Our Analytics Said 49 Visitors. The Truth Was ~10.</title>
      <dc:creator>王冲</dc:creator>
      <pubDate>Sat, 10 Oct 2026 02:05:00 +0000</pubDate>
      <link>https://dev.to/chongwang/our-analytics-said-49-visitors-the-truth-was-10-49da</link>
      <guid>https://dev.to/chongwang/our-analytics-said-49-visitors-the-truth-was-10-49da</guid>
      <description>&lt;p&gt;We launched &lt;a href="https://elmren.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=elmren-voice" rel="noopener noreferrer"&gt;Elmren Voice&lt;/a&gt; — a local-first Mac app that clones a voice from 10 seconds of audio and turns any reading into a read-along player — on Product Hunt this week. Zero audience, no hunter, no mailing list. The dashboard lit up the next day: &lt;strong&gt;49 unique visitors.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I almost screenshotted it. Then I looked closer, and the number fell apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that killed the number
&lt;/h2&gt;

&lt;p&gt;We ship a localized site (11 languages) with ~330 indexed URLs. Filtering the raw event stream in PostHog, one fingerprint showed up everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every localized page hit exactly 2 times.&lt;/strong&gt; Not 1, not 3 — 2. Real visitors don't schedule themselves like that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All of it marked "Mac OS X"&lt;/strong&gt;, all of it &lt;code&gt;$direct&lt;/code&gt;, sessions spread across every language version we ship — including languages that had never sent us a single human.&lt;/li&gt;
&lt;li&gt;Datacenter ASNs and obvious bot flags were already excluded. These weren't. Multilingual SEO crawlers walk your sitemap wearing a consumer OS user agent and your analytics believes them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dedup check we now run daily (HogQL, adapt to your stack):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;uniqIf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distinct_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'$pageview'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;visitors&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;host&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'elmren.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'www.elmren.com'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;virt_is_bot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;geoip_city_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Ashburn'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Council Bluffs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Reston'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Boardman'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Des Moines'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Clifton'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;-- datacenter cities&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then one more pass: group pageviews by URL and look for the "every page exactly N times" signature. If your per-language counts are uniform, you're looking at a crawler, not a market.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was left after filtering
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Product Hunt launch day: 6 visitors.&lt;/strong&gt; Not 600. Six.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best post-launch day: ~10.&lt;/strong&gt; That "49" was ~80% crawler.&lt;/li&gt;
&lt;li&gt;Some days since: genuinely zero. We verified the pipeline itself wasn't broken by sending test events through the same collector — pipe healthy, humans absent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sales: 0.&lt;/strong&gt; Which, at 2–3 visitors/day, is not a conversion problem. It's arithmetic: even a great 3% visitor→trial→buy funnel needs ~30+ visitors/day to produce a sale a month. &lt;strong&gt;Traffic is the bottleneck, and no amount of landing-page polish fixes a denominator.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The number that's actually going up
&lt;/h2&gt;

&lt;p&gt;Google Search Console impressions, week over week: &lt;strong&gt;123 → 177 → 362&lt;/strong&gt;. One click so far, average position ~14. The first page to rank isn't in English — it's Dutch (&lt;em&gt;tekst voorlezen nederlands&lt;/em&gt;, position 8): a long-tail "read text aloud" query where the incumbents are thin and a localized page can actually win.&lt;/p&gt;

&lt;p&gt;That's the real shape of a zero-audience launch: social spikes evaporate (ours never materialized), but indexed, localized, genuinely-useful pages compound. Our plan now is boring on purpose — keep shipping content pages in languages where the competition is weak, keep the scoreboard honest, and let the denominator grow before we judge the funnel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things we changed because of the filtered number
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The scoreboard reads filtered numbers only.&lt;/strong&gt; A daily notification with "visitors X/100 (bots excluded)". Unfiltered numbers get rounded up by robots and round down your judgment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We stopped celebrating channel spikes and started auditing them.&lt;/strong&gt; Any spike gets the URL-uniformity check before it earns an emotion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roadmap re-ordered around the denominator.&lt;/strong&gt; Localization depth and long-tail content beat another coat of paint on the homepage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're pre-audience right now: filter your traffic before you interpret it. The gap between 49 and 10 is the gap between "growth is happening" and "growth hasn't started yet" — and only one of those diagnoses leads to work that matters.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Elmren Voice is $39.99 once, runs entirely on your Mac (no cloud), and the read-along player it exports works in any browser. If you're curious: &lt;a href="https://elmren.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=elmren-voice" rel="noopener noreferrer"&gt;elmren.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>seo</category>
      <category>indiehacking</category>
      <category>postmortem</category>
    </item>
    <item>
      <title>One HTML File, Zero Requests: Building a Read-Along Player With Word-Level Highlighting</title>
      <dc:creator>王冲</dc:creator>
      <pubDate>Sun, 04 Oct 2026 16:01:50 +0000</pubDate>
      <link>https://dev.to/chongwang/one-html-file-zero-requests-building-a-read-along-player-with-word-level-highlighting-43lj</link>
      <guid>https://dev.to/chongwang/one-html-file-zero-requests-building-a-read-along-player-with-word-level-highlighting-43lj</guid>
      <description>&lt;p&gt;My favorite artifact from the app I'm building isn't in the app. It's the file the app &lt;em&gt;exports&lt;/em&gt;: a single HTML page that plays a chapter of audio while every spoken word lights up in sync — karaoke-style — with &lt;strong&gt;zero network requests, zero dependencies, zero accounts&lt;/strong&gt;. Open it on a phone from 2015 with airplane mode on; it works. Email it to a grandparent; it works.&lt;/p&gt;

&lt;p&gt;This is the heart of &lt;a href="https://www.elmren.com/?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=readalong-player"&gt;Elmren Voice&lt;/a&gt;, a local-first text-to-speech app for Mac I've covered here before — &lt;a href="https://dev.to/chongwang/three-bake-offs-to-find-a-voice-cloning-engine-that-runs-entirely-on-a-macbook-p8l"&gt;how I picked the TTS engine&lt;/a&gt;, then &lt;a href="https://dev.to/chongwang/packaging-a-python-ml-sidecar-inside-a-signed-mac-app-9-traps-from-production-3cjk"&gt;how I packaged the Python sidecar&lt;/a&gt;. This post is the last leg: how synthesized audio becomes a self-contained read-along player, and the alignment trick that makes the highlighting exact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a file instead of a page on our server
&lt;/h2&gt;

&lt;p&gt;The product's promise is that a child's reading material and a family's voice recordings never leave the machine. A hosted player — even a nice one — would break that promise the moment playback needed a server. A plain file keeps it: generate locally, open anywhere, and the privacy story is structural instead of contractual. It also means the thing you made in 2026 still works in 2036 regardless of anyone's business model, including ours.&lt;/p&gt;

&lt;p&gt;The constraint drove the format: one HTML file, audio embedded as a data URI, the word-timestamp table inline, and a few hundred lines of dependency-free vanilla JS for the highlighter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline: text → audio → word timestamps
&lt;/h2&gt;

&lt;p&gt;Synthesis runs sentence-by-sentence (that's a cloning-quality requirement, not a design preference). Then the generated audio goes through &lt;strong&gt;whisper.cpp&lt;/strong&gt; for alignment — not transcription quality, alignment. The numbers from my reference machine: &lt;strong&gt;58.8 seconds of audio → complete word-level timestamps in 2.87 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Then the part nobody warns you about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whisper mishears words. The highlighter must not care.
&lt;/h2&gt;

&lt;p&gt;The timestamp says &lt;em&gt;when&lt;/em&gt; a word was spoken, but the transcript whisper produces says &lt;em&gt;what&lt;/em&gt; it heard. Feed the transcript's words straight into the UI and you get the classic failure mode of naive karaoke: the highlight drifts to what was &lt;em&gt;heard&lt;/em&gt; while the page shows what was &lt;em&gt;written&lt;/em&gt;, the kid's eyes track the wrong word, and the whole point of read-along — eyes locked on the correct print — collapses.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;the highlighter renders the source text, never the transcript.&lt;/strong&gt; The source is ground truth (it's literally what we sent to the TTS engine); the transcript is only a carrier of &lt;em&gt;timing&lt;/em&gt;. A &lt;code&gt;SequenceMatcher&lt;/code&gt; pass maps each transcribed word to its counterpart in the source — on my 166-word reference passage it covers &lt;strong&gt;166/166 words&lt;/strong&gt; — and any word whisper dropped or mangled simply inherits timing from its neighbors. Mishearings degrade to slightly looser sync, never to highlighting the wrong word.&lt;/p&gt;

&lt;p&gt;If you build anything with forced alignment, this inversion — &lt;em&gt;source text for rendering, transcript for timing only&lt;/em&gt; — is the whole ballgame.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runtime: boring on purpose
&lt;/h2&gt;

&lt;p&gt;The player itself is deliberately uninteresting technology, which is the compliment: an &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; element, a word table, and a &lt;code&gt;timeupdate&lt;/code&gt;-driven binary search for the current word. A &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; per word gets a class toggle. Pages chunk by paragraph so a long chapter never renders a wall of ten thousand spans at once. There's a progress memory in &lt;code&gt;localStorage&lt;/code&gt;, a dark mode that respects &lt;code&gt;prefers-color-scheme&lt;/code&gt;, and — because some kids share devices with siblings — everything also works with the keyboard alone.&lt;/p&gt;

&lt;p&gt;No framework. The file has to outlive every framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same timestamps, two more products
&lt;/h2&gt;

&lt;p&gt;Once word timing exists, it composes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;M4B audiobook export&lt;/strong&gt; — chapters and bookmarks straight from the sentence boundaries the pipeline already knows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Karaoke MP4&lt;/strong&gt; — the word-highlight render as video. The shipped ffmpeg build carries no subtitle filters, so it's rendered per-word as PNG frames and concatenated — a constraint I've written about before, and frame-exact highlighting came out of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One alignment pass, three deliverables.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;There's a &lt;a href="https://www.elmren.com/demo?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=readalong-player"&gt;live demo player&lt;/a&gt; up — it's a real export from the product (a chapter of &lt;em&gt;The Secret Garden&lt;/em&gt;), not a mockup: play it and watch the words light up. The app itself is $39.99 once for macOS, with a free trial (120 synthesis minutes in your first week), and voices are either one of the 64 built-in or a clone of your own — learned and spoken entirely on your Mac.&lt;/p&gt;

&lt;p&gt;One more thing: &lt;strong&gt;Elmren Voice launches on Product Hunt tomorrow, Tuesday Oct 6.&lt;/strong&gt; If read-along tooling for kids who struggle with reading is your corner of the world, I'd genuinely value a look — and criticism; the comment threads are where the good corrections come from.&lt;/p&gt;

&lt;p&gt;As before, thanks to the stack that makes a one-person product possible: &lt;a href="https://github.com/ggml-org/whisper.cpp" rel="noopener noreferrer"&gt;whisper.cpp&lt;/a&gt;, &lt;a href="https://github.com/hexgrad/kokoro" rel="noopener noreferrer"&gt;Kokoro&lt;/a&gt;, &lt;a href="https://github.com/QwenLM/Qwen3-TTS" rel="noopener noreferrer"&gt;Qwen3-TTS&lt;/a&gt; and &lt;a href="https://github.com/Blaizzy/mlx-audio" rel="noopener noreferrer"&gt;mlx-audio&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Packaging a Python ML Sidecar Inside a Signed Mac App: 9 Traps from Production</title>
      <dc:creator>王冲</dc:creator>
      <pubDate>Sun, 04 Oct 2026 14:37:29 +0000</pubDate>
      <link>https://dev.to/chongwang/packaging-a-python-ml-sidecar-inside-a-signed-mac-app-9-traps-from-production-3cjk</link>
      <guid>https://dev.to/chongwang/packaging-a-python-ml-sidecar-inside-a-signed-mac-app-9-traps-from-production-3cjk</guid>
      <description>&lt;p&gt;In my &lt;a href="https://dev.to/chongwang/three-bake-offs-to-find-a-voice-cloning-engine-that-runs-entirely-on-a-macbook-p8l"&gt;last post&lt;/a&gt; I covered how I picked the TTS engines for &lt;a href="https://www.elmren.com/?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=sidecar-packaging"&gt;Elmren Voice&lt;/a&gt; — a Mac app that clones &lt;em&gt;your own voice&lt;/em&gt; locally and turns any text into a read-along player with word-by-word highlighting. Everything runs on-device: no cloud, no API keys, no account.&lt;/p&gt;

&lt;p&gt;That post was the fun part. This one is the part nobody warns you about: &lt;strong&gt;getting a Python ML stack to live inside a signed, notarized, sandbox-friendly Mac app&lt;/strong&gt; — and keeping the download at ~122MB while the models themselves weigh gigabytes.&lt;/p&gt;

&lt;p&gt;The stack: Tauri 2 shell (Rust + React/TypeScript), Python sidecar for the engine (&lt;code&gt;mlx-audio&lt;/code&gt;, Kokoro, whisper.cpp, ffmpeg). Nine traps, all paid for in real debugging hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your sidecar "launcher" silently swallows arguments
&lt;/h2&gt;

&lt;p&gt;The packaged launcher must start with &lt;strong&gt;no arguments&lt;/strong&gt;. My first build passed &lt;code&gt;rw-engine --serve&lt;/code&gt; style args through a wrapper that resolved to &lt;code&gt;python --serve&lt;/code&gt; — Python happily starts an interactive session and your bridge never handshakes. Symptom: app launches, engine "runs", zero responses. The fix is boring: the launcher takes nothing; the serve mode is the default; mode switches ride the JSONL protocol, not argv.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Version your wire protocol… as strings
&lt;/h2&gt;

&lt;p&gt;Our bridge is newline-delimited JSON over stdin/stdout. Every message is enveloped, and the envelope is &lt;code&gt;{"v":"1"}&lt;/code&gt; — &lt;strong&gt;v is a string&lt;/strong&gt;. I shipped one call site with a numeric &lt;code&gt;1&lt;/code&gt; once. Strict parsers see two different protocols. Pick string versions on day one; JSON numbers will bite you across languages (Rust serde and Python json disagree on int-vs-float edge cases).&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Control commands must never queue behind data commands
&lt;/h2&gt;

&lt;p&gt;The nastiest bug I shipped: user clicks &lt;strong&gt;Cancel&lt;/strong&gt; on a synthesis job — nothing happens. Why? One mutex guarded the whole stdio bridge, and a 10-minute chapter synthesis held it on every roundtrip. The cancel command was &lt;em&gt;delivered&lt;/em&gt;, then sat in the write queue behind the job it was supposed to kill.&lt;/p&gt;

&lt;p&gt;The fix, shipped in v1.9.1: split the hub into a &lt;strong&gt;data plane&lt;/strong&gt; (long roundtrips) and a &lt;strong&gt;control plane&lt;/strong&gt; (short commands: cancel, pause, settings) with an independent write-side lock, and make control messages fire-and-forget. Cancels now land even mid-roundtrip. If your GUI freezes "sometimes" during long native calls, audit your lock topology before anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. PEP 668 means zero runtime pip. Ever.
&lt;/h2&gt;

&lt;p&gt;Modern macOS Pythons are externally managed; runtime &lt;code&gt;pip install&lt;/code&gt; was never going to survive anyway (no compiler, no network assumptions, Gatekeeper). The rule that falls out: &lt;strong&gt;the entire Python environment ships inside the app&lt;/strong&gt; as a vendored &lt;code&gt;sidecar-site&lt;/code&gt; — including things you'd normally &lt;code&gt;pip install&lt;/code&gt; at build time, like &lt;code&gt;en_core_web_sm&lt;/code&gt; for spaCy. If a wheel can't be vendored, that dependency doesn't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. brew's whisper-cli dies on every machine but yours
&lt;/h2&gt;

&lt;p&gt;whisper.cpp does our word-level alignment. The Homebrew build hardcodes its ggml backend search path to the &lt;strong&gt;Cellar absolute path&lt;/strong&gt; — on my dev Mac, perfect; on a clean Mac, &lt;code&gt;Abort trap 6&lt;/code&gt;. The only reliable answer is building from source statically: v1.9.2, &lt;code&gt;BUILD_SHARED_LIBS=OFF&lt;/code&gt;, Metal embedded. One self-contained binary, seeded on first launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Hardened runtime rejects your relocated dylibs
&lt;/h2&gt;

&lt;p&gt;Relocating Python extension modules and native tools means library paths that no longer match their signatures. Under Hardened Runtime (required for notarized distribution) that's a hard kill at load. You need &lt;code&gt;disable-library-validation&lt;/code&gt; in the signing entitlements for those binaries — plus re-signing them after every copy/move. Also: keep native binaries in their own &lt;code&gt;native-tools/&lt;/code&gt; tree, or your Rust build will try to scan Mach-O files sitting inside the Python site-packages and waste minutes per build.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The bundled ffmpeg has no subtitle filters. Karaoke videos anyway.
&lt;/h2&gt;

&lt;p&gt;The redistributable ffmpeg build carries no &lt;code&gt;ass&lt;/code&gt;/&lt;code&gt;drawtext&lt;/code&gt; filters, which kills the obvious "render word-highlighting onto video" path. The workaround that shipped: render each word as a &lt;strong&gt;PNG, concat them per-frame, and pad the video to audio length&lt;/strong&gt;. Less elegant, zero extra dependencies, and frame-exact highlighting came out of it. Constraint beat cleverness.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. A symlink in your data root is a jailbreak
&lt;/h2&gt;

&lt;p&gt;Users will symlink &lt;code&gt;~/Application Support/.../models&lt;/code&gt; to an external drive. Tempting to allow — until path resolution follows the link out of the sandbox root and your escape checks pass things they shouldn't. Our &lt;code&gt;PathService.resolve&lt;/code&gt; &lt;strong&gt;follows links and rejects any path that escapes the data root&lt;/strong&gt;; the supported path for moving data is &lt;code&gt;cp -cR&lt;/code&gt; (clonefile, near-free on APFS). Related: Hugging Face downloads behind some proxies hang forever on the new Xet channel — &lt;code&gt;HF_HUB_DISABLE_XET=1&lt;/code&gt; forces the classic HTTP path.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Ship thin, hydrate on first launch
&lt;/h2&gt;

&lt;p&gt;The final architecture: the DMG is &lt;strong&gt;~122MB&lt;/strong&gt; — app shell, UI, a sample book, and &lt;em&gt;no models&lt;/em&gt;. On first run the app downloads engine packs on demand: ~700MB for the base stack (54 Kokoro preset voices + alignment + media), ~1.7GB adds the multilingual set, ~2.3GB all-in with the Qwen3-TTS cloning engine (64 voices total). Downloads are cancelable, disk space is pre-checked, and a background reconciliation loop repairs interrupted packs. First launch went from "download 2.3GB before you see anything" to "usable in a minute, clone-ready when you are."&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;A local-first AI product is two products: the ML pipeline, and the &lt;strong&gt;distribution engineering&lt;/strong&gt; around it. The second one has no benchmark blogs. If you're heading down this road: vendor everything, sign everything, keep your control plane off your data plane's locks, and treat every binary that worked on &lt;em&gt;your&lt;/em&gt; machine as untested.&lt;/p&gt;




&lt;p&gt;I'm building &lt;a href="https://www.elmren.com/?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=sidecar-packaging"&gt;Elmren Voice&lt;/a&gt; ($39.99 one-time, macOS 13+) — there's a &lt;a href="https://www.elmren.com/demo?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=sidecar-packaging"&gt;live demo player&lt;/a&gt; exported from the real product if you want to hear the word-highlighting in action. Engine selection backstory in &lt;a href="https://dev.to/chongwang/three-bake-offs-to-find-a-voice-cloning-engine-that-runs-entirely-on-a-macbook-p8l"&gt;part 1&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Update (Oct 5): Elmren Voice launches on Product Hunt this Tuesday, Oct 6. Part 3 of this series — &lt;a href="https://dev.to/chongwang/one-html-file-zero-requests-building-a-read-along-player-with-word-level-highlighting-43lj"&gt;how the read-along player with word-level highlighting works&lt;/a&gt; — is up.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>macos</category>
      <category>tauri</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Three Bake-offs to Find a Voice Cloning Engine That Runs Entirely on a MacBook</title>
      <dc:creator>王冲</dc:creator>
      <pubDate>Fri, 02 Oct 2026 09:15:18 +0000</pubDate>
      <link>https://dev.to/chongwang/three-bake-offs-to-find-a-voice-cloning-engine-that-runs-entirely-on-a-macbook-p8l</link>
      <guid>https://dev.to/chongwang/three-bake-offs-to-find-a-voice-cloning-engine-that-runs-entirely-on-a-macbook-p8l</guid>
      <description>&lt;p&gt;I'm building a Mac app where a parent clones &lt;strong&gt;their own voice&lt;/strong&gt; on their own machine, and the app turns reading homework into audio with word-by-word highlighting — aimed at kids with dyslexia who can follow along while listening. The hard requirement came from the use case: a child's voice samples and reading materials never leave the laptop. No cloud calls, no subscription, no telemetry. Everything runs locally on Apple Silicon.&lt;/p&gt;

&lt;p&gt;That meant finding a TTS engine that can (a) clone a voice from ~6 seconds of reference audio, (b) run at or near real time on a base M1 Pro, (c) be &lt;strong&gt;commercially usable&lt;/strong&gt;, and (d) be small enough to ship inside a desktop app.&lt;/p&gt;

&lt;p&gt;It took three bake-offs, five engines, and a pile of traps. Here's what I learned — including one lesson that flipped the whole result.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the license minefield
&lt;/h2&gt;

&lt;p&gt;Before any benchmark: most of the famous zero-shot voice cloning models are legally unusable for a commercial product.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;F5-TTS&lt;/strong&gt; — CC-BY-NC. Non-commercial. Out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XTTS-v2&lt;/strong&gt; (Coqui) — CPML. Out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;self-hosted fish-speech&lt;/strong&gt; — research license. Out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That leaves the Apache/MIT family: &lt;strong&gt;Kokoro-82M&lt;/strong&gt;, &lt;strong&gt;ZipVoice&lt;/strong&gt;, &lt;strong&gt;Chatterbox&lt;/strong&gt; (MIT), and &lt;strong&gt;Qwen3-TTS&lt;/strong&gt;. All benchmarks below ran on a MacBook Pro M1 Pro / 32GB, cloning the same reference voice (a LibriVox narrator, 60s of dense speech) reading ~40 seconds of Peter Rabbit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1: The obvious candidates
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Chatterbox&lt;/strong&gt;: one-shot generation of the full 40s passage. The output sounded fine. The problem was speed: &lt;strong&gt;808 seconds of compute for 40 seconds of audio (RTF 20)&lt;/strong&gt;. On MPS it's an order of magnitude too slow for a consumer app. Out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ZipVoice&lt;/strong&gt; (123M, from the k2-fsa folks): genuinely fast — per-sentence wall clock of 10–16s including ~7s of model loading per call, which works out to roughly &lt;strong&gt;RTF 0.6–1.5&lt;/strong&gt; once you keep the model resident. But it had sharp edges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The prompt text must exactly match the prompt audio.&lt;/strong&gt; A 28s reference clip paired with only the first sentence's transcript produced 44 seconds of that one sentence repeated forever. A 6s clip paired with a 28s full transcript produced 0.4–2s fragments. Exact 6s/6s — perfect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch mode on MPS misbehaves&lt;/strong&gt;: the same inputs that work per-sentence went into repetition loops in batch mode. Suspected padding bug. Production pipeline calls it one sentence at a time.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;zipvoice&lt;/code&gt; package on PyPI is an empty shell — the real code is &lt;code&gt;pip install git+https://github.com/k2-fsa/ZipVoice&lt;/code&gt;, plus a handful of dependencies that aren't declared.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this point ZipVoice was the front-runner: near-real-time, Apache-2.0, 468MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: Qwen3-TTS-0.6B on PyTorch — "local is impossible"
&lt;/h2&gt;

&lt;p&gt;Qwen3-TTS-0.6B-Base is Apache-2.0 and the community reported RTF 0.86 for cloning. But every one of those numbers came from CUDA. On the M1 Pro, with the official &lt;code&gt;qwen-tts&lt;/code&gt; package:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MPS + bf16&lt;/strong&gt;: crashes in the embedding layer (&lt;code&gt;Placeholder storage has not been allocated&lt;/code&gt;), on both torch 2.9 and 2.13.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU, fp32&lt;/strong&gt;: seven minutes, zero output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MPS + fp32 with device_map&lt;/strong&gt;: runs — at &lt;strong&gt;RTF ≈ 9&lt;/strong&gt;. A ten-word sentence took ~6 minutes. Fifteen times slower than ZipVoice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a second trap with Qwen's in-context-learning (ICL) cloning: it's &lt;em&gt;extremely&lt;/em&gt; sensitive to reference audio quality. A trimmed reference clip fed with the full-length transcript generated garbage that ran to the token limit — mostly silence and noise.&lt;/p&gt;

&lt;p&gt;I wrote it off: local Qwen3-TTS on M-series is unusable today. I planned to fall back to a cloud API for the premium tier and keep watching for MPS support upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 3: Same weights, different runtime
&lt;/h2&gt;

&lt;p&gt;The round-2 verdict was only true for PyTorch. The community had already ported the weights to MLX: &lt;code&gt;mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16&lt;/code&gt; (2.4GB), running through &lt;code&gt;mlx-audio&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Same model, same machine:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;PyTorch (MPS)&lt;/th&gt;
&lt;th&gt;MLX&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Average RTF&lt;/td&gt;
&lt;td&gt;~9&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.88&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model load&lt;/td&gt;
&lt;td&gt;minutes / crashes&lt;/td&gt;
&lt;td&gt;1–2 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content correctness&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;7/7 sentences correct on whisper round-trip&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Faster than real time.&lt;/strong&gt; Suddenly the "unusable" model was the fastest cloning engine of everything I'd tested, with the best quality-to-size ratio.&lt;/p&gt;

&lt;p&gt;The biggest lesson of the whole project: &lt;strong&gt;the model card is not the performance story — the framework port is.&lt;/strong&gt; If a model looks too slow on your platform, check whether someone has re-implemented it for your runtime before you write it off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha that cost the most time
&lt;/h2&gt;

&lt;p&gt;Qwen's ICL cloning demands that the reference audio be &lt;strong&gt;sentence-aligned at the start, with a verbatim transcript&lt;/strong&gt;. Feed it a sloppy reference and it doesn't degrade gracefully — it fills the output to the token ceiling with noise. Interesting contrast: ZipVoice eats dirty references without blinking; Qwen does not.&lt;/p&gt;

&lt;p&gt;So the product pipeline is now mandatory for any engine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;record → whisper transcript → trim at sentence boundaries → feed engine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few more hard-won environment notes, in case you're walking the same road:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hugging Face downloads through a proxy can hang forever on the new Xet channel&lt;/strong&gt; — zero bytes, zero errors. &lt;code&gt;export HF_HUB_DISABLE_XET=1&lt;/code&gt; forces the classic HTTP path and fixes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reference length is your biggest speed lever.&lt;/strong&gt; A 28s reference re-encodes per sentence (RTF 7–94 in the worst case); 6s drops it to 1–5. Guide users to record 6–10 clean seconds.&lt;/li&gt;
&lt;li&gt;In &lt;code&gt;mlx_audio.generate_audio&lt;/code&gt;, &lt;code&gt;output_path&lt;/code&gt; is a &lt;em&gt;directory&lt;/em&gt; and &lt;code&gt;file_prefix&lt;/code&gt; is the filename.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4-bit quantization won on every axis
&lt;/h2&gt;

&lt;p&gt;Before shipping, I compared the 4-bit quantized weights against bf16, expecting a quality cliff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Speaker similarity (resemblyzer): &lt;strong&gt;0.928 (4-bit) vs 0.923 (bf16)&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Content fidelity (whisper round-trip): 0.992 vs 0.992 — parity&lt;/li&gt;
&lt;li&gt;Speed: &lt;strong&gt;RTF 0.63 vs 1.11&lt;/strong&gt; — 4-bit is nearly 2× faster&lt;/li&gt;
&lt;li&gt;Size: &lt;strong&gt;1.71GB vs 2.4GB&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cross-similarity between the two arms was 0.937 — effectively the same voice. An F0 analysis confirmed quantization didn't flatten prosody. The download pack ships 4-bit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The supporting cast
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kokoro-82M&lt;/strong&gt; (Apache-2.0, 0.39GB as MLX bf16) for the 54 preset voices — story-time character voices, script mode, etc. English is its A-tier language; we only use it as a complement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;whisper.cpp&lt;/strong&gt; (MIT, static build) for word-level alignment — the thing that makes karaoke-style highlighting possible. 58.8s of audio → complete word timestamps in 2.87s. One subtlety: the &lt;em&gt;timeline&lt;/em&gt; comes from whisper, but the &lt;em&gt;word shapes&lt;/em&gt; come from the source text; a SequenceMatcher maps one to the other (166/166 words covered on the reference passage), so whisper mishearings don't corrupt the highlighting.&lt;/li&gt;
&lt;li&gt;Shell: &lt;strong&gt;Tauri 2 + React/TypeScript + Python sidecar&lt;/strong&gt;, with the engine abstracted behind a provider interface — because MLX is Apple-only, and the Windows path (ZipVoice on CPU, or a cloud fallback) is phase two.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Real-world throughput on the M1 Pro: &lt;strong&gt;~1–2.5 minutes per 1,000 characters&lt;/strong&gt; (idle to loaded), a full ~10-minute chapter processes in the background in 10–25 minutes, and power draw is a rounding error (~0.2Wh per chapter by estimate — the battery doesn't notice).&lt;/p&gt;

&lt;h2&gt;
  
  
  What this adds up to
&lt;/h2&gt;

&lt;p&gt;A parent records 6 seconds of reading, and a few minutes later a self-contained HTML player exists with their voice reading the chapter, words highlighting as they're spoken. No cloud, no API keys, no monthly fee — which is only possible because of how good the Apache/MIT TTS ecosystem has gotten, and because of the MLX port of Qwen3-TTS.&lt;/p&gt;

&lt;p&gt;If you're evaluating local TTS on Apple Silicon, the short version: &lt;strong&gt;Kokoro for preset voices, Qwen3-TTS-0.6B (4-bit, via mlx-audio) for cloning, whisper.cpp for alignment&lt;/strong&gt; — and budget a day for the reference-audio pipeline, because that's where the quality actually lives.&lt;/p&gt;

&lt;p&gt;I'm building this as &lt;a href="https://www.elmren.com/?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=tts-bakeoff"&gt;Elmren Voice&lt;/a&gt; ($39.99 one-time, macOS 13+). There's a &lt;a href="https://www.elmren.com/demo?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=tts-bakeoff"&gt;live demo player&lt;/a&gt; exported from the real product if you want to hear the word-highlighting in action.&lt;/p&gt;

&lt;p&gt;Thanks to the maintainers of &lt;a href="https://github.com/Blaizzy/mlx-audio" rel="noopener noreferrer"&gt;mlx-audio&lt;/a&gt;, &lt;a href="https://github.com/QwenLM/Qwen3-TTS" rel="noopener noreferrer"&gt;Qwen3-TTS&lt;/a&gt;, &lt;a href="https://github.com/hexgrad/kokoro" rel="noopener noreferrer"&gt;Kokoro&lt;/a&gt;, &lt;a href="https://github.com/k2-fsa/ZipVoice" rel="noopener noreferrer"&gt;ZipVoice&lt;/a&gt;, and &lt;a href="https://github.com/ggml-org/whisper.cpp" rel="noopener noreferrer"&gt;whisper.cpp&lt;/a&gt; — this product only exists because your licenses and ports do.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Update (Oct 5): Elmren Voice launches on Product Hunt this Tuesday, Oct 6. Part 3 of this series — &lt;a href="https://dev.to/chongwang/one-html-file-zero-requests-building-a-read-along-player-with-word-level-highlighting-43lj"&gt;how the read-along player with word-level highlighting works&lt;/a&gt; — is up.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>apple</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built a body-doubling focus app: no camera, no booking, no signup to look around</title>
      <dc:creator>王冲</dc:creator>
      <pubDate>Sat, 01 Aug 2026 11:18:06 +0000</pubDate>
      <link>https://dev.to/chongwang/i-built-a-body-doubling-focus-app-no-camera-no-booking-no-signup-to-look-around-4na4</link>
      <guid>https://dev.to/chongwang/i-built-a-body-doubling-focus-app-no-camera-no-booking-no-signup-to-look-around-4na4</guid>
      <description>&lt;p&gt;I kept noticing that the thing stopping me wasn't &lt;em&gt;what&lt;/em&gt; to do — I knew that. It was the act of starting. So I built &lt;a href="https://dopastep.com/" rel="noopener noreferrer"&gt;Dopastep&lt;/a&gt;, and the whole design is about removing reasons not to start.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Disclosure up front: this is my own project. Feedback, including the harsh kind, is why I'm posting.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The two things it does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Breaks the task down.&lt;/strong&gt; You name the thing you've been circling for three days, and it gets split into steps small enough that the first one isn't a decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Puts other people in the room.&lt;/strong&gt; You drop into a live focus room and work in synced focus/break cycles alongside whoever is there. That's body doubling — the effect where someone else working nearby makes it easier to begin.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deliberately left out
&lt;/h2&gt;

&lt;p&gt;I used Focusmate for a while and it genuinely helped, but two things eventually killed it for me: the camera, and the booking. By the time a session was scheduled, the window where I could actually have started had closed.&lt;/p&gt;

&lt;p&gt;So Dopastep has no camera, no chat, no booking and no partner matching. You open a room, see how many people are in it and which phase the shared timer is in, and start. No account needed to look around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two implementation notes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The timer is derived, not synced.&lt;/strong&gt; Everyone in a room has to agree on which phase they're in, including people who joined late or just refreshed. Rather than broadcasting ticks, the phase is computed from a single server-held start time. Late joiners and refreshes land in the right phase for free, and a whole category of "my timer says 3 minutes, yours says 5" bugs never exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Occupancy has to be honest, including when it's zero.&lt;/strong&gt; The entire value proposition is "someone else is actually here", so an inflated number would poison the one thing the product is for. Presence runs over Supabase Realtime, and an empty room says it's empty. This is commercially the wrong choice and I'm keeping it.&lt;/p&gt;

&lt;p&gt;Stack, if useful: Next.js 16, React 19, Supabase, Tailwind v4, deployed on Vercel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually is
&lt;/h2&gt;

&lt;p&gt;Launched in July 2026. Outside one scheduled session a day the rooms are usually empty — which, for a product whose point is other people, is &lt;em&gt;the&lt;/em&gt; problem rather than &lt;em&gt;a&lt;/em&gt; problem. That's the part I'd most like to hear ideas on: how do you bootstrap something that only works when someone else shows up?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dopastep.com/" rel="noopener noreferrer"&gt;https://dopastep.com/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer anything about the presence setup or the timer derivation.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
