<?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: C W</title>
    <description>The latest articles on DEV Community by C W (@moersebene).</description>
    <link>https://dev.to/moersebene</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%2F4021314%2F03f47fcf-d610-4210-b273-e848d38d27fc.png</url>
      <title>DEV Community: C W</title>
      <link>https://dev.to/moersebene</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/moersebene"/>
    <language>en</language>
    <item>
      <title>The Ghost in the Machine: Hunting a Memory Leak I Couldn't See the Source Of.</title>
      <dc:creator>C W</dc:creator>
      <pubDate>Wed, 15 Jul 2026 08:09:42 +0000</pubDate>
      <link>https://dev.to/moersebene/the-ghost-in-the-machine-hunting-a-memory-leak-i-couldnt-see-the-source-of-54f1</link>
      <guid>https://dev.to/moersebene/the-ghost-in-the-machine-hunting-a-memory-leak-i-couldnt-see-the-source-of-54f1</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most bug stories start with a stack trace in your own code. This one starts with a stack trace in code I'd never seen, couldn't read, and wasn't allowed to touch — a closed-source, decades-old ERP that a critical overnight process depended on. No source, no debugger, and no PRs to link at the end of this post, because there was never any of my own code to change.&lt;/p&gt;

&lt;p&gt;What I &lt;em&gt;did&lt;/em&gt; have was a process that fell over in the small hours, a vendor whose only advice was to "call support," and a stubborn refusal to accept "it just does that sometimes" as an answer.&lt;/p&gt;

&lt;p&gt;Here's how I smashed a bug I could only ever observe from the outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Every night, an integration job that shuttled financial settlement data between two systems would throw this at me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;System.OutOfMemoryException
   at System.Text.Encoding.GetChars(Byte[] bytes, Int32 index, Int32 count)
   at System.Text.Encoding.Convert(...)
   at &amp;lt;VendorLib&amp;gt;.SQLSupport.Extensions.ToByteArray(String s)
   at &amp;lt;VendorLib&amp;gt;.SQLSupport.Helper.Serializer.ToDataBuffer(IEntity entity)
   at &amp;lt;VendorLib&amp;gt;.SQLSupport.ServiceProxy.ActivityServiceProxy.GetNextRecord(...)
   ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;OutOfMemoryException&lt;/code&gt;. On a server with tens of gigabytes of RAM. The recommended fix from the vendor? Just a phone number.&lt;/p&gt;

&lt;p&gt;The errors clustered in a roughly 20-minute window in the early hours, and I'd learn later that interactive users were hitting the same wall during the day without anyone connecting the dots. The problem had apparently been shrugged at for a long time.&lt;/p&gt;

&lt;h2&gt;
  
  
  False trail #1: "It's corrupt data"
&lt;/h2&gt;

&lt;p&gt;My first theory was a poisoned record. The stack trace dies while serialising a string into a byte buffer — &lt;code&gt;ToByteArray&lt;/code&gt; reading some field's length and trying to allocate. A garbage length value would make it try to allocate gigabytes in one shot and throw instantly. That fit the "fails on a specific record" idea perfectly.&lt;/p&gt;

&lt;p&gt;Except it didn't hold up. The errors pointed at &lt;em&gt;multiple different datasets&lt;/em&gt;, on &lt;em&gt;different internal methods&lt;/em&gt;, not one record in one place. When even a security/session-setup call throws &lt;code&gt;OutOfMemory&lt;/code&gt;, you're not looking at one bad row — you're looking at a process that's already on its knees before it even gets to your data.&lt;/p&gt;

&lt;h2&gt;
  
  
  False trail #2: "The server's out of memory"
&lt;/h2&gt;

&lt;p&gt;The obvious next thought: But here's the thing about a &lt;code&gt;.NET&lt;/code&gt; &lt;code&gt;OutOfMemoryException&lt;/code&gt;: &lt;strong&gt;it very often has nothing to do with physical RAM.&lt;/strong&gt; It means the process couldn't get the memory &lt;em&gt;it&lt;/em&gt; asked for — and a 32-bit process can only ever address about 2GB, no matter how much RAM the machine has. That ceiling is baked into the architecture, not the hardware.&lt;/p&gt;

