← dictionary
Hardware

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.

Category
Hardware
API
OfflineAudioContext
Reveals
platform audio stack, browser build
Spoofing
Spoofable, spoof detectable
Since
2016
Liverunning the probe…

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:

JavaScript
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:

JavaScript
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:

cpp
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

How the probe works

This is the exact source that ran in the panel above — no summary, no drift.

JavaScript
// 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"}`,
  };
}