diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 16518914d..a97df9aaa 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -9001,18 +9001,24 @@ void AudioEngine::setRadioTransmitting(bool tx) // adapt its internal state to TX silence, #367/#1505). But that leaves NR2 // holding pre-TX state when RX resumes: a stale overlap-add ring (read out // as a faint whistle, #3340) and a maxed-out startup-ramp counter, so - // suppression slams to full-wet on a stale noise estimate that then takes - // ~3-4s to reconverge — the audio "gap" users hear with NR2 engaged - // (#1863). reset() flushes the OA ring, re-seeds the noise floor high - // (gentle suppression), and re-arms the ~1s dry→wet ramp so audio returns - // immediately on the dry signal and NR2 fades back in cleanly. + // suppression slams to full-wet the instant RX resumes. resetTransient() + // flushes exactly that — the OA ring, the gain masks, the AGC common-mode + // references — and re-arms the ~1s dry→wet ramp, while RETAINING the + // converged noise estimate. The full reset() used here previously also + // re-seeded the noise floor, forcing a fresh multi-second estimator + // convergence on every over, heard as un-suppressed band noise after + // unkey (#3821); with the profile retained, suppression is back at full + // depth as the ramp completes. If the band or the AGC level moved during + // TX the estimator adapts from the retained floor — quickly downward, + // and upward within one minimum-statistics window (~1.5 s), which is no + // slower than the full convergence it replaced. // // Scoped to NR2 for now: it's the reported filter and this keeps testing // localized. RN2/NR4/DFNR/MNR share the same bypass + stale-state path and // can get the same flush as a follow-up once this is validated in the field. if (previous && !tx) { std::lock_guard dspLock(m_dspMutex); - if (m_nr2Enabled && m_nr2) m_nr2->reset(); + if (m_nr2Enabled && m_nr2) m_nr2->resetTransient(); } emit radioTransmittingChanged(tx); diff --git a/src/core/SpectralNR.cpp b/src/core/SpectralNR.cpp index 73680581a..d7107fe34 100644 --- a/src/core/SpectralNR.cpp +++ b/src/core/SpectralNR.cpp @@ -522,6 +522,12 @@ void SpectralNR::setNpeMethod(int method) } void SpectralNR::reset() +{ + resetTransient(); + resetNoiseEstimate(); +} + +void SpectralNR::resetTransient() { std::fill(m_inAccum.begin(), m_inAccum.end(), 0.0); std::fill(m_outAccum.begin(), m_outAccum.end(), 0.0); @@ -542,6 +548,58 @@ void SpectralNR::reset() m_outReadPos = 0; m_outputAvailable = m_fftSize; + // The AGC common-mode references flush with the transients rather than + // surviving alongside the noise estimate: their calibration re-runs + // inside the re-armed ramp window (m_frameCount < m_rampFrames), and its + // first frame overwrites the level reference with weight 1/(0+1) anyway, + // so a retained value could only live for one frame. Flushing keeps the + // recalibration deterministic — and post-TX the receiver AGC state that + // these references describe is exactly what may have changed. + // + // What this cannot preserve is a level step that straddles the gap. NR2 + // sees post-AGC audio on every path (the radio's AGC for a Flex, WDSP's + // inside Hl2RxDsp for an HL2), and the first post-TX frame re-seeds + // m_commonReferencePsd from the post-TX spectrum (detectCommonModeScale), + // so the scale corrector never observes the step and scalePowerHistory() + // will not rescale the retained noise estimate for it. The fallout is + // bounded rather than corrected: minimum statistics re-levels a floor that + // is now too high within a few frames, and one that is too low within one + // window (m_U * m_V frames, ~1.5 s) — no slower than the full reset() + // this path replaced. Pinned by the ±6 dB step rows in spectral_nr_test. + std::fill(m_commonWantedProtected.begin(), + m_commonWantedProtected.end(), 0); + std::fill(m_commonReferencePsd.begin(), + m_commonReferencePsd.end(), 0.0); + std::fill(m_residualReferencePsd.begin(), + m_residualReferencePsd.end(), 0.0); + std::fill(m_residualReferenceGainRatio.begin(), + m_residualReferenceGainRatio.end(), 1.0); + std::fill(m_residualReferenceValid.begin(), + m_residualReferenceValid.end(), 0); + std::fill(m_commonNoiseLike.begin(), m_commonNoiseLike.end(), 0); + + std::fill(m_prevMask.begin(), m_prevMask.end(), 1.0); + std::fill(m_prevGamma.begin(), m_prevGamma.end(), 1.0); + std::fill(m_mask.begin(), m_mask.end(), 1.0); + std::fill(m_smoothMask.begin(), m_smoothMask.end(), 1.0); + std::fill(m_aeMask.begin(), m_aeMask.end(), 1.0); + std::fill(m_aePrefix.begin(), m_aePrefix.end(), 0.0); + + m_commonReferenceInitialized = false; + m_commonReferenceReacquiring = false; + m_commonSilenceRecoveryContext = false; + m_commonLevelReferenceInitialized = false; + m_commonLevelReferencePower = 0.0; + m_commonScaleLog = 0.0; + m_commonAppliedScale = 1.0; + m_commonReturnScale = 1.0; + m_commonDetectedScale = 1.0; + m_frameCount = 0; + m_currentWet = 0.0; +} + +void SpectralNR::resetNoiseEstimate() +{ // Start with a HIGH noise estimate — gains will be < 1 during convergence, // producing gentle suppression rather than amplification spikes. // The OSMS tracker will converge downward to the true noise floor in ~2s. @@ -568,45 +626,16 @@ void SpectralNR::reset() m_nstatTonalProbability.end(), 0.0); std::fill(m_nstatTonalIndicator.begin(), m_nstatTonalIndicator.end(), 0); - std::fill(m_commonWantedProtected.begin(), - m_commonWantedProtected.end(), 0); std::fill(m_nstatNoisePsd.begin(), m_nstatNoisePsd.end(), 0.0); - std::fill(m_commonReferencePsd.begin(), - m_commonReferencePsd.end(), 0.0); - std::fill(m_residualReferencePsd.begin(), - m_residualReferencePsd.end(), 0.0); - std::fill(m_residualReferenceGainRatio.begin(), - m_residualReferenceGainRatio.end(), 1.0); - std::fill(m_residualReferenceValid.begin(), - m_residualReferenceValid.end(), 0); - std::fill(m_commonNoiseLike.begin(), m_commonNoiseLike.end(), 0); for (auto& v : m_actMinBuf) std::fill(v.begin(), v.end(), 1e30); - std::fill(m_prevMask.begin(), m_prevMask.end(), 1.0); - std::fill(m_prevGamma.begin(), m_prevGamma.end(), 1.0); - std::fill(m_mask.begin(), m_mask.end(), 1.0); - std::fill(m_smoothMask.begin(), m_smoothMask.end(), 1.0); - std::fill(m_aeMask.begin(), m_aeMask.end(), 1.0); - std::fill(m_aePrefix.begin(), m_aePrefix.end(), 0.0); - m_alphaC = 1.0; // WDSP rotates on the first complete frame so the estimator starts from // observed audio rather than waiting a full sub-window on its seed value. m_subwc = m_V; m_ambIdx = 0; - m_commonReferenceInitialized = false; - m_commonReferenceReacquiring = false; - m_commonSilenceRecoveryContext = false; - m_commonLevelReferenceInitialized = false; - m_commonLevelReferencePower = 0.0; - m_commonScaleLog = 0.0; - m_commonAppliedScale = 1.0; - m_commonReturnScale = 1.0; - m_commonDetectedScale = 1.0; - m_frameCount = 0; - m_currentWet = 0.0; } void SpectralNR::initWindow() diff --git a/src/core/SpectralNR.h b/src/core/SpectralNR.h index e3095e8b8..0a4314a3e 100644 --- a/src/core/SpectralNR.h +++ b/src/core/SpectralNR.h @@ -78,6 +78,17 @@ class SpectralNR { // Reset all internal state (call when toggling on or stream restarts). void reset(); + // Flush only the transient state — overlap-add rings, gain masks, the + // AGC common-mode references, and the dry→wet startup ramp — while + // retaining the converged OSMS/MMSE/NSTAT noise estimates. For the + // TX→RX edge, where the stream resumes on the same band and the stale + // overlap-add ring is the hazard (#3340): a full reset() there re-seeds + // the noise floor and costs a fresh estimator convergence on every + // over, heard as un-suppressed band noise after unkey (#3821). Not a + // substitute for reset() on enable or source switches, where the old + // noise profile does not describe the new stream. + void resetTransient(); + // User-adjustable parameters (thread-safe, called from main thread) void setGainMax(float v); void setGainFloor(float v); @@ -301,6 +312,7 @@ class SpectralNR { // ── Internal methods ─────────────────────────────────────────────── void initWindow(); + void resetNoiseEstimate(); void processFrame(); bool updateMaskFromCurrentFrame(); void synthesizeCurrentFrequencyBinsWithMask(); diff --git a/tests/spectral_nr_test.cpp b/tests/spectral_nr_test.cpp index 4f16dd7f4..cae9717ae 100644 --- a/tests/spectral_nr_test.cpp +++ b/tests/spectral_nr_test.cpp @@ -2392,6 +2392,162 @@ std::pair stereoRmsAfter(const std::vector& interleaved, }; } +void test_transient_reset_retains_noise_profile() +{ + // #3821: after the TX→RX edge, NR2 must re-engage within one dry→wet + // ramp, not a full estimator re-convergence. resetTransient() flushes + // the overlap-add ring (the #3340 stale-audio hazard), the gain masks, + // and the ramp, while retaining the converged noise estimate; a full + // reset() at the same point re-seeds the noise floor and leaves the + // band noise audibly louder for roughly half a second after the ramp + // completes — the window in which the reporter's quick callbacks were + // being drowned out. + constexpr int sampleRate = 24000; + constexpr int fftSize = 1024; + constexpr int overlap = 4; + constexpr int blockSamples = 73; + constexpr int settleSamples = 6 * sampleRate; + constexpr int resumeSamples = 6 * sampleRate; + + // One continuous synthetic noise timeline; the reset point models the TX + // gap, during which the bypassed filter sees no audio at all. + std::vector settle(settleSamples); + std::vector resume(resumeSamples); + std::uint32_t randomState = 0x33383231u; + auto nextWhite = [&randomState]() { + randomState = 1664525u * randomState + 1013904223u; + return 2.0 * (static_cast(randomState) / 4294967295.0) - 1.0; + }; + for (float& s : settle) { + s = static_cast(0.25 * nextWhite()); + } + for (float& s : resume) { + s = static_cast(0.25 * nextWhite()); + } + + struct Run { + double settledDb = 0.0; // depth reached before the edge + std::vector input; // what the filter saw after it + std::vector output; // what it produced + }; + // resumeScale models a receiver AGC that re-levelled during the over: the + // noise that comes back is louder (> 1) or quieter (< 1) than the profile + // NR2 retained. Each run measures its own settled depth so no assertion + // depends on the order the runs are made in. + const auto runResumed = [&](bool transientReset, double resumeScale) { + SpectralNR nr(fftSize, sampleRate, overlap); + std::vector out(settleSamples); + int offset = 0; + while (offset < settleSamples) { + const int count = std::min(blockSamples, settleSamples - offset); + nr.process(settle.data() + offset, out.data() + offset, count); + offset += count; + } + Run run; + run.settledDb = rmsGainDb(settle, out, 4 * sampleRate, 5 * sampleRate, + fftSize); + if (transientReset) { + nr.resetTransient(); + } else { + nr.reset(); + } + run.input.resize(resumeSamples); + for (int i = 0; i < resumeSamples; ++i) { + run.input[i] = static_cast(resume[i] * resumeScale); + } + run.output.assign(resumeSamples, 0.0f); + offset = 0; + while (offset < resumeSamples) { + const int count = std::min(blockSamples, resumeSamples - offset); + nr.process(run.input.data() + offset, run.output.data() + offset, + count); + offset += count; + } + return run; + }; + // Post-ramp window: the ~1 s ramp has completed, the full reset has not + // yet re-converged. Late window: both must agree with the settled depth. + const auto postRampDb = [&](const Run& r) { + return rmsGainDb(r.input, r.output, 11 * sampleRate / 10, + 8 * sampleRate / 5, fftSize); + }; + const auto lateDb = [&](const Run& r) { + return rmsGainDb(r.input, r.output, 5 * sampleRate / 2, 3 * sampleRate, + fftSize); + }; + + const Run transient = runResumed(true, 1.0); + const Run full = runResumed(false, 1.0); + const double settledDb = transient.settledDb; + + // #3340 guard: the flushed ring re-queues one frame of zero latency + // padding, so nothing recorded before the reset can leak out after it. + double stalePeak = 0.0; + bool allFinite = true; + for (int i = 0; i < fftSize; ++i) { + stalePeak = std::max(stalePeak, + static_cast(std::abs(transient.output[i]))); + } + for (const float sample : transient.output) { + allFinite = allFinite && std::isfinite(sample); + } + + // Audio must return immediately on the dry signal (ramp re-armed). The + // latency offset inside rmsGainDb() starts this window just past the + // zero padding checked above, so it measures real dry audio. + const double immediateDb = rmsGainDb(transient.input, transient.output, 0, + 3 * sampleRate / 20, fftSize); + const double transientPostRampDb = postRampDb(transient); + const double fullPostRampDb = postRampDb(full); + + std::printf(" settled %+.2f dB, post-reset 0-0.15 s %+.2f dB\n" + " 1.1-1.6 s transient %+.2f dB vs full-reset %+.2f dB " + "(gap %.1f dB), 2.5-3.0 s %+.2f vs %+.2f dB, stale peak %.3g\n", + settledDb, immediateDb, transientPostRampDb, fullPostRampDb, + fullPostRampDb - transientPostRampDb, lateDb(transient), + lateDb(full), stalePeak); + report("transient_reset: settle phase is identical across runs", + std::abs(full.settledDb - transient.settledDb) < 0.01); + report("transient_reset: output is finite", allFinite); + report("transient_reset: no stale ring audio leaks through the reset", + stalePeak < 1e-9); + report("transient_reset: audio returns immediately on the dry signal", + immediateDb > -3.0); + // The behavioural invariant, and the assertion that fails against the + // unfixed code: a full reset() is still several dB short of the settled + // depth in this window. Not asserted as an inter-path gap, so a future + // estimator retune that converges faster from cold does not fail it. + report("transient_reset: settled depth restored right at ramp end", + std::abs(transientPostRampDb - settledDb) < 2.5); + report("transient_reset: both variants converge again well after the edge", + std::abs(lateDb(transient) - settledDb) < 2.5 + && std::abs(lateDb(full) - settledDb) < 2.5); + + // A receiver AGC that re-levelled during the over. NR2 sees post-AGC + // audio on every path, and the retained profile cannot be rescaled for a + // step it never observed (see resetTransient()). The fallout must stay + // bounded: never worse than the full reset() this path replaced in the + // post-ramp window, and back at the settled depth once one + // minimum-statistics window (~1.5 s) has passed. + for (const double scale : {2.0, 0.5}) { + const Run stepTransient = runResumed(true, scale); + const Run stepFull = runResumed(false, scale); + const double stepPostRampT = postRampDb(stepTransient); + const double stepPostRampF = postRampDb(stepFull); + const double stepLateT = lateDb(stepTransient); + const double stepLateF = lateDb(stepFull); + std::printf(" %+.0f dB step: 1.1-1.6 s transient %+.2f dB vs full-reset " + "%+.2f dB, 2.5-3.0 s %+.2f vs %+.2f dB\n", + 20.0 * std::log10(scale), stepPostRampT, stepPostRampF, + stepLateT, stepLateF); + report("transient_reset: level step — no worse than a full reset post-ramp", + stepPostRampT <= stepPostRampF + 1.0); + report("transient_reset: level step — settled depth within one window", + std::abs(stepLateT - stepTransient.settledDb) < 2.5 + && std::abs(stepLateF - stepFull.settledDb) < 2.5); + } +} + void test_block_size_invariance() { // KiwiSDR audio arrives in packet-sized bursts, while native Flex RX audio @@ -2629,6 +2785,9 @@ int main() std::printf("\n-- NR2 live NPE switching --\n"); test_npe_switch_transients(); + std::printf("\n-- NR2 TX->RX transient reset (#3821) --\n"); + test_transient_reset_retains_noise_profile(); + std::printf("\n-- NR2 quick reply after speech release --\n"); test_quick_reply_after_speech_release(); test_weak_reply_after_speech_release();