Lab 3 — Hunt a saturation that nobody hears coming

A resonant low-pass biquad, ported to Q15. The input is −13 dBFS. The float output never touches full scale. And still the fixed build distorts — because the filter's intermediate sum passes ±1.0 even though its final result doesn't. Saturation is the sneakiest overflow: nothing wraps, nothing crashes, the output is merely wrong. 20–30 minutes.

What you'll need

The same toolchain as Lab 1 — VS Code, the C/C++ extension, a g++ on your PATH, and (optionally) the Mantissa Bridge extension for live variable binding. First lab? Do the Lab 1 setup once; everything here reuses it.

Get the lab project

Two builds (float reference + buggy fixed-point port), VS Code config pre-wired so F5 just works. Grab the zip and extract it anywhere you like:

Inside the Mantissa app this download step is skipped — the lab ships bundled with the install and the IDE button opens it directly.

Open the project

Point VS Code at the folder you extracted the zip into. Inside the Mantissa app the lab is bundled, so the IDE button just works without a path.

1. Run both builds

  1. Build and run the float target (F5). It writes out_float.wav — a 100 Hz → 8 kHz sweep through the filter.
  2. Build and run the fixed target. It writes out_fixed.wav alongside.
  3. Listen to both. The fixed one buzzes exactly while the sweep crosses the 1.2 kHz resonance — and is clean elsewhere. That locality is your first clue.

2. See it in Wave Compare

  1. Load out_float.wav as Float and out_fixed.wav as Fixed, press Compare. The view snaps straight to the worst sample — inside the resonance crossing, around t ≈ 0.18 s.
  2. The spectrogram tells the story at a glance: a clean diagonal sweep line in the float build, the same line sprouting harmonics in the fixed one, only near 1.2 kHz.
  3. Run the MLD analysis. Errors peak in the tens of thousands of LSBs — about 10% of all samples are corrupted, all of them clustered where the resonance pushed the internal sum past full scale.

3. Catch the clamp in the Debugger

  1. Set a breakpoint inside BiquadFixed::process in fixed/biquad_fx.h and one at the matching spot in float/biquad.h; F5 both builds.
  2. In Mantissa's Debugger, Search finds both sessions. Bind the pairs (all Q15 — let Auto-Q confirm): accacc_fx, y1_y1_fx_, y2_y2_fx_, yy_fx.
  3. Run both builds to around sample 8 700 (the resonance). Now step through one sample and watch the acc pair: the float side sails past ±1.0 mid-sum; the fixed side flat-lines at 32767. That clamp — a perfectly correct sat_add16() doing exactly what it was told — is the bug, and it feeds back through y1/y2 into every sample after it.

4. Fix it and prove the fix

The repair is documented next to the bug in fixed/biquad_fx.h: accumulate the raw Q28 products in one int32 and saturate once at the end. Rebuild, re-run, Compare again — the error collapses ~165×, down to the ~100-LSB floor set by Q13 coefficient quantization. That floor is the honest cost of fixed-point; the cliff above it was the bug.

What this lab teaches

Next

The last bug family is the quietest of all: continue with Lab 4 — the fade that never reaches silence, where the error is a rounding bias no single sample reveals.

Finished this step?

Mark it complete to track your progress through the tutorial.


Re-download lab zip · ← All tutorials