Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,9 @@ jobs:
- name: Build
run: cmake --build build -j$(sysctl -n hw.ncpu)

- name: Run macOS MNR regression test
run: ctest --test-dir build -R '^mac_nr_filter_test$' --output-on-failure

- name: Run ASR GPU probe test (#4535 regression)
# arm64 runner = Apple Silicon: exercises the real Metal device init and
# embedded precompiled-metallib load on every PR. On the pre-#4553 code
Expand Down
8 changes: 8 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4216,6 +4216,14 @@ target_link_libraries(radio_status_ownership_test PRIVATE Qt6::Core)
enable_testing()

if(APPLE)
add_executable(mac_nr_filter_test
tests/mac_nr_filter_test.cpp
src/core/MacNRFilter.cpp
)
target_include_directories(mac_nr_filter_test PRIVATE src)
target_link_libraries(mac_nr_filter_test PRIVATE Qt6::Core "-framework Accelerate")
add_test(NAME mac_nr_filter_test COMMAND mac_nr_filter_test)

Comment thread
rfoust marked this conversation as resolved.
add_executable(mac_startup_abort_guard_test
tests/mac_startup_abort_guard_test.cpp
src/MacStartupAbortGuard.cpp
Expand Down
132 changes: 79 additions & 53 deletions src/core/MacNRFilter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,12 @@ MacNRFilter::MacNRFilter()
m_outAccumR.clear();

// Noise estimator
m_noiseEst .assign(NBINS, 1e-6f);
m_prevGain .assign(NBINS, 1.0f);
m_prevPow .assign(NBINS, 1e-6f);
m_powerBuf .assign(NBINS, 0.0f);
m_gainBuf .assign(NBINS, 1.0f);
m_smoothGain.assign(NBINS, 1.0f);

// Warm up noise history so the estimator has a sensible floor from
// frame zero; this prevents the algorithm applying excessive gain in
// the first ~267 ms of operation.
for (int h = 0; h < HIST; ++h)
for (int k = 0; k < NBINS; ++k)
m_powerHistory[h][k] = 1e-6f;
m_noiseEst .assign(NBINS, 0.0f);
m_smoothedPower .assign(NBINS, 0.0f);
m_prevPostSnr .assign(NBINS, 1.0f);
m_filterGain .assign(NBINS, 1.0f);
m_powerBuf .assign(NBINS, 0.0f);
m_gainBuf .assign(NBINS, 1.0f);
}

MacNRFilter::~MacNRFilter()
Expand Down Expand Up @@ -81,23 +74,20 @@ void MacNRFilter::reset()
m_inAccumR.assign(N, 0.0f);

// Reset noise estimator
std::fill(m_noiseEst .begin(), m_noiseEst .end(), 1e-6f);
std::fill(m_prevGain .begin(), m_prevGain .end(), 1.0f);
std::fill(m_prevPow .begin(), m_prevPow .end(), 1e-6f);
std::fill(m_gainBuf .begin(), m_gainBuf .end(), 1.0f);
std::fill(m_smoothGain.begin(), m_smoothGain.end(), 1.0f);

for (int h = 0; h < HIST; ++h)
for (int k = 0; k < NBINS; ++k)
m_powerHistory[h][k] = 1e-6f;

m_histIdx = 0;
m_frameCount = 0;
std::fill(m_noiseEst.begin(), m_noiseEst.end(), 0.0f);
std::fill(m_smoothedPower.begin(), m_smoothedPower.end(), 0.0f);
std::fill(m_prevPostSnr.begin(), m_prevPostSnr.end(), 1.0f);
std::fill(m_filterGain.begin(), m_filterGain.end(), 1.0f);
std::fill(m_gainBuf.begin(), m_gainBuf.end(), 1.0f);
std::memset(m_powerHistory, 0, sizeof(m_powerHistory));

m_histIdx = 0;
m_noiseInitialized = false;
}

// ── updateGainFromFrame ─────────────────────────────────────────────────────
//
// inBuf: N mono analysis samples. Updates m_prevGain, which is then reused for
// inBuf: N mono analysis samples. Updates m_filterGain, which is then reused for
// independent left/right synthesis so balance survives the MNR stage.

void MacNRFilter::updateGainFromFrame(const float* inBuf)
Expand All @@ -120,19 +110,53 @@ void MacNRFilter::updateGainFromFrame(const float* inBuf)
for (int k = 1; k < H; ++k)
m_powerBuf[k] = m_splitRe[k] * m_splitRe[k] + m_splitIm[k] * m_splitIm[k];

