← Back to all posts

The Math Behind the Audio Fingerprint

The audio fingerprint stopped separating browsers and now separates CPUs: x86 and Apple Silicon build the oscillator wavetable with different FFTs. Why per-session noise cannot hide a 1-to-22-ULP gap, and what reproducing Apple's vDSP FFT on x86 down to a double-precision base case actually involves.

Summarize this article with

The audio fingerprint is one of the most durable signals in the browser. It does not touch your microphone. It synthesizes a sound, runs it through the Web Audio graph, and hashes the samples that come out. The hash is stable across sessions and incognito, and for years it varied by browser and OS. Fingerprint’s writeup is a good primer on the technique: fingerprint.com/blog/audio-fingerprinting. This post is about where the entropy lives today, which is math, and what it takes to reproduce it exactly.

The classic probe

The standard fingerprint renders a triangle wave through a compressor offline and sums the samples:

const ctx = new OfflineAudioContext(1, 5000, 44100);
const osc = ctx.createOscillator();
osc.type = 'triangle';
osc.frequency.value = 1000;

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

osc.connect(comp); comp.connect(ctx.destination); osc.start();
ctx.startRendering().then(buf => {
  const s = buf.getChannelData(0);
  let hash = 0;
  for (let i = 0; i < s.length; i++) hash += Math.abs(s[i]);
});

There is no audio hardware in the path. OfflineAudioContext1 runs on the CPU, so the output is a pure function of the arithmetic the browser performs. That is why it is stable, and it is why it fingerprints.

It used to split browsers. Now it splits CPUs.

The old measurements showed the signal separating engines: Chrome on macOS around 101.46, Safari 79.59, Firefox 80.95. Those gaps came from three separate Web Audio implementations drifting apart after a shared origin.

That has largely collapsed. The implementations converged, and on a given CPU the value no longer tells you the browser or the OS apart. What it still exposes is the CPU architecture. On x86, the audio comes out byte-identical no matter what OS the browser claims:

x86 (Linux persona, macOS persona, genuine Linux) : 258.09814036649186   // oscillator sum
Apple Silicon                                      : 258.09813991968986

Same number on every x86 configuration, a different number on ARM. The audio fingerprint became an architecture signature: ARM versus x86, roughly 4e-7 apart, spread as 1 to 22 float32 ULP2 across the buffer. Small, stable, and exactly what an exact-match hash keys on. For a browser that claims to be an Apple Silicon Mac while running on an x86 server, that gap is the tell.

Where the number comes from

The sum has two math stages, and only one of them carries the architecture split.

The DynamicsCompressor3 applies a gain curve per sample. Its knee and makeup gain call exp and sin; its decibel conversions call log10f (20 * log10f(x)) and powf (powf(10, x/20)). These are scalar calls into the platform math library, so they differ by an OS, Apple libsystem_m versus glibc versus UCRT. That is a per-OS layer, and it is fully reproducible with bit-exact libm reconstructions. It is not where the architecture split lives.

The oscillator is where the architecture leaks. A triangle wave is not generated sample by sample. Blink builds a band-limited wavetable up front, once, and it builds it with a Fast Fourier Transform. The call chain is PeriodicWaveImpl::CreateBandLimitedTables to FFTFrame, and FFTFrame is backed by a different FFT4 per platform:

  • Apple Silicon: the Accelerate framework, vDSP_fft_zrip5 (fft_frame_mac.cc)
  • x86: the pffft6 library (fft_frame_pffft.cc)

Two FFTs, two sets of bits, a slightly different wavetable, a slightly different oscillator output, a different sum. Accelerate is not just the FFT here. On Mac, Chrome routes the whole Web Audio DSP through it behind BUILDFLAG(IS_MAC): the FFT (fft_frame_mac.cc), the vector math (vDSP_vadd, vDSP_vmul, vDSP_vsmul in vector_math_mac.h), and the biquad filters (biquad.cc). Accelerate is the audio math framework on Mac. The FFT is the stage that turns that into a fingerprint.

Proving it is the FFT

The difference is a handful of ULP, so it is worth ruling out the alternatives before spending days on a reproduction. Each of these was checked directly.

Not profile noise. Every x86 configuration produces the identical oscillator sum above. If any injected randomness were involved, they would not match to the bit.

Not the vector stages. Accelerate does the vector math too, so the intuitive guess is that the ARM vector ops diverge from x86. They do not. The linear operations, vDSP_vadd, vDSP_vmul, vDSP_vsmul, are single correctly-rounded IEEE ops per element, and a correctly-rounded add or multiply is the same on any implementation, vDSP or SSE. And Chromium builds with -ffp-contract=off, so a multiply-add stays a separate fmul then fadd on both architectures, which means FMA7 availability does not explain a difference either. You cannot “fix” x86 by turning on FMA. That would make it wrong. What is left is the one stage with internal accumulation and twiddles: the FFT.

Not the compressor. Remove the DynamicsCompressor from the graph entirely and the oscillator alone still differs, 258.09813991968986 on ARM versus 258.09814036649186 on x86. The sin and exp in the compressor are not the source.

What is left is the wavetable FFT. vDSP_fft_zrip on Apple Silicon, pffft on x86.

Why noise does not work

The obvious defense is to perturb the samples. Browsers have tried. Brave multiplies by a per-session “farbling”8 factor. Safari 17 injects random noise in private mode. Fingerprint’s answer, in their article, was to take several samples and average or round, which recovers the underlying value to within 0.0000002%. Noise a fingerprinter can average away is not protection.

