Add look-ahead brickwall limiter and fix planar DSP indexing#468
Merged
alnitak merged 5 commits intoMay 21, 2026
Conversation
Original limiter was a feed-forward soft-knee compressor that smoothed the gain envelope after computing the required reduction, which let transients exceed the output ceiling. New implementation uses a look-ahead delay buffer (length = ATTACK_TIME ms), stereo-linked peak detection, backward-scan envelope ramping for instant attack, one-pole release smoothing, and a hard ceiling clamp as a safety net. Public parameter IDs and ranges are unchanged so existing Dart callers keep working. ATTACK_TIME's semantic shifts from envelope time-constant to look-ahead window length; for typical values (1 ms) the user-facing behaviour is the same.
Tests exercise the limiter without linking the SoLoud engine — minimal
SoLoud base-class stubs let limiter.cpp compile and run in isolation.
Coverage:
1. Ceiling honored on a full-scale 1-sample impulse (the case the
original limiter leaks because of attack smoothing)
2. Ceiling honored on a +6 dB sustained sine
3. Sub-threshold input passes through sample-accurately (after the
look-ahead delay)
4. Stereo image preserved under asymmetric limiting (stereo-link
verification — original tracked L/R independently and broke this)
5. Fuzz across parameter combinations: ceiling guarantee always holds
6. Pre-emptive ramp-down: gain reaches reqGain BEFORE the offending
sample arrives at the output, instead of catching up after it
SoLoud passes audio in PLANAR layout: channel ch occupies the contiguous range [ch*aBufferSize, ch*aBufferSize + aSamples). The default FilterInstance::filter loops over channels calling filterChannel(aBuffer + ch*aBufferSize, ...) and Freeverb confirms it with 'inputR = aSampleData + aStride'. The old limiter and the new one both used aBuffer[i*channels + ch] (interleaved). The old limiter masked the bug because per-channel envelopes change slowly, scrambling samples just smeared the audio slightly. The new look-ahead limiter copies samples into a delay ring and reads them back ~1 ms later — wrong-order samples come out as heavy distortion. Now uses aBuffer[i + ch*aBufferSize] for read/write. Internal delay ring stays frame-major (interleaved) for cache locality of the per-frame loop. Tests rewritten to use planar layout and a new regression test feeds 1 kHz into L and 7 kHz into R (both sub-threshold), then verifies each output channel matches its own delayed input — would have caught this on day one.
Owner
|
Hi @Kunstderfug, this is a great PR! Thank you. If you feel good about this, I can turn this PR ready for review and merge it. |
Contributor
Author
|
Thank you! Let's try it! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Why
The previous limiter smoothed attack after computing the required reduction, which could let transients leak above the configured ceiling. The filter buffers are planar, so interleaved indexing could also cross-feed channels and break stereo behavior.
Validation
./test/run_limiter_test.sh: 18/18 assertions passedflutter analyze: no issues foundgit diff --check origin/main..HEAD: no whitespace errors