&lt;p&gt;So I opened Task Manager, found the long-lived process behind the failing component, and watched it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;140 MB.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The process throwing "out of memory" was sitting at 140 megabytes. The server had oceans of free RAM. This was never a RAM problem. It was an &lt;em&gt;address space&lt;/em&gt; problem — or it would be, if something were filling that 2GB up. But at 140MB? Something didn't add up. Unless I was looking at the wrong moment in the cycle.&lt;/p&gt;

&lt;p&gt;That's when I stopped guessing and started measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stakeout: teaching myself what the process did all day
&lt;/h2&gt;

&lt;p&gt;I couldn't attach a profiler to vendor software in production. So I did the crude, reliable thing: a scheduled script that logged the process's committed memory, working set, and handle count once an hour, to a CSV. Keyed off the service account rather than the process ID, so it would re-find the right process automatically each day even across daily server restarts.&lt;/p&gt;

&lt;p&gt;Then I waited a day and looked at the curve.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;Time&lt;/span&gt;    &lt;span class="k"&gt;CommitMB&lt;/span&gt;   &lt;span class="k"&gt;Handles&lt;/span&gt;
&lt;span class="mf"&gt;10&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;00&lt;/span&gt;      &lt;span class="mf"&gt;293&lt;/span&gt;       &lt;span class="mf"&gt;2889&lt;/span&gt;
&lt;span class="mf"&gt;11&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;00&lt;/span&gt;      &lt;span class="mf"&gt;387&lt;/span&gt;       &lt;span class="mf"&gt;3779&lt;/span&gt;
&lt;span class="mf"&gt;12&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;00&lt;/span&gt;      &lt;span class="mf"&gt;485&lt;/span&gt;       &lt;span class="mf"&gt;4650&lt;/span&gt;
&lt;span class="mf"&gt;13&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;00&lt;/span&gt;      &lt;span class="mf"&gt;578&lt;/span&gt;       &lt;span class="mf"&gt;5596&lt;/span&gt;
&lt;span class="err"&gt;...&lt;/span&gt;        &lt;span class="err"&gt;...&lt;/span&gt;        &lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="mf"&gt;21&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;00&lt;/span&gt;     &lt;span class="mf"&gt;1361&lt;/span&gt;      &lt;span class="mf"&gt;13429&lt;/span&gt;
&lt;span class="mf"&gt;22&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;00&lt;/span&gt;     &lt;span class="mf"&gt;1451&lt;/span&gt;      &lt;span class="mf"&gt;14361&lt;/span&gt;   &lt;span class="err"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="k"&gt;flatlines&lt;/span&gt; &lt;span class="k"&gt;here&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;users&lt;/span&gt; &lt;span class="k"&gt;log&lt;/span&gt; &lt;span class="k"&gt;off&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="mf"&gt;02&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;00&lt;/span&gt;     &lt;span class="mf"&gt;1456&lt;/span&gt;      &lt;span class="mf"&gt;14492&lt;/span&gt;
&lt;span class="mf"&gt;03&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;00&lt;/span&gt;      &lt;span class="mf"&gt;178&lt;/span&gt;       &lt;span class="mf"&gt;1781&lt;/span&gt;   &lt;span class="err"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="k"&gt;reboot&lt;/span&gt; &lt;span class="k"&gt;resets&lt;/span&gt; &lt;span class="k"&gt;it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it was. A dead-straight climb of &lt;strong&gt;~96 MB every single hour&lt;/strong&gt;, from a low baseline right up toward the ceiling — and then a hard reset overnight. A textbook memory leak, drawn in a perfectly clean line.&lt;/p&gt;

