AudioBuffer.getChannelData() does not hand a page a copy of the samples. It hands back a Float32Array over the buffer's own memory. Our audio fingerprint noise multiplied every 149th sample of that array in place. So every read changed the real audio again, and the noise compounded: a sample came back as v·f on the first read and v·f² on the second. A page that reads the same buffer twice and compares the two caught it at once.

The bug was in a JavaScript fallback written for the days when Mosaic could run a profile on a stock Chromium. Mosaic now launches profiles only on Tesseract Chrome, our own engine, where audio values are changed in C++ and no script touches them. But the fallback was ours too, and the way it broke applies to any noise you put on a surface a page can read more than once.

How a getChannelData fingerprint is taken

An audio fingerprint needs no microphone and makes no sound. The page builds an OfflineAudioContext, runs an oscillator through a compressor, renders the result into an AudioBuffer, and reads the samples with getChannelData(0). Then it sums or hashes them. The floating-point maths of the audio stack differs slightly between machines and builds, so the number differs too, and it stays put on one machine.

Noise is meant to move that number per profile, so two profiles on one computer do not share it. The question is how you move it.

What multiplying in place did

The first version of our noise took the array getChannelData returned and multiplied every 149th sample by a small drift factor. It looked harmless, because the change was far below anything you could hear.

But the array is the buffer. The multiplication went into the page's own audio and stayed there. Read the buffer again, and the noise was applied again, to samples that already carried it:

read 1   v · f
read 2   v · f · f
read 3   v · f · f · f

Pixelscan's audio check reads the same surface twice and compares the results, and this was the one surface in the script that failed it. Measured on four fresh profiles on one machine, with nothing changed between them except the seed, the check came back consistent, inconsistent, consistent, inconsistent. A real device never reads two different values from one buffer. So the noise was worse than useless: it produced a reading no real browser produces.

Canvas noise had already been through this fix: stamp the pixels, never scale them. Audio was the one surface that rule had never reached.

Every profile is drawn from one real computer, not from separate listsIn Mosaic: Machines that exist. GPU, screen, CPU and fonts from one real device. Free holds 3 profiles. Pro is $49 a month for any number.

How it works: Machines that exist

The fix: set, don't scale

The rule from canvas carried over word for word: perturb by assignment, keyed on the sample's position rather than on the call. For each sample we stamp, the script now sets the float's lowest mantissa byte to a value drawn from the profile's seed. The floats are little-endian, so that byte is the first of each four, and a Uint8Array over the same memory reaches it without disturbing anything else.

Assignment is what makes it idempotent. The second read finds the byte already at its seeded value and writes the same value again. Measured after the change:

read 1            495.401644082944
read 2            the same
read 3            the same
copyFromChannel   the same

The first version of the stamp set a single bit, not a byte. That was too small. Across six profiles the audio fingerprint came out only 3 distinct values, because a one-bit change on a sample sits below the resolution of a hash taken over a rounded sum, so profiles collided. A whole low byte moves the sample by about 1e-5 of its value. That is about what the old multiplication moved it by, and still far below hearing.

Two routes to one surface

getChannelData is not the only way to read samples. copyFromChannel copies them into an array the page owns, and before the fix it returned the raw buffer while getChannelData returned a stamped one. A page calling both would have seen two different sounds from one buffer, the same contradiction as reading twice.

So copyFromChannel now applies the same stamp to the copy, at the same positions, offset by where the copy starts. Because the stamp is idempotent, it cannot disagree with the original however the two calls are interleaved. Canvas taught us this half too: our noise once reached toDataURL and missed toBlob, which is in the canvas method nobody noised.

Why it still writes into the buffer

The obvious alternative is to return a stamped copy and leave the buffer alone. We didn't, on purpose. getChannelData is also how a page writes audio: a synthesiser fills the array it gets back and plays the buffer. Return a copy and everything the page wrote is silently thrown away. Changing one low byte of every 149th sample, which nobody can hear, costs less than breaking every page that makes sound.

The question that finds this class of bug

The useful test was never "does the noise change the fingerprint". It was "does it change it twice". The same test found our geolocation answering the first call and timing out on every one after it, which is in geolocation that answered once. A first read is what every manual test does, and the second read is where the bug was.

It is also part of why Mosaic's engine moves values in C++ rather than in page script. There the browser computes the value itself and every route out of a surface reads the same thing. The case for that, and what a script cannot reach, is in why we change fingerprints in C++. How a page builds a fingerprint from canvas reads, the cousin of this one, is in canvas fingerprinting.