liarjs.dev
← Field notes
Audio

Audio fingerprinting: the oscillator, the compressor and why silence must stay silent

August 29, 2026 · 9 min read

The Web Audio fingerprint is one of the oldest tricks still in the kit. It survives because it is boringly reliable: render a fixed tone through a fixed DSP graph, sum the samples, and you get a float that stays stable to many decimal places across sessions on the same machine. That stability is exactly why spoofing it is dangerous. A real browser produces one deterministic number in a narrow, physically plausible range, and it produces pure zeros when you ask it for silence. Break either invariant and you do not blend in. You light up as a liar.

The Web Audio pipeline and the silence invariant that catches naive noise

What the classic probe actually does

The canonical audio fingerprint has not changed much since 2016. You build an OfflineAudioContext, wire an OscillatorNode into a DynamicsCompressorNode, render offline (no speakers involved), and read the resulting buffer back with AudioBuffer.getChannelData(). Tiny per-implementation differences in the compressor curve and the internal FFT/resampling math produce a float that differs between engine builds and, subtly, between hardware, yet comes back identical every time on one device.

async function audioFingerprint() {
  // 1 channel, ~5000 frames, 44.1 kHz, the shape most probes use.
  const ctx = new OfflineAudioContext(1, 5000, 44100);

  const osc = ctx.createOscillator();
  osc.type = "triangle";
  osc.frequency.value = 10000; // fixed tone

  const comp = ctx.createDynamicsCompressor();
  comp.threshold.value = -50;
  comp.knee.value = 40;
  comp.ratio.value = 12;
  comp.attack.value = 0;
  comp.release.value = 0.25;

  osc.connect(comp);
  comp.connect(ctx.destination);
  osc.start(0);

  const buffer = await ctx.startRendering();
  const data = buffer.getChannelData(0);

  // Most probes sum a slice; some scan the whole buffer.
  let sum = 0;
  for (let i = 4500; i < 5000; i++) sum += Math.abs(data[i]);
  return sum; // e.g. ~124.04, the "value"
}

Two things about the read path matter later. Probes vary in how much of the buffer they touch: BrowserLeaks sums a slice, while deviceinfo.me scans essentially the entire buffer. Its API-access log shows getChannelData hit roughly 24,000 times, i.e. the full 24k-frame render. If your noise only perturbs the tail slice, a full-buffer probe reads a different number than a slice probe, and the two disagree. There is a second wrinkle. AnalyserNode.getFloatFrequencyData() and getFloatTimeDomainData() are separate read surfaces that feed the same lie-detection machinery, so spoofing the compressor path alone leaves the analyser path telling a different story.

The three things a real audio fingerprint is

Before touching noise, be precise about what you are imitating. A genuine browser’s audio surface has three properties, and a spoof has to satisfy all three at once:

Property What it means Failure signature
Determinism Repeat renders of the same graph return byte-identical buffers A !== B on a re-run → judged fake
Realistic magnitude The summed/derived values sit in the range real devices produce Out-of-range value → judged fake
Blank invariance Silence in produces exact zeros out Non-zero in a silent buffer → judged fake

The first two are the same lesson canvas and DOMRect teach, and the failure mode is public. In controlled anti-detect browser testing, one source-modified Chromium ran audio noise per call rather than as a pure function of the seed. CreepJS caught it twice over: its audio trap value came back at 980.9 where real browsers sit near 0.9, and its unique value read 5000 against roughly 4735/4736 for browsers that were not lying. For contrast, plausible traps from other engines land at 0.93 (a BAS-based engine) and 0.003 (AdsPower). As the field notes from that testing put it:

The source-modified browser that randomized audio per call shipped a trap value of 980 where real browsers sit near 0.9. The noise wasn’t just non-deterministic, it wasn’t even the right size.

Two independent failures live in that one number. Per-call randomness means a second render disagrees with the first, which trips the “same operation must be idempotent” check directly. And a magnitude three orders out of range fails a bounds check that does not even need a second render to fire. Noise has to be a pure function of (seed, input) and bounded to what a real device emits. Neither alone is enough.

Silence is a trap and naive noise walks into it

This is the mistake that determinism alone does not save you from. The intuitive way to add audio entropy is “perturb every sample by a hash-derived epsilon.” That is deterministic, it can be well-scaled, and it still gets caught, because it corrupts data that a real browser guarantees to be exactly zero.

CreepJS’s hasFakeAudio() renders an oscillator at frequency = 0. A real engine outputs pure silence, so the entire channel is zeros. The check is blunt and effective:

// paraphrased from CreepJS lie detection
const data = renderSilentOscillator(); // frequency = 0
if ("" + [...new Set(data)] !== "0") {
  // more than one distinct value, or a non-zero value → "audio is fake"
}

