AudioContext Fingerprinting
Web Audio renders sound nobody hears and hashes it. What actually varies, why buffer size beats the hash, and four readings that only look identifying.
Published · Updated
- Category
- Hardware
- API
OfflineAudioContext- Reveals
- platform audio stack, browser build
- Spoofing
- Spoofable, spoof detectable
- Since
- 2016
Audio fingerprinting is canvas fingerprinting's less famous sibling and works the same way: ask the browser to compute something in a fixed configuration, read the result back, and rely on implementation differences to make the answer vary. Nothing is played, nothing is recorded, and no permission is involved.
The panel above ran two separate measurements, because they behave very differently and are usually conflated.
The classic AudioContext fingerprint hash
OfflineAudioContext renders an audio graph as fast as the CPU allows and
hands back the samples. Build a fixed graph — canonically an oscillator through
a DynamicsCompressor — render it, and hash the output:
const ctx = new OfflineAudioContext(1, 44100, 44100);
const osc = ctx.createOscillator();
osc.type = "triangle";
osc.frequency.value = 10000;
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();The compressor earns its place: its gain computation involves far more floating-point work than a bare oscillator, so differences in how the maths is implemented have somewhere to accumulate.
But be clear about what varies. This is not reading your sound card — offline rendering never touches audio hardware at all. It varies with the browser build: the engine, the compiled DSP routines, and the CPU's floating-point behaviour. Chromium even swaps FFT implementations by platform at build time — macOS builds use Apple's Accelerate framework, other platforms use a bundled library — which is the kind of thing that separates platforms without saying anything about the machine.
So it is closer to a platform-and-build label than to a device identifier, and it is stable across sessions because nothing about it is per-session.
baseLatency is the more interesting number
Every realtime AudioContext reports baseLatency: how far ahead the browser
must render to keep the output buffer fed. Multiply by the sample rate to get
the number that matters, the callback buffer size in frames:
const ctx = new AudioContext();
const frames = Math.round(ctx.baseLatency * ctx.sampleRate);You might expect that to describe your audio device. On some platforms it does not — it is a constant chosen at compile time. macOS:
int AudioManagerMac::ChooseBufferSize(bool is_input, int sample_rate) {
// kMinAudioBufferSize is too small for the output side because
// CoreAudio can get into under-run if the renderer fails delivering data
// to the browser within the allowed time by the OS. The workaround is to
// use 256 samples as the default output buffer size for sample rates
// smaller than 96KHz.
int buffer_size =
is_input ? limits::kMinAudioBufferSize : 2 * limits::kMinAudioBufferSize;with kMinAudioBufferSize = 128 under BUILDFLAG(IS_MAC), so output is 256.
The comment is candid: this is a workaround for CoreAudio under-runs, carrying
a TODO pointing at an unresolved Web Audio spec issue. It is not a reading of
anything.
The Linux PulseAudio path has its own floor — kMinimumOutputBufferSize = 512
— arrived at independently. The panel above measured 256 frames @ 48000 Hz
on the machine this was written on, exactly what the source predicts, and
1024 under latencyHint: "playback".
Two caveats that matter more than the numbers:
- Always carry the sample rate. The frame count alone is ambiguous: a Mac running at 96 kHz produces 512, colliding with the Linux PulseAudio default. The pair distinguishes them; the frame count alone does not.
- Not every platform hardcodes it. Windows, ChromeOS and Android query the real device, so there the value genuinely does reflect hardware — which makes it a confounded reading rather than a structural constant. The signal is only "structural" where a constant is involved.
And a caveat on the whole measurement: a machine with no audio output device
at all still gets an AudioContext, because the platform substitutes a
synthetic default. The value then describes that stand-in rather than any
hardware, which is worth knowing before you read meaning into an unusual
number.
The four readings that look like signals and are not
This is the part that saves time, because each of these is reachable, stable- looking, and dead for a different reason.
| Reading | Why it fails |
|---|---|
sampleRate |
Effectively always 48000. Where it does vary it is tracking the audio device, not the browser — a hardware confound with almost no spread. |
destination.maxChannelCount |
Pure hardware. It describes the speakers or headphones plugged in, which changes when the user plugs something in. |
outputLatency |
Quantized to 8 ms buckets without microphone permission — and reads 0 without it, as the panel shows. What you are measuring is the permission state, not the platform. |
getOutputTimestamp() |
Gesture-gated and continuously moving. Not an identifier at all. |
The pattern is worth naming: three of these fail because they measure the user's equipment or choices rather than the software, and equipment changes while the browser does not. A signal that moves when someone plugs in headphones was never identifying them.
outputLatency fails in the more interesting way — it does not tell you about
the platform, it tells you whether the page has microphone permission. That is
a real fact, just not the one it appears to offer.
How much any of this narrows you down depends on the population you are compared against, which this page cannot see. What it can tell you is which of the two measurements is a property of your software and which of your equipment.
Spoofing
Brave and Firefox's resistFingerprinting perturb Web Audio output the same
way they perturb canvas, and the consequence is the same: two renders of an
identical graph stop agreeing. The panel renders twice for exactly that
reason, and reports whether they matched. A mismatch does not mean your audio
is unusual; it means something is deliberately adding noise, which is itself a
narrowing observation — the same trade documented in
canvas.
Tor Browser takes the other route and makes everyone identical rather than everyone different.
The baseLatency value is harder to fake convincingly than the hash, because
it is not free-floating: it has to be consistent with the sample rate, with the
latencyHint you asked for, and with the platform the rest of the browser
claims to be. A Mac reporting 512 frames at 48 kHz has contradicted its own
source code.
References
- Chromium, public source:
media/audio/mac/audio_manager_mac.cc(ChooseBufferSize),media/base/limits.h(kMinAudioBufferSize),media/audio/pulse/audio_manager_pulse.cc(kMinimumOutputBufferSize). All quoted code is from that tree. - MDN:
OfflineAudioContext,AudioContext.baseLatency,DynamicsCompressorNode. - WebAudio/web-audio-api#348 — the user-defined buffer size issue the macOS workaround comment points at.
How the probe works
This is the exact source that ran in the panel above — no summary, no drift.
// Two different audio signals, deliberately kept apart.
//
// 1. The classic OfflineAudioContext hash — render a fixed graph and hash the
// samples. Rendered twice, because a defence that perturbs the output shows
// up as instability rather than as a different value.
// 2. baseLatency — the audio callback buffer size, which on some platforms is
// a compile-time constant rather than a property of your sound hardware.
//
// Plus the readings that look like signals and are not; see the entry text.
import { sha256Hex } from "../fp.js";
// A fixed graph: an oscillator through a compressor. The compressor matters —
// its curve involves more floating-point work than a bare oscillator, so
// implementation differences have somewhere to show up.
async function renderHash() {
const OfflineCtor = window.OfflineAudioContext || window.webkitOfflineAudioContext;
if (!OfflineCtor) throw new Error("OfflineAudioContext unavailable");
const ctx = new OfflineCtor(1, 44100, 44100);
const osc = ctx.createOscillator();
osc.type = "triangle";
osc.frequency.value = 10000;
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);
// Sum a slice well past the attack transient, at full precision.
let sum = 0;
for (let i = 4500; i < 5000; i++) sum += Math.abs(data[i]);
return { hash: await sha256Hex(String(sum)), sum };
}
// baseLatency is reported in seconds; frames is the number the platform
// actually chose, and only means something alongside the sample rate.
async function latencyOf(hint) {
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return null;
let ctx;
try {
ctx = hint ? new Ctor({ latencyHint: hint }) : new Ctor();
if (typeof ctx.baseLatency !== "number") return null;
return {
frames: Math.round(ctx.baseLatency * ctx.sampleRate),
sampleRate: ctx.sampleRate,
outputLatency: typeof ctx.outputLatency === "number" ? ctx.outputLatency : null,
maxChannelCount: ctx.destination.maxChannelCount,
};
} catch {
return null;
} finally {
if (ctx && ctx.state !== "closed") ctx.close().catch(() => {});
}
}
export async function probe() {
const first = await renderHash();
const second = await renderHash();
const stable = first.hash === second.hash;
const def = await latencyOf(null);
const playback = await latencyOf("playback");
const value = {
"Offline render hash (SHA-256)": first.hash,
"Identical across two renders": stable,
};
if (def) {
value["baseLatency (default)"] = `${def.frames} frames @ ${def.sampleRate} Hz`;
value["baseLatency (latencyHint: playback)"] = playback
? `${playback.frames} frames @ ${playback.sampleRate} Hz`
: "unavailable";
value["sampleRate"] = `${def.sampleRate} (hardware — see below)`;
value["destination.maxChannelCount"] = `${def.maxChannelCount} (hardware — see below)`;
value["outputLatency"] =
def.outputLatency === 0
? "0 (quantized to zero without microphone permission)"
: String(def.outputLatency);
} else {
value["AudioContext"] = "unavailable — realtime audio is blocked here";
}
return {
facts: def ? { "Audio buffer": `${def.frames} frames @ ${def.sampleRate} Hz` } : {},
value,
label: def
? `${def.frames}@${def.sampleRate} · ${first.hash.slice(0, 10)}…${stable ? "" : " noisy"}`
: `${first.hash.slice(0, 12)}…${stable ? "" : " noisy"}`,
};
}