Skip to content

Commit c19a802

Browse files
ten9876claude
andauthored
feat(audio): v26.5.3 — PAPR processor, split-band de-esser, peak hold (aethersdr#3024)
## Summary Three threads landed together. The de-esser fix is the **release-critical** part (multiple operators reporting RMS / forward-power loss vs SmartSDR — root-caused to this stage). The PAPR processor (aethersdr#2887) and the meter-UI polish came along for the ride during the same bench session. Bumps version to **26.5.3**. ## The de-esser bug (release-critical) `ClientDeEss::process()` was applying its sidechain-derived gain reduction to the **full signal** every time HF energy crossed threshold: ```cpp // OLD — broadband attenuation when sibilance triggers: l *= gainLin; r *= gainLin; ``` So every S / T / F phoneme pulled lows and mids down alongside the highs, crashing RMS. SmartSDR doesn't have this stage at all, which is why operators saw lower average power on AetherSDR than SmartSDR at the same drive setting (Antonio D'Arpino's report on the AetherSDR Users Group, plus matching symptoms from others). **Fix**: split-band topology — `output = full + bandpass × (gain − 1)`. The bandpass acts as both the sidechain detector and the slice to attenuate; lows and mids pass through unchanged. Also adds: - **Cascaded biquads** (1–4 stages) so the user can dial slope from 12 → 24 → 36 → 48 dB/oct via a new toggle in the de-esser panel. Default 24 dB/oct (2 stages). Narrower notch = less mid-band collateral on Ess-heavy phrases. - AppSettings persistence: `ClientDeEssTxSlopeStages` / `ClientDeEssRxSlopeStages`. ## PAPR processor (aethersdr#2887) New **ClientPhaseRotator** module — cascade of all-pass biquads that symmetrizes asymmetric voice peaks before downstream compression/limiting, so the clipping work produces primarily odd-order (musical) harmonics instead of even-order (buzzy) ones. Embedded inside **ClientComp** as a private member, exposed via two new controls in the comp panel: - **Drive** (0..18 dB) — pre-gain into the comp threshold. Pushes more material across the curve so the comp engages harder, lifting RMS. - **Phase** (0..6 stages) — pre-comp rotator stage count. The per-sample gain auto-tracks Drive (`gainLin = curve × makeup × driveLin`), so Drive doesn't fight the comp's natural GR response — same model as broadcast Optimod. Drive and Phase apply regardless of the comp's chain-bypass state. Bench result: with Drive +9 dB / Phase 4 stg, CRST drops from ~12 dB to ~8 dB cleanly, peaks pegged at the limiter ceiling, no audible squash. ## UI polish - **Comp GR + Out meters** — now render THRESH-style tick columns (Left/Right) and a right-anchored dB-value footer, so all three vertical meters in the comp editor read with one visual vocabulary. - **Final Output Stage peak-hold toggle** — latches PK/RMS/GR worst-case-since-engaged for capturing burst maxima. CRST always tracks live so adjustments visibly land in real time. - **10 Hz numeric-readout throttle** — new shared `kMeterReadoutUpdateMs` constant in `MeterSmoother.h`. Applied to Final Output Stage, comp GR/Out meters, Tube OUT meter, and RX chain output stage. Bars still animate at 125 Hz, only text setText is gated to 10 Hz so digits are readable. ## Stats - 22 files changed, +898 / −50 - 2 new source files (`ClientPhaseRotator.{h,cpp}`) - DSP changes covered by manual hardware test (Jeremy KK7GWY's bench) - No new tests added for the new DSPs — should follow up in a Phase 2 PR ## Test plan - [x] Clean build, 0 errors - [x] Manual: Drive 0..+18 dB lifts RMS as expected, GR meter responds - [x] Manual: Phase toggle audibly clean under heavy Drive - [x] Manual: De-esser no longer crashes RMS on sibilant phrases - [x] Manual: Slope toggle cycles 12 → 24 → 36 → 48 dB/oct - [x] Manual: Peak hold latches worst-case; CRST tracks live - [x] Manual: Meter readouts readable at 10 Hz, bars smooth at 125 Hz - [ ] CI: build, check-paths, check-windows green before merge ## Operator workaround for pre-v26.5.3 builds For anyone on v26.5.2 hitting the de-esser bug: **disable the DESS card** in the channel strip CHAIN row (single click to bypass). Forward power should recover immediately. v26.5.3 makes the fix permanent. Closes aethersdr#2887. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9bdb7a9 commit c19a802

22 files changed

Lines changed: 900 additions & 50 deletions

CMakeLists.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
cmake_minimum_required(VERSION 3.20)
2-
project(AetherSDR VERSION 26.5.2.1 LANGUAGES C CXX)
2+
project(AetherSDR VERSION 26.5.3 LANGUAGES C CXX)
33

44
set(CMAKE_CXX_STANDARD 20)
55
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -437,6 +437,7 @@ set(CORE_SOURCES
437437
src/core/ClientPudu.cpp
438438
src/core/ClientPuduMonitor.cpp
439439
src/core/ClientReverb.cpp
440+
src/core/ClientPhaseRotator.cpp
440441
src/core/ClientFinalLimiter.cpp
441442
src/core/ClientTxTestTone.cpp
442443
src/core/ClientQuindarTone.cpp
@@ -1310,6 +1311,7 @@ set_target_properties(client_eq_smoothing_test PROPERTIES AUTOMOC ON)
13101311
add_executable(client_comp_test
13111312
tests/client_comp_test.cpp
13121313
src/core/ClientComp.cpp
1314+
src/core/ClientPhaseRotator.cpp
13131315
)
13141316
target_include_directories(client_comp_test PRIVATE src)
13151317

src/core/AudioEngine.cpp

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,7 +1779,12 @@ void AudioEngine::applyClientEqTxFloat32(QByteArray& float32)
17791779

17801780
void AudioEngine::applyClientCompTxInt16(QByteArray& int16stereo)
17811781
{
1782-
if (!m_clientCompTx || !m_clientCompTx->isEnabled()) return;
1782+
if (!m_clientCompTx) return;
1783+
const bool compOn = m_clientCompTx->isEnabled();
1784+
const bool driveOn = m_clientCompTx->driveDb() > 0.0f;
1785+
const bool phaseOn = m_clientCompTx->phaseRotatorStages() > 0;
1786+
const bool limOn = m_clientCompTx->limiterEnabled();
1787+
if (!compOn && !driveOn && !phaseOn && !limOn) return;
17831788
if (int16stereo.isEmpty()) return;
17841789

17851790
const int samples = int16stereo.size() / static_cast<int>(sizeof(int16_t));
@@ -1802,7 +1807,16 @@ void AudioEngine::applyClientCompTxInt16(QByteArray& int16stereo)
18021807

18031808
void AudioEngine::applyClientCompTxFloat32(QByteArray& float32)
18041809
{
1805-
if (!m_clientCompTx || !m_clientCompTx->isEnabled()) return;
1810+
if (!m_clientCompTx) return;
1811+
// Drive and Phase (#2887) and the brickwall limiter inside the comp
1812+
// are useful even when the comp curve itself is bypassed, so the
1813+
// dispatch only short-circuits when none of the four sub-stages
1814+
// need to run.
1815+
const bool compOn = m_clientCompTx->isEnabled();
1816+
const bool driveOn = m_clientCompTx->driveDb() > 0.0f;
1817+
const bool phaseOn = m_clientCompTx->phaseRotatorStages() > 0;
1818+
const bool limOn = m_clientCompTx->limiterEnabled();
1819+
if (!compOn && !driveOn && !phaseOn && !limOn) return;
18061820
if (float32.isEmpty()) return;
18071821
const int samples = float32.size() / static_cast<int>(sizeof(float));
18081822
const int channels = (samples % 2 == 0) ? 2 : 1;
@@ -2608,6 +2622,10 @@ void AudioEngine::loadClientCompSettings()
26082622
s.value("ClientCompTxLimEnabled", "True").toString() == "True");
26092623
m_clientCompTx->setLimiterCeilingDb(
26102624
s.value("ClientCompTxLimCeilingDb", "-1.0").toFloat());
2625+
m_clientCompTx->setDriveDb(
2626+
s.value("ClientCompTxDriveDb", "0.0").toFloat());
2627+
m_clientCompTx->setPhaseRotatorStages(
2628+
s.value("ClientCompTxPhaseRotatorStages", "0").toInt());
26112629

26122630
// Load the generalised chain — stored as a comma-separated list of
26132631
// stage names (e.g. "Gate,Eq,DeEss,Comp,Tube,Enh"). Migrate from
@@ -2661,6 +2679,10 @@ void AudioEngine::saveClientCompSettings() const
26612679
s.setValue("ClientCompTxLimEnabled", toBool(m_clientCompTx->limiterEnabled()));
26622680
s.setValue("ClientCompTxLimCeilingDb",
26632681
QString::number(m_clientCompTx->limiterCeilingDb()));
2682+
s.setValue("ClientCompTxDriveDb",
2683+
QString::number(m_clientCompTx->driveDb()));
2684+
s.setValue("ClientCompTxPhaseRotatorStages",
2685+
QString::number(m_clientCompTx->phaseRotatorStages()));
26642686
// Chain stages persist as a comma-separated name list — already
26652687
// written live by setTxChainStages() but re-emitted here so a
26662688
// saveClientCompSettings() call dumps everything in sync.
@@ -2841,6 +2863,8 @@ void AudioEngine::loadClientDeEssSettings()
28412863
s.value("ClientDeEssTxAttackMs", "1.0").toFloat());
28422864
m_clientDeEssTx->setReleaseMs(
28432865
s.value("ClientDeEssTxReleaseMs", "100.0").toFloat());
2866+
m_clientDeEssTx->setSlopeStages(
2867+
s.value("ClientDeEssTxSlopeStages", "2").toInt());
28442868
}
28452869

28462870
void AudioEngine::saveClientDeEssSettings() const
@@ -2862,6 +2886,8 @@ void AudioEngine::saveClientDeEssSettings() const
28622886
QString::number(m_clientDeEssTx->attackMs()));
28632887
s.setValue("ClientDeEssTxReleaseMs",
28642888
QString::number(m_clientDeEssTx->releaseMs()));
2889+
s.setValue("ClientDeEssTxSlopeStages",
2890+
QString::number(m_clientDeEssTx->slopeStages()));
28652891
}
28662892