// ── 3. Minimum-statistics noise floor update ─────────────────────────────
for (int k = 0; k < NBINS; ++k)
m_powerHistory[m_histIdx][k] = m_powerBuf[k];
// ── 3. Smoothed-periodogram minimum-statistics noise update ─────────────
// A raw periodogram is exponentially distributed, so a 25-frame raw
// minimum estimates roughly 1/25 of stationary Gaussian noise power.
// Smooth first, then calibrate the history minimum. Ignore frames below a
// usable -80 dBFS RMS floor: latency prefill, mute/squelch, and TX silence
// must not initialize or collapse the learned noise estimate.
double framePowerSum = 0.0;
for (int i = 0; i < N; ++i) {
framePowerSum += static_cast<double>(inBuf[i]) * inBuf[i];
}
const float meanFramePower = static_cast<float>(framePowerSum / N);
if (meanFramePower < MIN_FRAME_POWER) {
return;
}

m_histIdx = (m_histIdx + 1) % HIST;
if (!m_noiseInitialized) {
for (int k = 0; k < NBINS; ++k) {
m_smoothedPower[k] = m_powerBuf[k];
// Enabling during speech must not initially classify the entire
// wanted signal as noise. Start conservatively; NOISE_RISE then
// walks the estimate upward without an over-suppressive transient.
m_noiseEst[k] = std::max(INITIAL_NOISE_FRACTION * m_powerBuf[k],
1e-10f);
for (int h = 0; h < HIST; ++h) {
m_powerHistory[h][k] = m_smoothedPower[k];
}
}
m_noiseInitialized = true;
} else {
for (int k = 0; k < NBINS; ++k) {
m_smoothedPower[k] = POWER_SMOOTH * m_smoothedPower[k]
+ (1.0f - POWER_SMOOTH) * m_powerBuf[k];
m_powerHistory[m_histIdx][k] = m_smoothedPower[k];
}

// Minimum over history window, then apply bias correction
for (int k = 0; k < NBINS; ++k) {
float minPow = m_powerHistory[0][k];
for (int h = 1; h < HIST; ++h)
minPow = std::min(minPow, m_powerHistory[h][k]);
m_noiseEst[k] = BIAS * minPow;
for (int k = 0; k < NBINS; ++k) {
float minPower = m_powerHistory[0][k];
for (int h = 1; h < HIST; ++h) {
minPower = std::min(minPower, m_powerHistory[h][k]);
}
const float candidate = std::max(MINSTAT_BIAS * minPower, 1e-10f);
m_noiseEst[k] = candidate < m_noiseEst[k]
? candidate
: NOISE_RISE * m_noiseEst[k] + (1.0f - NOISE_RISE) * candidate;
}
Comment thread
rfoust marked this conversation as resolved.
}
m_histIdx = (m_histIdx + 1) % HIST;

// ── 4. Decision-directed MMSE-Wiener gain ────────────────────────────────
for (int k = 0; k < NBINS; ++k) {
Expand All @@ -141,7 +165,7 @@ void MacNRFilter::updateGainFromFrame(const float* inBuf)

// A-priori SNR (decision-directed: blend previous clean estimate
// with new a-posteriori observation)
const float priorSnr = ALPHA * (m_prevGain[k] * m_prevGain[k]) * m_prevPow[k]
const float priorSnr = ALPHA * (m_filterGain[k] * m_filterGain[k]) * m_prevPostSnr[k]
+ (1.0f - ALPHA) * std::max(postSnr - 1.0f, 0.0f);

// Raw Wiener gain, clamped to [FLOOR, 1]
Expand All @@ -150,13 +174,9 @@ void MacNRFilter::updateGainFromFrame(const float* inBuf)

// ── Temporal gain smoothing ──────────────────────────────────────
// Suppresses "musical noise" (rapid frame-to-frame gain swings)
m_smoothGain[k] = GSMOOTH * m_smoothGain[k] + (1.0f - GSMOOTH) * m_gainBuf[k];

// Effective gain: strength=0 → bypass (1.0), strength=1 → full NR
const float appliedGain = 1.0f - m_strength.load() * (1.0f - m_smoothGain[k]);

m_prevGain[k] = appliedGain;
m_prevPow [k] = m_powerBuf[k];
m_filterGain[k] = GSMOOTH * m_filterGain[k]
+ (1.0f - GSMOOTH) * m_gainBuf[k];
m_prevPostSnr[k] = postSnr;
}
}

Expand All @@ -165,7 +185,8 @@ void MacNRFilter::updateGainFromFrame(const float* inBuf)
// inBuf : N channel samples
// outBuf : N synthesis samples (added into OLA buffer by caller)

