<?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: Hamed</title>
    <description>The latest articles on DEV Community by Hamed (@godofweb).</description>
    <link>https://dev.to/godofweb</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%2F4112847%2Fae60db95-65ca-4f5e-accd-77f060631c97.png</url>
      <title>DEV Community: Hamed</title>
      <link>https://dev.to/godofweb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/godofweb"/>
    <language>en</language>
    <item>
      <title>WaveRune: hiding a 32-bit ID inside a WAV file, and reading it back without the original</title>
      <dc:creator>Hamed</dc:creator>
      <pubDate>Fri, 11 Sep 2026 16:04:36 +0000</pubDate>
      <link>https://dev.to/godofweb/waverune-hiding-a-32-bit-id-inside-a-wav-file-and-reading-it-back-without-the-original-3mkk</link>
      <guid>https://dev.to/godofweb/waverune-hiding-a-32-bit-id-inside-a-wav-file-and-reading-it-back-without-the-original-3mkk</guid>
      <description>&lt;p&gt;A while ago I wanted to answer a small question about an audio file: which copy is this? Not who owns it, not whether it was tampered with, only which of several exported versions ended up in front of me. The obvious answers all had a catch. Metadata tags disappear the moment someone re-exports the file. Fingerprinting tells you &lt;em&gt;what&lt;/em&gt; a recording is, not which copy. Comparing against the original needs the original, and the whole point was that I did not have it at the time I was asking.&lt;/p&gt;

&lt;p&gt;So I built WaveRune. It embeds a 32-bit identifier inside the audio itself, and it reads the identifier back using only a key. The original recording is not needed for detection. It is a TypeScript library with zero runtime dependencies, a command-line tool, and a browser demo that never uploads your file.&lt;/p&gt;

&lt;p&gt;This post is a tour of what it does, how the signal processing works in plain terms, and what I measured. That last part matters more than the rest. Watermarking libraries tend to promise a lot, and I would rather show you the failure cases up front.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/hamedniroomand/waverune" rel="noopener noreferrer"&gt;https://github.com/hamedniroomand/waverune&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Live demo: &lt;a href="https://hamedniroomand.github.io/waverune/" rel="noopener noreferrer"&gt;https://hamedniroomand.github.io/waverune/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Package: &lt;code&gt;npm install waverune&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Thirty seconds with the CLI
&lt;/h2&gt;

&lt;p&gt;Install it globally, or run it through &lt;code&gt;npx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; waverune
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Embed the identifier 42 into a WAV file with a key of your choice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;waverune embed input.wav &lt;span class="nt"&gt;-o&lt;/span&gt; marked.wav &lt;span class="nt"&gt;--id&lt;/span&gt; 42 &lt;span class="nt"&gt;--key&lt;/span&gt; my-secret-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Wrote the watermark to "marked.wav".
Requested id: 42
Recovered id: 42
Correlation score: 0.176
SNR: 23.69 dB
MSE: 5.3213e-5
PSNR: 34.33 dB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that &lt;code&gt;embed&lt;/code&gt; did not stop at writing the file. It read &lt;code&gt;marked.wav&lt;/code&gt; back from disk and ran detection on it, so the "Recovered id" line is a real check on the saved bytes. If that check fails, the command keeps the file for inspection and exits with code 3 instead of pretending it worked.&lt;/p&gt;

&lt;p&gt;Now detect, with only the file and the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;waverune detect marked.wav &lt;span class="nt"&gt;--key&lt;/span&gt; my-secret-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Watermark found. Id: 42
Correlation score: 0.176
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And with the wrong key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;waverune detect marked.wav &lt;span class="nt"&gt;--key&lt;/span&gt; wrong-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No watermark found.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That run exits with code 2, which is useful in scripts. Add &lt;code&gt;--json&lt;/code&gt; to any command to get the full result as one line of JSON, including the diagnostics I describe further down.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9dm0fdgb7yx63oe6fi0u.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9dm0fdgb7yx63oe6fi0u.gif" alt="Terminal recording of installing waverune, embedding id 42 and detecting it again" width="800" height="349"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you do not want a JavaScript runtime at all, there are standalone executables for macOS, Linux and Windows on the releases page, with an install script that checks the SHA-256 before it puts anything on your PATH.&lt;/p&gt;