28672893
void AudioEngine::loadClientDeEssRxSettings()
@@ -2882,6 +2908,8 @@ void AudioEngine::loadClientDeEssRxSettings()
28822908
s.value("ClientDeEssRxAttackMs", "1.0").toFloat());
28832909
m_clientDeEssRx->setReleaseMs(
28842910
s.value("ClientDeEssRxReleaseMs", "100.0").toFloat());
2911+
m_clientDeEssRx->setSlopeStages(
2912+
s.value("ClientDeEssRxSlopeStages", "2").toInt());
28852913
}
28862914

28872915
void AudioEngine::saveClientDeEssRxSettings() const
@@ -2903,6 +2931,8 @@ void AudioEngine::saveClientDeEssRxSettings() const
29032931
QString::number(m_clientDeEssRx->attackMs()));
29042932
s.setValue("ClientDeEssRxReleaseMs",
29052933
QString::number(m_clientDeEssRx->releaseMs()));
2934+
s.setValue("ClientDeEssRxSlopeStages",
2935+
QString::number(m_clientDeEssRx->slopeStages()));
29062936
}
29072937

29082938
void AudioEngine::loadClientTubeSettings()

src/core/ClientComp.cpp

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#include "ClientComp.h"
22

3+
#include "ClientPhaseRotator.h"
4+
35
#include <algorithm>
46
#include <cmath>
57