&lt;p&gt;Two details made it click:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Handles rose in lockstep with memory&lt;/strong&gt; — about 970 per hour, tracking the megabytes almost proportionally. Memory &lt;em&gt;and&lt;/em&gt; handles leaking together is the fingerprint of unreleased references: every leaked object clings to both a chunk of memory and an OS handle. In an old reference-counted component, that's a missed release on a hot path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The leak flatlined when people logged off at night.&lt;/strong&gt; It was &lt;em&gt;usage-driven&lt;/em&gt; — every operation leaked a little, so when the work stopped, the growth stopped. Not a background timer; a per-operation leak.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the "why 02h00?" finally made sense. The leak built all day and by the wee hours the process was near its ceiling &lt;em&gt;and&lt;/em&gt; its free space was fragmented. That's exactly when the overnight job attempted its single largest allocation — a big data pull. An already-exhausted, fragmented 2GB address space couldn't hand over one contiguous block that size, and &lt;em&gt;bang&lt;/em&gt; — &lt;code&gt;OutOfMemoryException&lt;/code&gt;. The daily reboot then wiped the slate, and the cycle began again, which is precisely why nobody had ever caught it in daylight.&lt;/p&gt;

&lt;p&gt;The 140MB I'd seen earlier? I'd happened to look just after a reboot. The stakeout caught the whole story the snapshot couldn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming the culprit
&lt;/h2&gt;

&lt;p&gt;The process was a COM+ surrogate host. Its executable path gave away the last piece:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight batchfile"&gt;&lt;code&gt;&lt;span class="kd"&gt;C&lt;/span&gt;:\WINDOWS\SysWOW64\dllhost.exe &lt;span class="na"&gt;/Processid&lt;/span&gt;:&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="kd"&gt;GUID&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SysWOW64&lt;/code&gt; = 32-bit on 64-bit Windows (yes, the naming is backwards). Matching that GUID against the registered COM+ applications pinned it to one specific component — the vendor's data-access layer that &lt;em&gt;every&lt;/em&gt; operation, interactive and batch, funnelled through. One leaking process, every code path. That's why the overnight job and the daytime users failed for the same reason.&lt;/p&gt;

&lt;p&gt;Diagnosis complete:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A 32-bit COM+ component leaks memory and handles at ~96 MB/hr under load (unreleased references). Over a day it approaches the 2GB address-space ceiling; fragmentation plus the overnight job's large allocation tips it into &lt;code&gt;OutOfMemory&lt;/code&gt;. The nightly reboot masks it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So not RAM, or corrupt record. Just a reference leak meeting a 32-bit wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smashing it (without a compiler)
&lt;/h2&gt;

&lt;p&gt;I couldn't fix the leak since it's not my code. So the goal shifted from &lt;em&gt;fix the bug&lt;/em&gt; to &lt;em&gt;make the system resilient despite an unfixable dependency.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The component ran under COM+, and COM+ has a feature made for exactly this: &lt;strong&gt;application recycling&lt;/strong&gt;. You can tell it to tear down and respawn the process when it crosses a memory threshold — automatically, mid-life, without a server reboot.&lt;/p&gt;

&lt;p&gt;I set the memory limit to &lt;strong&gt;1 GB&lt;/strong&gt; (&lt;code&gt;1048576&lt;/code&gt; KB — a clean 1024×1024, comfortably below the 2GB wall with a full gigabyte of headroom so any large allocation still finds contiguous space):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;COM+ application → Properties → Pooling and Recycling → Memory limit (KB) = &lt;code&gt;1048576&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There was one nervous moment. The component's own logs kept saying &lt;code&gt;hasrecycled=[False]&lt;/code&gt; while messages were in process — it refused to recycle while busy, and it was &lt;em&gt;always&lt;/em&gt; busy. I braced for the fix to do nothing. But that flag was a per-instant status, not a permanent refusal: COM+ waits for a gap between operations and takes it. Under real load there were enough sub-second gaps, and it started recycling right at the 1GB mark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after
&lt;/h2&gt;

&lt;p&gt;The same homemade logger that caught the bug proved the fix. Same CSV, different shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; memory climbs to ~1.45GB, address space fragments, overnight job fails almost nightly, users hit errors as the day wears on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt; the process now cycles up and down — climbs to 1GB, recycles to baseline, climbs again — never approaching the ceiling. The tell-tale sign it was genuinely recycling and not just rebooting: &lt;strong&gt;the process ID changed mid-day, without a server restart&lt;/strong&gt;, with memory dropping to baseline at that exact moment.&lt;/p&gt;

