DEV Community

Jason Y. (dev_in_the_fog)
Jason Y. (dev_in_the_fog)

Posted on

Why Clean 48kHz Voice Clips Look Like 128kbps MP3s to Naive FFTs (And How to Fix It in WebAudio)

Imagine you are building an automated audio QA pipeline for a game studio. You want to catch fake "lossless" WAV files—assets where someone took a 128 kbps MP3 from a stock site, re-saved it as a 48.0 kHz · 16-bit PCM WAV, and committed it to the repository.

The textbook approach seems obvious: run a Fast-Fourier Transform (FFT) across the audio buffer, average the magnitude spectrum across all frames, and scan downward from the Nyquist frequency (24.0 kHz) until the energy rises above your noise floor threshold (say, -75 dBFS). If the energy dies out below 16.5 kHz, flag it as a 128 kbps MP3 transcode.

It works great on mastered synthwave tracks. Then a voice actor uploads a studio-clean, uncompressed 48.0 kHz · 16-bit PCM dialogue line—and your analyzer immediately flags it as:

❌ High-Frequency Roll-Off Detected — Brickwall cutoff at 2.0 kHz (~128 kbps MP3 transcode)

Wait—why did a pristine 48 kHz voice recording get flagged as a low-bitrate MP3 with a 2.0 kHz cutoff?

Here is the DSP postmortem of why time-averaged linear FFTs fail catastrophically on speech and game sound effects (SFX), and the exact math we used to fix it in pure TypeScript and WebAudio.


1. Why Time-Averaged FFTs Misclassify Human Speech & Game SFX

Mastered music is broadband and dense: cymbals, hi-hats, and stereo reverbs keep high-frequency bins (10–20 kHz) energized across nearly the entire track.

Dialogue and game SFX behave completely differently:

  1. Vowel Energy Is Concentrated Below 3.5 kHz: A male or female voice has a fundamental frequency ($F_0$) between 85 Hz and 300 Hz, and its primary vocal tract resonances (Formants $F_1, F_2, F_3$) sit between 300 Hz and 3,400 Hz. Above 4 kHz, natural glottal roll-off drops at roughly -12 dB/octave.
  2. High-Frequency Consonants Are Sparse Transients: Unvoiced fricatives and sibilance (s, sh, f, t) do contain energy up to 12–18 kHz, but they only last 40–80 ms—less than 6% to 8% of the frames in a spoken phrase.
  3. Temporal Averaging Erases Transient Highs: When you compute the mean dB across all STFT frames, those brief 8 kHz–16 kHz consonant bursts are averaged with 92% of voiced/quiet frames sitting at -95 dBFS. The mean spectrum plunges below your -75 dBFS detection floor right around 2.0–3.5 kHz.
┌─────────────────────────────────────────────────────────────────────────┐
│             Natural Voice Roll-off vs. Codec Brickwall Cliff            │
├──────────────────────────┬──────────────────────┬───────────────────────┤
│ Metric                   │ Clean 48kHz Voice    │ 128kbps MP3 Transcode │
├──────────────────────────┼──────────────────────┼───────────────────────┤
│ Mean Spectrum > 4 kHz    │ -82 dBFS (Sparse!)   │ -64 dBFS (Music)      │
│ 98th Percentile > 10 kHz │ -48 dBFS (Consonants)│ -98 dBFS (Dead)       │
│ Roll-off Slope           │ ~15–28 dB / octave   │ >= 60 dB / octave     │
│ Cutoff Band              │ Gradual (2–14 kHz)   │ Sharp (15.5–16.5 kHz) │
└──────────────────────────┴──────────────────────┴───────────────────────┘
Enter fullscreen mode Exit fullscreen mode

2. Fix #1: 98th-Percentile Peak Envelope ($E_{98}$) + Brickwall Slope ($ ext{dB/octave}$)

To distinguish a genuine lossy codec low-pass filter from natural acoustic roll-off, you need two mathematical invariants:

  1. Use the 98th-Percentile Active-Frame Envelope ($E_{98}$), Not the Mean: First, gate out silent frames (RMS < -60 dBFS). Across the remaining active frames, compute the 98th percentile magnitude in each frequency bin. If a voice clip has even a single crisp "s" or breath transient reaching 17 kHz, $E_{98}$ captures it immediately.
  2. Measure Cliff Steepness ($ ext{dB/octave}$) Inside Codec Cutoff Bands: Lossy encoders (LAME MP3, AAC, Opus) don't roll off gently at 2 kHz. They apply a polyphase filterbank cliff inside specific codec bands (14.5 kHz – 20.5 kHz) with a steepness of $ge 60 ext{ dB/octave}$:
