diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a3efc684..f65e5783c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index e6cdbb499..6b02f1038 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) + add_executable(mac_startup_abort_guard_test tests/mac_startup_abort_guard_test.cpp src/MacStartupAbortGuard.cpp diff --git a/src/core/MacNRFilter.cpp b/src/core/MacNRFilter.cpp index 4abf873df..c330e1a95 100644 --- a/src/core/MacNRFilter.cpp +++ b/src/core/MacNRFilter.cpp @@ -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() @@ -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) @@ -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(inBuf[i]) * inBuf[i]; + } + const float meanFramePower = static_cast(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; + } } + m_histIdx = (m_histIdx + 1) % HIST; // ── 4. Decision-directed MMSE-Wiener gain ──────────────────────────────── for (int k = 0; k < NBINS; ++k) { @@ -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] @@ -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; } } @@ -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); @@ -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 ────────────────────────────────────────────────── @@ -219,11 +244,14 @@ QByteArray MacNRFilter::process(const QByteArray& pcm24kStereo) // ── OLA processing — emit one hop per iteration ────────────────────────── while (static_cast(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 outFrameL(N, 0.0f); std::vector 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) { @@ -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 ──────── diff --git a/src/core/MacNRFilter.h b/src/core/MacNRFilter.h index 3bdd1015f..0d816f908 100644 --- a/src/core/MacNRFilter.h +++ b/src/core/MacNRFilter.h @@ -3,6 +3,7 @@ #ifdef __APPLE__ #include +#include #include #include #include @@ -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 @@ -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) @@ -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}; @@ -86,15 +97,15 @@ class MacNRFilter { std::vector 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 m_noiseEst; // current noise floor estimate [NBINS] - std::vector m_prevGain; // previous-frame Wiener gain [NBINS] - std::vector m_prevPow; // previous-frame power spectrum [NBINS] - std::vector m_powerBuf; // current power spectrum [NBINS] - std::vector m_gainBuf; // raw Wiener gain per bin [NBINS] - std::vector m_smoothGain; // temporally smoothed gain [NBINS] - int m_frameCount{0}; + std::vector m_noiseEst; // current noise floor estimate [NBINS] + std::vector m_smoothedPower; // current smoothed periodogram [NBINS] + std::vector m_prevPostSnr; // previous a-posteriori SNR [NBINS] + std::vector m_filterGain; // unblended synthesis mask [NBINS] + std::vector m_powerBuf; // current power spectrum [NBINS] + std::vector m_gainBuf; // raw Wiener gain per bin [NBINS] + bool m_noiseInitialized{false}; std::atomic m_strength{1.0f}; }; diff --git a/src/gui/AetherDspDialog.cpp b/src/gui/AetherDspDialog.cpp index 2971d628c..a8f8d998a 100644 --- a/src/gui/AetherDspDialog.cpp +++ b/src/gui/AetherDspDialog.cpp @@ -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, diff --git a/src/gui/AetherDspDialog.h b/src/gui/AetherDspDialog.h index 38da85479..586babe55 100644 --- a/src/gui/AetherDspDialog.h +++ b/src/gui/AetherDspDialog.h @@ -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); diff --git a/src/gui/AetherDspWidget.cpp b/src/gui/AetherDspWidget.cpp index a524be2eb..1abdb530a 100644 --- a/src/gui/AetherDspWidget.cpp +++ b/src/gui/AetherDspWidget.cpp @@ -453,7 +453,6 @@ void AetherDspWidget::resetCurrentTab() if (m_nr4MaskingSlider) m_nr4MaskingSlider->setValue(50); if (m_nr4SuppressionSlider)m_nr4SuppressionSlider->setValue(50); } else if (name == "MNR") { - if (m_mnrEnableCheck) m_mnrEnableCheck->setChecked(false); if (m_mnrStrengthSlider) m_mnrStrengthSlider->setValue(100); } else if (name == "DFNR") { if (m_dfnrAttenSlider) m_dfnrAttenSlider->setValue(100); @@ -1107,13 +1106,9 @@ QWidget* AetherDspWidget::buildMnrPage() auto labelStyle = QStringLiteral("QLabel { color: #8090a0; font-size: 11px; }"); auto valStyle = QStringLiteral("QLabel { color: #c8d8e8; font-size: 11px; min-width: 40px; }"); - m_mnrEnableCheck = new QCheckBox("Enable MNR (macOS only)"); - m_mnrEnableCheck->setToolTip("MMSE-Wiener spectral noise reduction with asymmetric gain smoothing.\n" - "Removes consistent background noise while preserving speech quality."); { auto* hdrRow = new QHBoxLayout; hdrRow->setContentsMargins(0, 0, 0, 0); - hdrRow->addWidget(m_mnrEnableCheck); hdrRow->addStretch(1); auto* resetBtn = makeResetIconButton(); connect(resetBtn, &QPushButton::clicked, @@ -1121,13 +1116,6 @@ QWidget* AetherDspWidget::buildMnrPage() hdrRow->addWidget(resetBtn); vbox->addLayout(hdrRow); } - connect(m_mnrEnableCheck, &QCheckBox::toggled, this, [this](bool checked) { - auto& s = AppSettings::instance(); - s.setValue("MnrEnabled", checked ? "True" : "False"); - s.save(); - emit mnrEnabledChanged(checked); - }); - { auto* row = new QHBoxLayout; auto* lbl = new QLabel("Strength"); @@ -1135,10 +1123,14 @@ QWidget* AetherDspWidget::buildMnrPage() row->addWidget(lbl); m_mnrStrengthSlider = new GuardedSlider(Qt::Horizontal); + m_mnrStrengthSlider->setObjectName(QStringLiteral("mnrStrengthSlider")); + m_mnrStrengthSlider->setAccessibleName(QStringLiteral("MNR Strength")); + m_mnrStrengthSlider->setAccessibleDescription( + QStringLiteral("Noise-reduction synthesis strength from 0 to 100 percent")); m_mnrStrengthSlider->setRange(0, 100); m_mnrStrengthSlider->setValue(100); applyPrimarySliderStyle(m_mnrStrengthSlider); - m_mnrStrengthSlider->setToolTip("Adjust noise reduction aggressiveness (0 = mild, 100 = maximum)"); + m_mnrStrengthSlider->setToolTip("Adjust noise reduction aggressiveness (0 = bypass, 100 = maximum)"); row->addWidget(m_mnrStrengthSlider, 1); m_mnrStrengthLabel = new QLabel("100%"); @@ -1156,8 +1148,8 @@ QWidget* AetherDspWidget::buildMnrPage() }); } - auto* info = new QLabel("Asymmetric temporal smoothing: fast release (~15ms) for quick noise suppression,\n" - "gentle attack (~64ms) to preserve speech transients without artifacts."); + auto* info = new QLabel("Smoothed minimum-statistics tracking learns steady background noise,\n" + "then applies a shared Wiener mask that preserves stereo balance."); info->setWordWrap(true); AetherSDR::ThemeManager::instance().applyStyleSheet(info, "QLabel { color: {{color.text.secondary}}; font-size: 11px; }"); vbox->addSpacing(8); @@ -1818,9 +1810,7 @@ void AetherDspWidget::syncFromEngine() auto& s = AppSettings::instance(); - if (m_mnrEnableCheck) { - { QSignalBlocker sb(m_mnrEnableCheck); - m_mnrEnableCheck->setChecked(m_audio->mnrEnabled()); } + if (m_mnrStrengthSlider) { { QSignalBlocker sb(m_mnrStrengthSlider); int strength = static_cast(m_audio->mnrStrength() * 100.0f); m_mnrStrengthSlider->setValue(strength); diff --git a/src/gui/AetherDspWidget.h b/src/gui/AetherDspWidget.h index a3c7f4494..b30070714 100644 --- a/src/gui/AetherDspWidget.h +++ b/src/gui/AetherDspWidget.h @@ -75,7 +75,6 @@ class AetherDspWidget : public QWidget { 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); @@ -152,7 +151,6 @@ class AetherDspWidget : public QWidget { QLabel* m_nr2QsppLabel{nullptr}; // MNR controls - QCheckBox* m_mnrEnableCheck{nullptr}; QSlider* m_mnrStrengthSlider{nullptr}; QLabel* m_mnrStrengthLabel{nullptr}; diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index a3bf6ccce..e213c479f 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -1454,9 +1454,6 @@ void MainWindow::wireAetherDspWidget(AetherDspWidget* w) QMetaObject::invokeMethod(m_audio, [this, v]() { m_audio->setDfnrPostFilterBeta(v); }); }); // MNR - connect(w, &AetherDspWidget::mnrEnabledChanged, this, [this](bool on) { - QMetaObject::invokeMethod(m_audio, [this, on]() { m_audio->setMnrEnabled(on); }); - }); connect(w, &AetherDspWidget::mnrStrengthChanged, this, [this](float v) { QMetaObject::invokeMethod(m_audio, [this, v]() { m_audio->setMnrStrength(v); }); }); diff --git a/tests/mac_nr_filter_test.cpp b/tests/mac_nr_filter_test.cpp new file mode 100644 index 000000000..cd53c4659 --- /dev/null +++ b/tests/mac_nr_filter_test.cpp @@ -0,0 +1,449 @@ +// Deterministic offline quality checks for the macOS minimum-statistics NR. +// CMake target: mac_nr_filter_test (macOS only). + +#include "core/MacNRFilter.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int kRate = 24000; +constexpr int kBlockFrames = 240; +constexpr int kTotalFrames = 6 * kRate; +constexpr int kWarmupFrames = static_cast(1.5 * kRate); +constexpr float kTwoPi = 2.0f * std::numbers::pi_v; + +int g_failed = 0; + +void report(const char* name, bool ok, const char* detail) +{ + std::printf("%s %-64s %s\n", ok ? "[ OK ]" : "[FAIL]", name, detail); + if (!ok) { + ++g_failed; + } +} + +class NoiseSource { +public: + explicit NoiseSource(uint32_t seed) : m_state(seed) {} + + float gaussian() + { + // Sum of twelve deterministic uniforms is an adequate N(0, 1) + // approximation for this broadband attenuation measurement. + float sum = 0.0f; + for (int i = 0; i < 12; ++i) { + m_state = m_state * 1664525u + 1013904223u; + sum += static_cast(m_state >> 8) / 16777216.0f; + } + return sum - 6.0f; + } + +private: + uint32_t m_state; +}; + +std::vector runFilter(const std::vector& input, float strength) +{ + AetherSDR::MacNRFilter filter; + if (!filter.isValid()) { + return {}; + } + filter.setStrength(strength); + + std::vector output; + output.reserve(input.size()); + for (int frame = 0; frame < static_cast(input.size() / 2); frame += kBlockFrames) { + const int count = std::min(kBlockFrames, + static_cast(input.size() / 2) - frame); + QByteArray block(count * 2 * static_cast(sizeof(float)), Qt::Uninitialized); + std::copy_n(input.data() + 2 * frame, 2 * count, + reinterpret_cast(block.data())); + const QByteArray processed = filter.process(block); + if (processed.size() != block.size()) { + return {}; + } + const auto* values = reinterpret_cast(processed.constData()); + output.insert(output.end(), values, + values + processed.size() / static_cast(sizeof(float))); + } + return output; +} + +double rms(const std::vector& samples, int firstFrame, int lastFrame, int channel) +{ + double sum = 0.0; + const int count = std::max(lastFrame - firstFrame, 0); + for (int frame = firstFrame; frame < lastFrame; ++frame) { + const double value = samples[2 * frame + channel]; + sum += value * value; + } + return count > 0 ? std::sqrt(sum / count) : 0.0; +} + +double dbRatio(double numerator, double denominator) +{ + return 20.0 * std::log10(std::max(numerator, 1e-12) / + std::max(denominator, 1e-12)); +} + +std::vector whiteNoise(float rmsValue, uint32_t seed) +{ + NoiseSource noise(seed); + std::vector samples(2 * kTotalFrames); + for (int frame = 0; frame < kTotalFrames; ++frame) { + const float value = rmsValue * noise.gaussian(); + samples[2 * frame] = value; + samples[2 * frame + 1] = value; + } + return samples; +} + +void test_white_noise_attenuation_and_scale_invariance() +{ + const std::vector unitNoise = whiteNoise(1.0f, 0x53a7u); + double referenceAttenuation = 0.0; + bool scaleInvariant = true; + char detail[192]{}; + + for (const float inputRms : {0.025f, 0.05f, 0.10f}) { + std::vector scaled = unitNoise; + for (float& sample : scaled) { + sample *= inputRms; + } + const std::vector output = runFilter(scaled, 1.0f); + if (output.empty()) { + report("MacNRFilter is available", false, "vDSP FFT setup failed"); + return; + } + const double inRms = rms(scaled, kWarmupFrames, kTotalFrames, 0); + const double outRms = rms(output, kWarmupFrames, kTotalFrames, 0); + const double attenuation = dbRatio(outRms, inRms); + if (inputRms == 0.025f) { + referenceAttenuation = attenuation; + } else { + scaleInvariant = scaleInvariant && std::abs(attenuation - referenceAttenuation) <= 1.0; + } + const bool withinNominalFloor = attenuation <= -12.0 && attenuation >= -26.0; + std::snprintf(detail, sizeof(detail), "RMS=%.3f attenuation=%.2f dB", inputRms, attenuation); + report("white-noise attenuation is 12 to 26 dB", withinNominalFloor, + detail); + } + std::snprintf(detail, sizeof(detail), "reference attenuation=%.2f dB", referenceAttenuation); + report("white-noise attenuation stays scale-invariant within 1 dB", scaleInvariant, detail); +} + +void test_speech_like_snr_improvement() +{ + NoiseSource noise(0x1a2b3c4du); + std::vector noisy(2 * kTotalFrames); + std::vector desired(kTotalFrames); + std::vector background(kTotalFrames); + for (int frame = 0; frame < kTotalFrames; ++frame) { + const float t = static_cast(frame) / kRate; + // A varying voiced envelope with three formant-like components. The + // envelope is deliberately nonstationary; MNR is not expected to + // preserve a continuous unmodulated tone. + const float envelope = 0.02f + 0.98f * std::pow(0.5f + 0.5f * std::sin(kTwoPi * 2.0f * t), 4.0f); + desired[frame] = 0.15f * envelope * ( + 0.72f * std::sin(kTwoPi * 430.0f * t) + + 0.48f * std::sin(kTwoPi * 970.0f * t + 0.7f) + + 0.32f * std::sin(kTwoPi * 1740.0f * t + 1.4f)); + background[frame] = 0.035f * noise.gaussian(); + + // The shared MNR mask is synthesized independently into L/R. Put + // desired+noise on L and the identical noise on R so L-R isolates + // the wanted component after the exact same adaptive mask. Unlike + // subtracting two independently adapted filter runs, this is a valid + // decomposition for a nonlinear, stateful suppressor. + noisy[2 * frame] = desired[frame] + background[frame]; + noisy[2 * frame + 1] = background[frame]; + } + + const std::vector noisyOut = runFilter(noisy, 1.0f); + if (noisyOut.empty()) { + report("speech-like test filter construction", false, "vDSP FFT setup failed"); + return; + } + double desiredInputPower = 0.0; + double noiseInputPower = 0.0; + double desiredOutputPower = 0.0; + double noiseOutputPower = 0.0; + for (int frame = kWarmupFrames; frame < kTotalFrames; ++frame) { + desiredInputPower += static_cast(desired[frame]) * desired[frame]; + noiseInputPower += static_cast(background[frame]) * background[frame]; + const double outputDesired = noisyOut[2 * frame] - noisyOut[2 * frame + 1]; + const double outputNoise = noisyOut[2 * frame + 1]; + desiredOutputPower += outputDesired * outputDesired; + noiseOutputPower += outputNoise * outputNoise; + } + const double measuredFrames = kTotalFrames - kWarmupFrames; + const double desiredIn = std::sqrt(desiredInputPower / measuredFrames); + const double inputNoise = std::sqrt(noiseInputPower / measuredFrames); + const double desiredOut = std::sqrt(desiredOutputPower / measuredFrames); + const double outputNoise = std::sqrt(noiseOutputPower / measuredFrames); + const double inputSnr = dbRatio(desiredIn, inputNoise); + const double outputSnr = dbRatio(desiredOut, outputNoise); + const double improvement = outputSnr - inputSnr; + const double desiredLoss = dbRatio(desiredOut, desiredIn); + char detail[192]{}; + std::snprintf(detail, sizeof(detail), "in=%.2f dB out=%.2f dB improvement=%.2f dB", inputSnr, outputSnr, improvement); + report("speech-like multitone SNR improves by at least 6 dB", improvement >= 6.0, detail); + std::snprintf(detail, sizeof(detail), "desired level change=%.2f dB", desiredLoss); + report("speech-like desired-signal loss is at most 4 dB", desiredLoss >= -4.0, detail); +} + +void test_strength_zero_reconstruction_and_stereo_balance() +{ + const std::vector input = whiteNoise(0.08f, 0x9911u); + const std::vector bypass = runFilter(input, 0.0f); + if (bypass.empty()) { + report("strength-zero test filter construction", false, "vDSP FFT setup failed"); + return; + } + + int bestLag = 0; + double bestCorrelation = -1.0; + for (int lag = 1; lag <= 1024; ++lag) { + double dot = 0.0, inputPower = 0.0, outputPower = 0.0; + for (int frame = kWarmupFrames + lag; frame < kTotalFrames; ++frame) { + const double in = input[2 * (frame - lag)]; + const double out = bypass[2 * frame]; + dot += in * out; + inputPower += in * in; + outputPower += out * out; + } + const double correlation = dot / std::sqrt(inputPower * outputPower); + if (correlation > bestCorrelation) { + bestCorrelation = correlation; + bestLag = lag; + } + } + const double bypassIn = rms(input, kWarmupFrames, kTotalFrames - bestLag, 0); + const double bypassOut = rms(bypass, kWarmupFrames + bestLag, kTotalFrames, 0); + char detail[192]{}; + std::snprintf(detail, sizeof(detail), "lag=%d frames correlation=%.6f level=%.3f dB", bestLag, + bestCorrelation, dbRatio(bypassOut, bypassIn)); + report("strength zero is a delayed, level-preserving reconstruction", + bestLag > 0 && bestCorrelation >= 0.999 && std::abs(dbRatio(bypassOut, bypassIn)) <= 0.10, + detail); + + NoiseSource noise(0x4411u); + std::vector stereo(2 * kTotalFrames); + for (int frame = 0; frame < kTotalFrames; ++frame) { + const float value = 0.04f * noise.gaussian(); + stereo[2 * frame] = 4.0f * value; + stereo[2 * frame + 1] = value; + } + const std::vector stereoOut = runFilter(stereo, 1.0f); + if (stereoOut.empty()) { + report("stereo balance test filter construction", false, "vDSP FFT setup failed"); + return; + } + const double ratio = rms(stereoOut, kWarmupFrames, kTotalFrames, 0) + / rms(stereoOut, kWarmupFrames, kTotalFrames, 1); + std::snprintf(detail, sizeof(detail), "output L/R RMS ratio=%.4f", ratio); + report("shared MNR mask preserves a 4:1 stereo ratio", std::abs(ratio - 4.0) <= 0.05, detail); +} + +void test_zero_prefill_and_reset_do_not_poison_noise_history() +{ + AetherSDR::MacNRFilter filter; + if (!filter.isValid()) { + report("startup/reset test filter construction", false, "vDSP FFT setup failed"); + return; + } + filter.setStrength(1.0f); + + QByteArray silence(kBlockFrames * 2 * static_cast(sizeof(float)), + Qt::Uninitialized); + std::fill_n(reinterpret_cast(silence.data()), + kBlockFrames * 2, 0.0f); + const QByteArray initialOutput = filter.process(silence); + bool initialOutputValid = initialOutput.size() == silence.size(); + const auto* initialSamples = + reinterpret_cast(initialOutput.constData()); + for (int i = 0; initialOutputValid && i < kBlockFrames * 2; ++i) { + initialOutputValid = std::isfinite(initialSamples[i]) + && initialSamples[i] == 0.0f; + } + report("latency-prefill silence stays finite and transparent", + initialOutputValid, initialOutputValid ? "all samples are zero" : "invalid output"); + + filter.reset(); + constexpr int kResetFrames = 2 * kRate; + NoiseSource noise(0x7812u); + std::vector input(2 * kResetFrames); + std::vector output; + output.reserve(input.size()); + for (int frame = 0; frame < kResetFrames; ++frame) { + const float value = 0.05f * noise.gaussian(); + input[2 * frame] = value; + input[2 * frame + 1] = value; + } + for (int frame = 0; frame < kResetFrames; frame += kBlockFrames) { + QByteArray block(kBlockFrames * 2 * static_cast(sizeof(float)), + Qt::Uninitialized); + std::copy_n(input.data() + 2 * frame, kBlockFrames * 2, + reinterpret_cast(block.data())); + const QByteArray processed = filter.process(block); + const auto* values = reinterpret_cast(processed.constData()); + output.insert(output.end(), values, + values + processed.size() / static_cast(sizeof(float))); + } + const int measureFrom = kResetFrames - kRate / 2; + const double attenuation = dbRatio( + rms(output, measureFrom, kResetFrames, 0), + rms(input, measureFrom, kResetFrames, 0)); + char detail[128]{}; + std::snprintf(detail, sizeof(detail), "post-reset attenuation=%.2f dB", attenuation); + report("reset estimator relearns stationary noise", attenuation <= -12.0, detail); +} + +void test_silence_gap_preserves_learned_noise_floor() +{ + constexpr int kTrainingFrames = 2 * kRate; + constexpr int kSilenceFrames = kRate; + constexpr int kResumeFrames = 2 * kRate; + constexpr int kResumeStart = kTrainingFrames + kSilenceFrames; + constexpr int kMeasureStart = kResumeStart + 512; + constexpr int kMeasureEnd = kResumeStart + kRate / 5; + + NoiseSource noise(0x51a7u); + std::vector input(2 * (kTrainingFrames + kSilenceFrames + kResumeFrames), 0.0f); + for (int frame = 0; frame < kTrainingFrames; ++frame) { + const float value = 0.05f * noise.gaussian(); + input[2 * frame] = value; + input[2 * frame + 1] = value; + } + for (int frame = kResumeStart; + frame < kTrainingFrames + kSilenceFrames + kResumeFrames; ++frame) { + const float value = 0.05f * noise.gaussian(); + input[2 * frame] = value; + input[2 * frame + 1] = value; + } + + const std::vector output = runFilter(input, 1.0f); + if (output.empty()) { + report("silence-gap test filter construction", false, "vDSP FFT setup failed"); + return; + } + const double attenuation = dbRatio( + rms(output, kMeasureStart, kMeasureEnd, 0), + rms(input, kMeasureStart, kMeasureEnd, 0)); + char detail[128]{}; + std::snprintf(detail, sizeof(detail), "early post-silence attenuation=%.2f dB", attenuation); + report("silence gap does not erase the learned noise floor", + attenuation <= -8.0, detail); +} + +void test_near_silence_does_not_seed_noise_floor() +{ + constexpr int kDitherFrames = kRate; + constexpr int kNoiseFrames = 2 * kRate; + constexpr int kMeasureStart = kDitherFrames + 512; + constexpr int kMeasureEnd = kDitherFrames + kRate / 4; + constexpr float kOnePcmLsb = 1.0f / 32768.0f; + + NoiseSource noise(0xd17eu); + std::vector input(2 * (kDitherFrames + kNoiseFrames)); + for (int frame = 0; frame < kDitherFrames; ++frame) { + const float value = (frame % 2 == 0) ? kOnePcmLsb : -kOnePcmLsb; + input[2 * frame] = value; + input[2 * frame + 1] = value; + } + for (int frame = kDitherFrames; frame < kDitherFrames + kNoiseFrames; ++frame) { + const float value = 0.05f * noise.gaussian(); + input[2 * frame] = value; + input[2 * frame + 1] = value; + } + + std::vector silencePrefixed = input; + std::fill(silencePrefixed.begin(), + silencePrefixed.begin() + 2 * kDitherFrames, 0.0f); + const std::vector ditherOutput = runFilter(input, 1.0f); + const std::vector silenceOutput = runFilter(silencePrefixed, 1.0f); + if (ditherOutput.empty() || silenceOutput.empty()) { + report("near-silence seed test filter construction", false, "vDSP FFT setup failed"); + return; + } + const double ditherAttenuation = dbRatio( + rms(ditherOutput, kMeasureStart, kMeasureEnd, 0), + rms(input, kMeasureStart, kMeasureEnd, 0)); + const double silenceAttenuation = dbRatio( + rms(silenceOutput, kMeasureStart, kMeasureEnd, 0), + rms(silencePrefixed, kMeasureStart, kMeasureEnd, 0)); + char detail[192]{}; + std::snprintf(detail, sizeof(detail), + "dither=%.2f dB exact-silence=%.2f dB delta=%.2f dB", + ditherAttenuation, silenceAttenuation, + ditherAttenuation - silenceAttenuation); + report("one-LSB dither does not delay noise-floor acquisition", + std::abs(ditherAttenuation - silenceAttenuation) <= 1.0, detail); +} + +void test_enable_during_speech_avoids_deep_ducking() +{ + constexpr int kFrames = 2 * kRate; + constexpr int kLatencyFrames = 512; + constexpr int kMeasureFrames = kRate / 10; + NoiseSource noise(0x5ee1u); + std::vector input(2 * kFrames); + std::vector desired(kFrames); + for (int frame = 0; frame < kFrames; ++frame) { + const float t = static_cast(frame) / kRate; + const float envelope = 0.15f + 0.85f + * std::pow(0.5f + 0.5f * std::sin(kTwoPi * 3.0f * t), 4.0f); + desired[frame] = 0.16f * envelope * ( + 0.70f * std::sin(kTwoPi * 430.0f * t) + + 0.45f * std::sin(kTwoPi * 970.0f * t + 0.7f) + + 0.30f * std::sin(kTwoPi * 1740.0f * t + 1.4f)); + const float background = 0.025f * noise.gaussian(); + input[2 * frame] = desired[frame] + background; + input[2 * frame + 1] = background; + } + + const std::vector output = runFilter(input, 1.0f); + if (output.empty()) { + report("mid-speech enable test filter construction", false, "vDSP FFT setup failed"); + return; + } + double desiredInputPower = 0.0; + double desiredOutputPower = 0.0; + for (int offset = 0; offset < kMeasureFrames; ++offset) { + const double inputDesired = desired[offset]; + const int outputFrame = kLatencyFrames + offset; + const double outputDesired = output[2 * outputFrame] + - output[2 * outputFrame + 1]; + desiredInputPower += inputDesired * inputDesired; + desiredOutputPower += outputDesired * outputDesired; + } + const double desiredLoss = 10.0 * std::log10( + std::max(desiredOutputPower, 1e-12) / + std::max(desiredInputPower, 1e-12)); + char detail[128]{}; + std::snprintf(detail, sizeof(detail), "first 100 ms desired loss=%.2f dB", desiredLoss); + report("enabling MNR during speech avoids deep startup ducking", + desiredLoss >= -1.75, detail); +} + +} // namespace + +int main() +{ + test_white_noise_attenuation_and_scale_invariance(); + test_speech_like_snr_improvement(); + test_strength_zero_reconstruction_and_stereo_balance(); + test_zero_prefill_and_reset_do_not_poison_noise_history(); + test_silence_gap_preserves_learned_noise_floor(); + test_near_silence_does_not_seed_noise_floor(); + test_enable_during_speech_avoids_deep_ducking(); + std::printf(g_failed == 0 ? "\nPASSED\n" : "\nFAILED (%d)\n", g_failed); + return g_failed == 0 ? 0 : 1; +}