Lab 4 — The fade that never reaches silence
A one-pole smoother — the workhorse behind every envelope follower
and gain ramp — ported with / 32 turned into
>> 5, like every textbook says. No sample is
ever wrong by more than a hair, which is exactly why this bug
family survives code review. The damage is statistical: a standing
DC bias, and a fade-out that parks below zero forever.
15–20 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 quiet 200 Hz tone (−34 dBFS, on purpose) fading to silence over two seconds. - Build and run the fixed target for
out_fixed.wav. - These two sound identical. That's the point — your ears won't find this one. The tools will.
2. See it in Wave Compare
- Load both files, press Compare, and look at the error trace: instead of hugging zero it sits on a flat offset of about 16 LSB — a bias, not noise. On a −34 dBFS signal that's several percent of the peak.
- Zoom into the tail, after the tone has faded. The float build decays to a true zero. The fixed build parks at −16 LSB and stays there — a permanent DC offset on "silence".
- The spectrogram shows the giveaway: a line at 0 Hz that the float build doesn't have.
3. Watch the bias build in the Debugger
- Set a breakpoint inside
SmootherFixed::processinfixed/smoother_fx.hand its float twin infloat/smoother.h; F5 both builds. - Bind
diff↔diff_fxandy↔y_fx(Q15 — Auto-Q agrees). - Step a few samples: every individual value looks plausibly
close. Now let both builds free-run a few thousand samples and
look at the
ypair's error — it has settled onto a constant offset. No single step was wrong; every step was wrong by a little, in the same direction. - At the very end, read
y_fxin the Locals panel: stuck below zero while the float side reads 0.0.
4. Fix it and prove the fix
The repair is one line, documented next to the bug in
fixed/smoother_fx.h: round to nearest before the
shift — (diff + 16) >> 5. Rebuild and Compare
again: the standing bias drops to about half an LSB and the tail
decays to a true zero.
What this lab teaches
>>rounds toward −∞, not toward zero and not to nearest. On signed signals that's a systematic bias, and biases integrate.- Per-sample inspection can't catch statistical bugs — you need the error trace, not the error value.
- Quiet signals are the stress test for rounding: a 16-LSB bias vanishes next to a full-scale sine and dominates a −34 dBFS fade.
Next
That's the tour: Lab 1 wraps, Lab 3 saturates, Lab 4 drifts — the three ways fixed-point ports actually go wrong, and the Mantissa workflow that catches each one. Head back to all tutorials, or revisit Lab 2 to go deeper on the analysis side.
Finished this step?
Mark it complete to track your progress through the tutorial.