Skip to content

Commit 88e84bf

Browse files
committed
Add a mastering Limiter rack effect
- Lookahead brickwall peak limiter with Threshold, Ceiling, Release and Lookahead controls, plus a Boost switch that lifts the threshold to the ceiling for maximum loudness.
1 parent 606eafa commit 88e84bf

17 files changed

Lines changed: 730 additions & 0 deletions

CHANGELOG

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ Release date:
55

66
New features:
77

8+
* Add a mastering Limiter rack effect
9+
- Lookahead brickwall peak limiter with Threshold, Ceiling, Release and Lookahead controls
10+
- Boost switch lifts the threshold to the ceiling for maximum loudness
11+
- Gain reduction meter
12+
813
Bug fixes:
914

1015
Other:

src/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ set(QML_SOURCE_FILES
3939
${QML_BASE_DIR}/Dialogs/AddPitchBendAutomationDialog.qml
4040
${QML_BASE_DIR}/Dialogs/AllPassFilterDialog.qml
4141
${QML_BASE_DIR}/Dialogs/DbtpMeterDialog.qml
42+
${QML_BASE_DIR}/Dialogs/LimiterDialog.qml
4243
${QML_BASE_DIR}/Dialogs/LufsMeterDialog.qml
4344
${QML_BASE_DIR}/Dialogs/LoudnessReportDialog.qml
4445
${QML_BASE_DIR}/Dialogs/AudioRenderDialog.qml

src/common/constants.cpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,11 @@ QString saturator()
173173
return "saturator";
174174
}
175175

176+
QString limiter()
177+
{
178+
return "limiter";
179+
}
180+
176181
QString panner()
177182
{
178183
return "panner";
@@ -1032,6 +1037,16 @@ QString xmlKeyMakeup()
10321037
return "makeup";
10331038
}
10341039

