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
- Build and run the float target (F5). It
writes
out_float.wav— a 100 Hz → 8 kHz sweep through the filter. - Build and run the fixed target. It writes
out_fixed.wavalongside. - 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
- Load
out_float.wavas Float andout_fixed.wavas Fixed, press Compare. The view snaps straight to the worst sample — inside the resonance crossing, around t ≈ 0.18 s. - 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.
- 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
- Set a breakpoint inside
BiquadFixed::processinfixed/biquad_fx.hand one at the matching spot infloat/biquad.h; F5 both builds. - In Mantissa's Debugger, Search finds both sessions.
Bind the pairs (all Q15 — let Auto-Q confirm):
acc↔acc_fx,y1_↔y1_fx_,y2_↔y2_fx_,y↔y_fx. - Run both builds to around sample 8 700 (the resonance).
Now step through one sample and watch the
accpair: the float side sails past ±1.0 mid-sum; the fixed side flat-lines at 32767. That clamp — a perfectly correctsat_add16()doing exactly what it was told — is the bug, and it feeds back throughy1/y2into 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
- Headroom is a property of intermediates, not just outputs — "the result fits" is not enough.
- Saturating basops (ITU-style
add()) turn hard failures into quiet distortion. Prefer wide accumulators, saturate once. - Coefficient quantization sets the noise floor you can't fix — knowing which regime you're in is the skill.
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.