&lt;h2&gt;
  
  
  The library
&lt;/h2&gt;

&lt;p&gt;The functional API is four calls. Read a file, embed, write, detect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readWavFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;writeWavFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;waverune&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-watermark-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;original&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readWavFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input.wav&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeWavFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;marked.wav&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readWavFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;marked.wav&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detected&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Verified:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payload is a &lt;code&gt;bigint&lt;/code&gt; in the range 0 to 4294967295. &lt;code&gt;embed&lt;/code&gt; returns a new buffer and leaves the input alone. If you already have bytes in memory, &lt;code&gt;decodeWav&lt;/code&gt; and &lt;code&gt;encodeWav&lt;/code&gt; work on &lt;code&gt;Uint8Array&lt;/code&gt;, which is what the browser demo uses. The codec handles 16, 24 and 32-bit PCM and 32-bit float.&lt;/p&gt;

&lt;p&gt;Everything runs on Node.js 22 or later and on Bun. There is no native addon, no model download and no network call anywhere in the package.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the data gets into the sound
&lt;/h2&gt;

&lt;p&gt;I want to explain this properly, because "it hides data in the audio" is not an explanation. The approach is classical spread-spectrum watermarking in the frequency domain. Nothing here is new research. The work was in choosing parameters that hold up on real recordings and in writing an honest acceptance rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Framing.&lt;/strong&gt; The signal is analysed in overlapping windows of about 46 milliseconds, moving forward 10 milliseconds at a time. Both values are set in seconds rather than samples, so the frame grid is the same at 44.1 and 48 kHz.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The band.&lt;/strong&gt; The payload is spread across 500 to 5000 Hz, divided into 48 frequency slots of equal width. That band survives most things people do to audio, and it stays clear of the very low end where a small change is easy to hear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A masking threshold.&lt;/strong&gt; For every frame and every slot, the embedder computes how much it is allowed to change the magnitude. The rule is simple: take the energy of the strongest neighbouring slot, let it spread at 10 dB per slot, then drop 14 dB below that. Frames quieter than 5% of the loudest frame carry nothing at all. I want to be clear that this is a simplified spreading model, not a calibrated psychoacoustic one. Staying under it is not proof that the change is inaudible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embedding.&lt;/strong&gt; A pseudo-random sequence derived from the key assigns each spectral cell a sign and a bit position. The embedder nudges each cell's magnitude up or down by a fraction of its threshold, according to the bit it carries. Because the analysis windows overlap, one analysis-and-synthesis pass only delivers part of the intended change, so the embedder runs eight passes, each one re-analysing the previous output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection.&lt;/strong&gt; The detector analyses the candidate file the same way, then removes the host audio statistically. It whitens each frame against its own slots and each slot against its average over the file, and it weights every cell by the inverse of its local residual power so a drum hit or a plosive does not dominate the correlation. Then it correlates against the keyed sequence for every possible block position and for eight sub-hop sample shifts, and keeps the alignment that agrees best with a sync pattern. This is why the original is not needed: the key regenerates the sequence, and the whitening step does the job the original would have done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What "detected" means.&lt;/strong&gt; The payload lives in a 1.5 second block along with a 16-bit sync pattern and an 8-bit checksum, and the block repeats for the length of the file. Acceptance is exact and deterministic. All 16 sync bits must match, and the 8 checksum bits must match the checksum of the decoded payload bits, at the chosen alignment. There is no correlation threshold to tune. If the rule fails, &lt;code&gt;detected&lt;/code&gt; is false, &lt;code&gt;payload&lt;/code&gt; is null, and the raw decoded bits are exposed as a diagnostic so you can see what the detector was looking at.&lt;/p&gt;