@@ -28,14 +30,27 @@ float coeffFromMs(float ms, double sampleRate) noexcept
2830

2931
} // namespace
3032

31-
ClientComp::ClientComp() = default;
33+
ClientComp::ClientComp()
34+
: m_phaseRotator(std::make_unique<ClientPhaseRotator>())
35+
{
36+
// Phase rotator stays internally "always enabled" — its stages
37+
// count gates whether it does anything (0 = pass-through). Drive
38+
// is owned by ClientComp; the rotator runs unconditionally and is
39+
// followed by the drive gain before the comp curve sees the signal.
40+
m_phaseRotator->setEnabled(true);
41+
m_phaseRotator->setStages(0);
42+
}
43+
44+
ClientComp::~ClientComp() = default;
3245

3346
void ClientComp::prepare(double sampleRate)
3447
{
3548
m_sampleRate = sampleRate;
3649
m_envLin = 0.0f;
3750
m_limEnvLin = 0.0f;
3851

52+
if (m_phaseRotator) m_phaseRotator->prepare(sampleRate);
53+
3954
// Bump version so recacheIfDirty rebuilds coefficients on first block.
4055
m_atomics.version.fetch_add(1, std::memory_order_release);
4156
recacheIfDirty();
@@ -122,6 +137,24 @@ void ClientComp::setLimiterCeilingDb(float db) noexcept
122137
float ClientComp::limiterCeilingDb() const noexcept
123138
{ return m_atomics.limCeilingDb.load(std::memory_order_relaxed); }
124139

140+
void ClientComp::setDriveDb(float db) noexcept
141+
{
142+
m_atomics.driveDb.store(std::clamp(db, 0.0f, 18.0f),
143+
std::memory_order_relaxed);
144+
m_atomics.version.fetch_add(1, std::memory_order_release);
145+
}
146+
float ClientComp::driveDb() const noexcept
147+
{ return m_atomics.driveDb.load(std::memory_order_relaxed); }
148+
149+
void ClientComp::setPhaseRotatorStages(int stages) noexcept
150+
{
151+
m_atomics.phaseRotatorStages.store(std::clamp(stages, 0, 6),
152+
std::memory_order_relaxed);
153+
m_atomics.version.fetch_add(1, std::memory_order_release);
154+
}
155+
int ClientComp::phaseRotatorStages() const noexcept
156+
{ return m_atomics.phaseRotatorStages.load(std::memory_order_relaxed); }
157+
125158
void ClientComp::reset() noexcept
126159
{
127160
m_envLin = 0.0f;
@@ -162,6 +195,13 @@ void ClientComp::recacheIfDirty() noexcept
162195
// Limiter ballistics: very fast attack, moderately fast release.
163196
m_cached.limAttackCoeff = coeffFromMs(0.1f, m_sampleRate);
164197
m_cached.limReleaseCoeff = coeffFromMs(50.0f, m_sampleRate);
198+
199+
m_cached.driveLin = dbToLin(
200+
m_atomics.driveDb.load(std::memory_order_relaxed));
201+
m_cached.phaseRotatorStages =
202+
m_atomics.phaseRotatorStages.load(std::memory_order_relaxed);
203+
if (m_phaseRotator)
204+
m_phaseRotator->setStages(m_cached.phaseRotatorStages);
165205
}
166206

167207
float ClientComp::staticCurveGainDb(float envDb) const noexcept
@@ -198,6 +238,24 @@ void ClientComp::process(float* interleaved, int frames, int channels) noexcept
198238
recacheIfDirty();
199239
const bool enabled = m_atomics.enabled.load(std::memory_order_acquire);
200240

241+
// Pre-comp PAPR shaping (#2887). The rotator runs first to
242+
// symmetrize asymmetric voice peaks; the drive gain then pushes
243+
// more material across the threshold so the existing comp curve
244+
// engages harder and the brickwall limiter below contains the
245+
// resulting hot peaks. These run independent of the comp's enabled
246+
// flag — they're a useful pair even when the comp curve itself is
247+
// bypassed (Drive + Limiter alone is the simplest broadcast PAPR
248+
// setup). Their work happens *before* the input peak meter so the
249+
// GR readout reflects the comp's workload at the boosted level.
250+
if (m_cached.phaseRotatorStages > 0 && m_phaseRotator) {
251+
m_phaseRotator->process(interleaved, frames, channels);
252+
}
253+
if (m_cached.driveLin != 1.0f) {
254+
const float gain = m_cached.driveLin;
255+
const int n = frames * channels;
256+
for (int i = 0; i < n; ++i) interleaved[i] *= gain;
257+
}
258+
201259
float inPeakLin = 0.0f;
202260
float outPeakLin = 0.0f;
203261
float worstGrDb = 0.0f; // most negative (largest reduction)
@@ -230,7 +288,14 @@ void ClientComp::process(float* interleaved, int frames, int channels) noexcept
230288
const float envDb = linToDb(std::max(m_envLin, 1e-6f));
231289
const float gainDb = staticCurveGainDb(envDb);
232290
if (gainDb < worstGrDb) worstGrDb = gainDb;
233-
gainLin = dbToLin(gainDb) * makeup;
291+
// Auto-makeup tracks Drive (#2887). Without this, dialing
292+
// Drive up makes the comp do more GR than the fixed Makeup
293+
// can compensate, and net RMS DROPS. Linking the post-curve
294+
// gain to Drive matches the broadcast-Optimod model: Drive
295+
// pushes more material into the curve AND adds equal gain
296+
// back at the output, so the user's fixed Makeup setting
297+
// stays a clean "post-everything trim" knob.
298+
gainLin = dbToLin(gainDb) * makeup * m_cached.driveLin;
234299
}
235300
l *= gainLin;
236301
r *= gainLin;

