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 what 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.
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.
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.
- Repository: https://github.com/hamedniroomand/waverune
- Live demo: https://hamedniroomand.github.io/waverune/
- Package:
npm install waverune
Thirty seconds with the CLI
Install it globally, or run it through npx:
npm install -g waverune
Embed the identifier 42 into a WAV file with a key of your choice:
waverune embed input.wav -o marked.wav --id 42 --key my-secret-key
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
Notice that embed did not stop at writing the file. It read marked.wav 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.
Now detect, with only the file and the key:
waverune detect marked.wav --key my-secret-key
Watermark found. Id: 42
Correlation score: 0.176
And with the wrong key:
waverune detect marked.wav --key wrong-key
No watermark found.
That run exits with code 2, which is useful in scripts. Add --json to any command to get the full result as one line of JSON, including the diagnostics I describe further down.
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.
The library
The functional API is four calls. Read a file, embed, write, detect:
import { detect, embed, readWavFile, writeWavFile } from 'waverune';
const key = 'my-watermark-key';
const payload = 42n;
const original = await readWavFile('input.wav');
const marked = embed(original, { key, payload });
await writeWavFile('marked.wav', marked);
const saved = await readWavFile('marked.wav');
const result = detect(saved, { key });
if (result.detected && result.payload === payload) {
console.log('Verified:', result.payload.toString());
}
The payload is a bigint in the range 0 to 4294967295. embed returns a new buffer and leaves the input alone. If you already have bytes in memory, decodeWav and encodeWav work on Uint8Array, which is what the browser demo uses. The codec handles 16, 24 and 32-bit PCM and 32-bit float.
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.
How the data gets into the sound
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.
Framing. 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.
The band. 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.
A masking threshold. 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.
Embedding. 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.
Detection. 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.
What "detected" means. 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, detected is false, payload is null, and the raw decoded bits are exposed as a diagnostic so you can see what the detector was looking at.
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.
The browser demo
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.
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.
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.
What I measured, and where it fails
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.
| Test set | Result |
|---|---|
| Clean synthetic audio, 20 key and payload pairs on each of two fixtures | 40 of 40 exact |
| The same trials after a 16-bit WAV round trip | 40 of 40 exact |
| Sample-rate conversions through macOS Core Audio | 50 of 50 exact |
| Clean recorded audio, three pairs across nine files | 24 of 27 exact |
| Prefix removal on eligible recorded files | 119 of 119 exact |
| Recorded excerpts of 5 seconds or less | 115 of 329 exact |
| Rejection trials with unmarked audio, wrong keys and silence | 0 acceptances in 585 |
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.
A few more things it does not do:
- It is WAV only. 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.
-
Audibility has not been validated by listening tests. The masking model is simplified. Some spectral cells exceed its threshold in the measurements. The SNR figures from
waverune metricsare a number, not an opinion about how it sounds. - A watermark is not proof of ownership. Anyone who has the key can embed one that passes. The checksum protects against decoding errors. It does not authenticate anything.
- False acceptance is possible in principle. 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.
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.
Why I published it anyway
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.
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.
The repository is at https://github.com/hamedniroomand/waverune, under the MIT licence. Try the demo, break it, and tell me how.



Top comments (3)
The scoping is what I like here: "which copy" vs "who owns it" is a distinction most watermarking threads conflate, and picking the weaker question made the design tractable. 32 bits is plenty to tell batch exports apart and small enough to stay inaudible.
Where I'd push on it is the lossy path. Most payload-in-audio schemes quietly die on an mp3 round-trip or a resample. Did you measure what the floor is before the ID stops surviving, or is the promise deliberately "survives honest re-export, not adversarial transcode"?
Thanks. The narrower question was the whole trick: "which copy" needs 32 bits and a key, "who owns it" needs a trust model I did not want to fake.
On lossy: the promise is deliberately "survives honest PCM re-export". I have not measured mp3, AAC or Opus at all, so I make no claim there.
What I did measure is in the reliability report. Resampling holds, 50 of 50 through five conversions down to 16 kHz, one resampler. Noise is where the floor shows and it depends on the audio: broadband survives to 20 dB SNR, tonal already fails at 40. In every failure the detector said "no watermark" rather than returning a wrong id.
An mp3 round-trip bench is the obvious next step.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.