&lt;p&gt;The correlation score you saw in the CLI output is diagnostic only. Unmarked audio scores about 0.05, with 0.083 the highest value seen across 585 unmarked, wrong-key and silent trials. The clean embed above scored 0.176, and digital silence scores exactly 0. The score plays no part in the accept or reject decision. I made that choice after an earlier detector accepted a wrong payload on a real-audio excerpt, and I did not want a tunable number standing between the user and a clear answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser demo
&lt;/h2&gt;

&lt;p&gt;The demo is the same library bundled for the browser. Choose a WAV file, type a key, and either embed a new identifier or detect an existing one. Processing happens on your machine. The page has no backend, and there is nothing to upload to.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5ymhrjy4x91m8nceygy7.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5ymhrjy4x91m8nceygy7.jpg" alt="WaveRune demo landing page in light theme" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After an embed, the results panel shows the original and the watermarked audio side by side for listening, the recovered identifier, the sync and checksum outcomes, how many frames were active, and a download button for the marked file.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwoao42d0h70khcid3skx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwoao42d0h70khcid3skx.jpg" alt="Demo results panel after embedding identifier 42: watermark embedded and verified, 0 of 16 sync errors, checksum valid, 803 of 805 frames active" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demo caps files at 120 seconds because the work happens on the main thread and a long stereo file can pause the page for a while. The library and the CLI have no such cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured, and where it fails
&lt;/h2&gt;

&lt;p&gt;Every number in the README comes from a reproducible benchmark run, and the reliability report in the repository lists the inputs, commands and per-file results. These are the results for the current detector on the test sets I have. They are not guarantees for your audio.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test set&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Clean synthetic audio, 20 key and payload pairs on each of two fixtures&lt;/td&gt;
&lt;td&gt;40 of 40 exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The same trials after a 16-bit WAV round trip&lt;/td&gt;
&lt;td&gt;40 of 40 exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sample-rate conversions through macOS Core Audio&lt;/td&gt;
&lt;td&gt;50 of 50 exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean recorded audio, three pairs across nine files&lt;/td&gt;
&lt;td&gt;24 of 27 exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefix removal on eligible recorded files&lt;/td&gt;
&lt;td&gt;119 of 119 exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recorded excerpts of 5 seconds or less&lt;/td&gt;
&lt;td&gt;115 of 329 exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rejection trials with unmarked audio, wrong keys and silence&lt;/td&gt;
&lt;td&gt;0 acceptances in 585&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The line I want you to read twice is the excerpt one. Short clips are where this breaks. The block is 1.5 seconds long and the detector wants to see it repeat, so a clip under about three seconds rarely recovers on recorded audio. On the recorded corpus the clean full-length recovery rate is 24 of 27, not 27 of 27, and the three misses happened on ordinary recordings, not on synthetic edge cases.&lt;/p&gt;

&lt;p&gt;A few more things it does not do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is WAV only.&lt;/strong&gt; MP3, AAC and Opus are out of scope. The transform coding in those formats does its own thing to the spectrum, and I have not measured survival through it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audibility has not been validated by listening tests.&lt;/strong&gt; The masking model is simplified. Some spectral cells exceed its threshold in the measurements. The SNR figures from &lt;code&gt;waverune metrics&lt;/code&gt; are a number, not an opinion about how it sounds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A watermark is not proof of ownership.&lt;/strong&gt; Anyone who has the key can embed one that passes. The checksum protects against decoding errors. It does not authenticate anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;False acceptance is possible in principle.&lt;/strong&gt; No wrong payload was accepted in the 585 rejection trials for the current detector. A finite test set does not establish a rate, and the previous detector did accept one, which is the whole reason the acceptance rule is now exact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try it on your own recordings and something fails, the most useful thing you can send me is a reproduction: duration, sample rate, bit depth, channel count, the command you ran, and what you did to the audio between embed and detect. Use a throwaway key. The contributing guide in the repo has the full list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I published it anyway
&lt;/h2&gt;

&lt;p&gt;There are commercial watermarking systems that do more than this, and there are research models that survive far harsher attacks. WaveRune is not competing with those. It is a small, readable implementation of the classical approach, with a test matrix that says exactly what it can and cannot do, and no dependency you have to audit.&lt;/p&gt;

