Skip to content

Commit 6b81dbb

Browse files
committed
feat: add loudness match
1 parent daa5171 commit 6b81dbb

12 files changed

+157
-24
lines changed

assets/icons/loudness-match.svg

+5
Loading

assets/icons/static-gain-compensation.svg

+1-1
Loading

source/dsp/chore_attach.cpp

+13-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ namespace zlDSP {
1818
: processorRef(processor),
1919
parameterRef(parameters), parameterNARef(parametersNA),
2020
controllerRef(controller),
21-
decaySpeed(zlState::ffTSpeed::speeds[static_cast<size_t>(zlState::ffTSpeed::defaultI)]) {
21+
decaySpeed(zlState::ffTSpeed::speeds[static_cast<size_t>(zlState::ffTSpeed::defaultI)]),
22+
agcUpdater(parameters, zlDSP::autoGain::ID),
23+
gainUpdater(parameters, zlDSP::outputGain::ID) {
2224
juce::ignoreUnused(parameterRef);
2325
addListeners();
2426
initDefaultValues();
@@ -60,7 +62,7 @@ namespace zlDSP {
6062
controllerRef.setEffectON(newValue > .5f);
6163
} else if (parameterID == phaseFlip::ID) {
6264
controllerRef.getPhaseFlipper().setON(newValue > .5f);
63-
}else if (parameterID == staticAutoGain::ID) {
65+
} else if (parameterID == staticAutoGain::ID) {
6466
controllerRef.setSgcON(newValue > .5f);
6567
} else if (parameterID == autoGain::ID) {
6668
controllerRef.getAutoGain().enable(newValue > .5f);
@@ -167,6 +169,15 @@ namespace zlDSP {
167169
zlState::conflictStrength::formatV(static_cast<FloatType>(newValue)));
168170
} else if (parameterID == zlState::conflictScale::ID) {
169171
controllerRef.getConflictAnalyzer().setConflictScale(static_cast<FloatType>(newValue));
172+
} else if (parameterID == loudnessMatcherON::ID) {
173+
if (newValue > .5f) {
174+
controllerRef.setLoudnessMatcherON(true);
175+
} else {
176+
controllerRef.setLoudnessMatcherON(false);
177+
const auto newGain = -controllerRef.getLoudnessMatcherDiff();
178+
agcUpdater.update(0.f);
179+
gainUpdater.update(zlDSP::outputGain::convertTo01(static_cast<float>(newGain)));
180+
}
170181
}
171182
}
172183

source/dsp/chore_attach.hpp

+5-2
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@ namespace zlDSP {
3232
Controller<FloatType> &controllerRef;
3333
std::atomic<float> decaySpeed;
3434
std::array<std::atomic<int>, 3> isFFTON{1, 1, 0};
35+
zlChore::ParaUpdater agcUpdater, gainUpdater;
3536

3637
constexpr static std::array IDs{
3738
sideChain::ID, dynLookahead::ID,
3839
dynRMS::ID, dynSmooth::ID,
3940
effectON::ID, phaseFlip::ID, staticAutoGain::ID, autoGain::ID,
4041
scale::ID, outputGain::ID,
41-
filterStructure::ID, dynHQ::ID, zeroLatency::ID
42+
filterStructure::ID, dynHQ::ID, zeroLatency::ID,
43+
loudnessMatcherON::ID
4244
};
4345
constexpr static std::array defaultVs{
4446
static_cast<float>(sideChain::defaultV),
@@ -53,7 +55,8 @@ namespace zlDSP {
5355
static_cast<float>(outputGain::defaultV),
5456
static_cast<float>(filterStructure::defaultI),
5557
static_cast<float>(dynHQ::defaultI),
56-
static_cast<float>(zeroLatency::defaultI)
58+
static_cast<float>(zeroLatency::defaultI),
59+
static_cast<float>(loudnessMatcherON::defaultV)
5760
};
5861

5962
constexpr static std::array NAIDs{

source/dsp/controller.cpp

+12
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ namespace zlDSP {
110110
dummySideDelay.setMaximumDelayInSamples(linearFilters[0].getLatency() * 3 + 10);
111111
dummySideDelay.prepare(subSpec);
112112

113+
loudnessMatcher.prepare(subSpec);
114+
113115
for (auto &t: trackers) {
114116
t.prepare(subSpec.sampleRate);
115117
}
@@ -168,6 +170,13 @@ namespace zlDSP {
168170
if (toUpdateHist.exchange(false)) {
169171
updateHistograms();
170172
}
173+
if (currentIsLoudnessMatcherON != isLoudnessMatcherON.load()) {
174+
currentIsLoudnessMatcherON = isLoudnessMatcherON.load();
175+
if (currentIsLoudnessMatcherON) {
176+
loudnessMatcher.reset();
177+
}
178+
}
179+
171180
currentIsEffectON = isEffectON.load();
172181

173182
juce::AudioBuffer<FloatType> mainBuffer{buffer.getArrayOfWritePointers() + 0, 2, buffer.getNumSamples()};
@@ -249,6 +258,9 @@ namespace zlDSP {
249258
processMixedCorrection<isBypassed>(subMainBuffer);
250259
}
251260
}
261+
if (currentIsLoudnessMatcherON) {
262+
loudnessMatcher.process(dummyMainBuffer, subMainBuffer);
263+
}
252264
autoGain.processPre(dummyMainBuffer);
253265
autoGain.template processPost<isBypassed>(subMainBuffer);
254266
outputGain.template process<isBypassed>(subMainBuffer);

source/dsp/controller.hpp

+13
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#include "phase/phase.hpp"
2525
#include "container/container.hpp"
2626
#include "eq_match/eq_match.hpp"
27+
#include "loudness/loudness.hpp"
2728

2829
namespace zlDSP {
2930
template<typename FloatType>
@@ -162,6 +163,14 @@ namespace zlDSP {
162163

163164
void setEditorOn(const bool x) { isEditorOn.store(x); }
164165

166+
void setLoudnessMatcherON(const bool x) {
167+
isLoudnessMatcherON.store(x);
168+
}
169+
170+
FloatType getLoudnessMatcherDiff() const {
171+
return loudnessMatcher.getDiff();
172+
}
173+
165174
private:
166175
juce::AudioProcessor &processorRef;
167176

@@ -292,6 +301,10 @@ namespace zlDSP {
292301
std::atomic<filterStructure::FilterStructure> mFilterStructure{filterStructure::minimum};
293302
filterStructure::FilterStructure currentFilterStructure{filterStructure::minimum};
294303

304+
zlLoudness::LUFSMatcher<FloatType, 2, false> loudnessMatcher;
305+
bool currentIsLoudnessMatcherON{false};
306+
std::atomic<bool> isLoudnessMatcherON{false};
307+
295308
void processSubBuffer(juce::AudioBuffer<FloatType> &subMainBuffer,
296309
juce::AudioBuffer<FloatType> &subSideBuffer);
297310

source/dsp/dsp_definitions.hpp

+9-1
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,13 @@ namespace zlDSP {
482482
int static constexpr defaultI = 0;
483483
};
484484

485+
class loudnessMatcherON : public BoolParameters<loudnessMatcherON> {
486+
public:
487+
auto static constexpr ID = "loudness_matcher_on";
488+
auto static constexpr name = "LoudnessMatcherON";
489+
auto static constexpr defaultV = false;
490+
};
491+
485492
inline juce::AudioProcessorValueTreeState::ParameterLayout getParameterLayout() {
486493
juce::AudioProcessorValueTreeState::ParameterLayout layout;
487494
for (int i = 0; i < bandNUM; ++i) {
@@ -492,7 +499,8 @@ namespace zlDSP {
492499
dynLookahead::get(), dynRMS::get(), dynSmooth::get(),
493500
effectON::get(), phaseFlip::get(), staticAutoGain::get(), autoGain::get(),
494501
scale::get(), outputGain::get(),
495-
filterStructure::get(), dynHQ::get(), zeroLatency::get());
502+
filterStructure::get(), dynHQ::get(), zeroLatency::get(),
503+
loudnessMatcherON::get());
496504
return layout;
497505
}
498506

source/dsp/loudness/k_weighting_filter.hpp

+3-3
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@ namespace zlLoudness {
4848
for (int i = 0; i < buffer.getNumSamples(); ++i) {
4949
for (int channel = 0; channel < buffer.getNumChannels(); ++channel) {
5050
auto sample = *(writerPointer[channel] + i);
51-
sample = highPassF.processSample(channel, sample);
52-
sample = highShelfF.processSample(channel, sample);
51+
sample = highPassF.processSample(static_cast<size_t>(channel), sample);
52+
sample = highShelfF.processSample(static_cast<size_t>(channel), sample);
5353
if (UseLowPass) {
54-
sample = lowPassF.processSample(channel, sample);
54+
sample = lowPassF.processSample(static_cast<size_t>(channel), sample);
5555
}
5656
*(writerPointer[channel] + i) = sample;
5757
}

source/dsp/loudness/loudness.hpp

+1
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@
1212

1313
#include "k_weighting_filter.hpp"
1414
#include "lufs_meter.hpp"
15+
#include "lufs_matcher.hpp"
1516

1617
#endif //ZL_LOUDNESS_HPP

source/dsp/loudness/lufs_matcher.hpp

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (C) 2025 - zsliu98
2+
// This file is part of ZLEqualizer
3+
//
4+
// ZLEqualizer is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License Version 3 as published by the Free Software Foundation.
5+
//
6+
// ZLEqualizer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
7+
//
8+
// You should have received a copy of the GNU Affero General Public License along with ZLEqualizer. If not, see <https://www.gnu.org/licenses/>.
9+
10+
#ifndef ZL_LOUDNESS_LUFS_MATCHER_HPP
11+
#define ZL_LOUDNESS_LUFS_MATCHER_HPP
12+
13+
#include "lufs_meter.hpp"
14+
15+
namespace zlLoudness {
16+
template<typename FloatType, size_t MaxChannels = 2, bool UseLowPass = false>
17+
class LUFSMatcher {
18+
public:
19+
LUFSMatcher() = default;
20+
21+
void prepare(const juce::dsp::ProcessSpec &spec) {
22+
preLoudnessMeter.prepare(spec);
23+
postLoudnessMeter.prepare(spec);
24+
sampleRate = spec.sampleRate;
25+
reset();
26+
}
27+
28+
void reset() {
29+
preLoudnessMeter.reset();
30+
postLoudnessMeter.reset();
31+
loudnessDiff.store(FloatType(0));
32+
currentCount = 0.0;
33+
}
34+
35+
void process(juce::AudioBuffer<FloatType> &pre, juce::AudioBuffer<FloatType> &post) {
36+
preLoudnessMeter.process(pre);
37+
postLoudnessMeter.process(post);
38+
currentCount += static_cast<double>(pre.getNumSamples());
39+
if (currentCount >= sampleRate) {
40+
currentCount -= sampleRate;
41+
const auto preLoudness = preLoudnessMeter.getIntegratedLoudness();
42+
const auto postLoudness = postLoudnessMeter.getIntegratedLoudness();
43+
loudnessDiff.store(postLoudness - preLoudness);
44+
}
45+
}
46+
47+
FloatType getDiff() const {
48+
return loudnessDiff.load();
49+
}
50+
51+
private:
52+
LUFSMeter<FloatType, MaxChannels, UseLowPass> preLoudnessMeter, postLoudnessMeter;
53+
std::atomic<FloatType> loudnessDiff{FloatType(0)};
54+
double sampleRate{48000}, currentCount{0};
55+
};
56+
}
57+
58+
#endif //ZL_LOUDNESS_LUFS_MATCHER_HPP

source/dsp/loudness/lufs_meter.hpp

+23-7
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ namespace zlLoudness {
3636
std::fill(histogramSums.begin(), histogramSums.end(), FloatType(0));
3737
currentIdx = 0;
3838
smallBuffer.clear();
39+
readyCount = 0;
3940
}
4041

4142
void process(juce::AudioBuffer<FloatType> &buffer) {
@@ -48,18 +49,28 @@ namespace zlLoudness {
4849
juce::dsp::AudioBlock<FloatType> smallBlock(smallBuffer);
4950
while (numTotal - startIdx >= maxIdx - currentIdx) {
5051
// now we get a full 100ms small block
51-
smallBlock.copyFrom(block,
52-
static_cast<size_t>(startIdx),
53-
static_cast<size_t>(currentIdx),
54-
static_cast<size_t>(maxIdx - currentIdx));
52+
const auto subBlock = block.getSubBlock(static_cast<size_t>(startIdx),
53+
static_cast<size_t>(maxIdx - currentIdx));
54+
auto smallSubBlock = smallBlock.getSubBlock(static_cast<size_t>(currentIdx),
55+
static_cast<size_t>(maxIdx - currentIdx));
56+
smallSubBlock.copyFrom(subBlock);
5557
startIdx += maxIdx - currentIdx;
5658
currentIdx = 0;
5759
update();
5860
}
61+
if (numTotal - startIdx > 0) {
62+
const auto subBlock = block.getSubBlock(static_cast<size_t>(startIdx),
63+
static_cast<size_t>(numTotal - startIdx));
64+
auto smallSubBlock = smallBlock.getSubBlock(static_cast<size_t>(currentIdx),
65+
static_cast<size_t>(numTotal - startIdx));
66+
smallSubBlock.copyFrom(subBlock);
67+
currentIdx += numTotal - startIdx;
68+
}
5969
}
6070

61-
FloatType getIntegratedLUFS() const {
71+
FloatType getIntegratedLoudness() const {
6272
const auto totalCount = std::reduce(histogram.begin(), histogram.end(), FloatType(0));
73+
if (totalCount < FloatType(0.5)) { return FloatType(0); }
6374
const auto totalSum = std::reduce(histogramSums.begin(), histogramSums.end(), FloatType(0));
6475
const auto totalLUFS = totalSum / totalCount;
6576
if (totalLUFS <= FloatType(-60)) {
@@ -76,6 +87,7 @@ namespace zlLoudness {
7687
KWeightingFilter<FloatType, UseLowPass> kWeightingFilter;
7788
juce::AudioBuffer<FloatType> smallBuffer;
7889
int currentIdx{0}, maxIdx{0};
90+
int readyCount{0};
7991
FloatType meanMul{1};
8092
std::array<FloatType, 4> sumSquares{};
8193

@@ -84,18 +96,22 @@ namespace zlLoudness {
8496
std::array<FloatType, MaxChannels> weights;
8597

8698
void update() {
99+
if (readyCount < 3) {
100+
readyCount += 1;
101+
return;
102+
}
87103
// perform K-weighting filtering
88104
kWeightingFilter.process(smallBuffer);
89105
// calculate the sum square of the small block
90106
FloatType sumSquare = 0;
91107
for (int channel = 0; channel < smallBuffer.getNumChannels(); ++channel) {
92108
const auto readerPointer = smallBuffer.getReadPointer(channel);
93109
FloatType channelSumSquare = 0;
94-
for (int i = 0; i < maxIdx; ++i) {
110+
for (int i = 0; i < smallBuffer.getNumSamples(); ++i) {
95111
const auto sample = *(readerPointer + i);
96112
channelSumSquare += sample * sample;
97113
}
98-
sumSquare += channelSumSquare * weights[channel];
114+
sumSquare += channelSumSquare * weights[static_cast<size_t>(channel)];
99115
}
100116
// shift circular sumSquares
101117
sumSquares[0] = sumSquares[1];

source/panel/state_panel/output_setting_panel.cpp

+14-8
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,21 @@ namespace zlPanel {
1919
uiBase(base),
2020
phaseC("phase", uiBase),
2121
agcC("A", uiBase),
22+
lmC("L", uiBase),
2223
scaleS("Scale", uiBase),
2324
outGainS("Out Gain", uiBase),
2425
phaseDrawable(
2526
juce::Drawable::createFromImageData(BinaryData::fadphase_svg,
2627
BinaryData::fadphase_svgSize)),
2728
agcDrawable(juce::Drawable::createFromImageData(BinaryData::autogaincompensation_svg,
28-
BinaryData::autogaincompensation_svgSize)) {
29+
BinaryData::autogaincompensation_svgSize)),
30+
lmDrawable(juce::Drawable::createFromImageData(BinaryData::loudnessmatch_svg,
31+
BinaryData::loudnessmatch_svgSize)) {
2932
phaseC.setDrawable(phaseDrawable.get());
3033
agcC.setDrawable(agcDrawable.get());
34+
lmC.setDrawable(lmDrawable.get());
3135

32-
for (auto &c: {&phaseC, &agcC}) {
36+
for (auto &c: {&phaseC, &agcC, &lmC}) {
3337
c->getLAF().enableShadow(false);
3438
c->getLAF().setShrinkScale(0.f);
3539
addAndMakeVisible(c);
@@ -38,8 +42,8 @@ namespace zlPanel {
3842
c->setPadding(uiBase.getFontSize() * .5f, 0.f);
3943
addAndMakeVisible(c);
4044
}
41-
attach({&phaseC.getButton(), &agcC.getButton()},
42-
{zlDSP::phaseFlip::ID, zlDSP::autoGain::ID},
45+
attach({&phaseC.getButton(), &agcC.getButton(), &lmC.getButton()},
46+
{zlDSP::phaseFlip::ID, zlDSP::autoGain::ID, zlDSP::loudnessMatcherON::ID},
4347
parametersRef, buttonAttachments);
4448
attach({&scaleS.getSlider(), &outGainS.getSlider()},
4549
{zlDSP::scale::ID, zlDSP::outputGain::ID},
@@ -54,13 +58,14 @@ namespace zlPanel {
5458
using Fr = juce::Grid::Fr;
5559

5660
grid.templateRows = {Track(Fr(60)), Track(Fr(60)), Track(Fr(60))};
57-
grid.templateColumns = {Track(Fr(50)), Track(Fr(50))};
61+
grid.templateColumns = {Track(Fr(50)), Track(Fr(50)), Track(Fr(50))};
5862

5963
grid.items = {
60-
juce::GridItem(scaleS).withArea(1, 1, 2, 3),
64+
juce::GridItem(scaleS).withArea(1, 1, 2, 4),
6165
juce::GridItem(phaseC).withArea(2, 1),
6266
juce::GridItem(agcC).withArea(2, 2),
63-
juce::GridItem(outGainS).withArea(3, 1, 4, 3)
67+
juce::GridItem(lmC).withArea(2, 3),
68+
juce::GridItem(outGainS).withArea(3, 1, 4, 4)
6469
};
6570

6671
grid.setGap(juce::Grid::Px(uiBase.getFontSize() * .2125f));
@@ -73,14 +78,15 @@ namespace zlPanel {
7378
juce::AudioProcessorValueTreeState &parametersRef;
7479
zlInterface::UIBase &uiBase;
7580

76-
zlInterface::CompactButton phaseC, agcC;
81+
zlInterface::CompactButton phaseC, agcC, lmC;
7782
juce::OwnedArray<zlInterface::ButtonCusAttachment<true> > buttonAttachments{};
7883

7984
zlInterface::CompactLinearSlider scaleS, outGainS;
8085
juce::OwnedArray<juce::AudioProcessorValueTreeState::SliderAttachment> sliderAttachments{};
8186

8287
const std::unique_ptr<juce::Drawable> phaseDrawable;
8388
const std::unique_ptr<juce::Drawable> agcDrawable;
89+
const std::unique_ptr<juce::Drawable> lmDrawable;
8490
};
8591

8692
OutputSettingPanel::OutputSettingPanel(PluginProcessor &p,

0 commit comments

Comments
 (0)