src/core/ClientComp.h

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
#include <atomic>
44
#include <cstdint>
5+
#include <memory>
56

67
namespace AetherSDR {
78

9+
class ClientPhaseRotator;
10+
811
// Client-side TX dynamics processor — the foundation of the Pro-XL-style
912
// compression chain (#1661). Phase 1 scope: core feed-forward compressor
1013
// with soft-knee static curve + attack/release envelope follower, plus a
@@ -22,7 +25,7 @@ namespace AetherSDR {
2225
class ClientComp {
2326
public:
2427
ClientComp();
25-
~ClientComp() = default;
28+
~ClientComp();
2629

2730
ClientComp(const ClientComp&) = delete;
2831
ClientComp& operator=(const ClientComp&) = delete;
@@ -54,6 +57,20 @@ class ClientComp {
5457
void setLimiterCeilingDb(float db) noexcept;
5558
float limiterCeilingDb() const noexcept;
5659

60+
// Pre-comp Drive (#2887). Linear gain in dB applied BEFORE the
61+
// compressor sees the signal. Pushes more material across the
62+
// threshold so the comp engages harder, raising RMS while the
63+
// existing limiter holds peaks. 0 dB = bypass.
64+
void setDriveDb(float db) noexcept; // 0 .. 18 dB
65+
float driveDb() const noexcept;
66+
67+
// Pre-comp phase rotator (#2887). Cascade of N second-order all-pass
68+
// filters at staggered audio centres — symmetrizes asymmetric voice
69+
// peaks so the harder compression and downstream limiting don't
70+
// sound trashy. 0 = off (bypass), 4 = broadcast default, 6 = max.
71+
void setPhaseRotatorStages(int stages) noexcept;
72+
int phaseRotatorStages() const noexcept;
73+
5774
// Audio thread — process in place. channels must be 1 or 2.
5875
void process(float* interleaved, int frames, int channels) noexcept;
5976

@@ -82,6 +99,8 @@ class ClientComp {
8299
std::atomic<float> makeupDb{0.0f};
83100
std::atomic<bool> limEnabled{true};
84101
std::atomic<float> limCeilingDb{-1.0f};
102+
std::atomic<float> driveDb{0.0f};
103+
std::atomic<int> phaseRotatorStages{0};
85104
std::atomic<uint64_t> version{0};
86105
};
87106

@@ -96,6 +115,8 @@ class ClientComp {
96115
float limCeilingLin{0.891f}; // 10^(-1/20)
97116
float limAttackCoeff{0.0f};
98117
float limReleaseCoeff{0.0f};
118+
float driveLin{1.0f}; // 10^(driveDb / 20)
119+
int phaseRotatorStages{0};
99120
};
100121

101122
// Meter snapshots — atomic stores on the audio thread after each
@@ -124,6 +145,11 @@ class ClientComp {
124145
// the peak (average of log|sin|), which is wrong for a peak compressor.
125146
float m_envLin{0.0f};
126147
float m_limEnvLin{0.0f}; // limiter envelope (linear)
148+
149+
// Pre-compressor phase rotator (#2887). Owned here so the comp's
150+
// Drive + Phase + threshold + ratio + ceiling all act as a single
151+
// PAPR-reducing block on the chain's COMP card.
152+
std::unique_ptr<ClientPhaseRotator> m_phaseRotator;
127153
};
128154

129155
} // namespace AetherSDR

src/core/ClientDeEss.cpp

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,22 @@ void ClientDeEss::setReleaseMs(float ms) noexcept
131131
float ClientDeEss::releaseMs() const noexcept
132132
{ return m_atomics.releaseMs.load(std::memory_order_relaxed); }
133133

134+
void ClientDeEss::setSlopeStages(int stages) noexcept
135+
{
136+
m_atomics.slopeStages.store(std::clamp(stages, 1, kMaxSlopeStages),
137+
std::memory_order_relaxed);
138+
m_atomics.version.fetch_add(1, std::memory_order_release);
139+
}
140+
int ClientDeEss::slopeStages() const noexcept
141+
{ return m_atomics.slopeStages.load(std::memory_order_relaxed); }
142+
134143
void ClientDeEss::reset() noexcept
135144
{
136145
m_envLin = 0.0f;
137-
m_bpL = {};
138-
m_bpR = {};
146+
for (int i = 0; i < kMaxSlopeStages; ++i) {
147+
m_bpL[i] = {};
148+
m_bpR[i] = {};
149+
}
139150
}
140151

141152
float ClientDeEss::inputPeakDb() const noexcept
@@ -164,6 +175,9 @@ void ClientDeEss::recacheIfDirty() noexcept
164175
m_atomics.attackMs.load(std::memory_order_relaxed), m_sampleRate);
165176
m_cached.releaseCoeff = coeffFromMs(
166177
m_atomics.releaseMs.load(std::memory_order_relaxed), m_sampleRate);
178+
m_cached.slopeStages = std::clamp(
179+
m_atomics.slopeStages.load(std::memory_order_relaxed),
180+
1, kMaxSlopeStages);
167181
}
168182

169183
float ClientDeEss::staticCurveGainDb(float envDb) const noexcept
@@ -203,11 +217,22 @@ void ClientDeEss::process(float* interleaved, int frames, int channels) noexcept
203217
const float inAbs = std::max(std::fabs(l), std::fabs(r));
204218
if (inAbs > inPeakLin) inPeakLin = inAbs;
205219

206-
// Sidechain: run the full signal through the bandpass filter
207-
// per channel. The filters stay live even when disabled so
208-
// their state is warm if the user toggles enable.
209-
const float scL = processBiquad(l, bp, m_bpL);
210-
const float scR = (channels == 2) ? processBiquad(r, bp, m_bpR) : scL;
220+
// Sidechain: cascade N bandpass biquads per channel where N
221+
// is the user-selectable slope-stage count (1..kMaxSlopeStages).
222+
// Each stage adds 12 dB/oct of rolloff, so the de-esser
223+
// notch can be dialled from broad (1 stage, 12 dB/oct) to
224+
// surgical (4 stages, 48 dB/oct). Filters stay live even
225+
// when disabled so their state is warm if the user toggles
226+
// enable.
227+
float scL = l;
228+
float scR = (channels == 2) ? r : l;
229+
const int stages = m_cached.slopeStages;
230+
for (int s = 0; s < stages; ++s) {
231+
scL = processBiquad(scL, bp, m_bpL[s]);
232+
if (channels == 2)
233+
scR = processBiquad(scR, bp, m_bpR[s]);
234+
}
235+
if (channels != 2) scR = scL;
211236
const float scAbs = std::max(std::fabs(scL), std::fabs(scR));
212237
if (scAbs > scPeakLin) scPeakLin = scAbs;
213238

@@ -223,8 +248,21 @@ void ClientDeEss::process(float* interleaved, int frames, int channels) noexcept
223248
gainLin = dbToLin(gainDb);
224249
}
225250

226-
l *= gainLin;
227-
r *= gainLin;
251+
// Split-band de-essing: only attenuate the sibilant band
252+
// (the bandpass output), leave lows + mids untouched. The
253+
// bandpass is a constant-0-dB-peak filter, so subtracting
254+
// bp*(1-gain) from the full signal reduces only the 4-8 kHz
255+
// slice by the gain amount. The original broadband-attenuate
256+
// implementation pulled the whole signal down on sibilance,
257+
// crashing RMS during S-heavy phrases.
258+
// output = full + bp * (gain - 1)
259+
// gain = 1 → output = full (no change, perfect pass-through)
260+
// gain = 0.5→ output = full - 0.5*bp (HF band reduced by 6 dB)
261+
if (enabled && gainLin < 0.9999f) {
262+
const float scGainDelta = gainLin - 1.0f;
263+
l += scL * scGainDelta;
264+
r += scR * scGainDelta;
265+
}
228266
interleaved[f * channels] = l;
229267
if (channels == 2) interleaved[f * channels + 1] = r;
230268
}

0 commit comments

Comments
 (0)