&lt;p&gt;If you need to tell copies of a WAV file apart in a pipeline you control, it does that job today. If you want to learn how spread-spectrum audio watermarking works by reading code instead of papers, the source is short enough to read in an afternoon, and every non-obvious signal-processing step has a comment explaining why it is there.&lt;/p&gt;

&lt;p&gt;The repository is at &lt;a href="https://github.com/hamedniroomand/waverune" rel="noopener noreferrer"&gt;https://github.com/hamedniroomand/waverune&lt;/a&gt;, under the MIT licence. Try the demo, break it, and tell me how.&lt;/p&gt;

</description>
      <category>audio</category>
      <category>typescript</category>
      <category>opensource</category>
      <category>dsp</category>
    </item>
    <item>
      <title>Browser first: the one rule behind every tool on KitDev Space</title>
      <dc:creator>Hamed</dc:creator>
      <pubDate>Sun, 06 Sep 2026 22:30:42 +0000</pubDate>
      <link>https://dev.to/godofweb/browser-first-the-one-rule-behind-every-tool-on-kitdev-space-19id</link>
      <guid>https://dev.to/godofweb/browser-first-the-one-rule-behind-every-tool-on-kitdev-space-19id</guid>
      <description>&lt;p&gt;A while ago I caught myself pasting a JWT from a production API into the first "JWT decoder" Google gave me. I wanted to read the claims. I didn't stop to think about where the token went, and honestly, neither does anyone else. We do the same with &lt;code&gt;.env&lt;/code&gt; files in JSON converters and with photos in EXIF strippers. The site has a privacy policy that says the server keeps nothing, and we take its word for it.&lt;/p&gt;

&lt;p&gt;That moment is roughly why &lt;a href="https://kitdev.space" rel="noopener noreferrer"&gt;KitDev Space&lt;/a&gt; exists. I wanted a set of everyday developer tools where I didn't have to take anyone's word for it, including my own. The way I got there was less about the tools and more about one rule I wrote down early and then refused to bend. This post is about that rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Run the work in the browser. Use a server only when the browser genuinely can't do the job.&lt;/p&gt;

&lt;p&gt;The first half is easy to agree with. The second half is where it gets interesting, because "can't" has to mean something concrete or every tool ends up on the server for convenience. So I wrote down the situations that actually qualify, and the list ended up shorter than I expected:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The work needs a native library. Encoding AVIF or rasterizing an SVG needs a real image codec. Opening a TLS handshake needs a socket.&lt;/li&gt;
&lt;li&gt;The work needs to talk to another host. DNS records, HTTP headers, RDAP, OpenGraph previews. The browser can't make those requests because of CORS, and it shouldn't be able to.&lt;/li&gt;
&lt;li&gt;The work needs a runtime API with no browser equivalent, like &lt;code&gt;Bun.dns&lt;/code&gt; or &lt;code&gt;Bun.Archive&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The input is too large for a browser tab to hold comfortably.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a tool doesn't hit one of those, it stays in the browser. I've been tempted to make exceptions a few times, usually when the server version would have been twenty lines and the browser version two hundred. I haven't yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser does more than I gave it credit for
&lt;/h2&gt;

&lt;p&gt;Going in, I assumed I'd be writing a lot of server code. I was wrong about that. Most of what a developer tool site does is already covered by platform APIs or by a small dependency that runs fine in a tab.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Work&lt;/th&gt;
&lt;th&gt;What handles it in the browser&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SHA-1, SHA-256, SHA-384, SHA-512&lt;/td&gt;
&lt;td&gt;&lt;code&gt;crypto.subtle.digest&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random bytes, UUIDs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;crypto.getRandomValues&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AES-256-GCM, PBKDF2, HMAC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;crypto.subtle&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resize, rotate, crop&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;OffscreenCanvas&lt;/code&gt;, &lt;code&gt;createImageBitmap&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPEG, PNG, WebP encode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OffscreenCanvas.convertToBlob&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading EXIF and GPS metadata&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;DataView&lt;/code&gt; over the file bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gzip, deflate, tar&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fflate&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YAML, TOML, JSONC, JSON5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;confbox&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sql.js&lt;/code&gt; (WebAssembly)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the JWT debugger verifies HS256 signatures with Web Crypto and never sees a server. The AES tool derives its key with PBKDF2 in your tab. The tar explorer opens an archive without uploading it. The SQLite studio runs an actual database in memory. None of that required anything clever, just a willingness to check what the platform already had before reaching for a route.&lt;/p&gt;