1040+
QString xmlKeyCeiling()
1041+
{
1042+
return "ceiling";
1043+
}
1044+
1045+
QString xmlKeyBoost()
1046+
{
1047+
return "boost";
1048+
}
1049+
10351050
QString xmlKeyDrive()
10361051
{
10371052
return "drive";

src/common/constants.hpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ QString delay();
7474
QString chorus();
7575
QString clipper();
7676
QString saturator();
77+
QString limiter();
7778
QString panner();
7879
QString autoPanner();
7980
QString eq8BandParametric();
@@ -292,6 +293,8 @@ QString xmlKeyThreshold();
292293
QString xmlKeyRatio();
293294
QString xmlKeyKnee();
294295
QString xmlKeyMakeup();
296+
QString xmlKeyCeiling();
297+
QString xmlKeyBoost();
295298
QString xmlKeyDrive();
296299
QString xmlKeySize();
297300
QString xmlKeyDamping();

src/domain/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ set(HEADER_FILES
4545
effects/effect_factory.hpp
4646
effects/effect_rack.hpp
4747
effects/eq_8_band_parametric.hpp
48+
effects/limiter.hpp
4849
effects/panner.hpp
4950
effects/reverb.hpp
5051
effects/saturator.hpp
@@ -136,6 +137,7 @@ set(SOURCE_FILES
136137
effects/effect_factory.cpp
137138
effects/effect_rack.cpp
138139
effects/eq_8_band_parametric.cpp
140+
effects/limiter.cpp
139141
effects/panner.cpp
140142
effects/reverb.cpp
141143
effects/saturator.cpp

src/domain/effects/effect_factory.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
#include "compressor.hpp"
2727
#include "delay.hpp"
2828
#include "eq_8_band_parametric.hpp"
29+
#include "limiter.hpp"
2930
#include "panner.hpp"
3031
#include "reverb.hpp"
3132
#include "saturator.hpp"
@@ -88,6 +89,7 @@ void EffectFactory::init()
8889
registerEffect(DbTpMeter::typeIdString(), []() { return std::make_shared<DbTpMeter>(); });
8990
registerEffect(Delay::typeIdString(), []() { return std::make_shared<Delay>(); });
9091
registerEffect(Eq8BandParametric::typeIdString(), []() { return std::make_shared<Eq8BandParametric>(); });
92+
registerEffect(Limiter::typeIdString(), []() { return std::make_shared<Limiter>(); });
9193
registerEffect(LufsMeter::typeIdString(), []() { return std::make_shared<LufsMeter>(); });
9294
registerEffect(Panner::typeIdString(), []() { return std::make_shared<Panner>(); });
9395
registerEffect(Rta::typeIdString(), []() { return std::make_shared<Rta>(); });
@@ -100,6 +102,7 @@ void EffectFactory::init()
100102
registerEffect(Constants::RackEffectType::compressor().toStdString(), []() { return std::make_shared<Compressor>(); });
101103
registerEffect(Constants::RackEffectType::delay().toStdString(), []() { return std::make_shared<Delay>(); });
102104
registerEffect(Constants::RackEffectType::eq8BandParametric().toStdString(), []() { return std::make_shared<Eq8BandParametric>(); });
105+
registerEffect(Constants::RackEffectType::limiter().toStdString(), []() { return std::make_shared<Limiter>(); });
103106
registerEffect(Constants::RackEffectType::panner().toStdString(), []() { return std::make_shared<Panner>(); });
104107
registerEffect(Constants::RackEffectType::reverb().toStdString(), []() { return std::make_shared<Reverb>(); });
105108
registerEffect(Constants::RackEffectType::rta().toStdString(), []() { return std::make_shared<Rta>(); });
@@ -112,6 +115,7 @@ void EffectFactory::init()
112115
registerLegacyEffect("compressor", []() { return std::make_shared<Compressor>(); });
113116
registerLegacyEffect("delay", []() { return std::make_shared<Delay>(); });
114117
registerLegacyEffect("eq_8_band_parametric", []() { return std::make_shared<Eq8BandParametric>(); });
118+
registerLegacyEffect("limiter", []() { return std::make_shared<Limiter>(); });
115119
registerLegacyEffect("panner", []() { return std::make_shared<Panner>(); });
116120
registerLegacyEffect("reverb", []() { return std::make_shared<Reverb>(); });
117121
}

src/domain/effects/limiter.cpp

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// This file is part of Noteahead.
2+
// Copyright (C) 2026 Jussi Lind <jussi.lind@iki.fi>
3+
//
4+
// Noteahead is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
// Noteahead is distributed in the hope that it will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
// GNU General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU General Public License
14+
// along with Noteahead. If not, see <http://www.gnu.org/licenses/>.
15+
16+
#include "limiter.hpp"
17+
#include "../dsp/audio_context.hpp"
18+
19+
#include "../../common/constants.hpp"
20+
#include "../../common/parameter_mapper.hpp"
21+
#include "../../common/utils.hpp"
22+
23+
#include <algorithm>
24+
#include <cmath>
25+
26+
namespace noteahead {
27+
28+
Limiter::Limiter()
29+
{
30+
addParameter(Parameter { Constants::NahdXml::xmlKeyThreshold().toStdString(), 1.0f, -2400, 0, 0, 100 });
31+
addParameter(Parameter { Constants::NahdXml::xmlKeyCeiling().toStdString(), 0.9f, -300, 0, -30, 100 });
32+
addParameter(Parameter { Constants::NahdXml::xmlKeyRelease().toStdString(), 0.667f, 1, 1000, 667 });
33+
addParameter(Parameter { Constants::NahdXml::xmlKeyLookahead().toStdString(), 0.5f, 0, 10, 5 });
34+
addParameter(Parameter { Constants::NahdXml::xmlKeyBoost().toStdString(), 0.0f, 0, 1, 0, 1, Parameter::Type::Boolean });
35+
36+
syncParameters();
37+
}
38+
39+
void Limiter::process(double & left, double & right)
40+
{
41+
if (m_sampleRate <= 0) {
42+
return;
43+
}
44+
45+
updateBuffers();
46+
updateCoefficients();
47+
applyLimiter(left, right);
48+
}
49+
50+
void Limiter::process(AudioContext & context)
51+
{
52+
if (m_sampleRate <= 0) {
53+
return;
54+
}
55+
56+
updateBuffers();
57+
updateCoefficients();
58+
59+
for (uint32_t i = 0; i < context.frameCount; i++) {
60+
applyLimiter(context.buffer[i * 2], context.buffer[i * 2 + 1]);
61+
}
62+
}
63+
64+
void Limiter::updateBuffers()
65+
{
66+
if (static_cast<uint32_t>(m_sampleRate) != m_lastSampleRate || m_shouldUpdateBuffers || m_delayBufferL.empty()) {
67+
syncParameters();
68+
const uint32_t lookaheadSamples = static_cast<uint32_t>(m_lookaheadMs * m_sampleRate / 1000.0f);
69+
const uint32_t bufferSize = std::max(1u, lookaheadSamples + 1);
70+
if (bufferSize != m_delayBufferL.size()) {
71+
m_delayBufferL.assign(bufferSize, 0.0);
72+
m_delayBufferR.assign(bufferSize, 0.0);
73+
m_peakBuffer.assign(bufferSize, 0.0);
74+
m_maxIndices.clear();
75+
m_writePos = 0;
76+
}
77+
m_delaySamples = lookaheadSamples;
78+
m_lastSampleRate = static_cast<uint32_t>(m_sampleRate);
79+
m_shouldUpdateBuffers = false;
80+
}
81+
}
82+
83+
void Limiter::updateCoefficients()
84+
{
85+
if (m_sampleRate > 0) {
86+
m_releaseCoeff = std::exp(-1.0 / (static_cast<double>(m_releaseMs) * m_sampleRate / 1000.0));
87+
}
88+
}
89+
90+
void Limiter::applyLimiter(double & left, double & right)
91+
{
92+
if (m_delayBufferL.empty()) {
93+
updateBuffers();
94+
}
95+
96+
const double thresholdLin = std::max(1.0e-6, static_cast<double>(Utils::Dsp::dbToLinear(m_thresholdDb)));
97+
const double ceilingLin = std::max(1.0e-6, static_cast<double>(Utils::Dsp::dbToLinear(m_ceilingDb)));
98+
99+
// With boost the signal is limited to the threshold and lifted so that the threshold reaches the
100+
// ceiling (make-up = ceiling / threshold). Without boost it is pure peak limiting, capped at whichever
101+
// of threshold and ceiling is lower.
102+
const double targetLevel = m_boost ? thresholdLin : std::min(thresholdLin, ceilingLin);
103+
const double postGain = m_boost ? ceilingLin / thresholdLin : 1.0;
104+
105+
const uint32_t size = static_cast<uint32_t>(m_delayBufferL.size());
106+
const double peak = std::max(std::abs(left), std::abs(right));
107+
108+
// Maintain a sliding-window maximum of the peak over the whole lookahead buffer using a monotonic
109+
// decreasing deque. The slot at m_writePos currently holds the sample that is leaving the window.
110+
if (!m_maxIndices.empty() && m_maxIndices.front() == m_writePos) {
111+
m_maxIndices.pop_front();
112+
}
113+
while (!m_maxIndices.empty() && m_peakBuffer[m_maxIndices.back()] <= peak) {
114+
m_maxIndices.pop_back();
115+
}
116+
m_peakBuffer[m_writePos] = peak;
117+
m_delayBufferL[m_writePos] = left;
118+
m_delayBufferR[m_writePos] = right;
119+
m_maxIndices.push_back(m_writePos);
120+
121+
// The gain is derived from the loudest sample within the lookahead window, so full reduction is reached
122+
// before the peak reaches the output (instant attack); the gain only eases back up via the release.
123+
const double windowMax = m_peakBuffer[m_maxIndices.front()];
124+
const double targetGain = windowMax > targetLevel ? targetLevel / windowMax : 1.0;
125+
if (targetGain < m_envGain) {
126+
m_envGain = targetGain;
127+
} else {
128+
m_envGain = m_releaseCoeff * m_envGain + (1.0 - m_releaseCoeff) * targetGain;
129+
}
130+
m_envGain = std::min(1.0, m_envGain);
131+
132+
const uint32_t readPos = (m_writePos + 1) % size;
133+
double outL = m_delayBufferL[readPos] * m_envGain * postGain;
134+
double outR = m_delayBufferR[readPos] * m_envGain * postGain;
135+
136+
m_writePos = readPos;
137+
138+
// Brickwall backstop: guarantee the output never exceeds the ceiling.
139+
left = std::clamp(outL, -ceilingLin, ceilingLin);
140+
right = std::clamp(outR, -ceilingLin, ceilingLin);
141+
142+
m_reductionDb = Utils::Dsp::linearToDb(static_cast<float>(m_envGain));
143+
144+
// Denormal protection
145+
if (std::abs(m_reductionDb) < 1.0e-15) {
146+
m_reductionDb = 0.0;
147+
}
148+
}
149+
150+
void Limiter::reset()
151+
{
152+
m_envGain = 1.0;
153+
m_reductionDb = 0.0;
154+
std::fill(m_delayBufferL.begin(), m_delayBufferL.end(), 0.0);
155+
std::fill(m_delayBufferR.begin(), m_delayBufferR.end(), 0.0);
156+
std::fill(m_peakBuffer.begin(), m_peakBuffer.end(), 0.0);
157+
m_maxIndices.clear();
158+
m_writePos = 0;
159+
}
160+
161+
void Limiter::sync()
162+
{
163+
m_shouldUpdateBuffers = true;
164+
}
165+
166+
float Limiter::reductionDb() const
167+
{
168+
return static_cast<float>(m_reductionDb);
169+
}
170+
171+
void Limiter::syncParameters()
172+
{
173+
if (const auto p = parameter(Constants::NahdXml::xmlKeyThreshold().toStdString()); p) {
174+
m_thresholdDb = -24.0f + p->get().value() * 24.0f;
175+
}
176+
if (const auto p = parameter(Constants::NahdXml::xmlKeyCeiling().toStdString()); p) {
177+
m_ceilingDb = -3.0f + p->get().value() * 3.0f;
178+
}
179+
if (const auto p = parameter(Constants::NahdXml::xmlKeyRelease().toStdString()); p) {
180+
m_releaseMs = static_cast<float>(ParameterMapper::mapExponential(p->get().value(), 1.0, 1000.0));
181+
}
182+
if (const auto p = parameter(Constants::NahdXml::xmlKeyLookahead().toStdString()); p) {
183+
m_lookaheadMs = p->get().value() * 10.0f;
184+
}
185+
if (const auto p = parameter(Constants::NahdXml::xmlKeyBoost().toStdString()); p) {
186+
m_boost = p->get().value() > 0.5f;
187+
}
188+
}
189+
190+
std::string Limiter::typeIdString()
191+
{
192+
return "3f8c1a2e-6b7d-4e9a-8f0c-2d3e4f5a6b7c";
193+
}
194+
195+
std::string Limiter::type() const
196+
{
197+
return Constants::RackEffectType::limiter().toStdString();
198+
}
199+
200+
std::string Limiter::typeId() const
201+
{
202+
return typeIdString();
203+
}
204+
205+
} // namespace noteahead

src/domain/effects/limiter.hpp

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// This file is part of Noteahead.
2+
// Copyright (C) 2026 Jussi Lind <jussi.lind@iki.fi>
3+
//
4+
// Noteahead is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
// Noteahead is distributed in the hope that it will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
// GNU General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU General Public License
14+
// along with Noteahead. If not, see <http://www.gnu.org/licenses/>.
15+
16+
#ifndef LIMITER_HPP
17+
#define LIMITER_HPP
18+
19+
#include "effect.hpp"
20+
21+
#include <cstdint>
22+
#include <deque>
23+
#include <vector>
24+
25+
namespace noteahead {
26+
27+
class Limiter : public Effect
28+
{
29+
public:
30+
Limiter();
31+
32+
static std::string typeIdString();
33+
std::string type() const override;
34+
std::string typeId() const override;
35+
36+
void process(double & left, double & right) override;
37+
void process(AudioContext & context) override;
38+
void reset() override;
39+
void sync() override;
40+
41+
float reductionDb() const;
42+
43+
private:
44+
void updateBuffers();
45+
void updateCoefficients();
46+
void applyLimiter(double & left, double & right);
47+
void syncParameters();
48+
49+
float m_thresholdDb { 0.0f };
50+
float m_ceilingDb { -0.3f };
51+
float m_releaseMs { 100.0f };
52+
float m_lookaheadMs { 5.0f };
53+
bool m_boost { false };
54+
55+
double m_releaseCoeff { 0.0 };
56+
57+
double m_envGain { 1.0 };
58+
double m_reductionDb { 0.0 };
59+
60+
std::vector<double> m_delayBufferL;
61+
std::vector<double> m_delayBufferR;
62+
std::vector<double> m_peakBuffer;
63+
std::deque<uint32_t> m_maxIndices; // Monotonic decreasing deque for the sliding-window peak maximum.
64+
uint32_t m_writePos { 0 };
65+
uint32_t m_delaySamples { 0 };
66+
67+
bool m_shouldUpdateBuffers { false };
68+
uint32_t m_lastSampleRate { 0 };
69+
};
70+
71+
} // namespace noteahead
72+
73+
#endif // LIMITER_HPP

0 commit comments

Comments
 (0)