<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>stormregion</title>
    <link>https://blog.stormregion.hu/</link>
    <description>Random side projects, engineering in depth and the occasional yak shave.</description>
    <language>en</language>
    <lastBuildDate>Sun, 18 Oct 2026 09:00:00 GMT</lastBuildDate>
    <atom:link href="https://blog.stormregion.hu/feed.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>Whistle Hungarian</title>
      <link>https://blog.stormregion.hu/posts/whistle-hu/</link>
      <guid isPermaLink="true">https://blog.stormregion.hu/posts/whistle-hu/</guid>
      <pubDate>Sun, 18 Oct 2026 09:00:00 GMT</pubDate>
      <description>Some fine-tuning and M5Stick deployment.</description>
      <category>m5stick</category>
      <category>stt</category>
      <content:encoded><![CDATA[<p>There is a super interesting model that was released a while back, <a href="https://huggingface.co/Cactus-Compute/whistle" target="_blank" rel="noopener">Cactus-Compute/whistle</a>, which is a small speech-to-text model — a single 16.9 MB file claiming a WER as low as 4.31% (on LibriSpeech test-clean).</p>
<p>Since I love a challenge when it comes to cramming anything onto the small M5Stick devices I own, I took it on.</p>
<h3>Model running on the stick</h3>
<p>Size optimization is always like saving a hot air balloon from crashing: deciding what needs to be kept on the &quot;ship&quot; and what can be thrown out. And then just starting to throw things out. :D Cramming everything onto an M5StickS3 with an ESP32-S3 is always a challenge. 8 MB of flash and 8 MB of PSRAM are always tight.</p>
<p>The first challenge is that the C++ engine that runs Whistle on CPUs (<code>libneedle</code>) only ships as a compiled library. The JAX training code is open, the inference engine is not. Rebuilding things with AI from the outside seems to be this week&#39;s topic anyway, with someone recreating the <a href="https://getartcraft.com/" target="_blank" rel="noopener">Adobe Suite using AI</a>. I won&#39;t publish the implementation, since keeping the engine closed was the authors&#39; original intent. I will only publish compiled builds.</p>
<p>After the initial code was created, the partition table looked like this:</p>
<table>
<thead>
<tr>
<th>Range</th>
<th>Size</th>
<th>What</th>
</tr>
</thead>
<tbody><tr>
<td><code>0x0000–0x8000</code></td>
<td>32 KB</td>
<td>Bootloader (not in the CSV). The ESP32-S3 puts it at <code>0x0</code>.</td>
</tr>
<tr>
<td><code>0x8000–0x9000</code></td>
<td>4 KB</td>
<td>The partition table itself, which is this CSV compiled to binary.</td>
</tr>
<tr>
<td>nvs <code>0x9000</code></td>
<td><code>0x5000</code> = 20 KB</td>
<td>Key-value store (NVS) that Arduino/IDF expects to exist. Whistle doesn&#39;t use it.</td>
</tr>
<tr>
<td><code>0xE000–0x10000</code></td>
<td>8 KB</td>
<td>Unused gap. Normally <code>otadata</code> for OTA updates, and there&#39;s no OTA here.</td>
</tr>
<tr>
<td>app0 <code>0x10000</code></td>
<td><code>0xA0000</code> = 640 KB</td>
<td>The firmware. <code>factory</code> means the only app, booted directly. App partitions must start on a 64 KB boundary, hence <code>0x10000</code>.</td>
</tr>
<tr>
<td>model <code>0xB0000</code></td>
<td><code>0x750000</code> = 7.3125 MiB</td>
<td>The packed Whistle blob. Type <code>data</code> with subtype <code>0x40</code>, from the user-defined range <code>0x40–0xFE</code>, so IDF leaves it alone. <code>main.cpp</code> finds it by name and memory-maps it with <code>esp_partition_mmap</code>, so the engine reads weights straight from flash through the cache, without copying them into RAM.</td>
</tr>
</tbody></table>
<p>The packed EN model is 7,584,960 bytes, which needed some fiddling and throwing away a couple of useful things. Remember, the base model is 16.9 MB, but the stick has 8 MB of flash in total, and only about 7.3 MB of that is left for the model.</p>
<table>
<thead>
<tr>
<th>Part</th>
<th>In the blob</th>
<th>In the original</th>
<th>How it got smaller</th>
</tr>
</thead>
<tbody><tr>
<td>Encoder (8 layers)</td>
<td>4.24 MB</td>
<td>4.24 MB</td>
<td>Unchanged</td>
</tr>
<tr>
<td>Conv stem + mel filterbank</td>
<td>0.19 MB</td>
<td>0.19 MB</td>
<td>Unchanged</td>
</tr>
<tr>
<td>Decoder</td>
<td>1.20 MB (2 layers)</td>
<td>~4.8 MB (8 layers)</td>
<td>Only layers 0 and 7 kept (Whistle&#39;s own <code>--audio-depth 2</code> “ladder rung”)</td>
</tr>
<tr>
<td>Engram n-gram tables</td>
<td>0</td>
<td>5.3 MB</td>
<td>Dropped entirely</td>
</tr>
<tr>
<td>Embedding / output head (4-bit)</td>
<td>1.84 MB (6955 tokens)</td>
<td>~2.16 MB (8199 tokens)</td>
<td>Vocabulary pruned to the tokens English needs: a word list plus inflections, and all byte/control tokens</td>
</tr>
<tr>
<td>Tokenizer, vocab IDs, misc</td>
<td>~0.12 MB</td>
<td>Similar</td>
<td></td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>7.58 MB</strong></td>
<td><strong>~16.8 MB</strong></td>
<td></td>
</tr>
</tbody></table>
<p>The engrams do help with decoding, but we don&#39;t have the space for them, and the decoder is actually trained to be cut down this way.</p>
<p>On my small test set (16 English sentences, 168 words, read by macOS text-to-speech), the official engine scored 8.9% WER with the full model, 6.5% at depth 2 (awesome, but on a set this small probably an outlier), and 9.5% at depth 2 without engrams. My reimplementation running the packed blob scored the same 9.5%, so the C port matches the official engine on the configuration that fits.</p>
<p>And running it on the stick turned out to be a mess — hitting the second common issue: CPU speed.</p>
<h3>Speeding up the model</h3>
<p>I had never used the S3&#39;s advanced &quot;AI features&quot;, described in the official docs as:</p>
<blockquote>
<p>AI Acceleration: Includes vector instructions and dedicated SIMD (Single Instruction, Multiple Data) capability, which significantly speeds up neural network computing and signal processing tasks</p>
</blockquote>
<p>At this point, processing took 2.4× the audio duration, around 25 seconds to transcribe a ten-second input, without listening to anything else during that time. That is ... how to say it kindly ... not the best experience. I am not an expert in this field, but Opus 5.5 and GPT 6.1 Sol had some ideas, and I threw in my ideas as well. Teamwork in 2026, right? :D</p>
<p>I can explain my part, but since I like to understand AI code as well, I did go into the details of all of this. Learning the tricks always helps with future projects.</p>
<p>The changes were the following:</p>
<ol>
<li><p>Silence gate: silent or steady-noise audio is rejected right after feature extraction (~0.5 s), before the encoder runs.</p>
</li>
<li><p>Doing less work</p>
<ul>
<li>Output head: scoring all 6955 tokens with 4-bit weights at every step was expensive.</li>
<li>A 2-bit sketch, later 1-bit (signs only), shortlists 32 candidates, which get exact scores.</li>
<li>Search: beam 5 → 3 → greedy by default.</li>
<li>Normalizer: greedy decoding computes the log-sum-exp over the top 6 candidates instead of all logits.</li>
<li>Pause squeezing: pauses of 300 ms or more are shortened to 160 ms in the feature domain, giving about 20% fewer encoder frames on natural speech.</li>
<li>Small wins:<ul>
<li>Text-token scans over a prefix.</li>
<li>Lookup-table sigmoid/exp.</li>
<li>Two real frames per FFT.</li>
<li>Angle-addition position embedding instead of sinf/cosf per element.</li>
</ul>
</li>
</ul>
</li>
<li><p>Memory: where data lives decides the speed</p>
<ul>
<li>The hierarchy: internal SRAM is fast, but only ~30 KB is free. PSRAM bandwidth was ~56 MB/s. Flash reads come through the same cache.</li>
<li>Hot buffers in internal RAM, allocated first because that heap fragments. Later: avoiding slow RTC memory that the heap also labels &quot;internal&quot;.</li>
<li>Decoder staging: decoder weights copied to PSRAM once per utterance (2.3× faster than flash), later with float norms precomputed.</li>
<li>Cache lines switched to 64 bytes at boot, at runtime. The prebuilt config uses 32, so PSRAM bandwidth went from 56 → 90 MB/s, reducing overall processing time by about 9%.</li>
<li>int16 encoder lanes (the 4-lane residual stream) halved that traffic, with no WER change.</li>
<li>Access patterns:<ul>
<li>Cache-friendly loop orders.</li>
<li>Fused passes, so data is read once instead of 3–4 times.</li>
<li>Transposes done in tiles. The decoder memory&#39;s byte-per-line scatter went from 185 → 30 ms.</li>
</ul>
</li>
</ul>
</li>
<li><p>The SIMD unit (PIE)</p>
<ul>
<li>int8 matmuls with Hadamard-rotated activations quantized in fixed point, so weights are never dequantized to float.</li>
<li>int8 attention (head-major K/V) and int16 Kronecker products for the HadamardMLP, with operands held in registers.</li>
<li>Bit-plane kernels: the 2-bit codebook [−127, −38, 38, 127] is exactly −127 + 89·b0 + 165·b1. A dot product therefore comes straight from shifting and masking the packed bits, with no table lookup and identical results. Same trick for the 1-bit head.</li>
<li>Row mode: one activation scale per frame, with group norms folded into the weights, so each output row is a single accumulator chain.</li>
<li>Measured on the chip, which shaped all of the above:<ul>
<li>A 128-bit load costs 2 cycles.</li>
<li>Reading out the accumulator costs about 9 cycles.</li>
<li>So the kernels are designed around loads and readouts, not multiplies.</li>
</ul>
</li>
</ul>
</li>
<li><p>Two cores</p>
<ul>
<li><code>wh_parallel</code> everywhere: features, mHC passes, attention prep, activation quantization, decoder memory.</li>
<li>Split choice: splitting matmuls by output rows rather than frames, so the cores don&#39;t both decode the same weights.</li>
<li>Ceiling: two cores give only ~1.35× the speed, because they share the PSRAM bus.</li>
</ul>
</li>
<li><p>Compiler and FPU lessons</p>
<ul>
<li>O3 + IRAM for hot kernels. GCC 8 most likely miscompiled one fused kernel at O3 early on (garbage text until it was built at O2). After it was rewritten, O3 was fine.</li>
<li>Float compares and branches are slow: integer abs-max and the hardware rounding instruction (<code>round.s</code>) replaced them in the quantizers.</li>
<li>GCC 8 has no instruction scheduling for this core, and its multiply-add forces register copies. Hand-interleaved SiLU, norm and elementwise loops: SiLU went from 47 → 29 cycles per element.</li>
<li>esp-dsp&#39;s S3-optimized FFT for feature extraction.</li>
</ul>
</li>
</ol>
<p>And for anyone trying to reproduce this, these were the fails:</p>
<ul>
<li>DCache preload (no extra bandwidth).</li>
<li>Branch-free sigmoid via float selects (slower).</li>
<li>Dropping encoder layers (WER 49–100%).</li>
<li>Hardware cache autoload (hangs the chip).</li>
<li>DMA double-buffering.</li>
<li>Turning off multiply-add contraction globally (slower).</li>
<li>Interleaved row blocks between the cores.</li>
<li>A smaller head sketch (costs WER).</li>
<li>Streaming without look-ahead (WER 50–90%).</li>
<li>An early-stop guard based on attention position.</li>
</ul>
<p>As it turns out, the S3&#39;s advanced AI features are just a set of 128-bit vector (SIMD) instructions — up to 16 int8 multiply-accumulates per instruction, no NPU or matrix unit — which is sad, but still, they can help a lot. Interestingly enough, the easiest ideas (mine) yielded some of the largest wins, like not processing silence (duh), RAM and allocation magic I learned from past projects (ESP32-specific), and doing things in parallel (another you-don&#39;t-say).</p>
<p>With all of this, processing went from 2.4× the audio duration to about 0.85× — faster than real time, using both cores. (The two-core split was there from the very first version. It just never gave more than ~1.35×, because both cores share one PSRAM bus.)</p>
<p>My pet benchmark that I used for this (processing time on the stick, greedy decoding, the default):</p>
<table>
<thead>
<tr>
<th>Clip</th>
<th>Audio</th>
<th>Processing</th>
</tr>
</thead>
<tbody><tr>
<td><code>bench/short.wav</code></td>
<td>3.8 s</td>
<td>3.3 s</td>
</tr>
<tr>
<td><code>bench/long10.wav</code></td>
<td>10.2 s</td>
<td>8.6 s</td>
</tr>
<tr>
<td><code>bench/long11.wav</code></td>
<td>11.0 s</td>
<td>9.4 s</td>
</tr>
</tbody></table>
<p>I was a happy camper once we got these results. At this point, I just directed the LLMs and didn&#39;t really touch any code. I know, vibecoding, but the question was: can I squeeze the code onto the stick without butchering everything, while running in real time?</p>
<h3>Let&#39;s teach Whistle some Hungarian</h3>
<p>Hungarian is a hard language. Words are long and carry many suffixes, so a single wrong letter makes a whole word wrong, and WER punishes that hard. CER (character error rate) shows better how close a transcript really is, so it matters more than WER in some ways. Since the original code includes LoRA fine-tuning options for other languages, I started doing just that. I did not know which datasets to use, but there were some sources I could use (I bet there are others as well), plus the special sauce: my own lectures.</p>
<table>
<thead>
<tr>
<th>Source</th>
<th>License</th>
<th>Hours</th>
<th>Clips</th>
<th>Weight</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://huggingface.co/datasets/disco-eth/WorldSpeech" target="_blank" rel="noopener">WorldSpeech</a> hu_hu (Hungarian parliament), clips with the dataset&#39;s CER score &lt; 0.15</td>
<td>CC BY-NC 4.0</td>
<td>1,136.5</td>
<td>266,772</td>
<td>1×</td>
</tr>
<tr>
<td><a href="https://huggingface.co/datasets/fsicoli/common_voice_19_0" target="_blank" rel="noopener">Common Voice 19</a> hu train</td>
<td>CC0</td>
<td>70.7</td>
<td>48,855</td>
<td>3×</td>
</tr>
<tr>
<td><a href="https://huggingface.co/datasets/facebook/voxpopuli" target="_blank" rel="noopener">VoxPopuli</a> hu train</td>
<td>CC0</td>
<td>51.3</td>
<td>17,592</td>
<td>3×</td>
</tr>
<tr>
<td>University lecture recordings from my own course (<a href="https://www.youtube.com/@thisisdone" target="_blank" rel="noopener">youtube.com/@thisisdone</a>), pseudo-labels from Whisper large-v3-turbo and YouTube captions</td>
<td>—</td>
<td>21.5</td>
<td>5,714</td>
<td>3×</td>
</tr>
<tr>
<td><a href="https://huggingface.co/datasets/google/fleurs" target="_blank" rel="noopener">FLEURS</a> hu_hu train + dev</td>
<td>CC BY 4.0</td>
<td>10.5</td>
<td>3,468</td>
<td>3×</td>
</tr>
<tr>
<td><a href="https://huggingface.co/datasets/KTH/hungarian-single-speaker-tts" target="_blank" rel="noopener">CSS10 Hungarian</a> (single speaker)</td>
<td>CC0</td>
<td>10.0</td>
<td>4,513</td>
<td>1×</td>
</tr>
</tbody></table>
<p>Nothing crazy here. After a couple of rounds, I did have a model. The training ran with the same 2-bit quantization and the same cut-down 2-layer decoder as the stick, so what is measured is what runs on the device (greedy decoding, 1000 test clips per set, WER / CER):</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>Data</th>
<th>Common Voice</th>
<th>VoxPopuli</th>
<th>FLEURS</th>
</tr>
</thead>
<tbody><tr>
<td>B2</td>
<td>Common Voice + VoxPopuli, 122 h</td>
<td>40.2% / 13.7%</td>
<td>35.1% / 14.6%</td>
<td>63.0% / 33.5%</td>
</tr>
<tr>
<td>E</td>
<td>+ WorldSpeech + lectures, 1,550 h</td>
<td>30.2% / 9.0%</td>
<td>18.4% / 7.5%</td>
<td>46.9% / 20.2%</td>
</tr>
<tr>
<td>I (released)</td>
<td>+ FLEURS train, CSS10, more lectures</td>
<td><strong>27.8% / 8.6%</strong></td>
<td>19.1% / 8.1%</td>
<td>40.1% / 19.0%</td>
</tr>
</tbody></table>
<p>Since it took hours, I did cheat and take inspiration from <a href="https://mimo.xiaomi.com/rl/#overview" target="_blank" rel="noopener">Mimo&#39;s dashboard</a>. Mine looks like this: <a href="https://blog-files.stormregion.hu/whistle-hu/dashboard.html" target="_blank" rel="noopener">Dashboard</a>. An eye-watering €0.81 of GPU electricity was burned on the project (3.23 kWh at €0.25/kWh). :D</p>
<p>As I said, nothing special here, but now I do understand a bit better why everyone wants good-quality datasets. More epochs on the same data stopped helping after run E. Introducing each new dataset did yield improvements, so if someone has 4 million hours of hand-annotated Hungarian voice recordings, feel free to reach out to me.</p>
<h3>Release and demo time</h3>
<p>I guess there is not a huge audience of M5StickS3 users who need a Hungarian fine-tuned model for it, but I did release the actual model:</p>
<p><a href="https://huggingface.co/realhidden/whistle-finetuned-hu" target="_blank" rel="noopener">https://huggingface.co/realhidden/whistle-finetuned-hu</a></p>
<p><strong>My first Hugging Face release as well. :3</strong> It is CC BY-NC 4.0, non-commercial only, because the WorldSpeech data it learned from is.</p>
<p>The two files you can flash onto the actual device to test it out (each is a complete image for address <code>0x0</code>, so it replaces whatever is on the stick: <code>esptool.py --chip esp32s3 write_flash 0x0 m5whistle-hu.bin</code>, or straight from the browser with <a href="https://espressif.github.io/esptool-js/" target="_blank" rel="noopener">esptool-js</a>):</p>
<ul>
<li>Hungarian one: <a href="https://blog-files.stormregion.hu/whistle-hu/m5whistle-hu.bin" target="_blank" rel="noopener">m5whistle-hu.bin</a></li>
<li>English one: <a href="https://blog-files.stormregion.hu/whistle-hu/m5whistle-en.bin" target="_blank" rel="noopener">m5whistle-en.bin</a></li>
</ul>
<p><a href="https://blog-files.stormregion.hu/whistle-hu/web/index.html" target="_blank" rel="noopener">And a web demo, since it&#39;s easier to test it out.</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>iStat Menus replacement</title>
      <link>https://blog.stormregion.hu/posts/istat-menus-replacement/</link>
      <guid isPermaLink="true">https://blog.stormregion.hu/posts/istat-menus-replacement/</guid>
      <pubDate>Fri, 18 Sep 2026 09:00:00 GMT</pubDate>
      <description>Prime time for vibecoding products for your own needs</description>
      <category>saaspocalypse</category>
      <category>iStat Menus</category>
      <category>vibecoding</category>
      <content:encoded><![CDATA[<p>I did love iStat Menus. I had (and I guess still have) a license for it, but something has felt off lately. It started getting laggy, hogging resources, and feeling somewhat limited now that I use multiple Macs daily — which also made the licensing troublesome.</p>
<p>After searching for an alternative, I found <a href="https://github.com/gao-sun/eul" target="_blank" rel="noopener">eul</a>, which looked awesome for my use case. The only thing was that its last release was years old, so it needed some love for today&#39;s Macs. GitHub is awesome in this sense: you can check out the forks through the network graph, so I could go fishing for the best version to use as a development base. :D</p>
<p>In this case, that meant multiple forks:</p>
<ul>
<li><a href="https://github.com/miaoweiwei/eul" target="_blank" rel="noopener">https://github.com/miaoweiwei/eul</a></li>
<li><a href="https://github.com/chrsomle/eul" target="_blank" rel="noopener">https://github.com/chrsomle/eul</a></li>
<li><a href="https://github.com/StoneOlo/eul" target="_blank" rel="noopener">https://github.com/StoneOlo/eul</a></li>
<li><a href="https://github.com/sclarkca/eul" target="_blank" rel="noopener">https://github.com/sclarkca/eul</a></li>
</ul>
<p>Everyone had added something useful, and after the great merge of different branches, <a href="https://github.com/realhidden/eul/commit/c9fc4bb3e788c7431a6a47860586f5183a419468" target="_blank" rel="noopener">version 2.1.0 was born</a>. Testing it for a week gave me the confidence that it would work well for my use case. Simple yet powerful stats, a modern-ish build of the old app, and some fixes here and there.</p>
<p><img src="https://blog-files.stormregion.hu/eul/panel.png" alt="eul"></p>
<h2>Act Two — Connecting multiple machines</h2>
<p>Ad-hoc networks, connections, and clever networking tricks have always had a special place in my random projects. I even managed to sell Ábel on the idea of creating an ad-hoc tracing service in Node.js five years ago. Check out the project; it&#39;s still awesome: <a href="https://github.com/abelptvts/peek" target="_blank" rel="noopener">peek</a>.</p>
<p>The idea is the same as it was back then: give a service a shared secret, and it automagically becomes part of a distributed network where different machines find each other automatically.</p>
<blockquote>
<p><strong>What&#39;s a shared secret?</strong>
In this case, it&#39;s a pairing code you enter on each machine you want to connect. Machines with the same code can find each other and read each other&#39;s stats. No account or central registration needed. Anyone with that code can join the group, so treat it like a password.</p>
</blockquote>
<p>Since I know more about networks and the technologies involved now, I split the job into two parts:</p>
<ul>
<li><strong>Local discovery:</strong> same Wi-Fi / LAN, talk directly.</li>
<li><strong>Remote discovery:</strong> different networks, meet somewhere on the internet.</li>
</ul>
<p>Both run at the same time, all the time.</p>
<h2>One secret, three keys</h2>
<p>Everything starts with the shared secret. A generated one looks like <code>k7mqp-x3ndr-h9wza-2vtfe</code>: 4 groups of 5 characters, from an alphabet without look-alikes (no 0/o, no 1/l/i), so you can type it on the other machine by hand without cursing. That&#39;s roughly 99 bits of randomness, assuming the characters are chosen uniformly — plenty for this use case.</p>
<p>The secret itself never goes on the wire. It gets stretched with <a href="https://www.rfc-editor.org/rfc/rfc8018" target="_blank" rel="noopener">PBKDF2</a> (200k rounds), then <a href="https://www.rfc-editor.org/rfc/rfc5869" target="_blank" rel="noopener">HKDF</a> derives separate values, each with its own label. <a href="https://www.starwars.com/series/the-mandalorian" target="_blank" rel="noopener">This is the way.</a></p>
<blockquote>
<p><strong>PBKDF2 and HKDF, without the alphabet soup</strong>
PBKDF2 turns the secret into key material through repeated calculations. That makes each password guess more expensive for someone trying to crack it.</p>
<p>HKDF then derives separate values for separate jobs. Think of it as making three different keys from the same starting material: one for local discovery, one for the internet meeting room, and one for encryption. Seeing a discovery value doesn&#39;t give you the encryption key.</p>
</blockquote>
<p>Actual copy-paste, starting with the master key material produced by PBKDF2:</p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">let</span> ikm <span class="hljs-operator">=</span> <span class="hljs-type">SymmetricKey</span>(data: master)

<span class="hljs-keyword">func</span> <span class="hljs-title function_">derive</span>(<span class="hljs-keyword">_</span> <span class="hljs-params">label</span>: <span class="hljs-type">String</span>) -&gt; <span class="hljs-type">SymmetricKey</span> {
    <span class="hljs-type">HKDF</span>&lt;<span class="hljs-type">SHA256</span>&gt;.deriveKey(
        inputKeyMaterial: ikm,
        info: <span class="hljs-type">Data</span>(label.utf8),
        outputByteCount: <span class="hljs-number">32</span>
    )
}

<span class="hljs-keyword">func</span> <span class="hljs-title function_">hex</span>(<span class="hljs-keyword">_</span> <span class="hljs-params">key</span>: <span class="hljs-type">SymmetricKey</span>) -&gt; <span class="hljs-type">String</span> {
    key.withUnsafeBytes {
        <span class="hljs-variable">$0</span>.prefix(<span class="hljs-number">16</span>).map { <span class="hljs-type">String</span>(format: <span class="hljs-string">&quot;%02x&quot;</span>, <span class="hljs-variable">$0</span>) }.joined()
    }
}

<span class="hljs-keyword">return</span> <span class="hljs-type">Keys</span>(
    lanToken: hex(derive(<span class="hljs-string">&quot;lan&quot;</span>)),
    topic: <span class="hljs-string">&quot;eul-peer/<span class="hljs-subst">\(hex(derive(<span class="hljs-string">&quot;topic&quot;</span>)))</span>&quot;</span>,
    seal: derive(<span class="hljs-string">&quot;seal&quot;</span>)
)
</code></pre><p>I do not code Swift, but Opus does. :D</p>
<ul>
<li><code>lan</code> — a token to recognize my peers on the local network.</li>
<li><code>topic</code> — the meeting room name on the internet.</li>
<li><code>seal</code> — the ChaChaPoly key that encrypts every snapshot.</li>
</ul>
<p>A snapshot is simply one sample of a machine&#39;s current stats, packaged into a message.</p>
<p>Why the PBKDF2 part? Because the topic isn&#39;t private. The broker can see <code>eul-peer/3f9a...</code>, and someone who learns that value can test guesses to see which secret produces it.</p>
<p>With a strong generated secret, that is hopeless anyway. But people do type <code>password123</code> into things, so the slow stretching is the seatbelt (and the UI warns below 12 characters). It makes guessing slower; it doesn&#39;t turn a terrible password into a good one.</p>
<h3>Local discovery: Bonjour</h3>
<p>Apple gave us Bonjour years ago, and the Network framework makes using it a couple of lines. Yet understanding what&#39;s going on underneath can take an afternoon. It&#39;s a pretty clever way to advertise services on the local network, and something I think everyone should know a little about.</p>
<blockquote>
<p><strong>What&#39;s Bonjour?</strong>
Bonjour is Apple&#39;s implementation of zero-configuration networking. Its local discovery uses multicast DNS (mDNS) and DNS Service Discovery (DNS-SD).</p>
<p>Instead of asking a central server where a service lives, machines send discovery messages to a shared address on the local network. Nearby machines can announce what they offer and answer requests. That&#39;s how an app can find another app without you typing an IP address.</p>
<p>A service announcement includes a service type, connection details, and optional metadata in a <strong>TXT record</strong>. Here, that metadata contains the pairing token. Discovery generally stays within the local network, and the network has to allow multicast traffic.</p>
</blockquote>
<p>Every eul instance does two things:</p>
<ul>
<li>Advertises an <code>_eul-peer._udp</code> service, with the <code>lan</code> token in the TXT record.</li>
<li>Browses for the same service type and keeps only the results where the token matches.</li>
</ul>
<pre><code class="hljs language-swift"><span class="hljs-keyword">var</span> txt <span class="hljs-operator">=</span> <span class="hljs-type">NWTXTRecord</span>()
txt[<span class="hljs-keyword">Self</span>.tokenKey] <span class="hljs-operator">=</span> token
listener.service <span class="hljs-operator">=</span> <span class="hljs-type">NWListener</span>.<span class="hljs-type">Service</span>(
    type: <span class="hljs-keyword">Self</span>.serviceType,
    txtRecord: txt
)
</code></pre><p>When a match shows up, it opens a UDP flow to that peer, and every 5 seconds a sealed snapshot goes straight to it. No broker, no internet.</p>
<blockquote>
<p><strong>Why UDP?</strong>
UDP sends individual packets without setting up the reliable, ordered stream you get with TCP. A packet can get lost or arrive late, but for stats refreshed every 5 seconds, the next update is usually more useful than retrying an old one.</p>
</blockquote>
<p>The token is a derived value, not the secret. Broadcasting it doesn&#39;t directly reveal the key needed to read the stats. It does let someone recognize machines in the same group and test password guesses, which is another reason to use the generated secret.</p>
<p>Two small gotchas I ran into:</p>
<ul>
<li>Results come back once per interface (Wi-Fi + Ethernet + whatever VPN you have), so the first endpoint per service name wins. Otherwise, you send everything 3 times.</li>
<li>You will find yourself. Bonjour can also rename your service after a name conflict, so the &quot;is this me?&quot; check uses the name the system actually registered, not the host name.</li>
</ul>
<h3>Remote discovery: someone else&#39;s MQTT broker</h3>
<p>Different networks are where it gets interesting. NAT, firewalls, no local multicast, and it&#39;s always a pain in the ass to provide a public service on the internet for an open-source project.</p>
<blockquote>
<p><strong>Why can&#39;t the machines just connect directly?</strong>
Your home router usually uses NAT: multiple devices share one public IP address, while each device has its own private address inside the network. Outgoing connections are straightforward, but an incoming connection generally needs port forwarding or some other way through.</p>
<p>A relay avoids that problem because both machines connect outward to a server they can reach.</p>
</blockquote>
<p>Then it clicked: public MQTT brokers. Free, open, and built exactly for &quot;many clients publish and subscribe to a topic.&quot;</p>
<blockquote>
<p><strong>What&#39;s MQTT?</strong>
MQTT is a lightweight messaging protocol. Clients connect to a server called a <strong>broker</strong>, which passes messages between them.</p>
<p>A client <strong>publishes</strong> a message to a named <strong>topic</strong>, and clients <strong>subscribed</strong> to that topic receive it. Think of the topic as a meeting room: everyone joins the same room, and the broker forwards whatever gets posted there.</p>
<p>The broker doesn&#39;t need to understand the message contents. That makes it useful as a relay for encrypted data.</p>
</blockquote>
<p>The topic is the derived <code>eul-peer/&lt;hex&gt;</code>. Everyone with the same secret subscribes to the same room and publishes a sealed snapshot into it every 5 seconds.</p>
<p>The broker sees encrypted bytes on a random-looking topic. Without the key, it can&#39;t read the stats or create a new message that passes the authentication check. It can still see connection details and message timing, and it can drop, delay, or replay messages.</p>
<blockquote>
<p><strong>What&#39;s ChaChaPoly?</strong>
CryptoKit&#39;s <code>ChaChaPoly</code> combines ChaCha20 encryption with Poly1305 authentication. Encryption hides the contents; authentication checks that the message was created by someone with the key and hasn&#39;t been altered.</p>
<p>A modified message, or one created without the key, fails that check and gets dropped. An unchanged copy of an old valid message still passes the cryptographic check, so the app also needs duplicate and freshness checks.</p>
<p>Since all peers share the key, this proves membership in the group, not the identity of a particular machine.</p>
</blockquote>
<p>The first version used one broker with a fallback list, which was suboptimal. :P Machine A falls back to broker #2, machine B is happy on broker #1, and they will never meet.</p>
<p>So now every instance joins all of them at once:</p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">let</span> brokers <span class="hljs-operator">=</span> [
    <span class="hljs-type">MQTTClient</span>.<span class="hljs-type">Broker</span>(host: <span class="hljs-string">&quot;broker.emqx.io&quot;</span>, port: <span class="hljs-number">8883</span>),
    <span class="hljs-type">MQTTClient</span>.<span class="hljs-type">Broker</span>(host: <span class="hljs-string">&quot;broker.hivemq.com&quot;</span>, port: <span class="hljs-number">8883</span>),
    <span class="hljs-type">MQTTClient</span>.<span class="hljs-type">Broker</span>(host: <span class="hljs-string">&quot;test.mosquitto.org&quot;</span>, port: <span class="hljs-number">8886</span>),
]
</code></pre><p>The MQTT client itself is a minimal, hand-written MQTT 3.1.1 implementation: QoS 0 only, one topic. Pulling in a full MQTT library for publish + subscribe felt like overkill.</p>
<blockquote>
<p><strong>What&#39;s QoS 0?</strong>
MQTT has different delivery levels, called Quality of Service (QoS). QoS 0 sends messages without MQTT-level acknowledgements or retries, so delivery isn&#39;t guaranteed.</p>
<p>That&#39;s a reasonable tradeoff for periodically refreshed stats: if one snapshot goes missing, another should arrive a few seconds later.</p>
</blockquote>
<p>These are public shared brokers, so I&#39;m relying on someone else&#39;s infrastructure. Handy for this project, but not something I&#39;d treat as a guaranteed service.</p>
<h2>Same snapshot, many times</h2>
<p>With Bonjour plus three brokers, the same snapshot can arrive up to 4 times. Plus, the brokers can echo your own publications back to you.</p>
<p>Each snapshot carries a per-launch instance ID and the sender&#39;s timestamp, so the receiver:</p>
<ul>
<li>Drops its own messages (same instance ID).</li>
<li>Drops anything older than 60 seconds, limiting how long an old snapshot can be replayed.</li>
<li>Keeps only the newest <code>sentAt</code> per peer. Duplicates from the slower paths just update &quot;last seen&quot; and the &quot;via LAN / via relay&quot; badge.</li>
</ul>
<p>The instance ID is a temporary identifier generated when the app starts. It lets the receiver distinguish running peers without needing a central registry.</p>
<p>The timestamp check also assumes the machines&#39; clocks are reasonably in sync. It isn&#39;t complete replay protection: a recent valid message can still be repeated inside that window, although it won&#39;t replace a newer snapshot.</p>
<p>After roughly three missed updates — a 16-second timeout — the peer disappears from the list. A restarted peer simply shows up as a new one, and the old entry expires on its own. No goodbye messages, no state to clean up.</p>
<p>I do love this solution, so I think I&#39;ll use it next time as well. Elegant, small(ish), and simple enough to have just a couple of moving parts.</p>
<h2>The Linux side</h2>
<p>Once I had the Mac solution tested, I wanted to create a Linux CLI port. Plain C99, runs on a Pi, and speaks the same protocol, so it shows up in the Mac panel like any other peer.</p>
<p>It only does the broker part, over plain TCP on port 1883, using the same brokers and topic. Adding TLS to a dependency-free C binary was not a yak I wanted to shave. The Bonjour portion would also need mDNS/DNS-SD support, so I left that out as well.</p>
<blockquote>
<p><strong>TCP, TLS, and the encrypted payload</strong>
TCP provides a reliable, ordered connection between the client and broker. TLS adds encryption to that connection and, with proper certificate validation, verifies the broker&#39;s identity.</p>
<p>The snapshots are already encrypted end-to-end: the sending machine encrypts them, and the receiving peers decrypt them. So even over plain TCP, the stats themselves remain encrypted.</p>
<p>TLS still adds something useful, though. Without it, someone watching the connection can see the MQTT topic and protocol traffic, and an active attacker can interfere with the connection. Payload encryption protects the stats; TLS protects the connection to the broker.</p>
</blockquote>
<p>So skipping TLS is a tradeoff in the Linux port, even though the payload is already encrypted.</p>
<p>The crypto had to be byte-compatible with CryptoKit, so I asked Opus to write PBKDF2, HKDF, and ChaChaPoly in C and test them against the RFC vectors. On macOS, there&#39;s also a cross-check that seals a message on one side and opens it on the other.</p>
<blockquote>
<p><strong>What&#39;s an RFC test vector?</strong>
It&#39;s a published example with known inputs and expected outputs. Feeding those inputs into your implementation should produce exactly the documented result.</p>
<p>The cross-check then tests whether the C implementation and CryptoKit can actually exchange encrypted messages. Useful checks, though passing them isn&#39;t the same as a full security review.</p>
</blockquote>
<h2>Open-source project</h2>
<p>This started as an open-source project, and I&#39;m keeping it that way, so you can see my exact modifications:</p>
<a class="gh" href="https://github.com/realhidden/eul" target="_blank" rel="noopener">
<span class="gh-bar"><span>GitHub</span></span>
<span class="gh-head"><svg class="icon gh-icon" aria-hidden="true" width="16" height="16"><use href="#i-repo"/></svg><span class="gh-name">realhidden/<b>eul</b></span><span class="gh-lang">Swift</span></span>
<span class="gh-desc">🖥️ macOS status monitoring app written in SwiftUI.</span>
<span class="gh-stats"><span class="gh-stat"><span class="gh-label">Stars</span><span class="gh-value"><svg class="icon" aria-hidden="true" width="16" height="16"><use href="#i-star"/></svg>1</span></span><span class="gh-stat"><span class="gh-label">Last commit</span><span class="gh-value"><svg class="icon" aria-hidden="true" width="16" height="16"><use href="#i-commit"/></svg>26 Sep 2026</span></span><span class="gh-stat"><span class="gh-label">Latest release</span><span class="gh-value"><svg class="icon" aria-hidden="true" width="16" height="16"><use href="#i-tag"/></svg>v2.3.0<small>25 Sep 2026</small></span></span></span>
</a>
<p>And just to clarify: a <strong>FREE and OPEN-SOURCE iStat Menus replacement</strong> that needs no server of your own, no accounts, and no monthly bill to check on your other machines remotely.</p>
<p>Local peers talk directly. Remote peers use public MQTT brokers as relays. Same secret, same group, stats in your menu bar.</p>
<p>Feel free to contribute, fork, or just use it. I hope it helps you as much as it helps me.</p>
]]></content:encoded>
    </item>
    <item>
      <title>ChatGPT Image MCP</title>
      <link>https://blog.stormregion.hu/posts/chatgpt-image-mcp/</link>
      <guid isPermaLink="true">https://blog.stormregion.hu/posts/chatgpt-image-mcp/</guid>
      <pubDate>Tue, 15 Sep 2026 09:00:00 GMT</pubDate>
      <description>Sometimes the boring tools are the best ones. This is the missing Sora MCP that never was.</description>
      <category>chatgpt</category>
      <category>image generator</category>
      <category>mcp</category>
      <content:encoded><![CDATA[<p>I love adding some extra assets to my projects to give them some spice. It&#39;s not strictly needed, but it makes a solution easier to remember. My favourite style is 8-bit. As I have talked about before, that style hides the &quot;slop&quot; well enough, but doesn&#39;t give off negative vibes when it&#39;s generated.</p>
<p>I use Minimax H3 for most of the mass-scale image generation, but for one-off images ChatGPT and Sora are way better and faster. They follow specific style hints and keep context. The only problem with that workflow is that it&#39;s either API cost (on top of the generic monthly plan) or a manual process. I know it doesn&#39;t make sense for them to provide it as an MCP, but that should not stop us from rigging something together.</p>
<h2>Chrome extension to interact with a chat</h2>
<p>The idea is not new: grab a ChatGPT tab, wire in some automation code, send messages and read the results back.
I did something really similar <a href="https://www.youtube.com/watch?v=Ty7DIU8E054" target="_blank" rel="noopener">in 2023 during my class</a>, but this time I went the proper way: a Chrome extension.</p>
<p>The extension itself is the &quot;connector&quot;. Using simple HTML selectors, it can:</p>
<ul>
<li>write messages into the chat</li>
<li>read messages back</li>
<li>download images</li>
</ul>
<p><img src="https://blog-files.stormregion.hu/chatgptimage/tab1.png" alt="ChatGPT chat"></p>
<p>To let the MCP work with the browser, you need to select a specific tab / chat (bottom right of the image). Once that chat / tab is locked, the MCP uses it for all queries. For a context switch, the extension can open a new chat, and by sending a sample query it can check whether the flow is in the receiving state. I used the string &quot;Are you ready?&quot;, which, as a side effect, also tags most of those chats as &quot;Ready check&quot; or &quot;Readiness check&quot;.</p>
<h2>The MCP</h2>
<p>Creating the MCP portion is the boring part. <a href="https://www.npmjs.com/package/@modelcontextprotocol/sdk" target="_blank" rel="noopener">@modelcontextprotocol/sdk</a> makes it super simple: throw in the tool definitions, package them into one Node.js project, register it in Claude, for example, and you are done.</p>
<p>Just for fun, here is what a tool definition looks like (actual copy-paste, some bootstrapping is not shown):</p>
<pre><code class="hljs language-js">server.<span class="hljs-title function_">registerTool</span>(
  <span class="hljs-string">&quot;chatgpt_last_message&quot;</span>,
  {
    <span class="hljs-attr">title</span>: <span class="hljs-string">&quot;Read ChatGPT&#x27;s last reply&quot;</span>,
    <span class="hljs-attr">description</span>:
      <span class="hljs-string">&quot;Return the newest reply in the marked ChatGPT conversation as markdown, without sending &quot;</span> +
      <span class="hljs-string">&quot;anything. Waits if ChatGPT is still answering. Use after chatgpt_ask timed out, or to &quot;</span> +
      <span class="hljs-string">&quot;read an answer produced by hand in the tab.&quot;</span>,
    <span class="hljs-attr">inputSchema</span>: {
      <span class="hljs-attr">timeout_seconds</span>: z.<span class="hljs-title function_">number</span>().<span class="hljs-title function_">int</span>().<span class="hljs-title function_">min</span>(<span class="hljs-number">10</span>).<span class="hljs-title function_">max</span>(<span class="hljs-number">3600</span>).<span class="hljs-title function_">optional</span>().<span class="hljs-title function_">describe</span>(<span class="hljs-string">&quot;Default 600.&quot;</span>),
      <span class="hljs-attr">output_dir</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">optional</span>(),
    },
  },
  <span class="hljs-title function_">async</span> ({ timeout_seconds = <span class="hljs-number">600</span>, output_dir }) =&gt; {
    <span class="hljs-keyword">let</span> res;
    <span class="hljs-keyword">try</span> {
      res = <span class="hljs-keyword">await</span> bridge.<span class="hljs-title function_">call</span>(<span class="hljs-string">&quot;last&quot;</span>, { <span class="hljs-attr">expectImages</span>: <span class="hljs-literal">false</span> }, { <span class="hljs-attr">timeoutMs</span>: timeout_seconds * <span class="hljs-number">1000</span> });
    } <span class="hljs-keyword">catch</span> (e) {
      <span class="hljs-keyword">return</span> <span class="hljs-title function_">fail</span>(<span class="hljs-string">`could not read the last reply: <span class="hljs-subst">${e.message}</span>`</span>);
    }
    <span class="hljs-keyword">return</span> <span class="hljs-title function_">textReply</span>(res, output_dir);
  }
);
</code></pre><p>And the Claude Code wiring:</p>
<pre><code class="hljs language-json"><span class="hljs-punctuation">{</span>
  <span class="hljs-attr">&quot;mcpServers&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span>
    <span class="hljs-attr">&quot;chatgpt-image&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span>
      <span class="hljs-attr">&quot;command&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;node&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">&quot;args&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">&quot;/absolute/path/to/chatgptimagemcp/server/src/index.js&quot;</span><span class="hljs-punctuation">]</span>
    <span class="hljs-punctuation">}</span>
  <span class="hljs-punctuation">}</span>
<span class="hljs-punctuation">}</span>
</code></pre><h2>Routing challenge</h2>
<p>The only interesting part, which gave some good old patterns a new shine, was selecting a leader in a distributed system and orchestrating multiple clients. Man, these bring back memories of the good old university days.</p>
<p>Why the heck do we need this in the first place? The way the system is built, the Chrome extension connects to a local port, and not the other way around, to keep the extension simple. This simplification brings another problem: on the MCP side, only one process can listen on a specific port. That works great, as long as you don&#39;t want to use it from multiple sessions. :D</p>
<p>When multiple sessions exist, the first MCP gets the port and can communicate with the extension, while everyone else fails at the socket listen. That failure is the trigger for another mode inside the MCP: the second instance connects to the first and asks it to proxy requests over to the extension.</p>
<p>If the &quot;first&quot; MCP instance dies for some reason, which can be as simple as ending the session, a leader election starts. It does the same &quot;dance&quot; as the first round: try to listen on the port, and fall back to being a peer if that fails.</p>
<p><img src="https://blog-files.stormregion.hu/chatgptimage/session.png" alt="Showing the flow of leader election and request proxying between MCP instances"></p>
<p><strong>Of course, the image was generated using the same flow.</strong> :D</p>
<h2>Conclusion and remarks for others</h2>
<p>Sometimes the more boring projects are the most useful. Since I built this MCP, I have used it almost every time I need &quot;just a small graphic&quot; or a rendered UML chart. The project is relatively easy to reimplement, so sharing the source does not make sense (and there is a bit of a &quot;can I even do that considering the TOS?&quot; question). Things not mentioned above are refusals and rate limits, and the fact that after 20-30 images in one chat the solution becomes less reliable, even if you scroll, etc. Maybe it&#39;s running the tab in the background, or some timing issue; I&#39;m not sure. My solution is to open a new chat and keep grinding. :D</p>
<h2>Demo</h2>
<p><video src="https://blog-files.stormregion.hu/chatgptimage/demo_low.mp4" controls autoplay muted loop style="width: 100%; height: auto;"></video></p>
]]></content:encoded>
    </item>
    <item>
      <title>Keep flyin'</title>
      <link>https://blog.stormregion.hu/posts/keep-flyin/</link>
      <guid isPermaLink="true">https://blog.stormregion.hu/posts/keep-flyin/</guid>
      <pubDate>Tue, 18 Aug 2026 09:00:00 GMT</pubDate>
      <description>Qwen 3 TTS experiment on the best sci-fi show ever, Firefly.</description>
      <category>tts</category>
      <category>qwen3</category>
      <category>firefly</category>
      <content:encoded><![CDATA[<p>Playing around with the local AI stack for the last couple of months, TTS or Speech-to-Text was not my main focus. I installed <a href="https://voicebox.sh/" target="_blank" rel="noopener">Voicebox</a> in the past, cloned my voice, made myself speak Korean, but it was slow and painful and not developer-friendly.</p>
<p>Then during my PTO I did see <a href="https://www.youtube.com/watch?v=jDudeaWppSE" target="_blank" rel="noopener">Qwen3-TTS vs dots.tts for local AI voice cloning by No place like localhost</a> and the idea was born to create an end-to-end voice cloning PoC from source material. I did not want to use a 10-sec sample to generate a couple of lines, I wanted to see how mature this part of the LLM stack is.</p>
<p>After reading a bit, the plan is kind of simple: backpropagation on a pre-trained model, generating a LoRA (and/or baking it into the model itself).</p>
<p>The &quot;core&quot; part of the training is quite simple to be honest.</p>
<pre><code class="hljs language-python"><span class="hljs-comment"># Load the base Qwen3-TTS model in bf16</span>
qwen3tts = Qwen3TTSModel.from_pretrained(
    args.model,
    device_map=device,
    dtype=torch.bfloat16,
    attn_implementation=<span class="hljs-string">&quot;sdpa&quot;</span>,
)

model = qwen3tts.model

<span class="hljs-comment"># Freeze the original model weights</span>
<span class="hljs-keyword">for</span> p <span class="hljs-keyword">in</span> model.parameters():
    p.requires_grad = <span class="hljs-literal">False</span>

<span class="hljs-comment"># Add small trainable LoRA adapters</span>
<span class="hljs-keyword">from</span> peft <span class="hljs-keyword">import</span> LoraConfig, get_peft_model

targets = [<span class="hljs-string">&quot;q_proj&quot;</span>, <span class="hljs-string">&quot;k_proj&quot;</span>, <span class="hljs-string">&quot;v_proj&quot;</span>, <span class="hljs-string">&quot;o_proj&quot;</span>]

<span class="hljs-comment"># Also adapt the MLP layers to better learn voice/timbre</span>
targets += [<span class="hljs-string">&quot;gate_proj&quot;</span>, <span class="hljs-string">&quot;up_proj&quot;</span>, <span class="hljs-string">&quot;down_proj&quot;</span>]

lora = LoraConfig(
    r=<span class="hljs-number">32</span>,
    lora_alpha=<span class="hljs-number">64</span>,
    lora_dropout=<span class="hljs-number">0.05</span>,
    bias=<span class="hljs-string">&quot;none&quot;</span>,
    target_modules=targets,
    task_type=<span class="hljs-string">&quot;CAUSAL_LM&quot;</span>,
)

model.talker = get_peft_model(model.talker, lora)
model.talker.print_trainable_parameters()

<span class="hljs-comment"># Train only the LoRA parameters</span>
trainable = [p <span class="hljs-keyword">for</span> p <span class="hljs-keyword">in</span> model.parameters() <span class="hljs-keyword">if</span> p.requires_grad]

optimizer = AdamW(
    trainable,
    lr=<span class="hljs-number">2e-5</span>,
    weight_decay=<span class="hljs-number">0.01</span>,
)

<span class="hljs-comment"># Example training step</span>
main_loss, sub_loss = losses(batch)
loss = main_loss + <span class="hljs-number">0.3</span> * sub_loss

loss.backward()
optimizer.step()
optimizer.zero_grad()
</code></pre><p>But the training is one thing; there is a whole flow that I created to get to the final result. But the TL;DR is: we need input data for the training (WAV files and transcripts).</p>
<h2>Getting the training data</h2>
<p><img src="/posts/keep-flyin/firefly1.png" alt="Best sci-fi series of all time"></p>
<p>The input was a 5.1 Blu-ray of the Firefly series and Serenity the movie (treated as the last episode of the series). Let&#39;s pretend that we have this on disk, MKV format, all subtitles ripped. I wanted to have just series-specific lines; characters can have different tempo, pitch, and timbre even if it&#39;s the same actor.</p>
<p>The grind was real in here. To get (per character) transcribed lines, I did:</p>
<ol>
<li>FFmpeg: 16 kHz + 48 kHz mono WAVs (5.1 center channel) - voice is usually muxed into the center</li>
<li>Demux the subtitle stream (text) or PGS bitmap track (<a href="https://github.com/ratoaq2/pgsrip" target="_blank" rel="noopener">pgsrip</a>). Also considered OpenSubtitles.com at some point</li>
<li>Strip SDH annotations (Subtitles for the Deaf and Hard of Hearing) and other noise from the text, like [Music], [Applause], etc.</li>
</ol>
<p>And at this point you will have a (badly) synced subtitle track which is somewhat clean at least, and an audio file per episode. No clue on speaker, no separation, doing a cut of the timestamps will leave you with a lot of half-finished sentences.</p>
<h2>Fixing the training data</h2>
<p>The desync is an issue we can deal with. Running Whisper will tell you exactly which word is where, so you can build out a shift database - since it&#39;s not just a constant per episode shift.</p>
<p>Listening to a couple of samples in here, I noticed a lot of background music and noise, so I decided to do some quick research with Claude on what I could do and <a href="https://github.com/facebookresearch/demucs" target="_blank" rel="noopener">Demucs</a> was the recommended way. That can separate the voice from the music and other background noise, so you can get a clean voice track. I used the default model, which yielded great results - in most cases. Where the noise was too loud I simply removed it from the training dataset.</p>
<p>Now we have a whole bunch of cleanish lines, a reasonable amount to manually label, so I started doing that for some time, but in the background I started a new search for automatic labeling.</p>
<p>I learned about the <a href="https://www.robots.ox.ac.uk/~vgg/data/voxceleb/" target="_blank" rel="noopener">VoxCeleb</a> dataset, which is awesome. I tried using anything built on top of that, but single-pass embeddings of ~3s lines are noisy and same-speaker similarity lands around 0.25-0.5 (not the textbook 0.7+).</p>
<p>I needed to use a small but useful trick to get it working:</p>
<pre><code class="hljs language-python"><span class="hljs-keyword">from</span> speechbrain.inference.speaker <span class="hljs-keyword">import</span> EncoderClassifier
<span class="hljs-keyword">from</span> sklearn.cluster <span class="hljs-keyword">import</span> AgglomerativeClustering
<span class="hljs-keyword">import</span> numpy <span class="hljs-keyword">as</span> np
<span class="hljs-keyword">import</span> torch

<span class="hljs-comment"># Load pretrained ECAPA speaker-embedding model</span>
speaker_model = EncoderClassifier.from_hparams(
    source=<span class="hljs-string">&quot;speechbrain/spkrec-ecapa-voxceleb&quot;</span>,
    run_opts={<span class="hljs-string">&quot;device&quot;</span>: <span class="hljs-string">&quot;cpu&quot;</span>},
)


<span class="hljs-keyword">def</span> <span class="hljs-title function_">embed_segment</span>(<span class="hljs-params">audio</span>):
    <span class="hljs-string">&quot;&quot;&quot;
    Split one dialogue line into overlapping crops,
    embed each crop, then average them.
    &quot;&quot;&quot;</span>
    n = <span class="hljs-built_in">len</span>(audio)

    crops = [
        audio[<span class="hljs-number">0</span>:<span class="hljs-built_in">int</span>(<span class="hljs-number">0.6</span> * n)],
        audio[<span class="hljs-built_in">int</span>(<span class="hljs-number">0.2</span> * n):<span class="hljs-built_in">int</span>(<span class="hljs-number">0.8</span> * n)],
        audio[<span class="hljs-built_in">int</span>(<span class="hljs-number">0.4</span> * n):n],
    ]

    embeddings = []

    <span class="hljs-keyword">with</span> torch.no_grad():
        <span class="hljs-keyword">for</span> crop <span class="hljs-keyword">in</span> crops:
            emb = speaker_model.encode_batch(
                crop.unsqueeze(<span class="hljs-number">0</span>)
            )
            embeddings.append(emb.squeeze().numpy())

    embedding = np.mean(embeddings, axis=<span class="hljs-number">0</span>)

    <span class="hljs-comment"># normalize for cosine distance</span>
    <span class="hljs-keyword">return</span> embedding / np.linalg.norm(embedding)


<span class="hljs-comment"># Create one speaker embedding for every dialogue segment</span>
embeddings = [
    embed_segment(segment_audio)
    <span class="hljs-keyword">for</span> segment_audio <span class="hljs-keyword">in</span> clean_segments
]

X = np.stack(embeddings)


<span class="hljs-comment"># Group similar voices together</span>
clustering = AgglomerativeClustering(
    n_clusters=<span class="hljs-literal">None</span>,
    metric=<span class="hljs-string">&quot;cosine&quot;</span>,
    linkage=<span class="hljs-string">&quot;average&quot;</span>,
    distance_threshold=<span class="hljs-number">0.78</span>,
)

cluster_ids = clustering.fit_predict(X)

<span class="hljs-comment"># ...</span>

<span class="hljs-comment"># Small clusters are often fragments/noise.</span>
<span class="hljs-comment"># Merge them into the most similar large speaker cluster.</span>

<span class="hljs-keyword">for</span> small_cluster <span class="hljs-keyword">in</span> small_clusters:

    small_centroid = mean_embedding(small_cluster)

    nearest_cluster = <span class="hljs-built_in">min</span>(
        large_clusters,
        key=<span class="hljs-keyword">lambda</span> cluster:
            cosine_distance(
                small_centroid,
                mean_embedding(cluster),
            ),
    )

    <span class="hljs-keyword">if</span> cosine_distance(
        small_centroid,
        mean_embedding(nearest_cluster),
    ) &lt; <span class="hljs-number">0.86</span>:
        merge(small_cluster, nearest_cluster)
</code></pre><p>I did some manual checking, seemed fine (but from the final results I still think there is a bit of mismatch somewhere).</p>
<p>Another source that I found is <a href="https://central-cortex.livejournal.com/" target="_blank" rel="noopener">https://central-cortex.livejournal.com/</a>, which had a stage-play-like version of the episodes that matched 95%+ with the auto-clustering.</p>
<p>Fun fact, none of the sources matched perfectly: Whisper, official subtitles, stage play, OpenSubtitles.</p>
<h2>Then the boring part, the training</h2>
<p><img src="/posts/keep-flyin/firefly2.png" alt="Some more awesome Serenity images"></p>
<p>Once the pipeline was in order, I could start to do the plumbing and training. At this point I had 100 GB of audio, random transcripts, more WAVs than I ever wanted to deal with. Running it on my dual RTX 3060s was simple: the first run ran overnight, and once I had a working v1 I could throw Claude at it. The last run (I did around 12–14 rounds) was 1.5 hours long.</p>
<p>Just as a side note, these are the stats:</p>
<p><strong>Subtitle cards, 14 episodes + film</strong></p>
<pre><code>  <span class="hljs-attribute">9</span>,<span class="hljs-number">452</span>  <span class="hljs-number">100</span>.<span class="hljs-number">0</span>%   <span class="hljs-number">73</span>,<span class="hljs-number">150</span>w   subtitle cards, <span class="hljs-number">14</span> eps + film
  <span class="hljs-attribute">8</span>,<span class="hljs-number">353</span>   <span class="hljs-number">88</span>.<span class="hljs-number">4</span>%   <span class="hljs-number">66</span>,<span class="hljs-number">131</span>w   a speaker name could be aligned      −<span class="hljs-number">1</span>,<span class="hljs-number">099</span>
  <span class="hljs-attribute">6</span>,<span class="hljs-number">799</span>   <span class="hljs-number">71</span>.<span class="hljs-number">9</span>%   <span class="hljs-number">53</span>,<span class="hljs-number">960</span>w   that speaker is one of the ten crew  −<span class="hljs-number">1</span>,<span class="hljs-number">554</span>
  <span class="hljs-attribute">3</span>,<span class="hljs-number">046</span>   <span class="hljs-number">32</span>.<span class="hljs-number">2</span>%   <span class="hljs-number">26</span>,<span class="hljs-number">293</span>w   clean: CER ≤ <span class="hljs-number">0</span>.<span class="hljs-number">15</span>, rel_db ≥ <span class="hljs-number">18</span>       −<span class="hljs-number">3</span>,<span class="hljs-number">753</span>
    <span class="hljs-attribute">903</span>    <span class="hljs-number">9</span>.<span class="hljs-number">6</span>%   <span class="hljs-number">10</span>,<span class="hljs-number">307</span>w   recut into whole sentences           −<span class="hljs-number">2</span>,<span class="hljs-number">143</span>
    <span class="hljs-attribute">580</span>    <span class="hljs-number">6</span>.<span class="hljs-number">1</span>%    <span class="hljs-number">7</span>,<span class="hljs-number">459</span>w   survived clip_quality → trained <span class="hljs-literal">on</span>     −<span class="hljs-number">323</span>
</code></pre><p>The recut stage looks like the second-biggest cull but mostly isn&#39;t — it merges consecutive cards into whole sentences, so the card count drops 70% while words only drop 61%. The cleanliness gate is the real loss.</p>
<p>Per character, words spoken vs what reached training:</p>
<pre><code>  ┌───────────────┬────────┬───────┬───────┬───────┬──────┬───────┐
  │               │ series │ film  │ lines │ clips │ min  │ kept  │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Mal Reynolds  │ 17,574 │ 2,406 │ 2,360 │  <span class="hljs-number"> 188 </span>│ 14.3 │ 13.0% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Simon Tam     │  5,688 │  <span class="hljs-number"> 316 </span>│  <span class="hljs-number"> 814 </span>│   <span class="hljs-number"> 56 </span>│  4.0 │ 10.8% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Jayne Cobb    │  4,829 │  <span class="hljs-number"> 450 </span>│  <span class="hljs-number"> 642 </span>│   <span class="hljs-number"> 57 </span>│  4.9 │ 15.3% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Inara Serra   │  4,467 │  <span class="hljs-number"> 227 </span>│  <span class="hljs-number"> 597 </span>│   <span class="hljs-number"> 54 </span>│  3.9 │ 14.8% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Wash          │  4,410 │  <span class="hljs-number"> 236 </span>│  <span class="hljs-number"> 560 </span>│   <span class="hljs-number"> 47 </span>│  3.8 │ 14.4% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Zoe Washburne │  3,459 │  <span class="hljs-number"> 493 </span>│  <span class="hljs-number"> 483 </span>│   <span class="hljs-number"> 46 </span>│  3.2 │ 13.9% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Kaylee Frye   │  3,372 │  <span class="hljs-number"> 309 </span>│  <span class="hljs-number"> 501 </span>│   <span class="hljs-number"> 52 </span>│  3.5 │ 14.3% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Shepherd Book │  2,169 │  <span class="hljs-number"> 117 </span>│  <span class="hljs-number"> 313 </span>│   <span class="hljs-number"> 36 </span>│  2.9 │ 19.7% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ Saffron       │  1,813 │     — │  <span class="hljs-number"> 237 </span>│   <span class="hljs-number"> 18 </span>│  1.6 │ 14.5% │
  ├───────────────┼────────┼───────┼───────┼───────┼──────┼───────┤
  │ River Tam     │  1,498 │  <span class="hljs-number"> 127 </span>│  <span class="hljs-number"> 292 </span>│   <span class="hljs-number"> 26 </span>│  1.8 │ 15.3% │
  └───────────────┴────────┴───────┴───────┴───────┴──────┴───────┘
</code></pre><p>One caveat you should know before quoting the film column. Speaker alignment covers 95.8% of series cards but only 42.4% of the film cards (560 of 1,321) — the Serenity fan transcript aligned badly. The film actually has 8,908 spoken words; only 4,720 got a name. So film counts are a floor and the series-vs-film ratio is unreliable.</p>
<h2>Final result</h2>
<p>For copyright reasons I cannot share the model or the training data, but the final results are here, you can be the judge of how good it is. If you want the easy-mode way of doing this, just use a clean voice clip and the clone mode (test that here: <a href="https://huggingface.co/spaces/Qwen/Qwen3-TTS" target="_blank" rel="noopener">https://huggingface.co/spaces/Qwen/Qwen3-TTS</a>).</p>
<p><img src="/posts/keep-flyin/firefly3.png" alt="Best cast of any series ever"></p>
<p>My result, two lines per character:</p>
<p><audio controls src="/posts/keep-flyin/stormregion_all_voices.mp3"></audio></p>
<p><a href="/posts/keep-flyin/stormregion_all_voices.mp3" download>Download the MP3</a></p>
<p>Considering this is just a PoC, I do think it&#39;s better than I hoped for. Also I worked mostly with <a href="https://z.ai/blog/glm-5.3" target="_blank" rel="noopener">GLM 5.3</a> on the code side, which just came out, and <a href="https://huggingface.co/collections/Qwen/qwen3-tts" target="_blank" rel="noopener">Qwen 3 TTS</a> on the training and LoRA side.</p>
]]></content:encoded>
    </item>
    <item>
      <title>SKZ-GOTCHI</title>
      <link>https://blog.stormregion.hu/posts/skz-gotchi/</link>
      <guid isPermaLink="true">https://blog.stormregion.hu/posts/skz-gotchi/</guid>
      <pubDate>Mon, 10 Aug 2026 09:00:00 GMT</pubDate>
      <description>Stray Kids tamagotchi game, as a 5 hour hackathon</description>
      <category>m5stick</category>
      <category>pixel art</category>
      <category>8bit</category>
      <content:encoded><![CDATA[<p>Showing someone what a modern LLM / harness can do can be pretty hard. You show random demos, talk about how easily it did a refactor or some log digging, but capturing the magic of creating something from an idea really fast is a hard thing to do.</p>
<p>And yes, sometimes it&#39;s going to be a total AI slopfest, with the same UI elements, same texts, random missing features, etc. etc., but that can happen with humans as well — I do still remember Stack Overflow copy-paste reviews.</p>
<p>Anyway, my better half loves South Korean culture and bands, especially Stray Kids. Grabbing someone&#39;s sweet-spot topic for a vibe-coded project gets you almost halfway there. The other half is the idea, and if you pick the right representation, everything just snaps together.</p>
<p><img src="https://blog-files.stormregion.hu/skz-gotchi/m5.png" alt="Finished product"></p>
<p>I like doing 8bit / pixel art style GFX, since it hides most of the problems and gives 30-ish folks fond childhood memories, so there are some extra points in there. Since this is really a presentation of what technology can do, let me share my &quot;master prompt&quot; that yielded the whole flow, and how I worked from there.</p>
<h2>Master prompt</h2>
<blockquote>
<p>Let&#39;s create a small tamagotchi game, where you can / should manage one of the Stray Kids singers. Include everyone in the game and make it entertaining.</p>
<p>Be creative, but truthful to the original game and characters. 8bit-like color graphics, and it should run on an M5StickC PLUS2 ESP32.</p>
<p>I want to have some music in the game and diverse minigames of some sort.</p>
<p>First step: create a DETAILED idea for the game. The device should be held upwards, which will make 240 x 135 for the playfield.</p>
</blockquote>
<p>It has everything I just mentioned: the fun topic, the &quot;anti-slopify filter&quot; of 8bit, the nostalgic Tamagotchi factor and, of course, the &quot;no mistakes&quot; part with &quot;make it entertaining&quot;. :D</p>
<p>This went into ChatGPT, and 23k characters later we got a decent Tamagotchi game skeleton: <a href="https://blog-files.stormregion.hu/skz-gotchi/goal.md" target="_blank" rel="noopener">goal.md</a></p>
<p>Once this was ready, I just threw it into Z.ai GLM 5.1 with a &quot;build it man&quot; prompt and <a href="https://www.youtube.com/watch?v=TGfQu0bQTKc" target="_blank" rel="noopener">let that thread panic, for pleasure</a>.</p>
<p>The other part of the grind was the graphics. A couple of prompts later, in the same ChatGPT session, I had a list of assets that I needed to grab for the game. At that time I did not have my image generator or ChatGPT image MCP, so copy-pasting prompts that ChatGPT generated into a new session, then grabbing the images from that session, took around 1–1.5 hours.</p>
<p>Since this was a late-night vibecoding session, I was watching something on the TV the whole time and just let this run in the background.</p>
<p>The results were great, but the image generator still cannot keep a nice grid for 8bit games, so that needed some extra code on GLM&#39;s side to crop the images.</p>
<h2>Hyunjin</h2>
<p><img src="https://blog-files.stormregion.hu/skz-gotchi/char_hyunjin_core_32x40.png" alt="Hyunjin"></p>
<p>Once the characters were decent and every asset was generated, I connected the device itself and let it cook.</p>
<p>Once there was enough to see that we were heading in the right direction, I wanted some fine-tuning, a manual and so on, so v2 was born: <a href="https://blog-files.stormregion.hu/skz-gotchi/v2.md" target="_blank" rel="noopener">v2.md</a>. This was generated by Opus, since GLM is not that strong at planning, but great at execution.</p>
<p>Let me remind you that at this point, there had only been some light testing and the project had been started roughly 4 hours earlier. The timeline looks something like this based on git commits, once I started asking for those.</p>
<p>Background images and other assets turned out pretty decent as well:</p>
<p><img src="https://blog-files.stormregion.hu/skz-gotchi/bg_recording_studio.png" alt="Recording studio background"></p>
<h2>Timeline of the project</h2>
<pre><code class="hljs language-text">26b8ca0 - Mon Aug 10 00:40:43 2026 +0200 : Illustrated manual (HTML + PDF) and v2 notes
b72b078 - Mon Aug 10 00:27:54 2026 +0200 : Browser build via Emscripten
685de90 - Mon Aug 10 00:17:51 2026 +0200 : QUICK MODE and an explicit reset
618d344 - Mon Aug 10 00:10:56 2026 +0200 : Fix a freshly reset pet being charged for every hour since the epoch
3253f40 - Mon Aug 10 00:08:02 2026 +0200 : Iteration 8: birthdays, collection screen, adaptive soundtrack
6ced6c8 - Mon Aug 10 00:03:57 2026 +0200 : Iteration 7: comeback week and the concert finale
b22ff29 - Sun Aug 9 23:54:16 2026 +0200 : Iteration 6: the remaining seven minigames
bc6ae7e - Sun Aug 9 23:43:25 2026 +0200 : Iteration 5: minigame framework and the three input-defining games
bad1ee2 - Sun Aug 9 23:33:57 2026 +0200 : Iteration 4: random event and dialogue engine
fe1cde9 - Sun Aug 9 23:23:36 2026 +0200 : Fix save being rewritten immediately after a wipe
8879a37 - Sun Aug 9 23:19:53 2026 +0200 : Iteration 3 (WIP): locations, daily schedules, activity scene
74f3594 - Sun Aug 9 23:07:57 2026 +0200 : Iteration 2: fix sprite transparency, all eight members, new locations
fbfa599 - Sun Aug 9 22:42:47 2026 +0200 : Pin upload/monitor port glob to /dev/cu.usb* so auto-detect skips Bluetooth
f0fbeee - Sun Aug 9 22:42:05 2026 +0200 : Iteration 1: playable dorm loop for M5StickC PLUS2
</code></pre><h2>The next day</h2>
<p>Testing, giving feedback and iterating on a project is the long grind. Almost everything so far followed the &quot;simple&quot; route of letting the LLM decide what to do, what is fun, and in some cases where to cut corners.</p>
<p>I tend to pick the simplest answer when something is vibecoded, and iterate on it later.</p>
<p>The result is OK, runs well on the device, and is somewhat fun-ish to play. But most importantly, it helped show how &quot;easy&quot; getting from an idea to a POC is nowadays.</p>
<p>The manual can be accessed here: <a href="https://blog-files.stormregion.hu/skz-gotchi/docs/manual.pdf" target="_blank" rel="noopener">PDF</a> and <a href="https://blog-files.stormregion.hu/skz-gotchi/docs/index.html" target="_blank" rel="noopener">HTML</a></p>
<p>And you can play the game here, but since it&#39;s a Tamagotchi game, the browser version is more like a POC than the real thing: <a href="https://blog-files.stormregion.hu/skz-gotchi/game/index.html" target="_blank" rel="noopener">game</a></p>
<p>There were some small(ish) features implemented since v2, including support for two extra languages — Korean and Hungarian — and wrapping it into an Expo app with shims, but nothing fancy.</p>
<h2>TLDR</h2>
<p>You can create fun projects from basically a one-liner, co-creating something that can be thrown away but can still create long-term inspiration for the next project.</p>
<p>And if it motivates someone to look into this crazy world as well, that&#39;s a plus.</p>
<blockquote>
<p><strong>Fan project / legal notice</strong></p>
<p>SKZ-GOTCHI is an unofficial, non-commercial fan project made for fun, experimentation and as a technical demo. It is not affiliated with, endorsed by, sponsored by, or connected to Stray Kids, JYP Entertainment, or any related rights holders.</p>
<p>Stray Kids, member names, likenesses, trademarks, music, artwork and other associated intellectual property belong to their respective owners. This project does not claim ownership of any third-party intellectual property.</p>
</blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>Terminator Beeper</title>
      <link>https://blog.stormregion.hu/posts/beeping/</link>
      <guid isPermaLink="true">https://blog.stormregion.hu/posts/beeping/</guid>
      <pubDate>Thu, 30 Jul 2026 09:00:00 GMT</pubDate>
      <description>Self-contained beeping multi-layer data transfer magical leprechaun Hackathon project</description>
      <category>m5stick</category>
      <category>terminator</category>
      <category>hackathon</category>
      <content:encoded><![CDATA[<p>At <a href="https://thisisdone.com/" target="_blank" rel="noopener">DONE.</a> we had a nice Hackathon back in July, where every team got an M5Stick (I do love those small ESP32 devices), and they had one day to create something that might help with day-to-day work. The limits were that they could not use any US-based LLMs, forcing them to use free, mostly Chinese solutions like Qwen, DeepSeek, etc.</p>
<p>Now, that is interesting and all, and there were great products at the end of the day, but since I am not allowed to join any team (part of the ruleset), I needed to come up with my own idea. I always had this &quot;two-factor authentication using sound and beeps&quot; idea, where nearby machines can just beep to each other, creating ad-hoc channels. In 2024, some guys even ran with my idea for a TDK at BME. But I never tried it myself, and since it&#39;s a small enough problem to solve in a day, let&#39;s make it a bit more challenging: having JUST the device and somehow making a normal modern computer communicate with it, without using any other channel. A good metaphor is probably a message in a bottle, where you want to read the message but first need to somehow get it out of the bottle.</p>
<p>The M5Stick I used for this one is the M5StickC PLUS2, so there are WAY more limitations than you want to admit. :D</p>
<h2>Communication idea</h2>
<p>Breaking down this challenge is the key to solving it. So:</p>
<ol>
<li>Somehow bootstrap the channel between the computer and the stick (in a secure-ish way)</li>
<li>Write the message (using sound)</li>
<li>Keep it on the device.</li>
</ol>
<p>And when the time for the readback comes, same-ish process:</p>
<ol>
<li>Bootstrap channel</li>
<li>Read message (using sound)</li>
</ol>
<p>The bootstrap part is probably more interesting than the sound part, since that one is more like an old dial-up modem in a simplified way.</p>
<h2>Bootstrapping</h2>
<p>Let&#39;s assume there is no internet and we don&#39;t want to use Bluetooth or Wi-Fi, to make it more challenging. That leaves a couple of ways a computer can receive input:</p>
<ol>
<li>Physical wire things, like USB or so</li>
<li>Camera, let&#39;s assume we are talking about a laptop</li>
<li>Microphone</li>
</ol>
<p>Since the camera seems like a good idea, I started with the obvious &quot;does not need anything else, just the OS&quot; approach with QR-code-based communication. Well... a 1.14-inch, 135 × 240 LCD is not the largest thing in the world. :D I managed to squeeze 515 bytes of code into a 97×97 pixel grid, and mostly failed to read it as a QR code.</p>
<p>This is one of the test QR codes:</p>
<p><img src="/posts/beeping/qr_clean.png" alt="QR"></p>
<p>It can barely fit the 515-byte payload, but to be honest, it&#39;s mostly non-working, and the payload itself got larger.</p>
<p>That was the stage where I decided to do a multi-stage deal. The <strong>first stage</strong> is just minimal, small code that can receive data: small footprint, slow data transfer, but enough to receive the <strong>second stage</strong>, which is the actual code that we need for the fast(er) communication. Man, if that LCD had been just 50% larger, it might have worked.</p>
<p>Since there is no &quot;audio to code&quot; type tool (as far as I know) on a standard machine, and I did not want to mess around with implementing a USB protocol AND writing some things to my Mac, I fell back to <strong>let&#39;s type in the first stage</strong>.</p>
<p>Still, I needed a first stage. My solution was to create a super-minimal HTML page for this:</p>
<pre><code class="hljs language-html">data:text/html,<span class="hljs-tag">&lt;<span class="hljs-name">i</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">\</span>&quot;<span class="hljs-attr">a</span>=<span class="hljs-string">new</span> <span class="hljs-attr">AudioContext</span>;<span class="hljs-attr">N</span>=<span class="hljs-string">a.sampleRate/100|0;navigator.mediaDevices.getUserMedia({audio:1}).then(s</span>=&gt;</span>{sp=a.createScriptProcessor(256);a.createMediaStreamSource(s).connect(sp);st=b=n=k=x=0;o=&#x27;&#x27;;sp.onaudioprocess=e=&gt;{for(v of e.inputBuffer.getChannelData(0)){x+=v*v;w=st?N:44;if(++k&lt;w)continue;st&amp;&amp;x&gt;M&amp;&amp;(M=x);q=x&gt;(st?M/2:w/1e3);k=x=0;if(!st){st=q;M=0;continue}b=b*2+q;if(++n&amp;7)continue;st&lt;2?st++:st&lt;3?b!=85?st=0:st++:st&lt;4?(l=b,st=4):st&lt;5?(l=l*256+b,st=5):st&lt;6?(o+=String.fromCharCode(b),--l?0:st=6):0;b=n=0;if(st&gt;5)this.innerHTML=o}};sp.connect(a.destination)})\&quot;&gt;RECV<span class="hljs-tag">&lt;/<span class="hljs-name">i</span>&gt;</span>
</code></pre><p>Which looks awesome on the device BTW (a bit older screenshot, but still):</p>
<p><img src="https://blog-files.stormregion.hu/beeping/fridge.jpg" alt="M5 in the Fridge"></p>
<p>I know, I know. It will need some explaining:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">const</span> audioContext = <span class="hljs-keyword">new</span> <span class="hljs-title class_">AudioContext</span>();

<span class="hljs-keyword">const</span> preambleWindowSamples =
    <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">floor</span>(audioContext.<span class="hljs-property">sampleRate</span> / <span class="hljs-number">100</span>);

navigator.<span class="hljs-property">mediaDevices</span>
    .<span class="hljs-title function_">getUserMedia</span>({ <span class="hljs-attr">audio</span>: <span class="hljs-literal">true</span> })
    .<span class="hljs-title function_">then</span>(<span class="hljs-function"><span class="hljs-params">stream</span> =&gt;</span> {
        <span class="hljs-keyword">const</span> processor =
            audioContext.<span class="hljs-title function_">createScriptProcessor</span>(<span class="hljs-number">256</span>);

        <span class="hljs-keyword">const</span> source =
            audioContext.<span class="hljs-title function_">createMediaStreamSource</span>(stream);

        source.<span class="hljs-title function_">connect</span>(processor);

        <span class="hljs-keyword">let</span> state = <span class="hljs-number">0</span>;
        <span class="hljs-keyword">let</span> currentByte = <span class="hljs-number">0</span>;
        <span class="hljs-keyword">let</span> bitCount = <span class="hljs-number">0</span>;

        <span class="hljs-keyword">let</span> sampleCount = <span class="hljs-number">0</span>;
        <span class="hljs-keyword">let</span> energy = <span class="hljs-number">0</span>;

        <span class="hljs-keyword">let</span> output = <span class="hljs-string">&#x27;&#x27;</span>;
        <span class="hljs-keyword">let</span> maxPreambleEnergy = <span class="hljs-number">0</span>;
        <span class="hljs-keyword">let</span> remainingLength = <span class="hljs-number">0</span>;

        processor.<span class="hljs-property">onaudioprocess</span> = <span class="hljs-function"><span class="hljs-params">event</span> =&gt;</span> {
            <span class="hljs-keyword">const</span> samples =
                event.<span class="hljs-property">inputBuffer</span>.<span class="hljs-title function_">getChannelData</span>(<span class="hljs-number">0</span>);

            <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> sample <span class="hljs-keyword">of</span> samples) {
                energy += sample * sample;

                <span class="hljs-keyword">const</span> windowSize = state
                    ? preambleWindowSamples
                    : <span class="hljs-number">44</span>;

                sampleCount++;

                <span class="hljs-keyword">if</span> (sampleCount &lt; windowSize) {
                    <span class="hljs-keyword">continue</span>;
                }

                <span class="hljs-keyword">if</span> (
                    state &amp;&amp;
                    energy &gt; maxPreambleEnergy
                ) {
                    maxPreambleEnergy = energy;
                }

                <span class="hljs-keyword">const</span> threshold = state
                    ? maxPreambleEnergy / <span class="hljs-number">2</span>
                    : windowSize / <span class="hljs-number">1000</span>;

                <span class="hljs-keyword">const</span> bit = energy &gt; threshold;

                sampleCount = <span class="hljs-number">0</span>;
                energy = <span class="hljs-number">0</span>;

                <span class="hljs-comment">/*
                 * Wait for the first energetic block.
                 */</span>
                <span class="hljs-keyword">if</span> (!state) {
                    state = bit;
                    maxPreambleEnergy = <span class="hljs-number">0</span>;
                    <span class="hljs-keyword">continue</span>;
                }

                <span class="hljs-comment">/*
                 * Assemble one byte, MSB-first.
                 */</span>
                currentByte =
                    currentByte * <span class="hljs-number">2</span> + bit;

                bitCount++;

                <span class="hljs-keyword">if</span> (bitCount &amp; <span class="hljs-number">7</span>) {
                    <span class="hljs-keyword">continue</span>;
                }

                <span class="hljs-comment">/*
                 * Parse protocol bytes.
                 */</span>
                <span class="hljs-keyword">switch</span> (state) {
                    <span class="hljs-keyword">case</span> <span class="hljs-number">1</span>:
                        <span class="hljs-comment">// Guard byte.</span>
                        state = <span class="hljs-number">2</span>;
                        <span class="hljs-keyword">break</span>;

                    <span class="hljs-keyword">case</span> <span class="hljs-number">2</span>:
                        <span class="hljs-comment">// Sync byte = 0x55.</span>
                        <span class="hljs-keyword">if</span> (currentByte !== <span class="hljs-number">0x55</span>) {
                            state = <span class="hljs-number">0</span>;
                        } <span class="hljs-keyword">else</span> {
                            state = <span class="hljs-number">3</span>;
                        }
                        <span class="hljs-keyword">break</span>;

                    <span class="hljs-keyword">case</span> <span class="hljs-number">3</span>:
                        <span class="hljs-comment">// Length high byte.</span>
                        remainingLength =
                            currentByte;

                        state = <span class="hljs-number">4</span>;
                        <span class="hljs-keyword">break</span>;

                    <span class="hljs-keyword">case</span> <span class="hljs-number">4</span>:
                        <span class="hljs-comment">// Length low byte.</span>
                        remainingLength =
                            (remainingLength &lt;&lt; <span class="hljs-number">8</span>) |
                            currentByte;

                        state = <span class="hljs-number">5</span>;
                        <span class="hljs-keyword">break</span>;

                    <span class="hljs-keyword">case</span> <span class="hljs-number">5</span>:
                        <span class="hljs-comment">// Payload.</span>
                        output +=
                            <span class="hljs-title class_">String</span>.<span class="hljs-title function_">fromCharCode</span>(
                                currentByte
                            );

                        remainingLength--;

                        <span class="hljs-keyword">if</span> (remainingLength === <span class="hljs-number">0</span>) {
                            state = <span class="hljs-number">6</span>;
                        }
                        <span class="hljs-keyword">break</span>;
                }

                currentByte = <span class="hljs-number">0</span>;
                bitCount = <span class="hljs-number">0</span>;

                <span class="hljs-keyword">if</span> (state === <span class="hljs-number">6</span>) {
                    <span class="hljs-variable language_">this</span>.<span class="hljs-property">innerHTML</span> = output;
                }
            }
        };

        processor.<span class="hljs-title function_">connect</span>(
            audioContext.<span class="hljs-property">destination</span>
        );
    });
</code></pre><p>A small state machine with a simple &quot;on-off&quot; signal that is converted into one tone or silence. One long flow, no error correction, no checks, nothing. But at least it can wait for the first part of the transfer, the long beep, and then the sync. Also, it&#39;s super slow, since it should be somewhat robust to other noises, hence the 256 samples turning into 1 byte.</p>
<p>Giving a small ChatGPT explanation of this:</p>
<p><img src="https://blog-files.stormregion.hu/beeping/bootstrap.png" alt="Bootstrap html"></p>
<p>More than 3 minutes just to put 2413 bytes of HTML from point A to point B. The beeper on the device is not the best, and during the Hackathon I annoyed everyone enough that, for such a short time, this should be enough. I bet some improvements can be made.</p>
<p>The resulting HTML is a bit uglier than the original (CSS and debug strings cut from that), but this is how the page should have looked:</p>
<p><img src="https://blog-files.stormregion.hu/beeping/write.png" alt="HTML of stage2"></p>
<h2>Stage 2</h2>
<p>The Stage 2 interface is basically a huge textarea with 2 buttons (functions). The first is &quot;COPY&quot;: whatever the user enters into the textarea gets converted to sound and played. For example, this 25,187-byte-long fan fiction in the Terminator universe. Yes, 1.4 hours of beeping for roughly 5 bytes/sec. It uses the fancier, more robust, 4-tone version: checksum, everything fancy that I did learn at the university. :D ChatGPT explanation:</p>
<p><img src="https://blog-files.stormregion.hu/beeping/4fsk.png" alt="4-FSK protocol"></p>
<p>The beeper itself is limited, the microphone is limited, and it&#39;s an open-air communication protocol hacked together in 2413 bytes. Once the HTML is loaded onto a machine, it can act both as a receiver and sender, so if you are lucky, you only need to load the initial 3 minutes once.</p>
<p>A function of the stick itself is to store data persistently, so you can record messages (stored as messages, not voice, since we just have a couple of usable kBytes).</p>
<p><img src="https://blog-files.stormregion.hu/beeping/storage.jpg" alt="Storage"></p>
<h2>A hint of style</h2>
<p>Of course, presentation is half of the story in Hackathons, so slapping a Terminator story around it (keeping the communication secure-ish by using an obscure sound protocol, no internet, etc.), adding some design, and we got a crazy Hackathon project. :)</p>
<p><img src="https://blog-files.stormregion.hu/beeping/bg.gif" alt="Background for dramatic effect"></p>
<h2>Credits</h2>
<ul>
<li>OpenCode (Big Pickle, DeepSeek V4 Flash)</li>
<li>Z.AI GLM 5.2</li>
<li>Qwen Image 2.0</li>
<li>Me as the host, the guy orchestrating agents and pushing random buttons on a small beeping device</li>
</ul>
]]></content:encoded>
    </item>
  </channel>
</rss>