&lt;p&gt;Web Crypto does have gaps. There's no MD5, no CRC32, no xxHash. I could have pulled in a JavaScript MD5 library and kept everything client side, but the hash tool also offers xxhash64 and wyhash, and shipping all of that to the browser for algorithms most people rarely use felt wrong. So the hash generator keeps a server path for those, and the page says so in plain words when you pick one. I'd rather tell you which option leaves your machine than pretend nothing does.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2bu1tppttnsjq5wl4ii.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2bu1tppttnsjq5wl4ii.png" alt="Hash Generator with SHA-256 selected. A green notice reads " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One module, two paths
&lt;/h2&gt;

&lt;p&gt;A tool with both a browser path and a server path is a maintenance trap waiting to happen. Two implementations, one of them quietly drifts, and now the same input gives different output depending on where it ran. I got bitten by exactly this on the data converter early on.&lt;/p&gt;

&lt;p&gt;The fix I settled on: each shared module exports the function that does the work plus a small guard that answers "can this run here?". The page checks the guard. The server route imports the same module. The code that actually hashes a file exists once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shared/utils/crypto/hash.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SUBTLE_NAMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;sha1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sha384&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-384&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sha512&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-512&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;canHashInBrowser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;algorithm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HashAlgorithm&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;algorithm&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;SUBTLE_NAMES&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// simplified page logic&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canHashInBrowser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;algorithm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hashInBrowser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;algorithm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;$fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/hash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;algorithm&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hashing, data format conversion, code formatting and semver all use this shape now. When a new tool needs both paths, it gets the same treatment, and I don't have to think about it anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server is actually for
&lt;/h2&gt;

&lt;p&gt;The tools that do need a server run on &lt;a href="https://bun.sh" rel="noopener noreferrer"&gt;Bun&lt;/a&gt;. Each one is there because of one of the four reasons above, and I can tell you which:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The TLS Certificate Inspector opens a socket and reads the chain, the SANs and the cipher suite.&lt;/li&gt;
&lt;li&gt;DNS Lookup and the Email Health Inspector query A, MX, TXT, SPF, DKIM and DMARC records.&lt;/li&gt;
&lt;li&gt;The HTTP Inspector, OpenGraph Previewer and RDAP Lookup all fetch from a third-party host.&lt;/li&gt;
&lt;li&gt;Image Studio and the Favicon Set Generator use &lt;code&gt;Bun.Image&lt;/code&gt; for AVIF and ICO output.&lt;/li&gt;
&lt;li&gt;The TS/JSX Transpiler and the AST Playground run OXC, which is native code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because every server route is a liability, they all go through the same checklist before they ship. Validate the URL and allow only &lt;code&gt;http&lt;/code&gt; and &lt;code&gt;https&lt;/code&gt;. Block localhost, private ranges, link-local, cloud metadata hosts, and the IPv6 transition ranges that smuggle an IPv4 address inside (I learned about NAT64 and Teredo the hard way while writing that blocklist). Restrict the destination port, so the TLS inspector can't be turned into a port scanner. Check &lt;code&gt;content-length&lt;/code&gt;, then stream the body and stop at a cap instead of buffering it. Rate-limit by a client key that isn't derived from &lt;code&gt;x-forwarded-for&lt;/code&gt;, since anyone can set that header. Keep uploads in memory and never touch the disk. And store no input and no output, period.&lt;/p&gt;