void MacNRFilter::synthesizeFrameWithCurrentGain(const float* inBuf, float* outBuf)
void MacNRFilter::synthesizeFrameWithCurrentGain(const float* inBuf, float* outBuf,
float synthesisStrength)
{
vDSP_vmul(inBuf, 1, m_window.data(), 1, m_frameBuf.data(), 1, N);

Expand All @@ -174,11 +195,15 @@ void MacNRFilter::synthesizeFrameWithCurrentGain(const float* inBuf, float* outB
vDSP_fft_zrip(m_fftSetup, &sc, 1, LOG2N, kFFTDirection_Forward);

// ── Apply shared gain to spectrum ───────────────────────────────────────
m_splitRe[0] *= m_prevGain[0]; // DC
m_splitIm[0] *= m_prevGain[H]; // Nyquist (stored in im[0] by vDSP)
const auto synthesisGain = [synthesisStrength](float filterGain) {
return 1.0f - synthesisStrength * (1.0f - filterGain);
};
m_splitRe[0] *= synthesisGain(m_filterGain[0]); // DC
m_splitIm[0] *= synthesisGain(m_filterGain[H]); // Nyquist (stored in im[0] by vDSP)
for (int k = 1; k < H; ++k) {
m_splitRe[k] *= m_prevGain[k];
m_splitIm[k] *= m_prevGain[k];
const float gain = synthesisGain(m_filterGain[k]);
m_splitRe[k] *= gain;
m_splitIm[k] *= gain;
}

// ── 6. Inverse real FFT ──────────────────────────────────────────────────
Expand Down Expand Up @@ -219,11 +244,14 @@ QByteArray MacNRFilter::process(const QByteArray& pcm24kStereo)
// ── OLA processing — emit one hop per iteration ──────────────────────────
while (static_cast<int>(m_inAccum.size()) >= N) {
updateGainFromFrame(m_inAccum.data());
// Snapshot once per frame so both channels receive the same shared
// mask even if the UI changes strength while this block is processed.
const float synthesisStrength = m_strength.load();

std::vector<float> outFrameL(N, 0.0f);
std::vector<float> outFrameR(N, 0.0f);
synthesizeFrameWithCurrentGain(m_inAccumL.data(), outFrameL.data());
synthesizeFrameWithCurrentGain(m_inAccumR.data(), outFrameR.data());
synthesizeFrameWithCurrentGain(m_inAccumL.data(), outFrameL.data(), synthesisStrength);
synthesizeFrameWithCurrentGain(m_inAccumR.data(), outFrameR.data(), synthesisStrength);

// Add into OLA buffer
for (int i = 0; i < N; ++i) {
Expand All @@ -247,8 +275,6 @@ QByteArray MacNRFilter::process(const QByteArray& pcm24kStereo)
m_inAccum.erase(m_inAccum.begin(), m_inAccum.begin() + H);
m_inAccumL.erase(m_inAccumL.begin(), m_inAccumL.begin() + H);
m_inAccumR.erase(m_inAccumR.begin(), m_inAccumR.begin() + H);

++m_frameCount;
}

// ── Drain exactly nFrames processed L/R samples → stereo float32 ────────
Expand Down
45 changes: 28 additions & 17 deletions src/core/MacNRFilter.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#ifdef __APPLE__

#include <QByteArray>
#include <algorithm>
#include <atomic>
#include <vector>
#include <Accelerate/Accelerate.h>
Expand All @@ -14,12 +15,15 @@ namespace AetherSDR {
// MMSE-Wiener filter with minimum-statistics noise floor tracking.
// Uses vDSP's real FFT, hardware-accelerated on Apple Silicon via AMX.
//
// Improvements over original (v0.7.9):
// Design characteristics:
// - Processes at 24 kHz natively — no 24↔48 kHz resampling.
// Eliminates the double-resampler chain that caused clicks, phase
// distortion, and int16 mid-point quantisation noise.
// - 512-point FFT at 24 kHz → 46.9 Hz/bin (2× better than NR2).
// - 25-frame noise history (~267 ms) vs 10 frames (~107 ms) before.
// - Smoothed-periodogram minimum statistics with calibrated bias and
// rate-limited upward tracking, using a 25-frame history (~267 ms).
// - Near-silent frames freeze the learned noise floor so mute, squelch, and
// TX gaps cannot collapse it and cause a burst when audio resumes.
// - Per-bin Wiener gain is temporally smoothed (GSMOOTH) to suppress
// musical-noise artefacts caused by rapid frame-to-frame gain swings.
// - Output accumulator ensures exact byte-count match with no silence
Expand Down Expand Up @@ -52,7 +56,8 @@ class MacNRFilter {

private:
void updateGainFromFrame(const float* inBuf);
void synthesizeFrameWithCurrentGain(const float* inBuf, float* outBuf);
void synthesizeFrameWithCurrentGain(const float* inBuf, float* outBuf,
float synthesisStrength);

// ── FFT parameters ─────────────────────────────────────────────────
static constexpr int LOG2N = 9; // log2(512)
Expand All @@ -61,12 +66,18 @@ class MacNRFilter {
static constexpr int NBINS = N / 2 + 1; // 257 unique spectral bins

// ── Algorithm tuning ───────────────────────────────────────────────
static constexpr int HIST = 25; // noise history frames (~267 ms)
static constexpr float ALPHA = 0.92f; // decision-directed smoothing
static constexpr float OVER = 2.0f; // oversubtraction — punches harder at noise bins
static constexpr float FLOOR = 0.05f; // minimum Wiener gain (~26 dB max suppression)
static constexpr float BIAS = 1.2f; // min-stats bias correction
static constexpr float GSMOOTH = 0.70f; // temporal gain smoothing (faster response)
static constexpr int HIST = 25; // noise history frames (~267 ms)
static constexpr float POWER_SMOOTH = 0.80f; // smoothed-periodogram coefficient
static constexpr float MINSTAT_BIAS = 1.85f; // calibrated for a 25-frame smoothed minimum
static constexpr float NOISE_RISE = 0.90f; // avoid chasing speech on upward noise updates
static constexpr float MIN_FRAME_POWER = 1e-8f; // freeze estimator below -80 dBFS RMS
static constexpr float INITIAL_NOISE_FRACTION = 0.25f; // avoid learning speech at enable time
static constexpr float ALPHA = 0.92f; // decision-directed smoothing
// Full strength deliberately drives stationary bins near FLOOR. The user
// strength control blends this mask with dry audio for gentler reduction.
static constexpr float OVER = 2.0f;
static constexpr float FLOOR = 0.05f; // minimum Wiener gain (~26 dB max suppression)
static constexpr float GSMOOTH = 0.70f; // temporal gain smoothing (faster response)

// ── vDSP state ─────────────────────────────────────────────────────
FFTSetup m_fftSetup{nullptr};
Expand All @@ -86,15 +97,15 @@ class MacNRFilter {
std::vector<float> m_outAccumR; // processed 24 kHz right-channel output

// ── Noise estimator state ─────────────────────────────────────────
float m_powerHistory[HIST][NBINS]{};
float m_powerHistory[HIST][NBINS]{}; // smoothed periodograms
int m_histIdx{0};
std::vector<float> m_noiseEst; // current noise floor estimate [NBINS]
std::vector<float> m_prevGain; // previous-frame Wiener gain [NBINS]
std::vector<float> m_prevPow; // previous-frame power spectrum [NBINS]
std::vector<float> m_powerBuf; // current power spectrum [NBINS]
std::vector<float> m_gainBuf; // raw Wiener gain per bin [NBINS]
std::vector<float> m_smoothGain; // temporally smoothed gain [NBINS]
int m_frameCount{0};
std::vector<float> m_noiseEst; // current noise floor estimate [NBINS]
std::vector<float> m_smoothedPower; // current smoothed periodogram [NBINS]
std::vector<float> m_prevPostSnr; // previous a-posteriori SNR [NBINS]
std::vector<float> m_filterGain; // unblended synthesis mask [NBINS]
std::vector<float> m_powerBuf; // current power spectrum [NBINS]
std::vector<float> m_gainBuf; // raw Wiener gain per bin [NBINS]
bool m_noiseInitialized{false};

std::atomic<float> m_strength{1.0f};
};
Expand Down
2 changes: 0 additions & 2 deletions src/gui/AetherDspDialog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ AetherDspDialog::AetherDspDialog(AudioEngine* audio, QWidget* parent)
this, &AetherDspDialog::nr2AeFilterChanged);
connect(m_widget, &AetherDspWidget::nr2UseOriginalGeometryChanged,
this, &AetherDspDialog::nr2UseOriginalGeometryChanged);
connect(m_widget, &AetherDspWidget::mnrEnabledChanged,
this, &AetherDspDialog::mnrEnabledChanged);
connect(m_widget, &AetherDspWidget::mnrStrengthChanged,
this, &AetherDspDialog::mnrStrengthChanged);
connect(m_widget, &AetherDspWidget::rn2DryMixChanged,
Expand Down
1 change: 0 additions & 1 deletion src/gui/AetherDspDialog.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ class AetherDspDialog : public PersistentDialog {
void nr2AeFilterChanged(bool on);
void nr2UseOriginalGeometryChanged(bool useOriginal);
// MNR parameter changes
void mnrEnabledChanged(bool on);
void mnrStrengthChanged(float value);
// DFNR parameter changes
void rn2DryMixChanged(float mix);
Expand Down
Loading
Loading