&lt;p&gt;The overnight and daytime errors stopped. Days of clean logs, including busy days that used to be the worst offenders. Harmony restored — not by changing a line of the offending code, but by boxing it in so its worst habit could never reach the failure point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The twist: the bug has a sibling
&lt;/h2&gt;

&lt;p&gt;Here's the part that keeps it from being a tidy ending. A second environment runs its &lt;em&gt;own&lt;/em&gt; independent copy of the same software — same component, same leak, its own separate install. Fixing one did nothing for the other. The same recycling mitigation now has to be replicated there. A leak in shared-but-copied software isn't one bug; it's one bug per copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm proud of, and what I learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Proud of:&lt;/strong&gt; solving it with nothing but observation. No source, no profiler, no vendor cooperation — just a stack trace read carefully, a snapshot that lied, and a homemade logger that told the truth. The whole diagnosis came from treating a black box as knowable if you measure it patiently enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Learned:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OutOfMemoryException&lt;/code&gt; is a liar's name.&lt;/strong&gt; It rarely means "the machine is out of RAM." Nine times out of ten it means a single process hit its own ceiling — and for 32-bit processes that ceiling is ~2GB no matter what the server has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single snapshot can send you the wrong way.&lt;/strong&gt; 140MB "proved" it wasn't memory. Only the trend over time told the real story. Measure the curve, not the point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory and handles rising together&lt;/strong&gt; is a gift of a clue — it points straight at unreleased references rather than a runaway cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You don't always get to fix the bug but sometimes you fix the blast radius.&lt;/strong&gt; Recycling didn't cure the leak; it made the leak unable to hurt anyone. For a dependency you can't change, resilience &lt;em&gt;around&lt;/em&gt; it is a legitimate win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reboot that "fixes" something every night is a bug wearing a disguise.&lt;/strong&gt; If a scheduled restart is load-bearing, there's an unsmashed bug hiding behind it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ghost is still in the machine — I never exorcised the leak itself. But it's in a box now, recycled back to harmless before it can ever reach the wall. Sometimes smashing a bug means making sure it can never land the hit.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>dotnet</category>
      <category>devops</category>
    </item>
    <item>
      <title>Dōjō 道場 — karate made me do it.</title>
      <dc:creator>C W</dc:creator>
      <pubDate>Sat, 11 Jul 2026 07:46:00 +0000</pubDate>
      <link>https://dev.to/moersebene/dojo-dao-chang-karate-made-me-do-it-1abj</link>
      <guid>https://dev.to/moersebene/dojo-dao-chang-karate-made-me-do-it-1abj</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-07-09"&gt;Weekend Challenge: Passion Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Dōjō (道場) is a Shito-Ryu based karate kata training companion, built around the Passion Edition prompt taken literally, to say that passion is the fire that drives us.&lt;/p&gt;

&lt;p&gt;The home screen is a living flame that grows with your training, fed by both your streak and the effort you put in each day. If you show up consistently, it builds and feeds the flame; if you put in a hard session, it flares up that same day. It's a real canvas particle fire and not a static graphic.&lt;/p&gt;

&lt;p&gt;The core is a count-along kata drill with a live sensei voice. Pick a kata, set the tempo, and a sensei counts you through it in Japanese — ichi, ni, san… — on the beat, with a kiai shout on the traditional counts and an optional metronome. Pause/resume mid-drill, and every kata's counts are editable, since those conventions vary by branch. Finishing a drill logs the session and feeds the flame.&lt;/p&gt;

&lt;p&gt;Rounding it out: a kata hall grouped by their respective lineage (Pinan, Shuri-te, Naha-te), with a belt-and-history view, and a one-tap Kiai button to test the chosen voice.&lt;/p&gt;

&lt;p&gt;I have been practicing karate for 30 years and my goal was to build something that feels like stepping into a dōjō, not opening a fitness dashboard — and to make the voice feel like a real sensei counting beside you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;