new Set(data) collapses the buffer to its distinct values. For real silence that set is {0} and the string is "0". Add per-sample noise and the set explodes. Instant lie. There is a second, subtler blank check: the first ~100 rendered samples must also be zero, because they fall inside the DynamicsCompressorNode lookahead delay region where no signal has arrived yet. A naive “noise everywhere” pass corrupts those too.

The fix is the same shape as the canvas and DOMRect fixes: skip the blanks. The perturbation function tests each sample and leaves exact zeros untouched, so silence stays silent, the lookahead prefix stays zero, and only samples that were already non-zero get their deterministic nudge.

// Deterministic, blank-preserving perturbation applied ONCE in place.
function perturbAudio(data, seed) {
  for (let i = 0; i < data.length; i++) {
    const s = data[i];
    if (s === 0.0) continue;            // blank invariant: never touch a zero
    const eps = hashToUnit(seed, i);    // pure fn of (seed, i)
    data[i] = s + s * eps * 1e-7;       // scaled to the sample, tiny, bounded
  }
}

Scaling the offset relative to the sample (s * eps * 1e-7) rather than adding an absolute epsilon keeps the magnitude proportional and keeps the summed value inside the real range, so no 980.9 blowouts.

Two read paths, one backing store

There is a consistency trap that has nothing to do with magnitude. getChannelData(0) and copyFromChannel() are two APIs that read the same backing buffer. If your perturbation runs on each read path independently, the two paths can diverge, and a probe that reads both and compares flags “samples mismatch.”

The rule is to perturb the backing store once, in place, at the point the buffer is produced, not per read call. Then every read path, whichever API a probe reaches for, sees the same already-perturbed data. This is the audio instance of a general law: when you modify one signal, every alias of that signal has to move with it. Screen width has its matchMedia side channel. Audio has its second read API. Same discipline.

Clearing the orange marks without adding lies

Passing the red lie checks (lieCount = 0) is the hard gate. CreepJS also raises a softer, orange class of signal (LowerEntropy / “sent to trash”) for values that are technically honest but sit on a known, low-entropy cluster. Clearing these is secondary but worth doing, because they are visible and they invite a closer look. Two moves clear the audio ones without introducing any new lie:

  • Time-domain noise shifts the pattern key. CreepJS keys known audio patterns by the tuple [gain, freqSum, timeSum]. If your tuple matches a known entry, it then checks your perturbed sample sum against that entry’s expected list and marks you orange when it is absent. Adding deterministic time-domain noise (hooking RealtimeAnalyser::GetFloatTimeDomainData) moves timeSum, so the pattern key becomes unknown: known[pattern] is undefined and the whole known-value comparison is skipped rather than failed.
  • A per-profile compressor reduction offset breaks the diff cluster. The sum: character-level diff highlight is driven by KnownAudio[compressorGainReduction]. Applying a per-profile offset to DynamicsCompressorNode.reduction() (magnitude |offset| in [0.05, 0.2] dB) moves you off the known reduction value, so KnownAudio[reduction] is undefined and the diff renders as a plain value with no highlight.

Both are deterministic per profile and both stay in range. They do not lie about anything; they move the fingerprint off a memorized cluster so the known-value machinery has nothing to match against. This is calibrated to the 2026-07 CreepJS source. The tuple keys and the reduction-cluster logic are exactly the kind of thing that shifts when the detector is updated, so treat the specific hook points as a snapshot, not a permanent contract.

Audio is one dimension in a shared family

None of this is audio-specific in spirit. Audio, canvas, and DOMRect are the same deterministic-noise problem wearing different API surfaces, and they share one rulebook:

  • Noise is a pure function of (seed, input), so repeat calls are byte-identical.
  • Magnitude stays inside the range real devices emit.
  • Blanks stay blank: silence, clearRect regions, 0×0 rects all read as exact zeros.
  • Every alias of a modified signal moves together, so the second read path never disagrees with the first.

Internalize the family rather than the special case, because of what the comparative testing showed. The engines that scored zero lies were not the most technically sophisticated. The source-modified browser with the newest engine and the most patches was the one that shipped three lies, because it got the implementation wrong: per-call randomness and an out-of-range magnitude. Genuine browsers are, by definition, zero lies. The loud, cheap failure modes are per-call randomness and absurd magnitude, and both are entirely avoidable.

Takeaway

Treat the audio fingerprint as a value you are allowed to shift, never a value you are allowed to randomize. Make the noise a pure function of the seed and keep the summed magnitude within a real device’s range (near 0.9 for the trap, not 980). The part that catches careful spoofers: never write noise into a sample that a real browser leaves at zero. Perturb the backing store once so both read paths agree. Get those right and audio is a solved dimension. Get the blank invariant wrong and it is the first thing a detector flags.

Sources & further reading
  1. BrowserLeaks, audio (via /javascript)
  2. AudioContext fingerprint background (FingerprintJS)
  3. CreepJS (Abraham Juliot)
More field notes
← Back to field notes