/**
 * Evaluates whether a candidate cutoff frequency is a genuine codec brickwall
 * filter (e.g., 128/192 kbps MP3) vs. natural vocal or SFX roll-off.
 */
export function detectCodecBrickwall(
  e98Db: Float32Array,
  sampleRate: number,
  fftSize: number
): { isTranscoded: boolean; cutoffHz: number; slopeDbPerOctave: number } {
  const binHz = sampleRate / fftSize;
  const nyquist = sampleRate / 2;

  // Lossy codecs place low-pass filters between 14.5 kHz and 20.5 kHz
  const minCodecBin = Math.floor(14500 / binHz);
  const maxCodecBin = Math.min(e98Db.length - 4, Math.ceil(20500 / binHz));

  let steepestDrop = 0;
  let detectedCutoffHz = nyquist;

  for (let b = minCodecBin; b <= maxCodecBin; b++) {
    const fLow = b * binHz;
    // Measure energy drop across a narrow 1.15x frequency transition band (~0.2 octaves)
    const bHigh = Math.min(e98Db.length - 1, Math.round((fLow * 1.15) / binHz));
    if (bHigh <= b) continue;

    // Compare local passband average vs. stopband average above the cliff
    const passbandDb = (e98Db[b - 2] + e98Db[b - 1] + e98Db[b]) / 3;
    const stopbandDb = (e98Db[bHigh] + e98Db[Math.min(e98Db.length - 1, bHigh + 1)]) / 2;

    const dropDb = passbandDb - stopbandDb;
    const octaves = Math.log2((bHigh * binHz) / fLow);
    const slope = octaves > 0 ? dropDb / octaves : 0;

    // Require a genuine cliff (>= 60 dB/octave) dropping into the noise floor (< -78 dBFS)
    if (dropDb >= 18 && slope >= 60 && stopbandDb < -78 && slope > steepestDrop) {
      steepestDrop = slope;
      detectedCutoffHz = fLow;
    }
  }

  return {
    isTranscoded: steepestDrop >= 60,
    cutoffHz: Math.round(detectedCutoffHz),
    slopeDbPerOctave: Math.round(steepestDrop * 10) / 10,
  };
}
Enter fullscreen mode Exit fullscreen mode

With this dual gate, a clean 48 kHz voice line or bass-heavy explosion SFX with natural roll-off (18–25 dB/octave) is never falsely accused of being a 128 kbps MP3, while actual lossy transcodes are caught within ±300 Hz.


3. Fix #2: Mel-Scale Spectrograms & Sub-Sample YIN Pitch ($F_0$)

When inspecting dialogue or tonal game SFX on a standard linear spectrogram (0 Hz – 24,000 Hz), the entire human vocal range (80 Hz – 3,500 Hz) is squashed into the bottom 14% of the canvas—leaving the top 86% nearly blank.

Mapping the vertical canvas axis to the perceptual Mel scale expands the 50 Hz – 8 kHz vocal and SFX band across 75% of the viewport:

$$m(f) = 2595 cdot log_{10}left(1 + rac{f}{700}
ight), quad f(m) = 700 cdot left(10^{m / 2595} - 1
ight)$$