&lt;p&gt;Analytics follows the same idea. An event carries the tool id and nothing else. I can see that someone opened the cron visualizer today. I have no idea what expression they typed, and I like it that way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7st5wgc37gtusfgltfv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7st5wgc37gtusfgltfv.png" alt="SQLite Studio with a 7.5 MB database of 55,991 companies open in the tab. The query ran in 4 ms, and the file never left the browser." width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the rule visible
&lt;/h2&gt;

&lt;p&gt;A rule that only I know about isn't a privacy feature. It's a promise. So the sidebar shows where each tool runs.&lt;/p&gt;

&lt;p&gt;Every tool has a small badge next to its name. 🔒 Client means nothing on that page calls the server. ⚡ Bun means every action does. A tool with both paths shows no badge, and the page itself explains which action leaves the browser. The badge isn't something I type by hand, either. It comes from two flags in the tool registry, the same registry that generates the page title and the search index, so it can't drift out of sync with the code.&lt;/p&gt;

&lt;p&gt;And the code is public. KitDev Space is open source under the MIT license, and the repository is linked from the site footer. If a badge says Client, you don't have to trust the badge. You can open the tool's module and see for yourself that nothing in it calls a server. That was the last piece of the "don't take my word for it" idea, and honestly the part I should have done first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdd1bszl2vy1pqhs1pd0i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdd1bszl2vy1pqhs1pd0i.png" alt=" " width="576" height="1688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in there
&lt;/h2&gt;

&lt;p&gt;New tools land most weeks, so I won't give you a count. Here are the ones I open most often, by category.&lt;/p&gt;

&lt;p&gt;Data: a JSON formatter that accepts JSON5 and JSONC, JSON to TypeScript, converters between YAML, TOML, XML and CSV (including CSV to SQL inserts), a table viewer for large CSVs, and the SQLite studio.&lt;/p&gt;

&lt;p&gt;Dev: a regex tester that explains each token, a cron visualizer that shows the next runs, cURL to fetch/Axios/Python/Go, HTML and SVG to JSX or Vue, a glob tester, and the tar explorer.&lt;/p&gt;

&lt;p&gt;Network: TLS inspector, DNS lookup, email health check, HTTP header inspector, CIDR calculator, cookie inspector, RDAP lookup.&lt;/p&gt;

&lt;p&gt;Crypto: hash generator, HMAC, JWT debugger, TOTP generator, AES encrypt and decrypt, UUID and passphrase generators.&lt;/p&gt;

&lt;p&gt;Color: an OKLCH converter, a WCAG contrast checker, a Tailwind shade generator, a CSS gradient studio, and palette extraction from an image.&lt;/p&gt;

&lt;p&gt;Image: crop, resize, rotate and convert in one pass, an EXIF inspector and remover, SVG to PNG or WebP at 1x/2x/4x, and a favicon set generator that hands you a ZIP.&lt;/p&gt;

&lt;p&gt;There's no account, no paywall, and the whole thing is open source. The site follows your system color scheme, so you won't have to hunt for a toggle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood
&lt;/h2&gt;

&lt;p&gt;Nuxt 4 and Nuxt UI 4 on the front end, VueUse for composables, CodeMirror for the editors, Bun for the server routes, Vitest and Playwright for tests, deployed on Vercel. Nothing exotic.&lt;/p&gt;

&lt;h2&gt;
  
  
  I'd like your help
&lt;/h2&gt;

&lt;p&gt;If you try &lt;a href="https://kitdev.space" rel="noopener noreferrer"&gt;kitdev.space&lt;/a&gt;, I have two requests. Tell me which tool you wanted and couldn't find. And if you ever catch a tool sending something to the server that should have stayed in your browser, tell me that first. It would mean the rule has a hole, and I'd want to know.&lt;/p&gt;

&lt;p&gt;The repository is one click away from the footer. Issues and pull requests are welcome, whether it's a new tool, a fix, or just a note that a badge is wrong. A tool hub like this gets better with more hands on it, which is the main reason I opened the source.&lt;/p&gt;

&lt;p&gt;If you build tools of your own, take the rule with you. Write down the handful of cases where the browser truly can't do the work. Everything else belongs on the user's machine.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>privacy</category>
      <category>bunjs</category>
    </item>
  </channel>
</rss>