We learned the same lesson from the other direction. An earlier pipeline added multiplicative audio noise of 0.0001 to 0.0035. The real ARM-versus-x86 variation is 1 to 22 ULP, on the order of 1e-7. The noise was 100 to 1000 times larger than the signal it meant to hide. It matched no real machine, and its magnitude alone was a tell. Faking a bit-exact hardware output with randomness is a category error. You have to produce the real bits.

Reproducing Apple’s FFT

Reproducing vDSP_fft_zrip on x86 is a disassembly project. We pulled the routine out of libvDSP.dylib and worked out its shape, and it is not a textbook FFT.

It is a radix-49 decomposition in float, recursing down to small base cases. Two details make it hard to match.

First, each base case is a direct DFT accumulated in double precision, then rounded to float. A float-only reimplementation cannot reproduce it, because the intermediate sums round differently:

// vDSP base-case DFT: accumulate in double, store float.
// x = x[n]; twiddle w = W^(nk) promoted to double.
for (int k = 0; k < m; k++) {
  double acc_r = 0.0, acc_i = 0.0;
  for (int n = 0; n < m; n++) {
    double wc = tw_cos[(n * k) % m];      // from vDSP's own FFTSetup table
    double ws = tw_sin[(n * k) % m];      // (= -sin), not recomputed
    double xr = zr[n], xi = zi[n];
    acc_r += xr * wc - xi * ws;
    acc_i += xr * ws + xi * wc;
  }
  Zr[k] = (float)acc_r;
  Zi[k] = (float)acc_i;
}

Second, the twiddle factors10 are not idealized. vDSP_create_fftsetup precomputes them into an opaque FFTSetup structure with its own rounding, so its stored cos(pi/2) is 4.37e-8, not 0. Compute the twiddles yourself with cosf/sinf and even the tiny sizes come out wrong. You have to dump Apple’s table and index it exactly, per stage, as the triples (W^k, W^2k, W^3k) the radix-4 butterflies consume.

The larger butterflies fuse in specific positions (fmla, fmls, fmadd), so the reproduction has to place fma() in the same positions and compile -ffp-contract=off so nothing else fuses. And the transform has conventions to match: vDSP_fft_zrip forward returns twice the true DFT, packs DC in realp[0] and Nyquist in imagp[0], and Blink corrects the scale with a Vsmul(0.5). All of it has to line up for the wavetable to come out identical.

Validation

The only reference that counts is the genuine device. On a real Apple Silicon Mac we run vDSP_fft_zrip forward across the sizes Web Audio uses (log2n 8 through 12), and dump every output bin as float32 hex. That file is the bit-exact target, and the x86 reproduction has to match it bin for bin, at every size, on impulse, ramp, and random inputs. A delta input is the useful one: with x[k] = 1, the output equals twice the twiddle factor W^(mk), which reads Apple’s exact twiddle rounding straight out of the transform, including that 4.37e-8 where an idealized library would print 0.

Why it matters

CPU architecture is one of the hardest things to fake, because it is not a string you set. It is the behavior of the silicon. The audio fingerprint distills that behavior into one stable number, and after years of browsers converging, the architecture is the axis that remains. A profile claiming Apple Silicon has to build its oscillator with Apple’s FFT, down to a double-precision accumulation inside a base case most people never knew was there, gated on the CPU the profile claims. Noise cannot get you there. Only the real arithmetic can.

Scrapium is Scrapfly’s scraping browser, built to stay indistinguishable from real traffic. Our engineering blog goes deep on the rest of the stack.


  1. OfflineAudioContext: a Web Audio context that renders to a buffer on the CPU instead of playing to speakers, so the output is a pure function of arithmetic and therefore stable and fingerprintable. Reference: MDN↩︎

  2. ULP (unit in the last place): the smallest representable gap between two floats at a given magnitude. The ARM-versus-x86 audio gap is 1 to 22 float32 ULP. Reference: Wikipedia↩︎

  3. DynamicsCompressor: the Web Audio node whose gain curve calls exp, sin, log10f, and powf. This is the per-OS libm layer of the fingerprint, not the architecture split. Reference: MDN↩︎

  4. FFT (fast Fourier transform): the algorithm Blink uses to build the oscillator’s band-limited wavetable. The platform’s FFT is where the architecture split enters. Reference: Wikipedia↩︎

  5. vDSP / vDSP_fft_zrip: Apple Accelerate’s FFT routine, used on Apple Silicon. Its bits differ from the x86 library. Reference: Apple Developer↩︎

  6. pffft: a small, portable FFT library Chromium uses on x86. Reference: pffft↩︎

  7. FMA (fused multiply-add): a*b+c with a single rounding. Chromium builds -ffp-contract=off, so ARM’s vmlaq_f32 is not fused and matches x86, which rules out vector FMA as the source of the split. Reference: Wikipedia↩︎

  8. farbling: Brave’s defense of perturbing fingerprintable outputs with a per-session factor. It is recoverable by averaging, which is why it does not hold against an exact-match hash. Reference: Brave↩︎

  9. radix-4: an FFT decomposition that processes four points per butterfly. vDSP_fft_zrip recurses through it down to small base cases. Reference: Cooley-Tukey FFT↩︎

  10. twiddle factors: the complex roots-of-unity coefficients (W^k) an FFT multiplies by at each stage. Apple precomputes them with its own rounding, so they must be dumped and indexed exactly, not recomputed. Reference: Wikipedia↩︎