&lt;div class="ltag-netlify"&gt;
  &lt;iframe src="https://dojoh.netlify.app" title="Netlify embed"&gt;
  &lt;/iframe&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/chantleyw" rel="noopener noreferrer"&gt;
        chantleyw
      &lt;/a&gt; / &lt;a href="https://github.com/chantleyw/dojo" rel="noopener noreferrer"&gt;
        dojo
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Dōjō · 道場&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A Shito-Ryu (糸東流) training companion with a live sensei voice.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Dōjō is a karate practice app built around a single emotional idea: &lt;em&gt;passion is the
fire that drives us&lt;/em&gt;. The home screen shows a flame that grows with your training
streak — a cold ember at zero, a roaring fire past 30 days. Pick a kata and a sensei
voice counts you through it in Japanese, on the beat — &lt;em&gt;ichi, ni, san…&lt;/em&gt; — with a kiai
shout on the traditional counts and an optional metronome tick.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Built for the DEV.to Weekend Challenge (Passion Edition).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;✨ Highlights&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A flame that grows with your streak.&lt;/strong&gt; Layered SVG plume with a Framer Motion
flicker, driven by &lt;code&gt;fire = min(1, streak / 30)&lt;/code&gt;. Reduced-motion holds it still.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A live count-along drill.&lt;/strong&gt; Choose a kata; the sensei counts in Japanese on the
beat, kiai-ing on the traditional counts. Tempo…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/chantleyw/dojo" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The stack is Vite + React + TypeScript + Tailwind, with Framer Motion, Zustand for state, the Web Audio API for the metronome, and Netlify Functions for the backend. State persists per-device in localStorage — so no accounts are required.&lt;/p&gt;

&lt;p&gt;The sensei voice (ElevenLabs)&lt;br&gt;
This is the core of the app, and getting it to feel live drove most of the interesting decisions.&lt;/p&gt;

&lt;p&gt;A custom voice, built in Voice Design. The sensei is a purpose-built deep, gravelly grand-master voice created in ElevenLabs Voice Design — the kind of voice that could bark a battle command. The app pulls every voice on the account into a picker, but defaults to the sensei.&lt;/p&gt;

&lt;p&gt;Authentic Japanese counting. Numerals are generated programmatically as hiragana (いち, に, さん…, composing じゅういち for 11, and so on) and spoken with Turbo v2.5 using language_code: "ja". That language flag was the unlock because without it, an English-leaning accented voice pronounces さん like the English word "san"; with it, you get a proper sahn pronunciation.&lt;/p&gt;

&lt;p&gt;Making it land on the beat. A count drill can't afford a network round-trip per number. So on "Begin," the app pre-generates one clip per unique word, caches them as object URLs keyed by text, then plays the cached audio on each metronome beat. The count and the kiai are also given separate expressiveness profiles — the counts stay crisp and consistent, while the kiai runs at low stability / high style for a raw, shouted delivery.&lt;/p&gt;

&lt;p&gt;Keeping the key safe. The link is public, so the ElevenLabs key can never touches the browser. A tiny Netlify Function proxies /api/tts and /api/voices, holding the key in an env var and forwarding requests server-side. The client only ever talks to my own domain.&lt;/p&gt;

&lt;p&gt;Never hard-failing. If the proxy returns a non-200 — a quota cap, a rate limit — the app falls back to the browser's built-in speech synthesis (preferring a Japanese voice) and keeps counting. I actually hit this live during testing when a capped key ran dry, and the drill kept running on the fallback voice without a hiccup. Generation is also concurrency-limited with retries so the free tier's request limits don't drop individual clips.&lt;/p&gt;

&lt;p&gt;The flame is a canvas particle system — rising flame particles, floating embers, and an additive glow, all scaling with a single fire value from 0 to 1. Rather than tie that purely to the streak, I compute it from both consistency and effort: fire = 0.7 × (streak / 30) + 0.4 × (today's minutes / 45), capped at 1. A long streak carries a strong baseline, a hard session flares it up the same day, and a single huge day can't max it out alone. Reduced-motion users get a still flame instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;Best Use of ElevenLabs. The sensei voice is the core of the app: a custom generated Voice Design grand-master voice, spoken with Turbo v2.5 and language_code: "ja" for authentic Japanese counting, pre-generated and cached per word so it lands on the beat, with separate expressiveness profiles for the counts and the kiai which are all served through a serverless proxy that keeps the API key off of the client, with a seamless fallback to the browser voice so a public link never hard-fails.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>elevenlabs</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