And for voice actors checking pitch consistency or sound designers tuning tonal assets, FFT bin resolution at low frequencies is too coarse (48000 / 2048 = 23.4 Hz per bin—wider than a musical semitone at C3!). Instead, use time-domain YIN Cumulative Mean Normalized Difference ($d'( au)$) with parabolic interpolation for sub-Hz $F_0$ accuracy:

export function estimateYinPitchHz(frame: Float32Array, sampleRate: number): number | null {
  const halfLen = frame.length >> 1;
  const minTau = Math.floor(sampleRate / 1000); // Max 1000 Hz
  const maxTau = Math.min(halfLen - 1, Math.ceil(sampleRate / 55)); // Min 55 Hz (A1)

  const diff = new Float32Array(maxTau + 1);
  const cmnd = new Float32Array(maxTau + 1);
  cmnd[0] = 1;

  let runningSum = 0;
  for (let tau = 1; tau <= maxTau; tau++) {
    let sum = 0;
    for (let i = 0; i < halfLen; i++) {
      const delta = frame[i] - frame[i + tau];
      sum += delta * delta;
    }
    diff[tau] = sum;
    runningSum += sum;
    cmnd[tau] = runningSum > 0 ? (diff[tau] * tau) / runningSum : 1;
  }

  // Find first dip below threshold (0.15)
  let bestTau = -1;
  for (let tau = minTau; tau <= maxTau; tau++) {
    if (cmnd[tau] < 0.15) {
      while (tau + 1 <= maxTau && cmnd[tau + 1] < cmnd[tau]) tau++;
      bestTau = tau;
      break;
    }
  }
  if (bestTau === -1) return null;

  // Parabolic interpolation for sub-sample precision
  const y1 = cmnd[Math.max(1, bestTau - 1)];
  const y2 = cmnd[bestTau];
  const y3 = cmnd[Math.min(maxTau, bestTau + 1)];
  const denom = y1 - 2 * y2 + y3;
  const refinedTau = denom !== 0 ? bestTau + (y1 - y3) / (2 * denom) : bestTau;

  return sampleRate / refinedTau;
}
Enter fullscreen mode Exit fullscreen mode

4. Fix #3: Click-Free Game Audio Loops (Zero-Crossing + Equal-Power Crossfade)

Another classic game dev headache: you trim an ambient wind or engine loop, set audioSource.loop = true in Unity, Unreal, or Godot, and hear a sharp pop/click every time the buffer wraps from the end back to sample 0.

Two things cause this:

  1. Non-Zero Boundary Discontinuity: The last sample ends at +0.42 while sample 0 starts at -0.18, creating an instantaneous step function (infinite high-frequency transient).
  2. Linear Crossfade Dip: If you overlap the tail and head using a linear fade (1 - t and t), uncorrelated ambient signals suffer a -3 dB volume dip in the middle of the crossfade because $(0.5)^2 + (0.5)^2 = 0.5$ power.

Instead, snap trim handles to rising zero-crossings and apply an Equal-Power ($cos / sin$) Crossfade where $cos^2( heta) + sin^2( heta) = 1.0$:

export function applyEqualPowerLoopCrossfade(
  samples: Float32Array,
  crossfadeSamples: number
): Float32Array {
  const outLen = samples.length - crossfadeSamples;
  const out = new Float32Array(outLen);

  // Copy main body after the crossfade region
  out.set(samples.subarray(crossfadeSamples, outLen), crossfadeSamples);

  // Blend the tail into the head using constant-power trig gains
  for (let i = 0; i < crossfadeSamples; i++) {
    const t = i / crossfadeSamples; // 0 -> 1
    const angle = t * (Math.PI * 0.5);
    const gainIn = Math.sin(angle);  // Head fades in (0 -> 1)
    const gainOut = Math.cos(angle); // Tail fades out (1 -> 0)

    const headSample = samples[i];
    const tailSample = samples[outLen + i];
    out[i] = headSample * gainIn + tailSample * gainOut;
  }

  return out;
}
Enter fullscreen mode Exit fullscreen mode

🛠️ Try the Free Browser-Local Audio & Game Sound DSP Suite

We packaged all of these algorithms into three 100% browser-local, zero-upload WebAudio studios (your audio files never leave your device, and you can even record directly from your microphone to test voice pitch/formants or export trimmed 16/24/32-bit WAV loops):

  1. 👉 Audio Spectrum & Lossless Verifier (/audio-analyzer) Log/Mel & Linear 2D Spectrogram, 98th-percentile codec brickwall verification, YIN $F_0$ pitch + musical note tracking, $F_1/F_2/F_3$ formants, ITU-R BS.1770-4 LUFS, and live microphone capture.
  2. 👉 Game Audio Seamless Looper & Normalizer (/audio-looper) Interactive canvas drag-trimming, automatic zero-crossing snap, equal-power ($cos/sin$) seamless loop crossfading, LUFS normalization, and multi-bit WAV export.
  3. 👉 Frequency Tone & Game SFX Synthesizer (/tone-generator) 4-stage ADSR envelope + logarithmic frequency sweep synthesizer for speaker calibration and retro game sound effects.

Have you ever been bitten by false positives in spectral analysis or annoying boundary clicks in game audio loops? Let me know what audio pipeline tools you use in the comments!

Top comments (0)