The file header says 24-bit/96 kHz, but that only describes the container. If you want to know what is actually in the file, you have to look at the spectrum. I built a browser tool that does this for The Audio Stuff, the FLAC & Hi-Res Audio Checker. Here is how to build the core of one with the Web Audio API and a plain JavaScript FFT, with no server and no libraries.
1. Don't let decodeAudioData resample your file
This is the trap that catches almost everyone. decodeAudioData() always resamples the decoded audio to the sample rate of the context you call it on. A default AudioContext usually runs at 44.1 or 48 kHz, so a genuine 96 kHz file comes back at 48 kHz, and everything above 24 kHz has been filtered away before your analysis starts.
The fix is to read the real sample rate from the file header first, then decode with an OfflineAudioContext created at that rate.
function readSampleRate(bytes) {
const v = new DataView(bytes);
const tag = String.fromCharCode(...new Uint8Array(bytes, 0, 4));
if (tag === "fLaC") {
// STREAMINFO: rate is 20 bits starting at byte 18 of the file
const b = new Uint8Array(bytes, 18, 3);
return (b[0] << 12) | (b[1] << 4) | (b[2] >> 4);
}
if (tag === "RIFF") {
// walk the chunks until we find "fmt "
let p = 12;
while (p + 8 <= v.byteLength) {
const id = String.fromCharCode(...new Uint8Array(bytes, p, 4));
const size = v.getUint32(p + 4, true);
if (id === "fmt ") return v.getUint32(p + 12, true);
p += 8 + size + (size & 1);
}
}
return null; // MP3, AAC and others: fall back to a default context
}
async function decodeNative(file) {
const bytes = await file.arrayBuffer();
const rate = readSampleRate(bytes) ?? 48000;
let ctx;
try {
ctx = new OfflineAudioContext(1, 1, rate);
} catch {
ctx = new OfflineAudioContext(1, 1, 48000); // rate not supported here
}
return ctx.decodeAudioData(bytes.slice(0)); // slice: decode detaches the buffer
}
The Web Audio spec only guarantees sample rates from 8 kHz to 96 kHz. Browsers differ above that, so keep the try/catch, and tell the user when you had to fall back.
2. A small radix-2 FFT
An AnalyserNode is built for real-time display, not for walking through a whole file offline. For analysis, a plain iterative FFT is simpler and fast enough.
function fft(re, im) {
const n = re.length;
for (let i = 1, j = 0; i < n; i++) { // bit reversal
let bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) {
[re[i], re[j]] = [re[j], re[i]];
[im[i], im[j]] = [im[j], im[i]];
}
}
for (let len = 2; len <= n; len <<= 1) { // butterflies
const ang = (-2 * Math.PI) / len;
for (let i = 0; i < n; i += len) {
for (let k = 0; k < len / 2; k++) {
const wr = Math.cos(ang * k), wi = Math.sin(ang * k);
const a = i + k, b = a + len / 2;
const tr = re[b] * wr - im[b] * wi;
const ti = re[b] * wi + im[b] * wr;
re[b] = re[a] - tr; im[b] = im[a] - ti;
re[a] += tr; im[a] += ti;
}
}
}
}
Precompute the twiddle factors if you care about speed. For a few hundred frames it hardly matters.
3. Peak-hold across the whole file
A single FFT frame tells you about one moment. A cymbal crash two minutes in might be the only thing with energy above 22 kHz. So take a frame every second of audio, and for each frequency bin keep the loudest value you have seen. This is a peak-hold spectrum.
const N = 32768;
const hann = Float32Array.from({ length: N },
(_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (N - 1)));
function peakHold(buffer) {
const peak = new Float32Array(N / 2).fill(-Infinity);
const re = new Float32Array(N), im = new Float32Array(N);
const hop = buffer.sampleRate; // one frame per second
for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
const x = buffer.getChannelData(ch);
for (let start = 0; start + N <= x.length; start += hop) {
for (let i = 0; i < N; i++) { re[i] = x[start + i] * hann[i]; im[i] = 0; }
fft(re, im);
for (let k = 1; k < N / 2; k++) {
const mag = Math.hypot(re[k], im[k]);
const dbfs = 20 * Math.log10((4 * mag) / N + 1e-12);
if (dbfs > peak[k]) peak[k] = dbfs;
}
}
}
return peak; // bin k is k * sampleRate / N Hz
}
The 4 / N scaling makes a full-scale sine read about 0 dBFS. The one-sided spectrum contributes a factor of 2, and the Hann window halves the amplitude of a tone, so together they give 2 / (N × 0.5). A 32k frame gives about 2.9 Hz resolution at 96 kHz, which is plenty for finding a cutoff.
Move this into a Web Worker. Even simple loops over millions of samples will freeze the page otherwise.
4. Turn the spectrum into a number
People want to read one number, such as "content reaches 21 kHz". A robust way to get one is to group the bins into 1 kHz bands and find the highest band where most of the bins clear a noise floor, for example −90 dBFS. Requiring most of the band, rather than any single bin, stops one stray spike from counting.
function contentReaches(peak, rate, floor = -90) {
const binHz = rate / N;
let top = 0;
for (let f = 0; f + 1000 <= rate / 2; f += 1000) {
const k0 = Math.ceil(f / binHz), k1 = Math.floor((f + 1000) / binHz);
let above = 0;
for (let k = k0; k < k1; k++) if (peak[k] > floor) above++;
if (above > (k1 - k0) / 2) top = f + 1000;
}
return top; // Hz
}
5. Is the top a slope or a wall?
Where the content stops matters less than how it stops. Microphones and instruments roll off gradually. A resampler's anti-aliasing filter, or a lossy codec's lowpass, drops like a cliff. A simple test: compare the band levels just below and just above the top. If the level falls by 25 dB or more within 1 kHz, it is a wall. No acoustic roll-off is that steep.
Rough patterns you will see:
- A wall just under 22.05 kHz in a 96 kHz file: most likely upsampled from a 44.1 kHz source.
- A wall somewhere around 16 to 20 kHz: the lowpass of a lossy codec such as MP3 or AAC, even if the file is now FLAC.
- Energy tapering off gradually above 22 kHz: real ultrasonic content.
6. Be honest about what it can't prove
A spectrum is evidence, not proof. A native 96 kHz recording made with microphones or mastering that stop short of 22 kHz shows no ultrasonic content either, and a steep digital filter applied in mastering leaves the same wall that upsampling does. So word your results as evidence, for example "ultrasonic content found" or "lower-rate source likely". Avoid "genuine" and "fake".
It is also worth saying, since people will ask, that real content above 22 kHz does not automatically make a file sound better than a well-made 16-bit/44.1 kHz one.
Try it
The finished checker is free and runs entirely in the browser, with demo files for a native 96 kHz recording and an upsampled one: FLAC & Hi-Res Audio Checker. The other free audio tools I've made, including a tone generator, an ABX blind test and a set of calculators, are at theaudiostuff.com/tools/.
Jakub Charkiewicz, The Audio Stuff
Top comments (0)