From ae66f91cc5e26895607a9759a0032a1986de3e4e Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Sun, 9 Aug 2026 18:10:59 -0400 Subject: [PATCH 01/25] =?UTF-8?q?refactor(audio):=20move=20TX=20voice=20DS?= =?UTF-8?q?P=20to=2048=20kHz=20float=20=E2=80=94=20Principle=20VIII.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a backend-independent TxVoiceProcessor that normalizes captured voice audio to a fixed 48 kHz float domain, runs RNNoise and the user-ordered channel strip without per-stage integer round trips, then performs one 48-to-24 kHz egress conversion and one transport-boundary quantization. Preserve the existing Opus/VITA framing and the separate DAX and RADE paths. Add deterministic coverage for rate contracts, block continuity, reset behavior, measurement taps, native-float input, RNNoise framing, finite output, and latency accounting. --- CMakeLists.txt | 8 + src/core/AudioEngine.cpp | 588 ++++++------------------------ src/core/AudioEngine.h | 62 +--- src/core/AudioSummaryLogger.cpp | 5 +- src/core/AudioSummaryLogger.h | 3 +- src/core/ClientTxTestTone.cpp | 25 ++ src/core/ClientTxTestTone.h | 1 + src/core/DeviceDiagnostics.cpp | 7 +- src/core/RNNoiseFilter.cpp | 123 ++++++- src/core/RNNoiseFilter.h | 17 +- src/core/Resampler.cpp | 69 ++-- src/core/Resampler.h | 9 + src/core/TxVoiceProcessor.cpp | 425 +++++++++++++++++++++ src/core/TxVoiceProcessor.h | 135 +++++++ tests/tx_voice_processor_test.cpp | 339 +++++++++++++++++ 15 files changed, 1248 insertions(+), 568 deletions(-) create mode 100644 src/core/TxVoiceProcessor.cpp create mode 100644 src/core/TxVoiceProcessor.h create mode 100644 tests/tx_voice_processor_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d0d23cbdd..e663b0554 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -655,6 +655,7 @@ set(CORE_SOURCES src/core/AetherDspModePolicy.cpp src/core/AudioEngine.cpp src/core/TxMicChannelNormalizer.cpp + src/core/TxVoiceProcessor.cpp src/core/ChannelStripPresets.cpp src/core/Biquad.cpp src/core/StereoBiquad.cpp @@ -3655,6 +3656,13 @@ target_include_directories(tx_mic_channel_normalizer_test PRIVATE target_link_libraries(tx_mic_channel_normalizer_test PRIVATE Qt6::Core) add_test(NAME tx_mic_channel_normalizer_test COMMAND tx_mic_channel_normalizer_test) +add_executable(tx_voice_processor_test + tests/tx_voice_processor_test.cpp +) +target_include_directories(tx_voice_processor_test PRIVATE src) +target_link_libraries(tx_voice_processor_test PRIVATE aethercore Qt6::Core) +add_test(NAME tx_voice_processor_test COMMAND tx_voice_processor_test) + # Pins the SkyRoof-parity WFM DSP chain: NCO offset correction removes the # discriminator DC term (fixed pan + Doppler-stepped slice), twin linear-phase # resamplers deliver exactly 48 kHz from any native DAX IQ rate, and streaming diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index ea9023405..021b145af 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -43,6 +43,7 @@ #include "MacNRFilter.h" #endif #include "Resampler.h" +#include "TxVoiceProcessor.h" #ifdef Q_OS_MAC #include @@ -76,6 +77,23 @@ namespace AetherSDR { +static_assert(static_cast(AudioEngine::TxChainStage::None) + == static_cast(TxVoiceProcessor::Stage::None)); +static_assert(static_cast(AudioEngine::TxChainStage::Eq) + == static_cast(TxVoiceProcessor::Stage::Eq)); +static_assert(static_cast(AudioEngine::TxChainStage::Comp) + == static_cast(TxVoiceProcessor::Stage::Comp)); +static_assert(static_cast(AudioEngine::TxChainStage::Gate) + == static_cast(TxVoiceProcessor::Stage::Gate)); +static_assert(static_cast(AudioEngine::TxChainStage::DeEss) + == static_cast(TxVoiceProcessor::Stage::DeEss)); +static_assert(static_cast(AudioEngine::TxChainStage::Tube) + == static_cast(TxVoiceProcessor::Stage::Tube)); +static_assert(static_cast(AudioEngine::TxChainStage::Enh) + == static_cast(TxVoiceProcessor::Stage::Enh)); +static_assert(static_cast(AudioEngine::TxChainStage::Reverb) + == static_cast(TxVoiceProcessor::Stage::Reverb)); + static QString wisdomDir(); static void logNr2WisdomSummary(const QString& context); static void logNr2WisdomGenerationSummary(SpectralNR::WisdomResult result); @@ -1722,6 +1740,7 @@ AudioEngine::AudioEngine(QObject* parent) , m_clientTxTestTone(std::make_unique()) , m_wsprBeacon(std::make_unique()) , m_clientQuindarTone(std::make_unique()) + , m_txVoiceProcessor(std::make_unique()) { // Recorder-sidetone generator: always enabled at a fixed, audible level and // centre pan so a Client-Side QSO recording captures the operator's sent @@ -1778,25 +1797,34 @@ AudioEngine::AudioEngine(QObject* parent) emit txDecodeAudioReady(buf); }); - // Prepare client DSP at the native 24 kHz rate. Sink resampling is - // handled separately after EQ — EQ always runs at radio-native rate. + // RX remains radio-native at 24 kHz. TX voice is prepared below through + // TxVoiceProcessor in its fixed 48 kHz float processing domain. m_clientEqRx->prepare(DEFAULT_SAMPLE_RATE); - m_clientEqTx->prepare(DEFAULT_SAMPLE_RATE); - m_clientCompTx->prepare(DEFAULT_SAMPLE_RATE); - m_clientGateTx->prepare(DEFAULT_SAMPLE_RATE); m_clientGateRx->prepare(DEFAULT_SAMPLE_RATE); m_clientCompRx->prepare(DEFAULT_SAMPLE_RATE); m_clientTubeRx->prepare(DEFAULT_SAMPLE_RATE); m_clientPuduRx->prepare(DEFAULT_SAMPLE_RATE); - m_clientDeEssTx->prepare(DEFAULT_SAMPLE_RATE); m_clientDeEssRx->prepare(DEFAULT_SAMPLE_RATE); - m_clientTubeTx->prepare(DEFAULT_SAMPLE_RATE); - m_clientPuduTx->prepare(DEFAULT_SAMPLE_RATE); - m_clientReverbTx->prepare(DEFAULT_SAMPLE_RATE); - m_clientFinalLimiterTx->prepare(DEFAULT_SAMPLE_RATE); - m_clientTxTestTone->prepare(DEFAULT_SAMPLE_RATE); m_wsprBeacon->prepare(DEFAULT_SAMPLE_RATE); - m_clientQuindarTone->prepare(DEFAULT_SAMPLE_RATE); + + TxVoiceProcessor::Processors txProcessors; + txProcessors.eq = m_clientEqTx.get(); + txProcessors.comp = m_clientCompTx.get(); + txProcessors.gate = m_clientGateTx.get(); + txProcessors.deEss = m_clientDeEssTx.get(); + txProcessors.tube = m_clientTubeTx.get(); + txProcessors.pudu = m_clientPuduTx.get(); + txProcessors.reverb = m_clientReverbTx.get(); + txProcessors.finalLimiter = m_clientFinalLimiterTx.get(); + txProcessors.testTone = m_clientTxTestTone.get(); + txProcessors.quindar = m_clientQuindarTone.get(); + txProcessors.eqTapContext = this; + txProcessors.eqTap = [](void* context, const float* stereo, int frames) { + auto* engine = static_cast(context); + engine->tapClientEqTxFloat32(stereo, frames * 2, 2); + }; + m_txVoiceProcessor->setProcessors(txProcessors); + m_txVoiceProcessor->prepare(DEFAULT_SAMPLE_RATE, 16384); m_wsprPumpTimer = new QTimer(this); m_wsprPumpTimer->setTimerType(Qt::PreciseTimer); m_wsprPumpTimer->setInterval(5); @@ -2721,7 +2749,9 @@ QJsonArray AudioEngine::audioEndpointDiagnostics() const tx["sample_rate_hz"] = txRunning ? QJsonValue(m_txInputRate) : QJsonValue(); tx["channel_count"] = txRunning ? QJsonValue(m_txInputChannels) : QJsonValue(); tx["sample_format"] = txRunning ? QStringLiteral("Int16") : QString(); - tx["resampling_active"] = txRunning ? QJsonValue(m_txNeedsResample) : QJsonValue(); + tx["resampling_active"] = txRunning + ? QJsonValue(m_txInputRate != TxVoiceProcessor::kDspRate) + : QJsonValue(); tx["note"] = m_txInputMono ? QStringLiteral("mono input promoted to stereo for radio TX") : QString(); const TxCaptureHealthTracker::Snapshot txHealth = m_txCaptureHealth.snapshot(txCaptureNowMs()); @@ -4708,21 +4738,6 @@ void AudioEngine::tapClientEqRxStereo(const float* stereoInterleaved, int frames m_clientEqTapRxWrite = w; } -void AudioEngine::tapClientEqTxInt16(const int16_t* int16stereo, int frames) -{ - if (frames <= 0) return; - std::unique_lock lk(m_clientEqTapMutex, std::try_to_lock); - if (!lk.owns_lock()) return; - int w = m_clientEqTapTxWrite; - for (int i = 0; i < frames; ++i) { - const float l = int16stereo[i * 2] / 32768.0f; - const float r = int16stereo[i * 2 + 1] / 32768.0f; - m_clientEqTapTx[w] = 0.5f * (l + r); - w = (w + 1) & (kClientEqTapSize - 1); - } - m_clientEqTapTxWrite = w; -} - void AudioEngine::tapClientEqTxFloat32(const float* f32, int samples, int channels) { if (samples <= 0 || channels < 1 || channels > 2) return; @@ -4768,105 +4783,6 @@ bool AudioEngine::copyRecentClientEqTxSamples(float* out, int count) const return true; } -void AudioEngine::applyClientEqTxInt16(QByteArray& int16stereo) -{ - if (int16stereo.isEmpty()) return; - const int samples = int16stereo.size() / static_cast(sizeof(int16_t)); - if ((samples & 1) != 0) return; // must be stereo - const int frames = samples / 2; - - // EQ processing only when enabled. The tap below runs regardless - // so the editor's TX FFT analyzer always reflects live mic input, - // even when the EQ stage is bypassed in the CHAIN widget. - if (m_clientEqTx && m_clientEqTx->isEnabled()) { - m_clientEqTxScratch.resize(samples * static_cast(sizeof(float))); - auto* f32 = reinterpret_cast(m_clientEqTxScratch.data()); - const auto* i16 = reinterpret_cast(int16stereo.constData()); - for (int i = 0; i < samples; ++i) { - f32[i] = i16[i] / 32768.0f; - } - - m_clientEqTx->process(f32, frames, 2); - - auto* out = reinterpret_cast(int16stereo.data()); - for (int i = 0; i < samples; ++i) { - out[i] = static_cast(std::clamp(f32[i] * 32768.0f, - -32768.0f, 32767.0f)); - } - } - // Always tap — bypassed-EQ case means tap captures pre-EQ samples - // (which equal post-EQ samples since no processing happened). - tapClientEqTxInt16(reinterpret_cast(int16stereo.constData()), - frames); -} - -void AudioEngine::applyClientEqTxFloat32(QByteArray& float32) -{ - if (float32.isEmpty()) return; - const int samples = float32.size() / static_cast(sizeof(float)); - // feedDaxTxAudio can deliver mono OR stereo float32 (depends on packet - // class). Treat even sample counts as stereo, odd counts as mono. - const int channels = (samples % 2 == 0) ? 2 : 1; - const int frames = samples / channels; - - if (m_clientEqTx && m_clientEqTx->isEnabled()) { - m_clientEqTx->process(reinterpret_cast(float32.data()), - frames, channels); - } - // Always tap so the editor's TX FFT analyzer reflects live audio - // even when the EQ stage is bypassed in the CHAIN widget. - tapClientEqTxFloat32(reinterpret_cast(float32.constData()), - samples, channels); -} - -void AudioEngine::applyClientCompTxInt16(QByteArray& int16stereo) -{ - if (!m_clientCompTx) return; - const bool compOn = m_clientCompTx->isEnabled(); - const bool driveOn = m_clientCompTx->driveDb() > 0.0f; - const bool phaseOn = m_clientCompTx->phaseRotatorStages() > 0; - const bool limOn = m_clientCompTx->limiterEnabled(); - if (!compOn && !driveOn && !phaseOn && !limOn) return; - if (int16stereo.isEmpty()) return; - - const int samples = int16stereo.size() / static_cast(sizeof(int16_t)); - if ((samples & 1) != 0) return; - const int frames = samples / 2; - - m_clientCompTxScratch.resize(samples * static_cast(sizeof(float))); - auto* f32 = reinterpret_cast(m_clientCompTxScratch.data()); - const auto* i16 = reinterpret_cast(int16stereo.constData()); - for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; - - m_clientCompTx->process(f32, frames, 2); - - auto* out = reinterpret_cast(int16stereo.data()); - for (int i = 0; i < samples; ++i) { - out[i] = static_cast( - std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); - } -} - -void AudioEngine::applyClientCompTxFloat32(QByteArray& float32) -{ - if (!m_clientCompTx) return; - // Drive and Phase (#2887) and the brickwall limiter inside the comp - // are useful even when the comp curve itself is bypassed, so the - // dispatch only short-circuits when none of the four sub-stages - // need to run. - const bool compOn = m_clientCompTx->isEnabled(); - const bool driveOn = m_clientCompTx->driveDb() > 0.0f; - const bool phaseOn = m_clientCompTx->phaseRotatorStages() > 0; - const bool limOn = m_clientCompTx->limiterEnabled(); - if (!compOn && !driveOn && !phaseOn && !limOn) return; - if (float32.isEmpty()) return; - const int samples = float32.size() / static_cast(sizeof(float)); - const int channels = (samples % 2 == 0) ? 2 : 1; - const int frames = samples / channels; - m_clientCompTx->process(reinterpret_cast(float32.data()), - frames, channels); -} - void AudioEngine::applyClientCompRxFloat32(QByteArray& float32) { if (!m_clientCompRx || !m_clientCompRx->isEnabled()) return; @@ -4878,40 +4794,6 @@ void AudioEngine::applyClientCompRxFloat32(QByteArray& float32) frames, 2); } -void AudioEngine::applyClientGateTxInt16(QByteArray& int16stereo) -{ - if (!m_clientGateTx || !m_clientGateTx->isEnabled()) return; - if (int16stereo.isEmpty()) return; - - const int samples = int16stereo.size() / static_cast(sizeof(int16_t)); - if ((samples & 1) != 0) return; - const int frames = samples / 2; - - m_clientGateTxScratch.resize(samples * static_cast(sizeof(float))); - auto* f32 = reinterpret_cast(m_clientGateTxScratch.data()); - const auto* i16 = reinterpret_cast(int16stereo.constData()); - for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; - - m_clientGateTx->process(f32, frames, 2); - - auto* out = reinterpret_cast(int16stereo.data()); - for (int i = 0; i < samples; ++i) { - out[i] = static_cast( - std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); - } -} - -void AudioEngine::applyClientGateTxFloat32(QByteArray& float32) -{ - if (!m_clientGateTx || !m_clientGateTx->isEnabled()) return; - if (float32.isEmpty()) return; - const int samples = float32.size() / static_cast(sizeof(float)); - const int channels = (samples % 2 == 0) ? 2 : 1; - const int frames = samples / channels; - m_clientGateTx->process(reinterpret_cast(float32.data()), - frames, channels); -} - void AudioEngine::applyClientGateRxFloat32(QByteArray& float32) { if (!m_clientGateRx || !m_clientGateRx->isEnabled()) return; @@ -4923,29 +4805,6 @@ void AudioEngine::applyClientGateRxFloat32(QByteArray& float32) frames, 2); } -void AudioEngine::applyClientDeEssTxInt16(QByteArray& int16stereo) -{ - if (!m_clientDeEssTx || !m_clientDeEssTx->isEnabled()) return; - if (int16stereo.isEmpty()) return; - - const int samples = int16stereo.size() / static_cast(sizeof(int16_t)); - if ((samples & 1) != 0) return; - const int frames = samples / 2; - - m_clientDeEssTxScratch.resize(samples * static_cast(sizeof(float))); - auto* f32 = reinterpret_cast(m_clientDeEssTxScratch.data()); - const auto* i16 = reinterpret_cast(int16stereo.constData()); - for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; - - m_clientDeEssTx->process(f32, frames, 2); - - auto* out = reinterpret_cast(int16stereo.data()); - for (int i = 0; i < samples; ++i) { - out[i] = static_cast( - std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); - } -} - void AudioEngine::applyClientDeEssRxFloat32(QByteArray& float32) { if (!m_clientDeEssRx || !m_clientDeEssRx->isEnabled()) return; @@ -4956,51 +4815,6 @@ void AudioEngine::applyClientDeEssRxFloat32(QByteArray& float32) frames, 2); } -void AudioEngine::applyClientDeEssTxFloat32(QByteArray& float32) -{ - if (!m_clientDeEssTx || !m_clientDeEssTx->isEnabled()) return; - if (float32.isEmpty()) return; - const int samples = float32.size() / static_cast(sizeof(float)); - const int channels = (samples % 2 == 0) ? 2 : 1; - const int frames = samples / channels; - m_clientDeEssTx->process(reinterpret_cast(float32.data()), - frames, channels); -} - -void AudioEngine::applyClientTubeTxInt16(QByteArray& int16stereo) -{ - if (!m_clientTubeTx || !m_clientTubeTx->isEnabled()) return; - if (int16stereo.isEmpty()) return; - - const int samples = int16stereo.size() / static_cast(sizeof(int16_t)); - if ((samples & 1) != 0) return; - const int frames = samples / 2; - - m_clientTubeTxScratch.resize(samples * static_cast(sizeof(float))); - auto* f32 = reinterpret_cast(m_clientTubeTxScratch.data()); - const auto* i16 = reinterpret_cast(int16stereo.constData()); - for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; - - m_clientTubeTx->process(f32, frames, 2); - - auto* out = reinterpret_cast(int16stereo.data()); - for (int i = 0; i < samples; ++i) { - out[i] = static_cast( - std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); - } -} - -void AudioEngine::applyClientTubeTxFloat32(QByteArray& float32) -{ - if (!m_clientTubeTx || !m_clientTubeTx->isEnabled()) return; - if (float32.isEmpty()) return; - const int samples = float32.size() / static_cast(sizeof(float)); - const int channels = (samples % 2 == 0) ? 2 : 1; - const int frames = samples / channels; - m_clientTubeTx->process(reinterpret_cast(float32.data()), - frames, channels); -} - void AudioEngine::applyClientTubeRxFloat32(QByteArray& float32) { if (!m_clientTubeRx || !m_clientTubeRx->isEnabled()) return; @@ -5012,40 +4826,6 @@ void AudioEngine::applyClientTubeRxFloat32(QByteArray& float32) frames, 2); } -void AudioEngine::applyClientPuduTxInt16(QByteArray& int16stereo) -{ - if (!m_clientPuduTx || !m_clientPuduTx->isEnabled()) return; - if (int16stereo.isEmpty()) return; - - const int samples = int16stereo.size() / static_cast(sizeof(int16_t)); - if ((samples & 1) != 0) return; - const int frames = samples / 2; - - m_clientPuduTxScratch.resize(samples * static_cast(sizeof(float))); - auto* f32 = reinterpret_cast(m_clientPuduTxScratch.data()); - const auto* i16 = reinterpret_cast(int16stereo.constData()); - for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; - - m_clientPuduTx->process(f32, frames, 2); - - auto* out = reinterpret_cast(int16stereo.data()); - for (int i = 0; i < samples; ++i) { - out[i] = static_cast( - std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); - } -} - -void AudioEngine::applyClientPuduTxFloat32(QByteArray& float32) -{ - if (!m_clientPuduTx || !m_clientPuduTx->isEnabled()) return; - if (float32.isEmpty()) return; - const int samples = float32.size() / static_cast(sizeof(float)); - const int channels = (samples % 2 == 0) ? 2 : 1; - const int frames = samples / channels; - m_clientPuduTx->process(reinterpret_cast(float32.data()), - frames, channels); -} - void AudioEngine::applyClientPuduRxFloat32(QByteArray& float32) { if (!m_clientPuduRx || !m_clientPuduRx->isEnabled()) return; @@ -5057,120 +4837,6 @@ void AudioEngine::applyClientPuduRxFloat32(QByteArray& float32) frames, 2); } -void AudioEngine::applyClientReverbTxInt16(QByteArray& int16stereo) -{ - if (!m_clientReverbTx || !m_clientReverbTx->isEnabled()) return; - if (int16stereo.isEmpty()) return; - - const int samples = int16stereo.size() / static_cast(sizeof(int16_t)); - if ((samples & 1) != 0) return; - const int frames = samples / 2; - - m_clientReverbTxScratch.resize(samples * static_cast(sizeof(float))); - auto* f32 = reinterpret_cast(m_clientReverbTxScratch.data()); - const auto* i16 = reinterpret_cast(int16stereo.constData()); - for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; - - m_clientReverbTx->process(f32, frames, 2); - - auto* out = reinterpret_cast(int16stereo.data()); - for (int i = 0; i < samples; ++i) { - out[i] = static_cast( - std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); - } -} - -void AudioEngine::applyClientFinalLimiterTxInt16(QByteArray& int16stereo) -{ - if (!m_clientFinalLimiterTx) return; - if (int16stereo.isEmpty()) return; - - const int samples = int16stereo.size() / static_cast(sizeof(int16_t)); - if ((samples & 1) != 0) return; - const int frames = samples / 2; - - m_clientFinalLimiterTxScratch.resize(samples * static_cast(sizeof(float))); - auto* f32 = reinterpret_cast(m_clientFinalLimiterTxScratch.data()); - const auto* i16 = reinterpret_cast(int16stereo.constData()); - for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; - - m_clientFinalLimiterTx->process(f32, frames, 2); - - auto* out = reinterpret_cast(int16stereo.data()); - for (int i = 0; i < samples; ++i) { - out[i] = static_cast( - std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); - } -} - -void AudioEngine::applyClientFinalLimiterTxFloat32(QByteArray& float32) -{ - if (!m_clientFinalLimiterTx) return; - if (float32.isEmpty()) return; - const int samples = float32.size() / static_cast(sizeof(float)); - const int channels = (samples % 2 == 0) ? 2 : 1; - const int frames = samples / channels; - m_clientFinalLimiterTx->process(reinterpret_cast(float32.data()), - frames, channels); -} - -void AudioEngine::applyClientReverbTxFloat32(QByteArray& float32) -{ - if (!m_clientReverbTx || !m_clientReverbTx->isEnabled()) return; - if (float32.isEmpty()) return; - const int samples = float32.size() / static_cast(sizeof(float)); - const int channels = (samples % 2 == 0) ? 2 : 1; - const int frames = samples / channels; - m_clientReverbTx->process(reinterpret_cast(float32.data()), - frames, channels); -} - -void AudioEngine::applyClientTxDspInt16(QByteArray& int16stereo) -{ - // Order determines whether the compressor colours the raw mic signal - // before the EQ shapes it (default, Pro-XL "tone shaping after - // dynamics"), or the EQ shapes first and the compressor tames the - // resulting peaks. EQ's tap is always fed post-EQ so the analyzer - // shows the final signal leaving the TX DSP chain. - // Walk the packed chain-stage list and dispatch each entry to its - // matching per-stage apply helper. The audio thread loads the - // full chain in one atomic read — each byte is a TxChainStage. - const uint64_t packed = m_txChainPacked.load(std::memory_order_acquire); - for (int i = 0; i < kMaxTxChainStages; ++i) { - const auto stage = static_cast((packed >> (i * 8)) & 0xFF); - switch (stage) { - case TxChainStage::None: return; // end-of-list marker - case TxChainStage::Eq: applyClientEqTxInt16(int16stereo); break; - case TxChainStage::Comp: applyClientCompTxInt16(int16stereo); break; - case TxChainStage::Gate: applyClientGateTxInt16(int16stereo); break; - case TxChainStage::DeEss: applyClientDeEssTxInt16(int16stereo); break; - case TxChainStage::Tube: applyClientTubeTxInt16(int16stereo); break; - // "Enh" is the legacy enum name; the user-facing label is - // PUDU (Phase 5 exciter, Aphex/Behringer-modelled). - case TxChainStage::Enh: applyClientPuduTxInt16(int16stereo); break; - case TxChainStage::Reverb: applyClientReverbTxInt16(int16stereo); break; - } - } -} - -void AudioEngine::applyClientTxDspFloat32(QByteArray& float32) -{ - const uint64_t packed = m_txChainPacked.load(std::memory_order_acquire); - for (int i = 0; i < kMaxTxChainStages; ++i) { - const auto stage = static_cast((packed >> (i * 8)) & 0xFF); - switch (stage) { - case TxChainStage::None: return; - case TxChainStage::Eq: applyClientEqTxFloat32(float32); break; - case TxChainStage::Comp: applyClientCompTxFloat32(float32); break; - case TxChainStage::Gate: applyClientGateTxFloat32(float32); break; - case TxChainStage::DeEss: applyClientDeEssTxFloat32(float32); break; - case TxChainStage::Tube: applyClientTubeTxFloat32(float32); break; - case TxChainStage::Enh: applyClientPuduTxFloat32(float32); break; - case TxChainStage::Reverb: applyClientReverbTxFloat32(float32); break; - } - } -} - void AudioEngine::applyClientRxDspFloat32(QByteArray& float32) { // Walk the packed RX chain-stage list and dispatch each entry to @@ -5252,8 +4918,7 @@ AudioEngine::TxChainStage stageFromName(const QString& name) } // Canonical default order for a fresh install — stages appear in the -// order they'll typically be wanted in the signal chain. Only Eq and -// Comp do anything today; the others are no-ops until their DSP ships. +// order they'll typically be wanted in the signal chain. QVector defaultChain() { return { @@ -7107,7 +6772,8 @@ void AudioEngine::setRn2TxEnabled(bool on) std::lock_guard lock(m_dspMutex); if (on) { m_rn2Tx = std::make_unique( - RNNoiseFilter::OutputMode::ProcessedMono); + RNNoiseFilter::OutputMode::ProcessedMono, + RNNoiseFilter::RateDomain::Native48k); if (!m_rn2Tx->isValid()) { qCWarning(lcAudio) << "AudioEngine: RN2 TX rnnoise_create() failed — disabling"; m_rn2Tx.reset(); @@ -7674,22 +7340,32 @@ bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioP qCInfo(lcAudio) << "AudioEngine: selected TX input format:" << fmt.sampleRate() << "Hz" << fmt.channelCount() << "ch"; - // Record actual negotiated input format for resampling in onTxAudioReady + // Record the negotiated device format. Voice normalizes directly to the + // 48 kHz DSP island; the legacy 24 kHz resampler below is retained only + // for the separate RADE branch. m_txInputRate = fmt.sampleRate(); m_txInputChannels = fmt.channelCount(); m_txInputMono = (m_txInputChannels == 1); - m_txNeedsResample = (m_txInputRate != 24000); + m_radeTxNeedsResample = (m_txInputRate != DEFAULT_SAMPLE_RATE); - // Create polyphase resampler for high-quality rate conversion - if (m_txNeedsResample) + // Create the RADE path's polyphase resampler for high-quality conversion. + if (m_radeTxNeedsResample) { m_txResampler = std::make_unique(m_txInputRate, DEFAULT_SAMPLE_RATE, 16384); - else + } else { m_txResampler.reset(); + } + if (!m_txVoiceProcessor->prepare(m_txInputRate, 16384)) { + qCWarning(lcAudio) << "AudioEngine: failed to prepare 48 kHz TX voice processor" + << "for input rate" << m_txInputRate; + return false; + } qCDebug(lcAudio) << "AudioEngine: TX input device:" << dev.description() << "id:" << dev.id() << "rate:" << fmt.sampleRate() << "ch:" << fmt.channelCount() - << "resample:" << m_txNeedsResample; + << "voice normalize to 48k:" + << (m_txInputRate != TxVoiceProcessor::kDspRate) + << "RADE resample to 24k:" << m_radeTxNeedsResample; #ifdef Q_OS_MAC // macOS: QAudioSource pull mode broken — use push mode with QBuffer @@ -7804,12 +7480,21 @@ bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioP m_txInputRate = rate; m_txInputChannels = ch; m_txInputMono = (m_txInputChannels == 1); - m_txNeedsResample = (rate != 24000); - if (m_txNeedsResample) { + m_radeTxNeedsResample = (rate != DEFAULT_SAMPLE_RATE); + if (m_radeTxNeedsResample) { m_txResampler = std::make_unique(rate, 24000, 16384); } else { m_txResampler.reset(); } + if (!m_txVoiceProcessor->prepare(rate, 16384)) { + qCWarning(lcAudio) + << "AudioEngine: failed to prepare 48 kHz TX voice processor" + << "for fallback input rate" << rate; + delete m_audioSource; + m_audioSource = nullptr; + m_micDevice = nullptr; + continue; + } txOpened = true; break; } @@ -7886,13 +7571,17 @@ bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioP << ":" << radioPort << "streamId:" << Qt::hex << m_txStreamId << Qt::dec << "device:" << dev.description() << "id:" << dev.id() << "rate:" << m_txInputRate << "ch:" << m_txInputChannels - << "resample:" << m_txNeedsResample; + << "voice normalize to 48k:" + << (m_txInputRate != TxVoiceProcessor::kDspRate) + << "RADE resample to 24k:" << m_radeTxNeedsResample; AudioSummaryLogger::TxSourceSummary summary; summary.deviceDescription = dev.description(); summary.sampleRate = m_txInputRate; summary.channelCount = m_txInputChannels; summary.sampleFormat = fmt.sampleFormat(); - summary.resamplingTo24k = m_txNeedsResample; + summary.normalizingTo48k = + (m_txInputRate != TxVoiceProcessor::kDspRate); + summary.radeResamplingTo24k = m_radeTxNeedsResample; summary.fallbackOccurred = txFallbackOccurred; summary.fallbackReason = txFallbackReasons.join(QStringLiteral("; ")); AudioSummaryLogger::logTxSource(summary); @@ -7942,7 +7631,7 @@ void AudioEngine::stopTxStream() m_txInputChannels = 2; m_txInputMono = false; m_txInputRate = DEFAULT_SAMPLE_RATE; - m_txNeedsResample = false; + m_radeTxNeedsResample = false; m_txMicChannelState.reset(); m_lastTxMicChannelLog.invalidate(); m_txSourceStartTime.invalidate(); @@ -8090,11 +7779,11 @@ void AudioEngine::onTxAudioReady() if (data.isEmpty()) return; logTxInputChannelDiagnostics(channelDiagnostics, "TX mic"); - // Resample canonical mono int16 to 24kHz duplicated stereo if needed, then - // convert to float32 for RADE. Normal TX path stays int16 (Opus requires - // int16). Do not call processStereoToStereo() here: that helper would - // average raw mic L/R and reintroduce the one-sided-channel 6.02 dB loss. - if (m_txNeedsResample && m_txResampler) { + // RADE remains a fixed 24 kHz island. Voice skips this block and enters + // TxVoiceProcessor at the negotiated device rate. Do not call + // processStereoToStereo() here: that helper would average raw mic L/R and + // reintroduce the one-sided-channel 6.02 dB loss. + if (m_radeMode && m_radeTxNeedsResample && m_txResampler) { // Convert canonical duplicated int16 stereo → float32 mono for the // mono-to-stereo resampler. const auto* i16 = reinterpret_cast(data.constData()); @@ -8149,96 +7838,31 @@ void AudioEngine::onTxAudioReady() // Don't send mic audio — it would conflict with the DAX stream. if (m_daxTxMode) return; - // ── RN2 mic pre-amp (TX neural denoiser) ───────────────────── - // Runs strictly on the voice path — both digital-mode early-returns - // above (m_radeMode, m_daxTxMode) skip this hook so RN2 is guaranteed - // never to touch RADE / DAX / TCI / RTTY / FT8 / FDV audio. Placed - // BEFORE the test tone + user DSP chain so any downstream gate / - // comp / EQ / saturator processes denoised audio rather than - // amplifying the noise floor. - // - // RNNoiseFilter::process() takes / returns 24 kHz duplicated-stereo - // FLOAT32 (despite its header comment claiming int16). At this - // point in the flow `data` is 24 kHz duplicated-stereo int16, so we - // convert in → process → convert out. Conversion is in-place over - // pre-sized scratch buffers — no per-block heap traffic after the - // first call. (#2813) - if (m_rn2TxEnabled.load() && m_rn2Tx && m_rn2Tx->isValid()) { - const auto* i16 = reinterpret_cast(data.constData()); - const int samples = data.size() / static_cast(sizeof(int16_t)); - m_rn2TxF32In.resize(samples * static_cast(sizeof(float))); - auto* fin = reinterpret_cast(m_rn2TxF32In.data()); - for (int i = 0; i < samples; ++i) fin[i] = i16[i] / 32768.0f; - - m_rn2TxF32In = m_rn2Tx->process(m_rn2TxF32In); - - const int outSamples = m_rn2TxF32In.size() / static_cast(sizeof(float)); - const auto* fout = reinterpret_cast(m_rn2TxF32In.constData()); - data.resize(outSamples * static_cast(sizeof(int16_t))); - auto* i16Out = reinterpret_cast(data.data()); - for (int i = 0; i < outSamples; ++i) { - const float clamped = std::clamp(fout[i] * 32768.0f, -32768.0f, 32767.0f); - i16Out[i] = static_cast(clamped); - } - } - - // ── Client-side TX DSP: compressor + parametric EQ ────────────────── - // Runs after mic capture and resample, before PC mic gain / metering / - // Opus / VITA-49, so the user hears the shaped signal exactly as the - // radio will receive it. Chain order (CMP→EQ vs EQ→CMP) is user- - // selectable via setTxChainOrder(). - // ── Test tone (head of chain) ─────────────────────────────── - // When enabled, replaces mic input with a sine so the user can - // run the chain on a known signal. Runs BEFORE the user's DSP - // chain so the tone exits the strip with all stages applied. - if (m_clientTxTestTone && m_clientTxTestTone->isEnabled()) { - const int samples = data.size() / static_cast(sizeof(int16_t)); - const int frames = samples / 2; - m_clientTxTestTone->process( - reinterpret_cast(data.data()), frames, 2); - } - - applyClientTxDspInt16(data); - - // ── PUDU monitor tap ───────────────────────────────────────── - // Feeds the post-DSP int16 bytes into the TX monitor if one is - // registered. Lock-free atomic pointer load; the monitor's - // feedTxPostDsp() itself handles the not-recording fast-path. + // ── Fixed-rate TX voice processor ─────────────────────────────────── + // Canonical mic input enters this seam at the negotiated device rate, + // becomes float once, and stays float through RN2, the user-orderable + // channel strip, mic gain, Quindar, and the final limiter. A matched + // stereo r8brain pair performs the sole 48 -> 24 kHz egress conversion; + // quantization happens once at the unchanged Opus/VITA boundary. + // Radio-authoritative mode/passband filtering remains downstream. + m_txVoiceProcessor->setStageOrder( + m_txChainPacked.load(std::memory_order_acquire)); + m_txVoiceProcessor->setMicGain(m_pcMicGain.load()); + m_txVoiceProcessor->setRnnoise(m_rn2Tx.get()); + m_txVoiceProcessor->setRnnoiseEnabled(m_rn2TxEnabled.load()); + m_txVoiceProcessor->setMeasurementCaptureEnabled(false); + if (!m_txVoiceProcessor->processCapturedInt16(data)) { + return; + } + data = m_txVoiceProcessor->transportInt16Stereo(); + + // The legacy pre-tail monitor currently has no active GUI owner. Keep its + // feed alive at the stable 24 kHz representation until it is replaced by + // the explicit 48 kHz postChannelStripFloat48Stereo() measurement seam. if (auto* mon = m_txPostDspMonitor.load(std::memory_order_acquire)) { mon->feedTxPostDsp(data); } - // ── Apply client-side PC mic gain (int16) ─────────────────────────── - const float gain = m_pcMicGain.load(); - if (gain < 0.999f) { - auto* pcm = reinterpret_cast(data.data()); - int sampleCount = data.size() / static_cast(sizeof(int16_t)); - for (int i = 0; i < sampleCount; ++i) - pcm[i] = static_cast(std::clamp( - static_cast(pcm[i] * gain), -32768, 32767)); - } - - // ── Quindar tones (#2262) ─────────────────────────────────────────── - // Sits AFTER the user DSP chain and PC mic gain but BEFORE the final - // brickwall limiter, so the generated tone is unprocessed by Comp/EQ - // (no comp pumping, no EQ tilt) but is still bounded by the configured - // ceiling. Driven by TransmitModel's PTT coordinator on phone modes; - // the stage replaces samples wholesale during Engaging/Disengaging - // phases and is a no-op the rest of the time. - if (m_clientQuindarTone) { - const int frames = data.size() / static_cast(sizeof(int16_t) * 2); - m_clientQuindarTone->process( - reinterpret_cast(data.data()), frames, 2); - } - - // ── Final brickwall limiter (TX tail) ─────────────────────────────── - // Sits at the very end of the chain — after every user-configurable - // stage AND after PC mic gain — so no sample escapes louder than the - // configured ceiling regardless of upstream behaviour. Its meters - // (input / output peak, GR, active) are what the strip's "Final - // Output Stage" panel reads. - applyClientFinalLimiterTxInt16(data); - // ── Final-output monitor tap (+ local CW/CWX sidetone for recording) ── // Mirror the post-PUDU monitor at the chain's tail (post-limiter) for the // PUDU TX monitor and the Client-Side QSO recorder's VOICE tap (#3556). diff --git a/src/core/AudioEngine.h b/src/core/AudioEngine.h index aa03353b8..f82be7b99 100644 --- a/src/core/AudioEngine.h +++ b/src/core/AudioEngine.h @@ -44,6 +44,7 @@ class RNNoiseFilter; class DeepFilterFilter; class NvidiaAfxFilter; class Resampler; +class TxVoiceProcessor; class ClientEq; class ClientComp; class ClientGate; @@ -178,7 +179,8 @@ class AudioEngine : public QObject { bool hasKiwiSdrAudioSource(const QString& sourceId) const; int txInputSampleRate() const { return m_txInputRate; } int txInputChannelCount() const { return m_txInputChannels; } - bool txInputResamplingTo24k() const { return m_txNeedsResample; } + bool txInputNormalizationTo48k() const { return m_txInputRate != 48000; } + bool txRadeResamplingTo24k() const { return m_radeTxNeedsResample; } bool rxOutputResamplingActive() const { return m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE; } QJsonArray audioEndpointDiagnostics() const; QJsonObject startAutomationAudioCapture(int durationMs, @@ -363,11 +365,9 @@ class AudioEngine : public QObject { // registration — clear to nullptr before destroying the monitor. void setTxPostDspMonitor(ClientPuduMonitor* m) noexcept; - // Generalised TX DSP chain — each stage is a separate processing - // block run in order on the TX audio path. Only Eq and Comp are - // implemented today; the remaining stages are placeholders for - // Phase 2+ work (Gate, DeEss, Tube, Enh from #1661) and are no-ops - // until their DSP classes ship. + // Generalised TX DSP chain — each stage is a separate float processing + // block run in order by TxVoiceProcessor at 48 kHz. Numeric values are + // persisted in the packed atomic and therefore form a stable contract. enum class TxChainStage : uint8_t { None = 0, // sentinel / end-of-list marker Gate = 1, @@ -836,42 +836,16 @@ private slots: RxDspSource source, ExternalRxAudioSourceState* externalSource) const; #endif - // Apply client-side TX EQ in-place. No-op if disabled. Caller owns data. - void applyClientEqTxInt16(QByteArray& int16stereo); - void applyClientEqTxFloat32(QByteArray& float32); - // Apply client-side TX compressor in-place. No-op if disabled. - void applyClientCompTxInt16(QByteArray& int16stereo); - void applyClientCompTxFloat32(QByteArray& float32); // RX comp operates on the post-Gate float32 stereo buffer. void applyClientCompRxFloat32(QByteArray& float32); - // Apply client-side TX gate in-place. No-op if disabled. - void applyClientGateTxInt16(QByteArray& int16stereo); - void applyClientGateTxFloat32(QByteArray& float32); // RX gate operates on the post-EQ float32 stereo buffer. void applyClientGateRxFloat32(QByteArray& float32); - // Apply client-side TX de-esser in-place. No-op if disabled. - void applyClientDeEssTxInt16(QByteArray& int16stereo); - void applyClientDeEssTxFloat32(QByteArray& float32); // RX de-esser operates on the post-Comp float32 stereo buffer (#2425). void applyClientDeEssRxFloat32(QByteArray& float32); - // Apply client-side TX tube saturator in-place. No-op if disabled. - void applyClientTubeTxInt16(QByteArray& int16stereo); - void applyClientTubeTxFloat32(QByteArray& float32); // RX tube operates on the post-Comp float32 stereo buffer. void applyClientTubeRxFloat32(QByteArray& float32); - // Apply client-side TX PUDU exciter in-place. No-op if disabled. - void applyClientPuduTxInt16(QByteArray& int16stereo); - void applyClientPuduTxFloat32(QByteArray& float32); // RX pudu operates on the post-Tube float32 stereo buffer. void applyClientPuduRxFloat32(QByteArray& float32); - // Apply client-side TX reverb in-place. No-op if disabled. - void applyClientReverbTxInt16(QByteArray& int16stereo); - void applyClientReverbTxFloat32(QByteArray& float32); - void applyClientFinalLimiterTxInt16(QByteArray& int16stereo); - void applyClientFinalLimiterTxFloat32(QByteArray& float32); - // Apply the whole TX DSP chain (CMP + EQ) in the configured order. - void applyClientTxDspInt16(QByteArray& int16stereo); - void applyClientTxDspFloat32(QByteArray& float32); void accumulatePcMicMeterInt16Stereo(const QByteArray& int16stereo); void logTxInputChannelDiagnostics(const TxMicChannelNormalizer::Diagnostics& diagnostics, @@ -1014,7 +988,7 @@ private slots: // when TX-decode is off. std::atomic m_cwDecodeTxTapEnabled{false}; std::atomic m_tncRxTapEnabled{false}; - bool m_txNeedsResample{false}; // TX: input rate != 24kHz, needs resampling + bool m_radeTxNeedsResample{false}; // RADE: input rate != 24 kHz bool m_txInputMono{false}; // TX: legacy convenience mirror of m_txInputChannels == 1 int m_txInputChannels{2}; // TX: actual negotiated input channel count int m_txInputRate{24000}; // TX: actual input sample rate @@ -1026,7 +1000,7 @@ private slots: TxMicChannelNormalizer::AutoState m_daxRadioTxChannelState; QElapsedTimer m_lastTxMicChannelLog; QElapsedTimer m_lastDaxRadioChannelLog; - std::unique_ptr m_txResampler; // e.g. 48k→24k (lazy init) + std::unique_ptr m_txResampler; // RADE e.g. 48k -> 24k (lazy init) // DSP lifecycle mutex: held during feedAudioData() DSP section AND // during enable/disable to prevent use-after-free (#502) @@ -1068,9 +1042,6 @@ private slots: // RN2 ownership pattern above. (#2813) std::unique_ptr m_rn2Tx; std::atomic m_rn2TxEnabled{false}; - // Scratch buffer for int16 ↔ float32 conversion around RN2 TX. - // Audio-thread only — grows once to steady state, then alloc-free. - QByteArray m_rn2TxF32In; // Client-side DFNR (DeepFilterNet3) #ifdef HAVE_DFNR @@ -1111,6 +1082,7 @@ private slots: std::unique_ptr m_clientTxTestTone; std::unique_ptr m_wsprBeacon; std::unique_ptr m_clientQuindarTone; + std::unique_ptr m_txVoiceProcessor; // Audio-thread-loaded pointer for the post-final-limiter monitor // (final-output recording). Same lock-free atomic pointer pattern // as m_txPostDspMonitor. @@ -1122,8 +1094,7 @@ private slots: // a uint64_t so the audio thread can load the full order in a // single atomic read per block. TxChainStage::None terminates the // list; unused slots are zero. Default canonical order: - // [Gate, Eq, DeEss, Comp, Tube, Enh] — but only Eq and Comp have - // implementations today, so the others are no-op pass-throughs. + // [Gate, Eq, DeEss, Comp, Tube, Enh, Reverb]. std::atomic m_txChainPacked{0}; // Master-bypass snapshot — the stages that were enabled at the // moment setTxBypassed(true) was called. Used solely to restore @@ -1154,22 +1125,14 @@ private slots: bool m_rxBypassActive{false}; // Scratch buffer for in-place EQ on the RX path (avoids per-call alloc). QByteArray m_clientEqRxScratch; - QByteArray m_clientEqTxScratch; - QByteArray m_clientCompTxScratch; QByteArray m_clientCompRxScratch; - QByteArray m_clientGateTxScratch; QByteArray m_clientGateRxScratch; - QByteArray m_clientDeEssTxScratch; QByteArray m_clientDeEssRxScratch; - QByteArray m_clientTubeTxScratch; QByteArray m_clientTubeRxScratch; - QByteArray m_clientPuduTxScratch; QByteArray m_clientPuduRxScratch; - QByteArray m_clientReverbTxScratch; - QByteArray m_clientFinalLimiterTxScratch; // Post-EQ analyzer tap. One ring per path, mono (L+R averaged). - // Audio thread writes via tapClientEqRxStereo() / tapClientEqTxInt16() - // / tapClientEqTxFloat32(); UI thread snapshots via the public + // Audio thread writes via tapClientEqRxStereo() / tapClientEqTxFloat32(); + // UI thread snapshots via the public // copyRecent*() accessors. Mutex is held for microseconds only. mutable std::mutex m_clientEqTapMutex; float m_clientEqTapRx[kClientEqTapSize]{}; @@ -1177,7 +1140,6 @@ private slots: int m_clientEqTapRxWrite{0}; int m_clientEqTapTxWrite{0}; void tapClientEqRxStereo(const float* stereoInterleaved, int frames); - void tapClientEqTxInt16(const int16_t* int16stereo, int frames); void tapClientEqTxFloat32(const float* f32, int samples, int channels); // Pre-allocated NR2 output buffers (avoid per-call heap allocation) diff --git a/src/core/AudioSummaryLogger.cpp b/src/core/AudioSummaryLogger.cpp index e0772d1dc..9b25cba3b 100644 --- a/src/core/AudioSummaryLogger.cpp +++ b/src/core/AudioSummaryLogger.cpp @@ -168,8 +168,9 @@ QString formatTxSource(const TxSourceSummary& summary) .arg(summary.channelCount) .arg(modeText(summary.channelCount), sampleFormatName(summary.sampleFormat)) - << QStringLiteral(" resampleTo24k=%1 %2") - .arg(yesNo(summary.resamplingTo24k), + << QStringLiteral(" voiceNormalizeTo48k=%1 radeResampleTo24k=%2 %3") + .arg(yesNo(summary.normalizingTo48k), + yesNo(summary.radeResamplingTo24k), fallbackText(summary.fallbackOccurred, summary.fallbackReason)); return lines.join(QLatin1Char('\n')); } diff --git a/src/core/AudioSummaryLogger.h b/src/core/AudioSummaryLogger.h index c07d99759..a947b6ea6 100644 --- a/src/core/AudioSummaryLogger.h +++ b/src/core/AudioSummaryLogger.h @@ -21,7 +21,8 @@ struct TxSourceSummary { int sampleRate{0}; int channelCount{0}; QAudioFormat::SampleFormat sampleFormat{QAudioFormat::Unknown}; - bool resamplingTo24k{false}; + bool normalizingTo48k{false}; + bool radeResamplingTo24k{false}; bool fallbackOccurred{false}; QString fallbackReason; }; diff --git a/src/core/ClientTxTestTone.cpp b/src/core/ClientTxTestTone.cpp index 519081ffa..bc8dded16 100644 --- a/src/core/ClientTxTestTone.cpp +++ b/src/core/ClientTxTestTone.cpp @@ -89,4 +89,29 @@ void ClientTxTestTone::process(int16_t* interleaved, int frames, int channels) n } } +void ClientTxTestTone::process(float* interleaved, int frames, int channels) noexcept +{ + if (!interleaved || frames <= 0 || channels < 1 || channels > 2) { + return; + } + recacheIfDirty(); + if (!m_cached.enabled) { + return; + } + + const float inc = m_cached.phaseInc; + const float amp = m_cached.ampLin; + for (int f = 0; f < frames; ++f) { + const float sample = std::sin(m_phase) * amp; + interleaved[f * channels] = sample; + if (channels == 2) { + interleaved[f * channels + 1] = sample; + } + m_phase += inc; + if (m_phase > kTwoPi) { + m_phase -= kTwoPi; + } + } +} + } // namespace AetherSDR diff --git a/src/core/ClientTxTestTone.h b/src/core/ClientTxTestTone.h index 2a745be0e..697ebc560 100644 --- a/src/core/ClientTxTestTone.h +++ b/src/core/ClientTxTestTone.h @@ -37,6 +37,7 @@ class ClientTxTestTone { // Audio thread — overwrite int16 stereo samples with the // generated tone. No-op if disabled. void process(int16_t* interleaved, int frames, int channels) noexcept; + void process(float* interleaved, int frames, int channels) noexcept; void reset() noexcept; diff --git a/src/core/DeviceDiagnostics.cpp b/src/core/DeviceDiagnostics.cpp index 9ba24a3f0..d831089b1 100644 --- a/src/core/DeviceDiagnostics.cpp +++ b/src/core/DeviceDiagnostics.cpp @@ -276,8 +276,11 @@ QJsonObject buildAudioDevicesSnapshot(const AudioEngine* audio, const QJsonObjec txRoute["actual_sample_format"] = (audio && audio->isTxStreaming()) ? QJsonValue(QStringLiteral("Int16")) : QJsonValue(); - txRoute["resampling_to_24k"] = (audio && audio->isTxStreaming()) - ? QJsonValue(audio->txInputResamplingTo24k()) + txRoute["voice_normalizing_to_48k"] = (audio && audio->isTxStreaming()) + ? QJsonValue(audio->txInputNormalizationTo48k()) + : QJsonValue(); + txRoute["rade_resampling_to_24k"] = (audio && audio->isTxStreaming()) + ? QJsonValue(audio->txRadeResamplingTo24k()) : QJsonValue(); // Surface the active TX slice's id, mode, and per-slice DAX channel here // so the bundle's TX route summary has the same context a triager would diff --git a/src/core/RNNoiseFilter.cpp b/src/core/RNNoiseFilter.cpp index 949f0084e..acc7af919 100644 --- a/src/core/RNNoiseFilter.cpp +++ b/src/core/RNNoiseFilter.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include namespace AetherSDR { @@ -18,13 +19,16 @@ Q_LOGGING_CATEGORY(lcRn2, "aether.rn2") // RNNoise frame size: 480 samples at 48kHz = 10ms static constexpr int FRAME_SIZE = 480; -RNNoiseFilter::RNNoiseFilter(OutputMode outputMode) +RNNoiseFilter::RNNoiseFilter(OutputMode outputMode, RateDomain rateDomain) : m_outputMode(outputMode) + , m_rateDomain(rateDomain) { for (int channel = 0; channel < processingChannels(); ++channel) { m_states[channel] = rnnoise_create(nullptr); - m_up[channel] = std::make_unique(24000, 48000); - m_down[channel] = std::make_unique(48000, 24000); + if (m_rateDomain == RateDomain::Legacy24k) { + m_up[channel] = std::make_unique(24000, 48000); + m_down[channel] = std::make_unique(48000, 24000); + } } } @@ -60,8 +64,14 @@ void RNNoiseFilter::reset() rnnoise_destroy(m_states[channel]); const bool inUse = channel < channels; m_states[channel] = inUse ? rnnoise_create(nullptr) : nullptr; - m_up[channel] = inUse ? std::make_unique(24000, 48000) : nullptr; - m_down[channel] = inUse ? std::make_unique(48000, 24000) : nullptr; + const bool needsResamplers = inUse + && m_rateDomain == RateDomain::Legacy24k; + m_up[channel] = needsResamplers + ? std::make_unique(24000, 48000) + : nullptr; + m_down[channel] = needsResamplers + ? std::make_unique(48000, 24000) + : nullptr; m_inAccum[channel].clear(); m_input24k[channel].clear(); m_processed48k[channel].clear(); @@ -72,6 +82,11 @@ void RNNoiseFilter::reset() QByteArray RNNoiseFilter::process(const QByteArray& pcm24kStereo) { + if (m_rateDomain != RateDomain::Legacy24k) { + qCWarning(lcRn2) + << "RNNoiseFilter: 24 kHz process() called on native-48 kHz instance"; + return pcm24kStereo; + } if (!isValid() || pcm24kStereo.isEmpty()) return pcm24kStereo; @@ -211,4 +226,102 @@ QByteArray RNNoiseFilter::process(const QByteArray& pcm24kStereo) return QByteArray(needed, '\0'); } +QByteArray RNNoiseFilter::process48kStereo(const QByteArray& pcm48kStereo) +{ + QByteArray output; + process48kStereo(pcm48kStereo, output); + return output; +} + +int RNNoiseFilter::process48kStereo( + const QByteArray& pcm48kStereo, QByteArray& output) +{ + output.clear(); + if (!isValid() || pcm48kStereo.isEmpty()) { + output = pcm48kStereo; + return output.size() / (2 * static_cast(sizeof(float))); + } + + const int stereoFrames = + pcm48kStereo.size() / (2 * static_cast(sizeof(float))); + if (stereoFrames <= 0) { + return 0; + } + + const auto* src = reinterpret_cast(pcm48kStereo.constData()); + const int channels = processingChannels(); + int completeFrames = std::numeric_limits::max(); + std::array totalAccumSamples{0, 0}; + + for (int channel = 0; channel < channels; ++channel) { + const int start = m_inAccum[channel].size() / static_cast(sizeof(float)); + m_inAccum[channel].resize( + (start + stereoFrames) * static_cast(sizeof(float))); + auto* accum = reinterpret_cast(m_inAccum[channel].data()); + for (int frame = 0; frame < stereoFrames; ++frame) { + const float sample = channels == 2 + ? src[frame * 2 + channel] + : 0.5f * (src[frame * 2] + src[frame * 2 + 1]); + accum[start + frame] = sample * 32768.0f; + } + totalAccumSamples[channel] = start + stereoFrames; + completeFrames = std::min( + completeFrames, totalAccumSamples[channel] / FRAME_SIZE); + } + + if (completeFrames > 0) { + const int consumedSamples = completeFrames * FRAME_SIZE; + for (int channel = 0; channel < channels; ++channel) { + m_processed48k[channel].resize(consumedSamples); + auto* accum = reinterpret_cast(m_inAccum[channel].data()); + for (int frame = 0; frame < completeFrames; ++frame) { + float* output = &m_processed48k[channel][frame * FRAME_SIZE]; + float* input = &accum[frame * FRAME_SIZE]; + if (m_dryMix > 0.0f) { + rnnoise_process_frame_with_dry_mix( + m_states[channel], output, input, m_dryMix); + } else { + rnnoise_process_frame(m_states[channel], output, input); + } + } + + const int leftover = totalAccumSamples[channel] - consumedSamples; + if (leftover > 0) { + std::memmove(accum, &accum[consumedSamples], + leftover * sizeof(float)); + m_inAccum[channel].resize( + leftover * static_cast(sizeof(float))); + } else { + m_inAccum[channel].clear(); + } + } + + const int oldOutputSamples = + m_outAccum.size() / static_cast(sizeof(float)); + m_outAccum.resize( + (oldOutputSamples + consumedSamples * 2) + * static_cast(sizeof(float))); + auto* dst = reinterpret_cast(m_outAccum.data()) + + oldOutputSamples; + for (int frame = 0; frame < consumedSamples; ++frame) { + const float left = m_processed48k[0][frame] / 32768.0f; + const float right = channels == 2 + ? m_processed48k[1][frame] / 32768.0f + : left; + dst[frame * 2] = left; + dst[frame * 2 + 1] = right; + } + } + + const int needed = pcm48kStereo.size(); + if (m_outAccum.size() >= needed) { + output.resize(needed); + std::memcpy(output.data(), m_outAccum.constData(), needed); + m_outAccum.remove(0, needed); + return stereoFrames; + } + output.fill('\0', needed); + return stereoFrames; +} + } // namespace AetherSDR diff --git a/src/core/RNNoiseFilter.h b/src/core/RNNoiseFilter.h index b170094d3..01bce2283 100644 --- a/src/core/RNNoiseFilter.h +++ b/src/core/RNNoiseFilter.h @@ -32,7 +32,14 @@ class RNNoiseFilter { ProcessedMono, }; - explicit RNNoiseFilter(OutputMode outputMode = OutputMode::PreserveRxStereo); + enum class RateDomain { + Legacy24k, + Native48k, + }; + + explicit RNNoiseFilter( + OutputMode outputMode = OutputMode::PreserveRxStereo, + RateDomain rateDomain = RateDomain::Legacy24k); ~RNNoiseFilter(); // Process a block of 24kHz stereo FLOAT32 PCM (NOT int16 @@ -41,6 +48,13 @@ class RNNoiseFilter { // Returns the processed block in the same format and frame count. QByteArray process(const QByteArray& pcm24kStereo); + // Process native RNNoise-rate audio without the wrapper's 24 <-> 48 kHz + // resamplers. Input and output are interleaved 48 kHz stereo float32 with + // an identical frame count. This is the TX voice path's fixed-rate seam; + // the existing process() entry point remains the 24 kHz RX-compatible API. + QByteArray process48kStereo(const QByteArray& pcm48kStereo); + int process48kStereo(const QByteArray& pcm48kStereo, QByteArray& output); + // Fraction of the original spectrum retained in each RX frame, clamped to // [0, 1]. 0 (the default) is full suppression — RN2's behavior since it // shipped. Owned by Rn2SettingsModel; AudioEngine calls this under its @@ -66,6 +80,7 @@ class RNNoiseFilter { std::array, 2> m_processed48k; std::array, 2> m_processed48kFloat; OutputMode m_outputMode{OutputMode::PreserveRxStereo}; + RateDomain m_rateDomain{RateDomain::Legacy24k}; float m_dryMix{0.0f}; bool m_warnedChannelLengthMismatch{false}; }; diff --git a/src/core/Resampler.cpp b/src/core/Resampler.cpp index b5a8d9344..c2140d1ac 100644 --- a/src/core/Resampler.cpp +++ b/src/core/Resampler.cpp @@ -18,34 +18,47 @@ Resampler::~Resampler() = default; QByteArray Resampler::process(const float* in, int numSamples) { - if (numSamples <= 0) return {}; + QByteArray result; + process(in, numSamples, result); + return result; +} - // r8b does not bounds-check against aMaxInLen; exceeding it silently - // overflows internal filter buffers. Chunk so each call stays within limit. - if (numSamples > m_maxBlockSamples) { - QByteArray result; - for (int offset = 0; offset < numSamples; offset += m_maxBlockSamples) - result.append(process(in + offset, std::min(numSamples - offset, m_maxBlockSamples))); - return result; +int Resampler::process(const float* in, int numSamples, QByteArray& output) +{ + output.clear(); + if (!in || numSamples <= 0) { + return 0; } - // Convert float32 → double - m_inBuf.resize(numSamples); - for (int i = 0; i < numSamples; ++i) - m_inBuf[i] = static_cast(in[i]); - - // Resample - double* outPtr = nullptr; - int outLen = m_resampler->process(m_inBuf.data(), numSamples, outPtr); - - if (outLen <= 0 || !outPtr) return {}; - - // Convert double → float32 - QByteArray result(outLen * static_cast(sizeof(float)), Qt::Uninitialized); - auto* dst = reinterpret_cast(result.data()); - for (int i = 0; i < outLen; ++i) - dst[i] = static_cast(outPtr[i]); - return result; + // r8b does not bounds-check against aMaxInLen; exceeding it silently + // overflows internal filter buffers. Feed bounded chunks while appending + // directly into the caller's reusable output storage. + int totalOutputSamples = 0; + for (int offset = 0; offset < numSamples; offset += m_maxBlockSamples) { + const int chunkSamples = std::min( + numSamples - offset, m_maxBlockSamples); + m_inBuf.resize(chunkSamples); + for (int index = 0; index < chunkSamples; ++index) { + m_inBuf[static_cast(index)] = + static_cast(in[offset + index]); + } + + double* outputPointer = nullptr; + const int outputSamples = m_resampler->process( + m_inBuf.data(), chunkSamples, outputPointer); + if (outputSamples <= 0 || !outputPointer) { + continue; + } + + const int oldBytes = output.size(); + output.resize(oldBytes + outputSamples * static_cast(sizeof(float))); + auto* destination = reinterpret_cast(output.data() + oldBytes); + for (int index = 0; index < outputSamples; ++index) { + destination[index] = static_cast(outputPointer[index]); + } + totalOutputSamples += outputSamples; + } + return totalOutputSamples; } QByteArray Resampler::processStereoToMono(const float* stereoIn, int numStereoFrames) @@ -142,6 +155,12 @@ QByteArray Resampler::processStereoToStereo(const float* stereoIn, int numStereo return result; } +void Resampler::reset() +{ + m_resampler->clear(); + prewarm(); +} + void Resampler::prewarm() { if (std::abs(m_srcRate - m_dstRate) < 0.001) return; diff --git a/src/core/Resampler.h b/src/core/Resampler.h index 4d7672602..b8a754e88 100644 --- a/src/core/Resampler.h +++ b/src/core/Resampler.h @@ -27,6 +27,11 @@ class Resampler { // Resample mono float32 PCM. Returns resampled mono float32. QByteArray process(const float* in, int numSamples); + // Reuse caller-owned storage. The returned count is mono float samples. + // Reserving output before the first call keeps steady-state conversion + // allocation-free for blocks no larger than maxBlockSamples. + int process(const float* in, int numSamples, QByteArray& output); + // Convenience: stereo float32 → mono downsample → resampled mono float32 QByteArray processStereoToMono(const float* stereoIn, int numStereoFrames); @@ -36,6 +41,10 @@ class Resampler { // Convenience: stereo float32 → downmix to mono → resample → duplicate to stereo float32 QByteArray processStereoToStereo(const float* stereoIn, int numStereoFrames); + // Discard streaming state and consume startup latency again. Call between + // independent streams, never from the realtime process callback. + void reset(); + double srcRate() const { return m_srcRate; } double dstRate() const { return m_dstRate; } diff --git a/src/core/TxVoiceProcessor.cpp b/src/core/TxVoiceProcessor.cpp new file mode 100644 index 000000000..33484d736 --- /dev/null +++ b/src/core/TxVoiceProcessor.cpp @@ -0,0 +1,425 @@ +#include "TxVoiceProcessor.h" + +#include "ClientComp.h" +#include "ClientDeEss.h" +#include "ClientEq.h" +#include "ClientFinalLimiter.h" +#include "ClientGate.h" +#include "ClientPudu.h" +#include "ClientQuindarTone.h" +#include "ClientReverb.h" +#include "ClientTube.h" +#include "ClientTxTestTone.h" +#include "RNNoiseFilter.h" +#include "Resampler.h" + +#include +#include + +namespace AetherSDR { + +TxVoiceProcessor::TxVoiceProcessor() = default; +TxVoiceProcessor::~TxVoiceProcessor() = default; + +bool TxVoiceProcessor::prepare(int inputRate, int maxInputFrames) +{ + if (inputRate <= 0 || maxInputFrames <= 0) { + return false; + } + + m_inputRate = inputRate; + m_maxInputFrames = maxInputFrames; + if (inputRate != kDspRate) { + m_inputResampler = std::make_unique( + inputRate, kDspRate, maxInputFrames); + } else { + m_inputResampler.reset(); + } + + m_maxDspFrames = static_cast( + std::ceil(static_cast(maxInputFrames) * kDspRate / inputRate)) + 32; + m_outputLeftResampler = std::make_unique( + kDspRate, kTransportRate, m_maxDspFrames); + m_outputRightResampler = std::make_unique( + kDspRate, kTransportRate, m_maxDspFrames); + + m_inputMono.reserve(static_cast(maxInputFrames)); + m_outputLeft.reserve(static_cast(m_maxDspFrames)); + m_outputRight.reserve(static_cast(m_maxDspFrames)); + m_resampledMono48.reserve(m_maxDspFrames * static_cast(sizeof(float))); + m_resampledLeft24.reserve( + (m_maxDspFrames / 2 + 32) * static_cast(sizeof(float))); + m_resampledRight24.reserve( + (m_maxDspFrames / 2 + 32) * static_cast(sizeof(float))); + m_rnnoiseOutput48.reserve( + m_maxDspFrames * kChannels * static_cast(sizeof(float))); + m_work48.reserve(m_maxDspFrames * kChannels * static_cast(sizeof(float))); + m_normalized48.reserve(m_work48.capacity()); + m_postStrip48.reserve(m_work48.capacity()); + m_transportFloat.reserve( + (m_maxDspFrames / 2 + 32) * kChannels * static_cast(sizeof(float))); + m_transportInt16.reserve( + (m_maxDspFrames / 2 + 32) * kChannels * static_cast(sizeof(int16_t))); + + prepareProcessors(); + m_prepared = true; + reset(); + return true; +} + +void TxVoiceProcessor::prepareProcessors() +{ + if (m_processors.eq) { + m_processors.eq->prepare(kDspRate); + } + if (m_processors.comp) { + m_processors.comp->prepare(kDspRate); + } + if (m_processors.gate) { + m_processors.gate->prepare(kDspRate); + } + if (m_processors.deEss) { + m_processors.deEss->prepare(kDspRate); + } + if (m_processors.tube) { + m_processors.tube->prepare(kDspRate); + } + if (m_processors.pudu) { + m_processors.pudu->prepare(kDspRate); + } + if (m_processors.reverb) { + m_processors.reverb->prepare(kDspRate); + } + if (m_processors.finalLimiter) { + m_processors.finalLimiter->prepare(kDspRate); + } + if (m_processors.testTone) { + m_processors.testTone->prepare(kDspRate); + } + if (m_processors.quindar) { + m_processors.quindar->prepare(kDspRate); + } +} + +void TxVoiceProcessor::reset() +{ + if (m_inputResampler) { + m_inputResampler->reset(); + } + if (m_outputLeftResampler) { + m_outputLeftResampler->reset(); + } + if (m_outputRightResampler) { + m_outputRightResampler->reset(); + } + if (m_processors.eq) { + m_processors.eq->reset(); + } + if (m_processors.comp) { + m_processors.comp->reset(); + } + if (m_processors.gate) { + m_processors.gate->reset(); + } + if (m_processors.deEss) { + m_processors.deEss->reset(); + } + if (m_processors.tube) { + m_processors.tube->reset(); + } + if (m_processors.pudu) { + m_processors.pudu->reset(); + } + if (m_processors.reverb) { + m_processors.reverb->reset(); + } + if (m_processors.finalLimiter) { + m_processors.finalLimiter->reset(); + } + if (m_processors.testTone) { + m_processors.testTone->reset(); + } + if (m_processors.quindar) { + m_processors.quindar->reset(); + } + if (m_processors.rnnoise) { + m_processors.rnnoise->reset(); + } + m_work48.clear(); + m_resampledMono48.clear(); + m_resampledLeft24.clear(); + m_resampledRight24.clear(); + m_rnnoiseOutput48.clear(); + m_normalized48.clear(); + m_postStrip48.clear(); + m_transportFloat.clear(); + m_transportInt16.clear(); +} + +void TxVoiceProcessor::setProcessors(const Processors& processors) noexcept +{ + m_processors = processors; + if (m_prepared) { + prepareProcessors(); + } +} + +void TxVoiceProcessor::setStageOrder(uint64_t packedStages) noexcept +{ + m_packedStages = packedStages; +} + +void TxVoiceProcessor::setRnnoiseEnabled(bool enabled) noexcept +{ + m_rnnoiseEnabled = enabled; +} + +void TxVoiceProcessor::setRnnoise(RNNoiseFilter* rnnoise) noexcept +{ + m_processors.rnnoise = rnnoise; +} + +void TxVoiceProcessor::setMicGain(float gain) noexcept +{ + m_micGain = std::isfinite(gain) ? std::clamp(gain, 0.0f, 1.0f) : 1.0f; +} + +void TxVoiceProcessor::setMeasurementCaptureEnabled(bool enabled) noexcept +{ + m_captureMeasurements = enabled; + if (!enabled) { + m_normalized48.clear(); + m_postStrip48.clear(); + } +} + +bool TxVoiceProcessor::processCapturedInt16(const QByteArray& canonicalInput) +{ + if (!m_prepared || canonicalInput.isEmpty()) { + return false; + } + const int inputFrames = canonicalInput.size() + / (kChannels * static_cast(sizeof(int16_t))); + if (inputFrames <= 0 || inputFrames > m_maxInputFrames) { + return false; + } + + const auto* input = reinterpret_cast(canonicalInput.constData()); + m_inputMono.resize(static_cast(inputFrames)); + for (int frame = 0; frame < inputFrames; ++frame) { + m_inputMono[static_cast(frame)] = input[frame * 2] / 32768.0f; + } + + const float* mono48Samples = m_inputMono.data(); + int frames48 = inputFrames; + if (m_inputResampler) { + frames48 = m_inputResampler->process( + m_inputMono.data(), inputFrames, m_resampledMono48); + mono48Samples = reinterpret_cast( + m_resampledMono48.constData()); + } + if (frames48 <= 0) { + return false; + } + + m_work48.resize(frames48 * kChannels * static_cast(sizeof(float))); + auto* work = reinterpret_cast(m_work48.data()); + for (int frame = 0; frame < frames48; ++frame) { + work[frame * 2] = mono48Samples[frame]; + work[frame * 2 + 1] = mono48Samples[frame]; + } + return processWorkBuffer(frames48); +} + +bool TxVoiceProcessor::processFloat48(const float* interleavedStereo, int frames) +{ + if (!m_prepared || !interleavedStereo || frames <= 0 + || frames > m_maxDspFrames) { + return false; + } + m_work48.resize(frames * kChannels * static_cast(sizeof(float))); + std::copy_n(interleavedStereo, frames * kChannels, + reinterpret_cast(m_work48.data())); + return processWorkBuffer(frames); +} + +bool TxVoiceProcessor::processWorkBuffer(int frames48) +{ + if (m_captureMeasurements) { + m_normalized48 = m_work48; + } + + if (m_rnnoiseEnabled && m_processors.rnnoise + && m_processors.rnnoise->isValid()) { + m_processors.rnnoise->process48kStereo(m_work48, m_rnnoiseOutput48); + m_work48.swap(m_rnnoiseOutput48); + } + + auto* work = reinterpret_cast(m_work48.data()); + if (m_processors.testTone && m_processors.testTone->isEnabled()) { + m_processors.testTone->process(work, frames48, kChannels); + } + processChannelStrip(m_work48); + if (m_captureMeasurements) { + m_postStrip48 = m_work48; + } + + work = reinterpret_cast(m_work48.data()); + if (m_micGain < 0.999f) { + for (int sample = 0; sample < frames48 * kChannels; ++sample) { + work[sample] *= m_micGain; + } + } + if (m_processors.quindar) { + m_processors.quindar->process(work, frames48, kChannels); + } + if (m_processors.finalLimiter) { + m_processors.finalLimiter->process(work, frames48, kChannels); + } + + // A malformed parameter transition or third-party DSP failure must not + // poison the stateful egress SRC with NaN/Inf values. Replace only + // non-finite samples; finite over-range samples are still measured and + // clipped once at the integer transport boundary below. + for (int sample = 0; sample < frames48 * kChannels; ++sample) { + if (!std::isfinite(work[sample])) { + work[sample] = 0.0f; + } + } + + m_outputLeft.resize(static_cast(frames48)); + m_outputRight.resize(static_cast(frames48)); + for (int frame = 0; frame < frames48; ++frame) { + m_outputLeft[static_cast(frame)] = work[frame * 2]; + m_outputRight[static_cast(frame)] = work[frame * 2 + 1]; + } + const int leftOutputFrames = m_outputLeftResampler->process( + m_outputLeft.data(), frames48, m_resampledLeft24); + const int rightOutputFrames = m_outputRightResampler->process( + m_outputRight.data(), frames48, m_resampledRight24); + const int outputFrames = std::min( + leftOutputFrames, rightOutputFrames); + if (outputFrames <= 0) { + return false; + } + + m_transportFloat.resize( + outputFrames * kChannels * static_cast(sizeof(float))); + m_transportInt16.resize( + outputFrames * kChannels * static_cast(sizeof(int16_t))); + auto* outputFloat = reinterpret_cast(m_transportFloat.data()); + auto* outputInt16 = reinterpret_cast(m_transportInt16.data()); + const auto* left = reinterpret_cast(m_resampledLeft24.constData()); + const auto* right = reinterpret_cast(m_resampledRight24.constData()); + for (int frame = 0; frame < outputFrames; ++frame) { + outputFloat[frame * 2] = left[frame]; + outputFloat[frame * 2 + 1] = right[frame]; + for (int channel = 0; channel < kChannels; ++channel) { + const float scaled = std::clamp( + outputFloat[frame * 2 + channel] * 32768.0f, + -32768.0f, 32767.0f); + outputInt16[frame * 2 + channel] = static_cast(scaled); + } + } + return true; +} + +void TxVoiceProcessor::processChannelStrip(QByteArray& float48Stereo) noexcept +{ + auto* samples = reinterpret_cast(float48Stereo.data()); + const int frames = float48Stereo.size() + / (kChannels * static_cast(sizeof(float))); + for (int index = 0; index < kMaxStages; ++index) { + const Stage stage = static_cast( + (m_packedStages >> (index * 8)) & 0xFF); + switch (stage) { + case Stage::None: + return; + case Stage::Eq: + if (m_processors.eq && m_processors.eq->isEnabled()) { + m_processors.eq->process(samples, frames, kChannels); + } + if (m_processors.eqTap) { + m_processors.eqTap(m_processors.eqTapContext, samples, frames); + } + break; + case Stage::Comp: + if (m_processors.comp + && (m_processors.comp->isEnabled() + || m_processors.comp->driveDb() > 0.0f + || m_processors.comp->phaseRotatorStages() > 0 + || m_processors.comp->limiterEnabled())) { + m_processors.comp->process(samples, frames, kChannels); + } + break; + case Stage::Gate: + if (m_processors.gate && m_processors.gate->isEnabled()) { + m_processors.gate->process(samples, frames, kChannels); + } + break; + case Stage::DeEss: + if (m_processors.deEss && m_processors.deEss->isEnabled()) { + m_processors.deEss->process(samples, frames, kChannels); + } + break; + case Stage::Tube: + if (m_processors.tube && m_processors.tube->isEnabled()) { + m_processors.tube->process(samples, frames, kChannels); + } + break; + case Stage::Enh: + if (m_processors.pudu && m_processors.pudu->isEnabled()) { + m_processors.pudu->process(samples, frames, kChannels); + } + break; + case Stage::Reverb: + if (m_processors.reverb && m_processors.reverb->isEnabled()) { + m_processors.reverb->process(samples, frames, kChannels); + } + break; + } + } +} + +const QByteArray& TxVoiceProcessor::transportInt16Stereo() const noexcept +{ + return m_transportInt16; +} + +const QByteArray& TxVoiceProcessor::transportFloat32Stereo() const noexcept +{ + return m_transportFloat; +} + +const QByteArray& TxVoiceProcessor::normalizedFloat48Stereo() const noexcept +{ + return m_normalized48; +} + +const QByteArray& TxVoiceProcessor::postChannelStripFloat48Stereo() const noexcept +{ + return m_postStrip48; +} + +int TxVoiceProcessor::latencyFrames() const noexcept +{ + int frames = m_rnnoiseEnabled && m_processors.rnnoise + && m_processors.rnnoise->isValid() + ? 480 + : 0; + for (int index = 0; index < kMaxStages; ++index) { + const Stage stage = static_cast( + (m_packedStages >> (index * 8)) & 0xFF); + if (stage == Stage::None) { + break; + } + if (stage == Stage::Gate && m_processors.gate + && m_processors.gate->isEnabled()) { + frames += static_cast(std::lround( + m_processors.gate->lookaheadMs() * kDspRate / 1000.0f)); + } + } + return frames; +} + +} // namespace AetherSDR diff --git a/src/core/TxVoiceProcessor.h b/src/core/TxVoiceProcessor.h new file mode 100644 index 000000000..e650d9c05 --- /dev/null +++ b/src/core/TxVoiceProcessor.h @@ -0,0 +1,135 @@ +#pragma once + +#include + +#include +#include +#include + +namespace AetherSDR { + +class ClientComp; +class ClientDeEss; +class ClientEq; +class ClientFinalLimiter; +class ClientGate; +class ClientPudu; +class ClientQuindarTone; +class ClientReverb; +class ClientTube; +class ClientTxTestTone; +class RNNoiseFilter; +class Resampler; + +// Headless, backend-independent TX voice rate-domain processor. AudioEngine +// remains responsible for capture normalization, mode routing, metering, and +// transport. This class makes the canonical 48 kHz float DSP island explicit +// and returns the unchanged 24 kHz stereo int16 transport representation. +class TxVoiceProcessor { +public: + static constexpr int kDspRate = 48000; + static constexpr int kTransportRate = 24000; + static constexpr int kChannels = 2; + static constexpr int kMaxStages = 8; + + enum class Stage : uint8_t { + None = 0, + Gate = 1, + Eq = 2, + DeEss = 3, + Comp = 4, + Tube = 5, + Enh = 6, + Reverb = 7, + }; + + struct Processors { + using EqTap = void (*)(void* context, const float* stereo, int frames); + + ClientEq* eq{nullptr}; + ClientComp* comp{nullptr}; + ClientGate* gate{nullptr}; + ClientDeEss* deEss{nullptr}; + ClientTube* tube{nullptr}; + ClientPudu* pudu{nullptr}; + ClientReverb* reverb{nullptr}; + ClientFinalLimiter* finalLimiter{nullptr}; + ClientTxTestTone* testTone{nullptr}; + ClientQuindarTone* quindar{nullptr}; + RNNoiseFilter* rnnoise{nullptr}; + EqTap eqTap{nullptr}; + void* eqTapContext{nullptr}; + }; + + TxVoiceProcessor(); + ~TxVoiceProcessor(); + + TxVoiceProcessor(const TxVoiceProcessor&) = delete; + TxVoiceProcessor& operator=(const TxVoiceProcessor&) = delete; + + // Call outside the realtime callback when the capture format changes. + bool prepare(int inputRate, int maxInputFrames); + void reset(); + + void setProcessors(const Processors& processors) noexcept; + void setStageOrder(uint64_t packedStages) noexcept; + void setRnnoiseEnabled(bool enabled) noexcept; + void setRnnoise(RNNoiseFilter* rnnoise) noexcept; + void setMicGain(float gain) noexcept; + void setMeasurementCaptureEnabled(bool enabled) noexcept; + + // Input must already be canonical duplicated-stereo int16. The channel + // selection/averaging policy remains in TxMicChannelNormalizer. + bool processCapturedInt16(const QByteArray& canonicalInput); + + // Offline/test entry point for audio already in the canonical DSP domain. + // Input is interleaved stereo float32 at exactly 48 kHz. + bool processFloat48(const float* interleavedStereo, int frames); + + const QByteArray& transportInt16Stereo() const noexcept; + const QByteArray& transportFloat32Stereo() const noexcept; + const QByteArray& normalizedFloat48Stereo() const noexcept; + const QByteArray& postChannelStripFloat48Stereo() const noexcept; + + int inputRate() const noexcept { return m_inputRate; } + bool isPrepared() const noexcept { return m_prepared; } + + // Deterministic end-to-end delay expressed in 48 kHz DSP frames. Includes + // RNNoise's one-frame WOLA delay and enabled gate lookahead. r8brain is + // configured to consume its integer startup latency; reverb pre-delay is + // an artistic wet-path parameter rather than whole-signal latency. + int latencyFrames() const noexcept; + +private: + void processChannelStrip(QByteArray& float48Stereo) noexcept; + bool processWorkBuffer(int frames48); + void prepareProcessors(); + + int m_inputRate{kDspRate}; + int m_maxInputFrames{0}; + int m_maxDspFrames{0}; + bool m_prepared{false}; + bool m_rnnoiseEnabled{false}; + bool m_captureMeasurements{false}; + float m_micGain{1.0f}; + uint64_t m_packedStages{0}; + Processors m_processors; + + std::unique_ptr m_inputResampler; + std::unique_ptr m_outputLeftResampler; + std::unique_ptr m_outputRightResampler; + std::vector m_inputMono; + std::vector m_outputLeft; + std::vector m_outputRight; + QByteArray m_resampledMono48; + QByteArray m_resampledLeft24; + QByteArray m_resampledRight24; + QByteArray m_rnnoiseOutput48; + QByteArray m_work48; + QByteArray m_normalized48; + QByteArray m_postStrip48; + QByteArray m_transportFloat; + QByteArray m_transportInt16; +}; + +} // namespace AetherSDR diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp new file mode 100644 index 000000000..7ed86ac34 --- /dev/null +++ b/tests/tx_voice_processor_test.cpp @@ -0,0 +1,339 @@ +#include "core/ClientTube.h" +#include "core/ClientGate.h" +#include "core/RNNoiseFilter.h" +#include "core/TxVoiceProcessor.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +using AetherSDR::ClientTube; +using AetherSDR::ClientGate; +using AetherSDR::RNNoiseFilter; +using AetherSDR::TxVoiceProcessor; + +namespace { + +int g_failed = 0; + +void report(const char* name, bool ok, const std::string& detail = {}) +{ + std::printf("%s %-68s %s\n", + ok ? "[ OK ]" : "[FAIL]", + name, + detail.c_str()); + if (!ok) { + ++g_failed; + } +} + +QByteArray makeCanonicalTone(int frames, int sampleRate, float frequencyHz) +{ + QByteArray result( + frames * 2 * static_cast(sizeof(int16_t)), Qt::Uninitialized); + auto* samples = reinterpret_cast(result.data()); + constexpr double kTwoPi = 6.28318530717958647692; + for (int frame = 0; frame < frames; ++frame) { + const float value = 0.25f * static_cast( + std::sin(kTwoPi * frequencyHz * frame / sampleRate)); + const int16_t quantized = static_cast(value * 32767.0f); + samples[frame * 2] = quantized; + samples[frame * 2 + 1] = quantized; + } + return result; +} + +bool finiteFloatBuffer(const QByteArray& bytes) +{ + const auto* samples = reinterpret_cast(bytes.constData()); + const int count = bytes.size() / static_cast(sizeof(float)); + for (int index = 0; index < count; ++index) { + if (!std::isfinite(samples[index])) { + return false; + } + } + return true; +} + +bool duplicatedStereo(const QByteArray& bytes, bool floatSamples) +{ + const int sampleBytes = floatSamples + ? static_cast(sizeof(float)) + : static_cast(sizeof(int16_t)); + const int frames = bytes.size() / (2 * sampleBytes); + if (floatSamples) { + const auto* samples = reinterpret_cast(bytes.constData()); + for (int frame = 0; frame < frames; ++frame) { + if (samples[frame * 2] != samples[frame * 2 + 1]) { + return false; + } + } + } else { + const auto* samples = reinterpret_cast(bytes.constData()); + for (int frame = 0; frame < frames; ++frame) { + if (samples[frame * 2] != samples[frame * 2 + 1]) { + return false; + } + } + } + return true; +} + +uint64_t packedSingleStage(TxVoiceProcessor::Stage stage) +{ + return static_cast(stage); +} + +void testFixedRateContract() +{ + report("DSP rate is pinned to 48 kHz", TxVoiceProcessor::kDspRate == 48000); + report("transport rate remains 24 kHz", + TxVoiceProcessor::kTransportRate == 24000); +} + +void test48kBypassAndMeasurementBoundaries() +{ + TxVoiceProcessor processor; + processor.setMeasurementCaptureEnabled(true); + const bool prepared = processor.prepare(48000, 1024); + const bool processed = processor.processCapturedInt16( + makeCanonicalTone(480, 48000, 1000.0f)); + + report("48 kHz processor prepares", prepared); + report("48 kHz block processes", processed); + report("normalized measurement contains 480 stereo float frames", + processor.normalizedFloat48Stereo().size() + == 480 * 2 * static_cast(sizeof(float))); + report("post-strip measurement contains 480 stereo float frames", + processor.postChannelStripFloat48Stereo().size() + == 480 * 2 * static_cast(sizeof(float))); + report("egress contains 240 stereo float frames", + processor.transportFloat32Stereo().size() + == 240 * 2 * static_cast(sizeof(float))); + report("egress contains 240 stereo int16 frames", + processor.transportInt16Stereo().size() + == 240 * 2 * static_cast(sizeof(int16_t))); + report("bypass output remains finite", + finiteFloatBuffer(processor.transportFloat32Stereo())); + report("mono voice remains duplicated stereo through egress", + duplicatedStereo(processor.transportFloat32Stereo(), true) + && duplicatedStereo(processor.transportInt16Stereo(), false)); +} + +void testDeviceRateNormalization() +{ + TxVoiceProcessor processor; + processor.setMeasurementCaptureEnabled(true); + const bool prepared = processor.prepare(44100, 1024); + const bool processed = processor.processCapturedInt16( + makeCanonicalTone(441, 44100, 1000.0f)); + const int normalizedFrames = processor.normalizedFloat48Stereo().size() + / (2 * static_cast(sizeof(float))); + const int transportFrames = processor.transportInt16Stereo().size() + / (2 * static_cast(sizeof(int16_t))); + + report("44.1 kHz capture rate prepares", prepared); + report("44.1 kHz capture block processes", processed); + report("10 ms at 44.1 kHz normalizes to 480 frames", + normalizedFrames == 480, + "frames=" + std::to_string(normalizedFrames)); + report("normalized 10 ms block exits as 240 transport frames", + transportFrames == 240, + "frames=" + std::to_string(transportFrames)); +} + +void testFloat48OfflineEntryAvoidsInputQuantization() +{ + std::vector input(480 * 2); + for (int frame = 0; frame < 480; ++frame) { + const float sample = 1.0e-6f * static_cast(frame + 1); + input[static_cast(frame * 2)] = sample; + input[static_cast(frame * 2 + 1)] = -sample; + } + + TxVoiceProcessor processor; + processor.setMeasurementCaptureEnabled(true); + processor.prepare(48000, 480); + const bool processed = processor.processFloat48(input.data(), 480); + + report("native float 48 kHz offline block processes", processed); + report("native float entry preserves sub-int16 input values exactly", + processor.normalizedFloat48Stereo().size() + == static_cast(input.size() * sizeof(float)) + && std::memcmp(processor.normalizedFloat48Stereo().constData(), + input.data(), input.size() * sizeof(float)) == 0); +} + +void testChannelStripRunsAt48k() +{ + ClientTube tube; + tube.setEnabled(true); + tube.setDriveDb(12.0f); + tube.setDryWet(1.0f); + + TxVoiceProcessor processor; + TxVoiceProcessor::Processors processors; + processors.tube = &tube; + processor.setProcessors(processors); + processor.setStageOrder(packedSingleStage(TxVoiceProcessor::Stage::Tube)); + processor.setMeasurementCaptureEnabled(true); + processor.prepare(48000, 1024); + const bool processed = processor.processCapturedInt16( + makeCanonicalTone(480, 48000, 1000.0f)); + + report("channel-strip block processes", processed); + report("tube is prepared at the canonical 48 kHz rate", + std::fabs(tube.sampleRate() - 48000.0) < 0.1); + report("enabled tube changes the post-strip measurement", + processor.normalizedFloat48Stereo() + != processor.postChannelStripFloat48Stereo()); +} + +void testNonFiniteSamplesCannotPoisonEgressSrc() +{ + std::vector input(480 * 2, 0.1f); + input[20] = std::nanf(""); + input[51] = std::numeric_limits::infinity(); + input[92] = -std::numeric_limits::infinity(); + + TxVoiceProcessor processor; + processor.prepare(48000, 480); + const bool processed = processor.processFloat48(input.data(), 480); + + report("non-finite input block still processes", processed); + report("non-finite samples cannot poison float transport output", + finiteFloatBuffer(processor.transportFloat32Stereo())); +} + +void testMeasurementCaptureCanBeDisabled() +{ + TxVoiceProcessor processor; + processor.setMeasurementCaptureEnabled(false); + processor.prepare(48000, 1024); + processor.processCapturedInt16(makeCanonicalTone(480, 48000, 1000.0f)); + + report("disabled normalized tap holds no copied block", + processor.normalizedFloat48Stereo().isEmpty()); + report("disabled post-strip tap holds no copied block", + processor.postChannelStripFloat48Stereo().isEmpty()); + report("transport output remains available with taps disabled", + !processor.transportInt16Stereo().isEmpty()); +} + +void testBlockBoundaryContinuityAndReset() +{ + const QByteArray input = makeCanonicalTone(4800, 48000, 997.0f); + + TxVoiceProcessor whole; + whole.prepare(48000, 4800); + whole.processCapturedInt16(input); + const QByteArray wholeOutput = whole.transportInt16Stereo(); + + TxVoiceProcessor blocked; + blocked.prepare(48000, 480); + QByteArray blockedOutput; + constexpr int kInputBlockBytes = 480 * 2 * static_cast(sizeof(int16_t)); + for (int offset = 0; offset < input.size(); offset += kInputBlockBytes) { + const bool processed = blocked.processCapturedInt16( + input.mid(offset, kInputBlockBytes)); + if (!processed) { + report("streaming blocks all produce output", false); + return; + } + blockedOutput.append(blocked.transportInt16Stereo()); + } + + report("48 -> 24 SRC is invariant to 10 ms block boundaries", + blockedOutput == wholeOutput, + "wholeBytes=" + std::to_string(wholeOutput.size()) + + " blockedBytes=" + std::to_string(blockedOutput.size())); + + blocked.reset(); + QByteArray afterReset; + for (int offset = 0; offset < input.size(); offset += kInputBlockBytes) { + blocked.processCapturedInt16(input.mid(offset, kInputBlockBytes)); + afterReset.append(blocked.transportInt16Stereo()); + } + report("reset restores deterministic SRC stream state", + afterReset == blockedOutput); +} + +void testRnnoiseNative48kIsland() +{ + RNNoiseFilter rnnoise( + RNNoiseFilter::OutputMode::ProcessedMono, + RNNoiseFilter::RateDomain::Native48k); + TxVoiceProcessor processor; + TxVoiceProcessor::Processors processors; + processors.rnnoise = &rnnoise; + processor.setProcessors(processors); + processor.setRnnoiseEnabled(true); + processor.prepare(48000, 480); + + bool allProcessed = true; + bool allSized = true; + bool allDuplicated = true; + for (int block = 0; block < 12; ++block) { + allProcessed = processor.processCapturedInt16( + makeCanonicalTone(480, 48000, 700.0f)) && allProcessed; + allSized = processor.transportInt16Stereo().size() + == 240 * 2 * static_cast(sizeof(int16_t)) && allSized; + allDuplicated = duplicatedStereo( + processor.transportInt16Stereo(), false) && allDuplicated; + } + + report("RNNoise native 48 kHz island processes complete frames", allProcessed); + report("RNNoise path preserves 10 ms transport framing", allSized); + report("TX RNNoise ProcessedMono remains duplicated stereo", allDuplicated); +} + +void testLatencyAccounting() +{ + ClientGate gate; + gate.setEnabled(true); + gate.setLookaheadMs(2.0f); + RNNoiseFilter rnnoise( + RNNoiseFilter::OutputMode::ProcessedMono, + RNNoiseFilter::RateDomain::Native48k); + + TxVoiceProcessor processor; + TxVoiceProcessor::Processors processors; + processors.gate = &gate; + processors.rnnoise = &rnnoise; + processor.setProcessors(processors); + processor.setStageOrder(packedSingleStage(TxVoiceProcessor::Stage::Gate)); + processor.setRnnoiseEnabled(true); + processor.prepare(48000, 480); + + report("latency report includes RNNoise frame plus gate lookahead", + processor.latencyFrames() == 480 + 96, + "frames=" + std::to_string(processor.latencyFrames())); +} + +} // namespace + +int main() +{ + testFixedRateContract(); + test48kBypassAndMeasurementBoundaries(); + testDeviceRateNormalization(); + testFloat48OfflineEntryAvoidsInputQuantization(); + testChannelStripRunsAt48k(); + testNonFiniteSamplesCannotPoisonEgressSrc(); + testMeasurementCaptureCanBeDisabled(); + testBlockBoundaryContinuityAndReset(); + testRnnoiseNative48kIsland(); + testLatencyAccounting(); + + std::printf("\n%s (%d failure%s)\n", + g_failed == 0 ? "PASS" : "FAIL", + g_failed, + g_failed == 1 ? "" : "s"); + return g_failed == 0 ? 0 : 1; +} From a86fbc23eadb27e466fae71326b5c4d428e73ec9 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Sun, 9 Aug 2026 19:46:41 -0400 Subject: [PATCH 02/25] =?UTF-8?q?Applies=20unshaped=20TPDF=20at=20=C2=B11?= =?UTF-8?q?=20LSB=20immediately=20before=20int16=20conversion,=20identical?= =?UTF-8?q?=20dither=20values=20are=20applied=20to=20both=20channels=20to?= =?UTF-8?q?=20preserve=20duplicate=20mono.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/audio-pipeline.md | 219 +++++++++++++++++----------- src/core/TxVoiceProcessor.cpp | 38 ++++- src/core/TxVoiceProcessor.h | 7 + tests/tx_voice_processor_test.cpp | 94 ++++++++++++ 4 files changed, 269 insertions(+), 89 deletions(-) diff --git a/docs/architecture/audio-pipeline.md b/docs/architecture/audio-pipeline.md index b0fce5d9f..bdf2444d3 100644 --- a/docs/architecture/audio-pipeline.md +++ b/docs/architecture/audio-pipeline.md @@ -20,9 +20,11 @@ all run through the same DSP chain: monitor tones use local output sinks that are independent of the RX speaker buffer. They are logically tone generators, represented as stereo output frames. -- **PC mic voice TX path**: `QAudioSource` captures Int16 mic audio, converts it - to the internal 24 kHz stereo frame shape, then runs the voice TX strip before - Opus remote audio TX or an uncompressed VITA fallback. +- **PC mic voice TX path**: `QAudioSource` captures Int16 mic audio at the + negotiated device rate. After channel canonicalization, `TxVoiceProcessor` + converts to float once, normalizes to a fixed 48 kHz DSP domain, runs optional + TX RN2 and the complete voice strip, then performs one 48-to-24 kHz conversion + and one Int16 quantization at the unchanged transport boundary. - **Opus `remote_audio_tx` path**: the normal PC mic voice path sends 24 kHz stereo Int16 frames as 10 ms Opus packets over VITA-49 PCC `0x8005`. - **RADE TX/RX path**: RADE branches early from PC mic capture and bypasses the @@ -38,10 +40,11 @@ The internal voice and modem paths are mostly **logically mono** but are often represented as **stereo frames** because the radio/audio interfaces expect interleaved L/R samples. Important cases: -- PC mic voice TX is carried as stereo Int16 frames after capture normalization. - Stereo capture is first collapsed to a canonical mono voice signal using Auto - Left/Right/Average selection, then duplicated to L/R. If resampling is needed, - that canonical mono signal is sent through `Resampler::processMonoToStereo()`. +- PC mic voice TX is canonicalized as duplicated-stereo Int16 at the negotiated + device rate. Stereo capture is first collapsed to a canonical mono voice + signal using Auto Left/Right/Average selection. `TxVoiceProcessor` converts + that signal to float, resamples mono to 48 kHz when needed, and duplicates it + to L/R for continuous float32 processing. - Opus `remote_audio_tx` packets are always 24 kHz stereo Int16 frames. - RADE modem/speech processing is logically mono, but AudioEngine handoff and VITA packetization use 24 kHz stereo float32 frames. @@ -287,28 +290,24 @@ radio audio streams. ```mermaid flowchart TD A["QAudioSource mic capture
Int16, preferred stereo"] --> B["AudioEngine::onTxAudioReady()"] - B --> C["Canonicalize mic channels
Auto Left/Right/Average/Mono
duplicate mono to L/R"] - C --> D{"Needs resample?"} - D -->|yes| E["Use canonical mono
processMonoToStereo()
24 kHz stereo Int16"] - D -->|no| H{"RADE mode?"} - E --> H - H -->|yes| I["PC mic gain -> canonical meter
Int16 stereo -> float32 stereo
txRawPcmReady()"] - I --> J["RADEEngine"] - H -->|no| K{"DAX TX mode?"} - K -->|yes| L["return
DAX/TCI feed feedDaxTxAudio()"] - K -->|no| M["ClientTxTestTone
optional replacement tone"] - M --> N["applyClientTxDspInt16()
Gate -> EQ -> DeEss -> Comp -> Tube -> PUDU -> Reverb"] - N --> O["Post-DSP monitor tap"] - O --> P["PC mic gain
0..100 -> 0.0..1.0 attenuation"] - P --> Q["Quindar tone insertion"] - Q --> R["ClientFinalLimiter"] - R --> S["Final monitor tap"] - S --> T["txPostChainScopeReady
average L/R
voice post-limiter"] - T --> U["PC mic meter
canonical stereo frame level"] - U --> V["scopeSamplesReady
average L/R"] - V --> W{"Opus enabled?"} - W -->|yes| X["10 ms Opus remote_audio_tx
PCC 0x8005"] - W -->|no| Y["Uncompressed VITA fallback
PCC 0x03E3"] + B --> C["Canonicalize mic channels at device rate
Auto Left/Right/Average/Mono
duplicate mono to L/R Int16"] + C --> D{"RADE mode?"} + D -->|yes| E["Legacy RADE device-rate -> 24 kHz SRC if needed
PC mic gain, meter, Int16 -> float32"] + E --> F["RADEEngine
separate fixed-rate modem path"] + D -->|no| G{"DAX TX mode?"} + G -->|yes| H["return
DAX/TCI feed feedDaxTxAudio()"] + G -->|no| I["TxVoiceProcessor
Int16 -> mono float32 once"] + I --> J["Input SRC when needed
device rate -> fixed 48 kHz DSP rate
duplicate mono to L/R"] + J --> K["Optional TX RN2 at native 48 kHz"] + K --> L["Optional 48 kHz test tone"] + L --> M["User-ordered 48 kHz float strip
Gate / EQ / DeEss / Comp / Tube / PUDU / Reverb"] + M --> N["PC mic gain -> Quindar -> final limiter
48 kHz float32 stereo"] + N --> O["Independent L/R egress SRC
48 kHz -> 24 kHz float32"] + O --> P["Quantize once
24 kHz stereo Int16"] + P --> Q["Final monitor, scopes, PC mic meter"] + Q --> R{"Opus enabled?"} + R -->|yes| S["10 ms Opus remote_audio_tx
PCC 0x8005"] + R -->|no| T["Uncompressed VITA fallback
PCC 0x03E3"] ``` ### QAudioSource format negotiation @@ -330,13 +329,15 @@ flowchart TD - Linux and Windows use pull mode: the device's `readyRead` signal calls `onTxAudioReady()` directly. -When the capture sample rate is not 24 kHz, `m_txResampler` converts to the -internal 24 kHz voice rate. +The negotiated rate is not the voice DSP rate. For the normal voice path, +`TxVoiceProcessor` normalizes it to a fixed 48 kHz float domain. The older +`m_txResampler` remains only for the separate RADE branch, which still expects +24 kHz stereo at its `RADEEngine` handoff. ### Capture normalization and resampling behavior -`onTxAudioReady()` normalizes the mic data to interleaved 24 kHz stereo Int16 -before the voice TX chain or early RADE/DAX branches: +`onTxAudioReady()` first canonicalizes mic channels as interleaved stereo Int16 +at the negotiated device rate. Rate conversion then depends on the route: - The actual negotiated channel count is stored as `m_txInputChannels`. - Mono input is duplicated to stereo with no level change. @@ -345,15 +346,21 @@ before the voice TX chain or early RADE/DAX branches: channel when the weaker side is at least 12 dB down above a -65 dBFS floor, otherwise averages balanced L/R. A short hold keeps the previous one-sided selection through quiet pauses. -- The canonical mono sample is duplicated back to L/R for the rest of the voice - path. -- Input that needs resampling is converted from the canonical duplicated stereo - to float32 mono and resampled with `Resampler::processMonoToStereo()`. - `processStereoToStereo()` is not used on raw mic stereo. +- The canonical mono sample is duplicated back to L/R without changing the + capture rate. +- The normal voice route passes that canonical Int16 frame to + `TxVoiceProcessor::processCapturedInt16()`. It converts the duplicated signal + to mono float32 once, uses the stateful r8brain `Resampler::process()` path to + reach 48 kHz when needed, and then duplicates the 48 kHz mono result to L/R. +- Native 48 kHz input skips the ingress SRC but still enters the same float32 + processing domain. +- RADE retains its separate conversion to 24 kHz before its early branch. DAX + TX does not consume this mic buffer; DAX/TCI audio arrives separately through + `feedDaxTxAudio()`. ### Voice TX ordering after capture/resampling -After capture normalization, `onTxAudioReady()` uses this ordering: +After capture channel canonicalization, `onTxAudioReady()` uses this ordering: 1. **RADE early path**: if RADE mode is active, PC mic gain is applied, the client mic meter is computed from the canonical stereo frame level, Int16 @@ -362,28 +369,43 @@ After capture normalization, `onTxAudioReady()` uses this ordering: 2. **DAX TX bypass**: if DAX TX mode is active, the PC mic voice handler returns. DAX/TCI audio enters through `feedDaxTxAudio()` and intentionally bypasses the voice DSP chain. -3. **Test tone**: `ClientTxTestTone` can replace the mic data with a generated - 24 kHz stereo Int16 tone before user voice DSP. -4. **Voice DSP strip**: `applyClientTxDspInt16()` runs the ordered chain. The - default order is Gate, EQ, DeEss, Comp, Tube, PUDU, Reverb, though the order - is user-configurable. -5. **Post-DSP monitor tap**: `m_txPostDspMonitor->feedTxPostDsp()` receives the - post-strip Int16 stereo signal before PC mic gain. -6. **PC mic gain**: `setPcMicGain(0..100)` maps to `0.0..1.0` and attenuates all - samples. It is not a boost stage. -7. **Quindar tone**: `ClientQuindarTone::process()` inserts Quindar tones after - PC mic gain and before the final limiter. -8. **Final limiter**: `ClientFinalLimiter` runs after all voice-strip work and - Quindar insertion. -9. **Final monitor tap**: `m_txFinalMonitor->feedTxFinal()` receives the - post-limiter signal. -10. **TX post-chain scope**: `txPostChainScopeReady` receives a mono scope signal - made by averaging L/R from the post-limiter Int16 stereo signal. -11. **PC mic meter**: `pcMicLevelChanged` uses the post-limiter canonical stereo - frame level, so right-only input devices meter correctly. -12. **Main scope**: `scopeSamplesReady(..., true)` receives a mono scope signal +3. **Float conversion and ingress SRC**: `TxVoiceProcessor` converts canonical + Int16 to mono float32 once. If the device is not already at 48 kHz, a + stateful r8brain SRC converts it to the fixed DSP rate; the result is then + duplicated to float32 stereo. +4. **Optional TX RN2**: the mic-preamp RNNoise instance runs directly in the + native 48 kHz domain. RX RN2 retains its existing legacy rate wrapper. +5. **Test tone**: `ClientTxTestTone` can replace the mic signal with a generated + 48 kHz float32 stereo tone before the user voice strip. +6. **Voice DSP strip**: `TxVoiceProcessor::processChannelStrip()` runs float32 + processors directly in the user-selected order. The default order is Gate, + EQ, DeEss, Comp, Tube, PUDU, Reverb. +7. **PC mic gain**: `setPcMicGain(0..100)` maps to `0.0..1.0` and attenuates the + 48 kHz float samples. It is not a boost stage. +8. **Quindar tone**: `ClientQuindarTone::process()` inserts Quindar tones at + 48 kHz after PC mic gain and before the final limiter. +9. **Final limiter**: `ClientFinalLimiter::process()` runs at 48 kHz after all + voice-strip work and Quindar insertion. Non-finite samples are replaced with + silence before they can enter the stateful egress SRC. +10. **Egress SRC**: independent left and right `Resampler` instances convert + 48 kHz float32 to 24 kHz float32, preserving the stereo representation. +11. **Transport quantization**: the resampled float output is clipped and + converted once to 24 kHz stereo Int16. No dither is currently added. +12. **Measurement seams**: when enabled for offline/tests, + `normalizedFloat48Stereo()` exposes the normalized 48 kHz input and + `postChannelStripFloat48Stereo()` exposes the 48 kHz post-strip/pre-gain + signal. `transportFloat32Stereo()` and `transportInt16Stereo()` expose the + final 24 kHz representations. +13. **Monitor taps**: both legacy monitor pointers currently receive the stable + post-limiter, post-SRC 24 kHz Int16 representation. The named 48 kHz + measurement seam above is the accurate post-strip tap. +14. **TX post-chain scope**: `txPostChainScopeReady` receives a mono scope signal + made by averaging L/R from the final 24 kHz Int16 stereo signal. +15. **PC mic meter**: `pcMicLevelChanged` uses the final transport-rate canonical + stereo frame level, so right-only input devices meter correctly. +16. **Main scope**: `scopeSamplesReady(..., true)` receives a mono scope signal made by averaging L/R. -13. **Packetization**: +17. **Packetization**: - If Opus TX is enabled, the path encodes 10 ms Opus packets for `remote_audio_tx`. - Otherwise, the fallback path packetizes 128 stereo frames as float32 VITA @@ -402,9 +424,23 @@ The final limiter defaults are: - output trim: `0.0 dB` - DC block: true -The limiter is channel-linked. It optionally DC-blocks each channel, applies -output trim as a pre-limiter drive stage, and then limits peaks against the -ceiling with attack/release smoothing. +The limiter is channel-linked and prepared at 48 kHz. It optionally DC-blocks +each channel, applies output trim as a pre-limiter drive stage, and then limits +peaks against the ceiling with attack/release smoothing. + +### Passband authority, SRC filtering, and oversampling + +The client does not add a selectable TX passband filter in this path. The radio +remains authoritative for the configured transmit mode and passband. The +low-pass filtering inherent in the 48-to-24 kHz r8brain conversion is the SRC's +anti-alias filter; it must not be treated as the artistic or operator-selected +TX passband filter. + +All voice stages currently run at the fixed 48 kHz DSP rate. This refactor does +not add local oversampling around tube saturation, PUDU excitation, compressor +drive/limiting, or the final limiter. Any future oversampling belongs around the +specific harmonic-generating stage and must return to the 48 kHz domain before +the single egress SRC. ## Opus TX/RX @@ -415,6 +451,9 @@ The normal remote voice path uses `OpusCodec`: - PCM format: interleaved Int16 - Frame duration: 10 ms - Frame size: 240 sample frames, or 480 Int16 samples +- Encoder bitrate: 70 kbps by default +- Encoder complexity: 10 +- Encoder signal hint: `OPUS_SIGNAL_VOICE` - Encoded packet cadence: one Opus frame per VITA packet For TX, `AudioEngine::onTxAudioReady()` accumulates exactly 10 ms of 24 kHz @@ -422,10 +461,10 @@ stereo Int16 audio before calling `OpusCodec::encode()`. The encoded Opus frame is wrapped in a VITA-49 packet using PCC `0x8005` for `remote_audio_tx` and the current remote TX stream id. -Encoded packets are not written immediately. They are appended to -`m_opusTxQueue`, capped to roughly 20 packets, and a 10 ms pacing timer drains -one packet per tick. If the queue grows beyond the cap, the oldest packet is -dropped. +Encoded packets are not written immediately. `OpusTxPacer` holds at most 20 +packets and follows 10 ms elapsed-time deadlines. A late timer event can drain a +bounded catch-up batch; an empty queue re-anchors the schedule, and an overflow +drops the oldest queued packet. For RX Opus audio, `PanadapterStream::decodeOpusAudio()` decodes PCC `0x8005` payloads to 24 kHz stereo Int16 and converts them to float32 stereo before @@ -639,8 +678,8 @@ equivalent taps. Local/client taps: -- `pcMicLevelChanged` on the PC mic voice path is a post-final-limiter Int16 - meter over the canonical stereo frame level. +- `pcMicLevelChanged` on the PC mic voice path is measured from the final + post-limiter, post-egress-SRC, quantized 24 kHz Int16 representation. - `pcMicLevelChanged` on the RADE early branch is after PC mic gain but before Int16-to-float conversion and uses the canonical stereo frame level. - `pcMicLevelChanged` on the DAX/TCI path is computed in `feedDaxTxAudio()` from @@ -651,10 +690,11 @@ Local/client taps: output trim or optional 48 kHz resampling. - `scopeSamplesReady` is a shared local scope signal. Stereo sources are converted to mono by averaging L/R. -- `txPostChainScopeReady` is taken after the PC mic voice final limiter and - converts stereo to mono by averaging L/R. DAX/TCI and RADE also emit this - high-rate TX scope from their pre-packetization bypass audio so the WAVE - display continues to show digital-mode transmit waveforms. +- `txPostChainScopeReady` on the PC mic voice path is taken from the final + post-limiter, post-egress-SRC 24 kHz Int16 representation and converts stereo + to mono by averaging L/R. DAX/TCI and RADE also emit this high-rate TX scope + from their pre-packetization bypass audio so the WAVE display continues to + show digital-mode transmit waveforms. - `rxPostChainScopeReady` is taken after the RX client strip, optional RX upsampling, RX boost, and RX output trim on the non-BNR path. For BNR it is taken after BNR output resampling and trim. In both cases it is before speaker @@ -685,12 +725,16 @@ Radio-provided taps: | Speaker write | RX drain timer in `AudioEngine::startRxStream()` | float32 stereo buffers | `QAudioSink` writes | 24 or 48 kHz | 2 | Caps buffers and mixes RADE decoded speech | | CW sidetone | `CwSidetoneGenerator` | key state | float32 stereo | normally 48 kHz | 2 | Local-only sidetone sink | | Quindar local monitor | `QuindarLocalSink` | tone state | float32 stereo | 48 kHz | 2 | Local-only Quindar monitor sink | -| PC mic capture | `AudioEngine::startTxStream()` | device Int16 | Int16 from `QAudioSource` | 24, 44.1, or 48 kHz | 1 or 2 | macOS push-buffer polling; Linux/Windows pull mode | -| PC mic normalization | `AudioEngine::onTxAudioReady()` | Int16 mono/stereo | Int16 stereo | 24 kHz | 1 or 2 -> 1 -> 2 | Auto selects stronger one-sided stereo channel or averages balanced stereo before resampling/duplication | -| PC mic voice strip | `AudioEngine::applyClientTxDspInt16()` | Int16 stereo | Int16 stereo | 24 kHz | 2 | Ordered Gate/EQ/DeEss/Comp/Tube/PUDU/Reverb | -| PC mic gain | `AudioEngine::onTxAudioReady()` | Int16 stereo | Int16 stereo | 24 kHz | 2 | 0..100 maps to 0.0..1.0 attenuation | -| Quindar TX insertion | `ClientQuindarTone::process()` | Int16 stereo | Int16 stereo | 24 kHz | 2 | Inserts tones before final limiter | -| Final voice limiter | `ClientFinalLimiter::processInt16Stereo()` | Int16 stereo | Int16 stereo | 24 kHz | 2 | DC block, output trim, linked peak limiting | +| PC mic capture | `AudioEngine::startTxStream()` | device Int16 | Int16 from `QAudioSource` | negotiated device rate | 1 or 2 | macOS push-buffer polling; Linux/Windows pull mode | +| PC mic channel canonicalization | `TxMicChannelNormalizer::canonicalizeInt16ToMonoStereo()` | Int16 mono/stereo | duplicated-stereo Int16 | negotiated device rate | 1 or 2 -> 1 -> 2 | Auto selects stronger one-sided stereo channel or averages balanced stereo; no SRC here | +| Voice float conversion / ingress SRC | `TxVoiceProcessor::processCapturedInt16()` | duplicated-stereo Int16 | float32 stereo | device rate -> 48 kHz when needed | 2 -> 1 -> 2 | Converts to float once; stateful mono r8brain SRC; native 48 kHz skips SRC | +| TX RN2 | `RNNoiseFilter::process48kStereo()` | float32 stereo | float32 stereo | 48 kHz | 2 -> 1 -> 2 | Optional mic denoiser in `Native48k` rate domain | +| PC mic voice strip | `TxVoiceProcessor::processChannelStrip()` | float32 stereo | float32 stereo | 48 kHz | 2 | Ordered Gate/EQ/DeEss/Comp/Tube/PUDU/Reverb | +| PC mic gain | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | float32 stereo | 48 kHz | 2 | 0..100 maps to 0.0..1.0 attenuation | +| Quindar TX insertion | `ClientQuindarTone::process()` | float32 stereo | float32 stereo | 48 kHz | 2 | Inserts tones before final limiter | +| Final voice limiter | `ClientFinalLimiter::process()` | float32 stereo | float32 stereo | 48 kHz | 2 | DC block, output trim, linked peak limiting | +| Voice egress SRC | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | float32 stereo | 48 kHz -> 24 kHz | 2 | Independent matched L/R r8brain instances preserve stereo; state persists across blocks | +| Voice transport quantization | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | Int16 stereo | 24 kHz | 2 | One clipped float-to-Int16 conversion; no dither | | Opus TX packetization | `AudioEngine::onTxAudioReady()` | Int16 stereo | VITA PCC `0x8005` Opus | 24 kHz | 2 | 10 ms packets, paced queue | | Uncompressed voice fallback | `AudioEngine::onTxAudioReady()` | Int16 stereo | VITA PCC `0x03E3` float32 stereo | 24 kHz | 2 | 128 stereo frames per packet | | RADE TX branch | `AudioEngine::onTxAudioReady()` | Int16 stereo | float32 stereo | 24 kHz | 2 | Applies PC mic gain, canonical meter, emits `txRawPcmReady()` | @@ -723,9 +767,11 @@ warning before it is discarded and regenerated. | `AudioEngine::resampleStereo()` | Resample without downmix | float32 stereo | float32 stereo | Uses independent L/R resamplers; preserves pan | | `AudioEngine::processNr2()` | Downmix and duplicate | float32 stereo | float32 stereo | Averages L/R, NR2 mono processing, duplicates, reapplies pan | | `AudioEngine::processBnr()` | Downmix, 24->48, 48->24, duplicate | float32 stereo | float32 stereo | BNR path is mono internally | -| `AudioEngine::onTxAudioReady()`, mono input | Duplicate canonical mono | Int16 mono | Int16 stereo | Direct Int16 mono duplication before optional resample | -| `AudioEngine::onTxAudioReady()`, stereo input | Canonicalize and duplicate | Int16 stereo | Int16 stereo | Auto Left/Right/Average avoids one-sided stereo 6.02 dB loss | -| `AudioEngine::onTxAudioReady()`, resample | Resample canonical mono and duplicate | Int16 stereo | Int16 stereo | Canonical duplicated stereo -> float32 mono -> `processMonoToStereo()` -> Int16 | +| `TxMicChannelNormalizer`, mono input | Duplicate canonical mono | Int16 mono | Int16 stereo | Direct Int16 mono duplication at the negotiated device rate | +| `TxMicChannelNormalizer`, stereo input | Canonicalize and duplicate | Int16 stereo | Int16 stereo | Auto Left/Right/Average avoids one-sided stereo 6.02 dB loss; retains device rate | +| `TxVoiceProcessor`, ingress | Format conversion, resample, duplicate | canonical Int16 stereo at device rate | float32 stereo 48 kHz | Takes one canonical channel, converts to float once, uses mono `Resampler::process()` when needed, then duplicates | +| `TxVoiceProcessor`, egress | Preserve stereo and downsample | float32 stereo 48 kHz | float32 stereo 24 kHz | Separate L/R `Resampler` instances; no downmix | +| `TxVoiceProcessor`, transport boundary | Quantize | float32 stereo 24 kHz | Int16 stereo 24 kHz | Single clipped conversion; no dither | | `AudioEngine::onTxAudioReady()`, RADE branch | Format conversion | Int16 stereo | float32 stereo | After PC mic gain and canonical meter | | `AudioEngine::onTxAudioReady()`, Opus TX | Encoding | Int16 stereo | Opus payload | 10 ms / 240 frame packets | | `AudioEngine::onTxAudioReady()`, VITA fallback | Format conversion | Int16 stereo | float32 stereo VITA | 128 stereo frames per packet | @@ -747,18 +793,18 @@ warning before it is discarded and regenerated. | Signal/name | File/function | Pre/post stage | Channel policy | Units | | --- | --- | --- | --- | --- | -| `AudioEngine::pcMicLevelChanged`, PC voice | `AudioEngine::onTxAudioReady()` | Post final limiter, before Opus/fallback packetization | Canonical stereo frame level | dBFS peak and RMS | +| `AudioEngine::pcMicLevelChanged`, PC voice | `AudioEngine::onTxAudioReady()` | Post final limiter, 48-to-24 kHz SRC, and Int16 quantization; before Opus/fallback packetization | Canonical stereo frame level | dBFS peak and RMS | | `AudioEngine::pcMicLevelChanged`, RADE | `AudioEngine::onTxAudioReady()` RADE branch | After PC mic gain, before Int16->float32 and RADE engine | Canonical stereo frame level | dBFS peak and RMS | | `AudioEngine::pcMicLevelChanged`, DAX/TCI | `AudioEngine::feedDaxTxAudio()` | Before DAX route packetization | All float samples | dBFS peak and RMS | | `AudioEngine::levelChanged` | `AudioEngine::feedAudioData()` | After selected RX NR, before RX client strip/boost/trim/resample | RMS over all float samples in the buffer | Linear RMS | | `AudioEngine::scopeSamplesReady`, TX voice | `AudioEngine::emitScopeFromInt16Stereo()` | Post PC mic meter, before packetization | Average L/R | float PCM scope samples, sample rate | | `AudioEngine::scopeSamplesReady`, TX DAX | `AudioEngine::emitScopeFromFloat32Stereo()` | In `feedDaxTxAudio()` before route packetization | Average L/R | float PCM scope samples, sample rate | | `AudioEngine::scopeSamplesReady`, RX | `AudioEngine::emitScopeFromFloat32Stereo()` | In `writeAudio()` or BNR output path near RX buffering | Average L/R | float PCM scope samples, sample rate | -| `AudioEngine::txPostChainScopeReady`, TX voice | `AudioEngine::emitTxPostChainScopeFromInt16Stereo()` | Post final limiter and final monitor tap | Average L/R | float PCM scope samples, sample rate | +| `AudioEngine::txPostChainScopeReady`, TX voice | `AudioEngine::emitTxPostChainScopeFromInt16Stereo()` | Final 24 kHz Int16 representation, post limiter/SRC/quantization and final monitor tap | Average L/R | float PCM scope samples, sample rate | | `AudioEngine::txPostChainScopeReady`, TX DAX/TCI/RADE | `AudioEngine::emitTxPostChainScopeFromFloat32Stereo()` | Pre-packetization digital bypass audio | Average L/R | float PCM scope samples, sample rate | | `AudioEngine::rxPostChainScopeReady` | `AudioEngine::emitRxPostChainScopeFromFloat32Stereo()` | Non-BNR: after RX strip, upsample, boost, and trim; BNR: after BNR resample/trim; before buffer append | Average L/R | float PCM scope samples, sample rate | | RX EQ analyzer | `AudioEngine::tapClientEqRxStereo()` | After RX EQ, before RX Gate/Comp/DeEss/Tube/PUDU | Average L/R | float mono analyzer samples | -| TX EQ analyzer | `AudioEngine::tapClientEqTxInt16()` / `tapClientEqTxFloat32()` | After TX EQ or EQ bypass inside TX strip | Average L/R for stereo | float mono analyzer samples | +| TX EQ analyzer | `AudioEngine::tapClientEqTxFloat32()` | After TX EQ or EQ bypass inside the 48 kHz TX strip | Average L/R for stereo | float mono analyzer samples | | macOS DAX RX level | `VirtualAudioBridge::feedDaxAudio()` | After DAX RX gain, before shared-memory write | Left channel only | Linear RMS | | macOS DAX TX level | `VirtualAudioBridge::readTxAudio()` | After DAX TX gain, before `txAudioReady()` | Left channel only | Linear RMS | | PipeWire DAX RX level | `PipeWireAudioBridge::feedDaxAudio()` | After downmix/upconvert for PipeWire source | All output mono samples | Linear RMS | @@ -775,6 +821,9 @@ warning before it is discarded and regenerated. preserving stereo image or radio pan matters. - PC mic capture is canonicalized before resampling and metering, so one-sided stereo microphones keep full level and right-only microphones meter correctly. +- Keep the normal voice strip in the fixed 48 kHz float domain. The 24 kHz rate + is the existing transport boundary, not the voice DSP engine rate. Quantize + only once after the final 48-to-24 kHz SRC. - DAX/TCI and RADE intentionally bypass the client voice strip. Do not move them through voice EQ/compression/limiting unless the digital-mode behavior is deliberately being redesigned. diff --git a/src/core/TxVoiceProcessor.cpp b/src/core/TxVoiceProcessor.cpp index 33484d736..aa25c0cdf 100644 --- a/src/core/TxVoiceProcessor.cpp +++ b/src/core/TxVoiceProcessor.cpp @@ -103,6 +103,7 @@ void TxVoiceProcessor::prepareProcessors() void TxVoiceProcessor::reset() { + m_ditherState = kDitherSeed; if (m_inputResampler) { m_inputResampler->reset(); } @@ -314,16 +315,45 @@ bool TxVoiceProcessor::processWorkBuffer(int frames48) for (int frame = 0; frame < outputFrames; ++frame) { outputFloat[frame * 2] = left[frame]; outputFloat[frame * 2 + 1] = right[frame]; + const float ditherLsb = nextTpdfDitherLsb(); for (int channel = 0; channel < kChannels; ++channel) { - const float scaled = std::clamp( - outputFloat[frame * 2 + channel] * 32768.0f, - -32768.0f, 32767.0f); - outputInt16[frame * 2 + channel] = static_cast(scaled); + outputInt16[frame * 2 + channel] = quantizeTransportSample( + outputFloat[frame * 2 + channel], ditherLsb); } } return true; } +uint32_t TxVoiceProcessor::nextDitherRandom24() noexcept +{ + // SplitMix64 is deterministic, allocation-free, and has no zero-state + // trap. The upper 24 bits provide more than enough resolution for dither + // applied at an int16 boundary. + m_ditherState += 0x9E3779B97F4A7C15ULL; + uint64_t mixed = m_ditherState; + mixed = (mixed ^ (mixed >> 30U)) * 0xBF58476D1CE4E5B9ULL; + mixed = (mixed ^ (mixed >> 27U)) * 0x94D049BB133111EBULL; + mixed ^= mixed >> 31U; + return static_cast(mixed >> 40U); +} + +float TxVoiceProcessor::nextTpdfDitherLsb() noexcept +{ + constexpr float kRandom24Scale = 1.0f / 16777216.0f; + const int32_t first = static_cast(nextDitherRandom24()); + const int32_t second = static_cast(nextDitherRandom24()); + return static_cast(first - second) * kRandom24Scale; +} + +int16_t TxVoiceProcessor::quantizeTransportSample( + float sample, float ditherLsb) noexcept +{ + const double dithered = static_cast(sample) * 32768.0 + + static_cast(ditherLsb); + const double saturated = std::clamp(dithered, -32768.0, 32767.0); + return static_cast(std::lround(saturated)); +} + void TxVoiceProcessor::processChannelStrip(QByteArray& float48Stereo) noexcept { auto* samples = reinterpret_cast(float48Stereo.data()); diff --git a/src/core/TxVoiceProcessor.h b/src/core/TxVoiceProcessor.h index e650d9c05..1d3332426 100644 --- a/src/core/TxVoiceProcessor.h +++ b/src/core/TxVoiceProcessor.h @@ -101,9 +101,15 @@ class TxVoiceProcessor { int latencyFrames() const noexcept; private: + static constexpr uint64_t kDitherSeed = 0x6A09E667F3BCC909ULL; + void processChannelStrip(QByteArray& float48Stereo) noexcept; bool processWorkBuffer(int frames48); void prepareProcessors(); + uint32_t nextDitherRandom24() noexcept; + float nextTpdfDitherLsb() noexcept; + static int16_t quantizeTransportSample( + float sample, float ditherLsb) noexcept; int m_inputRate{kDspRate}; int m_maxInputFrames{0}; @@ -113,6 +119,7 @@ class TxVoiceProcessor { bool m_captureMeasurements{false}; float m_micGain{1.0f}; uint64_t m_packedStages{0}; + uint64_t m_ditherState{kDitherSeed}; Processors m_processors; std::unique_ptr m_inputResampler; diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp index 7ed86ac34..bec117f32 100644 --- a/tests/tx_voice_processor_test.cpp +++ b/tests/tx_voice_processor_test.cpp @@ -264,6 +264,98 @@ void testBlockBoundaryContinuityAndReset() afterReset == blockedOutput); } +void testTransportTpdfDither() +{ + constexpr int kInputFrames = 48000; + std::vector silence(kInputFrames * 2, 0.0f); + + TxVoiceProcessor processor; + processor.prepare(48000, kInputFrames); + const bool processed = processor.processFloat48( + silence.data(), kInputFrames); + const QByteArray firstOutput = processor.transportInt16Stereo(); + const QByteArray floatOutput = processor.transportFloat32Stereo(); + const auto* quantized = reinterpret_cast( + firstOutput.constData()); + const int sampleCount = firstOutput.size() + / static_cast(sizeof(int16_t)); + + bool bounded = true; + int nonZeroSamples = 0; + int64_t sampleSum = 0; + for (int sample = 0; sample < sampleCount; ++sample) { + bounded = quantized[sample] >= -1 && quantized[sample] <= 1 + && bounded; + nonZeroSamples += quantized[sample] != 0 ? 1 : 0; + sampleSum += quantized[sample]; + } + + report("silent float transport block processes with dither", processed); + report("dither does not alter the float transport measurement tap", + finiteFloatBuffer(floatOutput) + && std::all_of( + reinterpret_cast(floatOutput.constData()), + reinterpret_cast(floatOutput.constData()) + + floatOutput.size() / static_cast(sizeof(float)), + [](float sample) { return sample == 0.0f; })); + report("linked TPDF preserves duplicated mono transport channels", + duplicatedStereo(firstOutput, false)); + report("silent-input TPDF quantization remains within one int16 LSB", + bounded); + report("TPDF decorrelates digital silence from the zero code", + nonZeroSamples > sampleCount / 10, + "nonZero=" + std::to_string(nonZeroSamples) + + " samples=" + std::to_string(sampleCount)); + report("silent-input TPDF has near-zero DC bias", + std::abs(sampleSum) < sampleCount / 100, + "sum=" + std::to_string(sampleSum)); + + processor.reset(); + processor.processFloat48(silence.data(), kInputFrames); + report("reset restores deterministic TPDF stream state", + processor.transportInt16Stereo() == firstOutput); +} + +void testDitheredTransportSaturatesAtInt16Rails() +{ + constexpr int kInputFrames = 48000; + const auto verifyRail = [](float inputSample, int16_t expectedRail) { + std::vector input(kInputFrames * 2, inputSample); + TxVoiceProcessor processor; + processor.prepare(48000, kInputFrames); + if (!processor.processFloat48(input.data(), kInputFrames)) { + return false; + } + + const QByteArray& floatBytes = processor.transportFloat32Stereo(); + const QByteArray& int16Bytes = processor.transportInt16Stereo(); + const auto* floatSamples = reinterpret_cast( + floatBytes.constData()); + const auto* int16Samples = reinterpret_cast( + int16Bytes.constData()); + const int sampleCount = int16Bytes.size() + / static_cast(sizeof(int16_t)); + bool testedOverRangeSample = false; + for (int sample = 0; sample < sampleCount; ++sample) { + const bool overRange = expectedRail > 0 + ? floatSamples[sample] >= 1.0f + : floatSamples[sample] <= -1.0f; + if (overRange) { + testedOverRangeSample = true; + if (int16Samples[sample] != expectedRail) { + return false; + } + } + } + return testedOverRangeSample; + }; + + report("dithered quantizer saturates positive over-range samples", + verifyRail(100.0f, std::numeric_limits::max())); + report("dithered quantizer saturates negative over-range samples", + verifyRail(-100.0f, std::numeric_limits::min())); +} + void testRnnoiseNative48kIsland() { RNNoiseFilter rnnoise( @@ -328,6 +420,8 @@ int main() testNonFiniteSamplesCannotPoisonEgressSrc(); testMeasurementCaptureCanBeDisabled(); testBlockBoundaryContinuityAndReset(); + testTransportTpdfDither(); + testDitheredTransportSaturatesAtInt16Rails(); testRnnoiseNative48kIsland(); testLatencyAccounting(); From 365d275e3c6c063a84d461e4c2e0bb2070795e6e Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Sun, 9 Aug 2026 20:37:23 -0400 Subject: [PATCH 03/25] Update Audio Pipeline Architecture Documentation with Dither Information --- docs/architecture/audio-pipeline.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/architecture/audio-pipeline.md b/docs/architecture/audio-pipeline.md index bdf2444d3..e6e683fda 100644 --- a/docs/architecture/audio-pipeline.md +++ b/docs/architecture/audio-pipeline.md @@ -24,7 +24,7 @@ all run through the same DSP chain: negotiated device rate. After channel canonicalization, `TxVoiceProcessor` converts to float once, normalizes to a fixed 48 kHz DSP domain, runs optional TX RN2 and the complete voice strip, then performs one 48-to-24 kHz conversion - and one Int16 quantization at the unchanged transport boundary. + and one TPDF-dithered Int16 quantization at the unchanged transport boundary. - **Opus `remote_audio_tx` path**: the normal PC mic voice path sends 24 kHz stereo Int16 frames as 10 ms Opus packets over VITA-49 PCC `0x8005`. - **RADE TX/RX path**: RADE branches early from PC mic capture and bypasses the @@ -303,7 +303,7 @@ flowchart TD L --> M["User-ordered 48 kHz float strip
Gate / EQ / DeEss / Comp / Tube / PUDU / Reverb"] M --> N["PC mic gain -> Quindar -> final limiter
48 kHz float32 stereo"] N --> O["Independent L/R egress SRC
48 kHz -> 24 kHz float32"] - O --> P["Quantize once
24 kHz stereo Int16"] + O --> P["Linked TPDF dither + round-to-nearest
quantize once to 24 kHz stereo Int16"] P --> Q["Final monitor, scopes, PC mic meter"] Q --> R{"Opus enabled?"} R -->|yes| S["10 ms Opus remote_audio_tx
PCC 0x8005"] @@ -389,8 +389,12 @@ After capture channel canonicalization, `onTxAudioReady()` uses this ordering: silence before they can enter the stateful egress SRC. 10. **Egress SRC**: independent left and right `Resampler` instances convert 48 kHz float32 to 24 kHz float32, preserving the stereo representation. -11. **Transport quantization**: the resampled float output is clipped and - converted once to 24 kHz stereo Int16. No dither is currently added. +11. **Transport quantization**: one unshaped TPDF value with a 2-LSB + peak-to-peak range is added per frame, identically to L/R so the duplicated + mono voice representation remains exact. Samples are then rounded to the + nearest Int16 code and saturated at the Int16 rails. The allocation-free + deterministic PRNG has persistent streaming state and is returned to its + fixed seed by `TxVoiceProcessor::reset()` for reproducible offline tests. 12. **Measurement seams**: when enabled for offline/tests, `normalizedFloat48Stereo()` exposes the normalized 48 kHz input and `postChannelStripFloat48Stereo()` exposes the 48 kHz post-strip/pre-gain @@ -734,7 +738,7 @@ Radio-provided taps: | Quindar TX insertion | `ClientQuindarTone::process()` | float32 stereo | float32 stereo | 48 kHz | 2 | Inserts tones before final limiter | | Final voice limiter | `ClientFinalLimiter::process()` | float32 stereo | float32 stereo | 48 kHz | 2 | DC block, output trim, linked peak limiting | | Voice egress SRC | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | float32 stereo | 48 kHz -> 24 kHz | 2 | Independent matched L/R r8brain instances preserve stereo; state persists across blocks | -| Voice transport quantization | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | Int16 stereo | 24 kHz | 2 | One clipped float-to-Int16 conversion; no dither | +| Voice transport quantization | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | Int16 stereo | 24 kHz | 2 | Linked unshaped TPDF (2 LSB peak-to-peak), round-to-nearest, and Int16 saturation; deterministic state persists across blocks | | Opus TX packetization | `AudioEngine::onTxAudioReady()` | Int16 stereo | VITA PCC `0x8005` Opus | 24 kHz | 2 | 10 ms packets, paced queue | | Uncompressed voice fallback | `AudioEngine::onTxAudioReady()` | Int16 stereo | VITA PCC `0x03E3` float32 stereo | 24 kHz | 2 | 128 stereo frames per packet | | RADE TX branch | `AudioEngine::onTxAudioReady()` | Int16 stereo | float32 stereo | 24 kHz | 2 | Applies PC mic gain, canonical meter, emits `txRawPcmReady()` | @@ -771,7 +775,7 @@ warning before it is discarded and regenerated. | `TxMicChannelNormalizer`, stereo input | Canonicalize and duplicate | Int16 stereo | Int16 stereo | Auto Left/Right/Average avoids one-sided stereo 6.02 dB loss; retains device rate | | `TxVoiceProcessor`, ingress | Format conversion, resample, duplicate | canonical Int16 stereo at device rate | float32 stereo 48 kHz | Takes one canonical channel, converts to float once, uses mono `Resampler::process()` when needed, then duplicates | | `TxVoiceProcessor`, egress | Preserve stereo and downsample | float32 stereo 48 kHz | float32 stereo 24 kHz | Separate L/R `Resampler` instances; no downmix | -| `TxVoiceProcessor`, transport boundary | Quantize | float32 stereo 24 kHz | Int16 stereo 24 kHz | Single clipped conversion; no dither | +| `TxVoiceProcessor`, transport boundary | Dither and quantize | float32 stereo 24 kHz | Int16 stereo 24 kHz | Single linked-channel TPDF-dithered conversion using round-to-nearest and Int16 saturation | | `AudioEngine::onTxAudioReady()`, RADE branch | Format conversion | Int16 stereo | float32 stereo | After PC mic gain and canonical meter | | `AudioEngine::onTxAudioReady()`, Opus TX | Encoding | Int16 stereo | Opus payload | 10 ms / 240 frame packets | | `AudioEngine::onTxAudioReady()`, VITA fallback | Format conversion | Int16 stereo | float32 stereo VITA | 128 stereo frames per packet | @@ -822,8 +826,8 @@ warning before it is discarded and regenerated. - PC mic capture is canonicalized before resampling and metering, so one-sided stereo microphones keep full level and right-only microphones meter correctly. - Keep the normal voice strip in the fixed 48 kHz float domain. The 24 kHz rate - is the existing transport boundary, not the voice DSP engine rate. Quantize - only once after the final 48-to-24 kHz SRC. + is the existing transport boundary, not the voice DSP engine rate. Apply + dither and quantize only once after the final 48-to-24 kHz SRC. - DAX/TCI and RADE intentionally bypass the client voice strip. Do not move them through voice EQ/compression/limiting unless the digital-mode behavior is deliberately being redesigned. From 114fcae1fadacafee726ed7e595800d5e16dc2f2 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 11:57:11 -0400 Subject: [PATCH 04/25] Applies 10kHz specific 12% SRC profile where appropriate and associated corrections (including additional tests) --- src/core/Resampler.cpp | 3 +- src/core/Resampler.h | 6 ++ src/core/TxVoiceProcessor.cpp | 29 ++++++-- src/core/TxVoiceProcessor.h | 10 ++- tests/tx_voice_processor_test.cpp | 109 +++++++++++++++++++++++++++++- 5 files changed, 148 insertions(+), 9 deletions(-) diff --git a/src/core/Resampler.cpp b/src/core/Resampler.cpp index c2140d1ac..33963a4b5 100644 --- a/src/core/Resampler.cpp +++ b/src/core/Resampler.cpp @@ -9,6 +9,7 @@ Resampler::Resampler(double srcRate, double dstRate, int maxBlockSamples, double , m_dstRate(dstRate) , m_maxBlockSamples(maxBlockSamples) , m_resampler(std::make_unique(srcRate, dstRate, maxBlockSamples, reqTransBand)) + , m_groupDelayInputFrames(m_resampler->getInLenBeforeOutPos(0)) { m_inBuf.reserve(maxBlockSamples); prewarm(); @@ -171,7 +172,7 @@ void Resampler::prewarm() // first real audio sample produces output immediately, removing the transient that would // otherwise appear at the start of every audio session. double* outPtr = nullptr; - int lenRequired = m_resampler->getInLenBeforeOutPos(0); + int lenRequired = m_groupDelayInputFrames; while (lenRequired > 0) { int len = std::min(lenRequired, m_maxBlockSamples); std::vector zeros(len, 0.0); diff --git a/src/core/Resampler.h b/src/core/Resampler.h index b8a754e88..2a3374894 100644 --- a/src/core/Resampler.h +++ b/src/core/Resampler.h @@ -48,6 +48,11 @@ class Resampler { double srcRate() const { return m_srcRate; } double dstRate() const { return m_dstRate; } + // Linear-phase signal delay expressed in source-rate samples. prewarm() + // consumes r8brain's no-output startup interval, but it does not remove + // this acoustic delay from the converted waveform. + int groupDelayInputFrames() const noexcept { return m_groupDelayInputFrames; } + private: void prewarm(); @@ -55,6 +60,7 @@ class Resampler { double m_dstRate; int m_maxBlockSamples; std::unique_ptr m_resampler; + int m_groupDelayInputFrames{0}; std::vector m_inBuf; // float32 → double conversion buffer }; diff --git a/src/core/TxVoiceProcessor.cpp b/src/core/TxVoiceProcessor.cpp index aa25c0cdf..074c17e73 100644 --- a/src/core/TxVoiceProcessor.cpp +++ b/src/core/TxVoiceProcessor.cpp @@ -31,7 +31,10 @@ bool TxVoiceProcessor::prepare(int inputRate, int maxInputFrames) m_maxInputFrames = maxInputFrames; if (inputRate != kDspRate) { m_inputResampler = std::make_unique( - inputRate, kDspRate, maxInputFrames); + inputRate, + kDspRate, + maxInputFrames, + kVoiceSrcTransitionBandPercent); } else { m_inputResampler.reset(); } @@ -39,9 +42,15 @@ bool TxVoiceProcessor::prepare(int inputRate, int maxInputFrames) m_maxDspFrames = static_cast( std::ceil(static_cast(maxInputFrames) * kDspRate / inputRate)) + 32; m_outputLeftResampler = std::make_unique( - kDspRate, kTransportRate, m_maxDspFrames); + kDspRate, + kTransportRate, + m_maxDspFrames, + kVoiceSrcTransitionBandPercent); m_outputRightResampler = std::make_unique( - kDspRate, kTransportRate, m_maxDspFrames); + kDspRate, + kTransportRate, + m_maxDspFrames, + kVoiceSrcTransitionBandPercent); m_inputMono.reserve(static_cast(maxInputFrames)); m_outputLeft.reserve(static_cast(m_maxDspFrames)); @@ -433,7 +442,19 @@ const QByteArray& TxVoiceProcessor::postChannelStripFloat48Stereo() const noexce int TxVoiceProcessor::latencyFrames() const noexcept { - int frames = m_rnnoiseEnabled && m_processors.rnnoise + int frames = 0; + if (m_inputResampler) { + frames += static_cast(std::lround( + static_cast(m_inputResampler->groupDelayInputFrames()) + * kDspRate / m_inputRate)); + } + if (m_outputLeftResampler) { + frames += static_cast(std::lround( + static_cast(m_outputLeftResampler->groupDelayInputFrames()) + * kDspRate / m_outputLeftResampler->srcRate())); + } + + frames += m_rnnoiseEnabled && m_processors.rnnoise && m_processors.rnnoise->isValid() ? 480 : 0; diff --git a/src/core/TxVoiceProcessor.h b/src/core/TxVoiceProcessor.h index 1d3332426..86bbd56de 100644 --- a/src/core/TxVoiceProcessor.h +++ b/src/core/TxVoiceProcessor.h @@ -31,6 +31,9 @@ class TxVoiceProcessor { static constexpr int kTransportRate = 24000; static constexpr int kChannels = 2; static constexpr int kMaxStages = 8; + // Voice only: preserve the supported 10 kHz modulation passband while + // avoiding the delay of r8brain's general-purpose 2% transition profile. + static constexpr double kVoiceSrcTransitionBandPercent = 12.0; enum class Stage : uint8_t { None = 0, @@ -95,9 +98,10 @@ class TxVoiceProcessor { bool isPrepared() const noexcept { return m_prepared; } // Deterministic end-to-end delay expressed in 48 kHz DSP frames. Includes - // RNNoise's one-frame WOLA delay and enabled gate lookahead. r8brain is - // configured to consume its integer startup latency; reverb pre-delay is - // an artistic wet-path parameter rather than whole-signal latency. + // serial ingress/egress SRC group delay, RNNoise's one-frame WOLA delay, + // and enabled gate lookahead. The matched L/R egress SRCs run in parallel + // and therefore contribute one delay. Reverb pre-delay is an artistic + // wet-path parameter rather than whole-signal latency. int latencyFrames() const noexcept; private: diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp index bec117f32..19d7b7038 100644 --- a/tests/tx_voice_processor_test.cpp +++ b/tests/tx_voice_processor_test.cpp @@ -49,6 +49,54 @@ QByteArray makeCanonicalTone(int frames, int sampleRate, float frequencyHz) return result; } +std::vector makeFloatStereoTone( + int frames, int sampleRate, float frequencyHz) +{ + std::vector result(static_cast(frames * 2)); + constexpr double kTwoPi = 6.28318530717958647692; + for (int frame = 0; frame < frames; ++frame) { + const float sample = 0.25f * static_cast( + std::sin(kTwoPi * frequencyHz * frame / sampleRate)); + result[static_cast(frame * 2)] = sample; + result[static_cast(frame * 2 + 1)] = sample; + } + return result; +} + +double leftChannelRms(const QByteArray& floatStereo, int skipFrames) +{ + const auto* samples = reinterpret_cast( + floatStereo.constData()); + const int frames = floatStereo.size() + / (2 * static_cast(sizeof(float))); + if (!samples || frames <= skipFrames) { + return 0.0; + } + + double sumSquares = 0.0; + for (int frame = skipFrames; frame < frames; ++frame) { + const double sample = samples[frame * 2]; + sumSquares += sample * sample; + } + return std::sqrt(sumSquares / (frames - skipFrames)); +} + +double transportToneRms(float frequencyHz) +{ + constexpr int kInputFrames = 48000; + constexpr int kSettlingOutputFrames = 4000; + const std::vector input = makeFloatStereoTone( + kInputFrames, TxVoiceProcessor::kDspRate, frequencyHz); + + TxVoiceProcessor processor; + if (!processor.prepare(TxVoiceProcessor::kDspRate, kInputFrames) + || !processor.processFloat48(input.data(), kInputFrames)) { + return 0.0; + } + return leftChannelRms( + processor.transportFloat32Stereo(), kSettlingOutputFrames); +} + bool finiteFloatBuffer(const QByteArray& bytes) { const auto* samples = reinterpret_cast(bytes.constData()); @@ -95,6 +143,63 @@ void testFixedRateContract() report("DSP rate is pinned to 48 kHz", TxVoiceProcessor::kDspRate == 48000); report("transport rate remains 24 kHz", TxVoiceProcessor::kTransportRate == 24000); + report("TX voice SRC transition profile is 12 percent", + TxVoiceProcessor::kVoiceSrcTransitionBandPercent == 12.0); +} + +void testVoiceSrcBandwidthAndAliasRejection() +{ + const double referenceRms = transportToneRms(1000.0f); + const double tenKhzRms = transportToneRms(10000.0f); + const double tenKhzGainDb = 20.0 * std::log10( + std::max(tenKhzRms / referenceRms, 1.0e-15)); + + report("voice SRC remains within 0.1 dB through 10 kHz", + referenceRms > 0.0 && std::abs(tenKhzGainDb) <= 0.1, + "gainDb=" + std::to_string(tenKhzGainDb)); + + double worstAliasDb = -300.0; + for (const float frequencyHz : { + 14000.0f, 16000.0f, 18000.0f, 20000.0f, 22000.0f}) { + const double aliasRms = transportToneRms(frequencyHz); + const double aliasDb = 20.0 * std::log10( + std::max(aliasRms / referenceRms, 1.0e-15)); + worstAliasDb = std::max(worstAliasDb, aliasDb); + } + report("aliases folding into 0-10 kHz remain below -100 dB", + referenceRms > 0.0 && worstAliasDb <= -100.0, + "worstAliasDb=" + std::to_string(worstAliasDb)); +} + +void testVoiceSrcLatencyBudgets() +{ + struct ExpectedLatency { + int inputRate; + int frames48; + }; + constexpr ExpectedLatency kExpected[] = { + {48000, 394}, + {44100, 615}, + {24000, 788}, + }; + + bool exact = true; + bool withinTwentyMs = true; + std::string detail; + for (const ExpectedLatency expected : kExpected) { + TxVoiceProcessor processor; + const bool prepared = processor.prepare(expected.inputRate, 1024); + const int frames = processor.latencyFrames(); + exact = prepared && frames == expected.frames48 && exact; + withinTwentyMs = prepared && frames <= 960 && withinTwentyMs; + detail += std::to_string(expected.inputRate) + "Hz=" + + std::to_string(frames) + " "; + } + + report("SRC group delay is reported for every capture rate", exact, detail); + report("worst-case serial SRC group delay remains below 20 ms", + withinTwentyMs, + detail); } void test48kBypassAndMeasurementBoundaries() @@ -404,7 +509,7 @@ void testLatencyAccounting() processor.prepare(48000, 480); report("latency report includes RNNoise frame plus gate lookahead", - processor.latencyFrames() == 480 + 96, + processor.latencyFrames() == 394 + 480 + 96, "frames=" + std::to_string(processor.latencyFrames())); } @@ -413,6 +518,8 @@ void testLatencyAccounting() int main() { testFixedRateContract(); + testVoiceSrcBandwidthAndAliasRejection(); + testVoiceSrcLatencyBudgets(); test48kBypassAndMeasurementBoundaries(); testDeviceRateNormalization(); testFloat48OfflineEntryAvoidsInputQuantization(); From ba804a6ea089c1bee90e567219a55d9617ca112b Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 12:25:35 -0400 Subject: [PATCH 05/25] Corrects RNNoise dangling-pointer blocker --- src/core/AudioEngine.cpp | 7 ++++++- src/core/TxVoiceProcessor.cpp | 2 +- src/core/TxVoiceProcessor.h | 2 ++ tests/tx_voice_processor_test.cpp | 23 +++++++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 021b145af..4a0184730 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -6776,13 +6776,19 @@ void AudioEngine::setRn2TxEnabled(bool on) RNNoiseFilter::RateDomain::Native48k); if (!m_rn2Tx->isValid()) { qCWarning(lcAudio) << "AudioEngine: RN2 TX rnnoise_create() failed — disabling"; + m_txVoiceProcessor->setRnnoise(nullptr); m_rn2Tx.reset(); emit rn2TxEnabledChanged(false); return; } + m_txVoiceProcessor->setRnnoise(m_rn2Tx.get()); m_rn2TxEnabled.store(true); } else { m_rn2TxEnabled.store(false); + // TxVoiceProcessor does not own this raw association. Clear it while + // the filter is still alive so a later prepare()/reset() cannot + // dereference an RNNoiseFilter destroyed here. + m_txVoiceProcessor->setRnnoise(nullptr); m_rn2Tx.reset(); } saveAetherialTubePreampTxSettings(); @@ -7848,7 +7854,6 @@ void AudioEngine::onTxAudioReady() m_txVoiceProcessor->setStageOrder( m_txChainPacked.load(std::memory_order_acquire)); m_txVoiceProcessor->setMicGain(m_pcMicGain.load()); - m_txVoiceProcessor->setRnnoise(m_rn2Tx.get()); m_txVoiceProcessor->setRnnoiseEnabled(m_rn2TxEnabled.load()); m_txVoiceProcessor->setMeasurementCaptureEnabled(false); if (!m_txVoiceProcessor->processCapturedInt16(data)) { diff --git a/src/core/TxVoiceProcessor.cpp b/src/core/TxVoiceProcessor.cpp index 074c17e73..6c0bfd6f7 100644 --- a/src/core/TxVoiceProcessor.cpp +++ b/src/core/TxVoiceProcessor.cpp @@ -152,7 +152,7 @@ void TxVoiceProcessor::reset() if (m_processors.quindar) { m_processors.quindar->reset(); } - if (m_processors.rnnoise) { + if (m_rnnoiseEnabled && m_processors.rnnoise) { m_processors.rnnoise->reset(); } m_work48.clear(); diff --git a/src/core/TxVoiceProcessor.h b/src/core/TxVoiceProcessor.h index 86bbd56de..4b90eef0b 100644 --- a/src/core/TxVoiceProcessor.h +++ b/src/core/TxVoiceProcessor.h @@ -77,6 +77,8 @@ class TxVoiceProcessor { void setProcessors(const Processors& processors) noexcept; void setStageOrder(uint64_t packedStages) noexcept; void setRnnoiseEnabled(bool enabled) noexcept; + // Non-owning association. The owner must pass nullptr before destroying + // the RNNoiseFilter; AudioEngine establishes and clears it at that seam. void setRnnoise(RNNoiseFilter* rnnoise) noexcept; void setMicGain(float gain) noexcept; void setMeasurementCaptureEnabled(bool enabled) noexcept; diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp index 19d7b7038..c14bcc900 100644 --- a/tests/tx_voice_processor_test.cpp +++ b/tests/tx_voice_processor_test.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -490,6 +491,27 @@ void testRnnoiseNative48kIsland() report("TX RNNoise ProcessedMono remains duplicated stereo", allDuplicated); } +void testDisabledRnnoiseIsNotDereferencedDuringPrepare() +{ + auto rnnoise = std::make_unique( + RNNoiseFilter::OutputMode::ProcessedMono, + RNNoiseFilter::RateDomain::Native48k); + TxVoiceProcessor processor; + processor.setRnnoise(rnnoise.get()); + processor.setRnnoiseEnabled(false); + + // Simulate the stale association that exposed the original ownership bug. + // A disabled processor must not touch it while prepare() resets the graph. + rnnoise.reset(); + const bool prepared = processor.prepare(48000, 480); + processor.setRnnoise(nullptr); + const bool processed = processor.processCapturedInt16( + makeCanonicalTone(480, 48000, 700.0f)); + + report("disabled stale RNNoise association is not reset during prepare", + prepared && processed); +} + void testLatencyAccounting() { ClientGate gate; @@ -530,6 +552,7 @@ int main() testTransportTpdfDither(); testDitheredTransportSaturatesAtInt16Rails(); testRnnoiseNative48kIsland(); + testDisabledRnnoiseIsNotDereferencedDuringPrepare(); testLatencyAccounting(); std::printf("\n%s (%d failure%s)\n", From cc8679488bd216c9cdda528db412c166ea39fc05 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 12:46:09 -0400 Subject: [PATCH 06/25] Corrects oversized capture block blocker --- src/core/TxVoiceProcessor.cpp | 8 ++++++- src/core/TxVoiceProcessor.h | 2 ++ tests/tx_voice_processor_test.cpp | 37 +++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/core/TxVoiceProcessor.cpp b/src/core/TxVoiceProcessor.cpp index 6c0bfd6f7..f0095deb1 100644 --- a/src/core/TxVoiceProcessor.cpp +++ b/src/core/TxVoiceProcessor.cpp @@ -210,10 +210,16 @@ bool TxVoiceProcessor::processCapturedInt16(const QByteArray& canonicalInput) } const int inputFrames = canonicalInput.size() / (kChannels * static_cast(sizeof(int16_t))); - if (inputFrames <= 0 || inputFrames > m_maxInputFrames) { + if (inputFrames <= 0) { return false; } + // m_maxInputFrames is the expected callback size used for preparation and + // steady-state allocation. Device callbacks can exceed it after a + // scheduling stall; the stateful resamplers already process such input in + // bounded chunks, and the scratch buffers may grow for this exceptional + // case. Process the delayed audio now instead of creating a silent hole. + const auto* input = reinterpret_cast(canonicalInput.constData()); m_inputMono.resize(static_cast(inputFrames)); for (int frame = 0; frame < inputFrames; ++frame) { diff --git a/src/core/TxVoiceProcessor.h b/src/core/TxVoiceProcessor.h index 4b90eef0b..819a96c92 100644 --- a/src/core/TxVoiceProcessor.h +++ b/src/core/TxVoiceProcessor.h @@ -71,6 +71,8 @@ class TxVoiceProcessor { TxVoiceProcessor& operator=(const TxVoiceProcessor&) = delete; // Call outside the realtime callback when the capture format changes. + // maxInputFrames sizes the normal realtime working set; an unusually + // large capture callback is still processed rather than discarded. bool prepare(int inputRate, int maxInputFrames); void reset(); diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp index c14bcc900..530d81580 100644 --- a/tests/tx_voice_processor_test.cpp +++ b/tests/tx_voice_processor_test.cpp @@ -370,6 +370,42 @@ void testBlockBoundaryContinuityAndReset() afterReset == blockedOutput); } +void testOversizedCaptureBlockIsProcessed() +{ + constexpr int kPreparedInputFrames = 1024; + constexpr int kInputFrames = 2000; + constexpr int kFrameBytes = 2 * static_cast(sizeof(int16_t)); + const QByteArray input = makeCanonicalTone(kInputFrames, 48000, 997.0f); + + TxVoiceProcessor oversized; + oversized.prepare(48000, kPreparedInputFrames); + const bool oversizedProcessed = oversized.processCapturedInt16(input); + const QByteArray oversizedOutput = oversized.transportInt16Stereo(); + + TxVoiceProcessor partitioned; + partitioned.prepare(48000, kPreparedInputFrames); + QByteArray partitionedOutput; + const bool firstProcessed = partitioned.processCapturedInt16( + input.left(kPreparedInputFrames * kFrameBytes)); + partitionedOutput.append(partitioned.transportInt16Stereo()); + const bool secondProcessed = partitioned.processCapturedInt16( + input.mid(kPreparedInputFrames * kFrameBytes)); + partitionedOutput.append(partitioned.transportInt16Stereo()); + + report("capture block larger than prepared size is processed", + oversizedProcessed); + report("oversized capture block preserves expected transport frame count", + oversizedOutput.size() + == (kInputFrames / 2) * kFrameBytes, + "bytes=" + std::to_string(oversizedOutput.size())); + report("oversized capture processing is stream-continuous", + firstProcessed && secondProcessed + && oversizedOutput == partitionedOutput, + "oversizedBytes=" + std::to_string(oversizedOutput.size()) + + " partitionedBytes=" + + std::to_string(partitionedOutput.size())); +} + void testTransportTpdfDither() { constexpr int kInputFrames = 48000; @@ -549,6 +585,7 @@ int main() testNonFiniteSamplesCannotPoisonEgressSrc(); testMeasurementCaptureCanBeDisabled(); testBlockBoundaryContinuityAndReset(); + testOversizedCaptureBlockIsProcessed(); testTransportTpdfDither(); testDitheredTransportSaturatesAtInt16Rails(); testRnnoiseNative48kIsland(); From 1d4aacb2f8d51a16064471c205788040b41a0946 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 13:02:11 -0400 Subject: [PATCH 07/25] Restores Allocation-free Buffer Reuse by replacing clear() with resize(0) --- src/core/RNNoiseFilter.cpp | 16 ++++++---- src/core/Resampler.cpp | 2 +- src/core/TxVoiceProcessor.cpp | 22 +++++++------- tests/rnnoise_filter_test.cpp | 28 ++++++++++++++++- tests/tx_voice_processor_test.cpp | 50 +++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 19 deletions(-) diff --git a/src/core/RNNoiseFilter.cpp b/src/core/RNNoiseFilter.cpp index acc7af919..56a014577 100644 --- a/src/core/RNNoiseFilter.cpp +++ b/src/core/RNNoiseFilter.cpp @@ -72,12 +72,12 @@ void RNNoiseFilter::reset() m_down[channel] = needsResamplers ? std::make_unique(48000, 24000) : nullptr; - m_inAccum[channel].clear(); + m_inAccum[channel].resize(0); m_input24k[channel].clear(); m_processed48k[channel].clear(); m_processed48kFloat[channel].clear(); } - m_outAccum.clear(); + m_outAccum.resize(0); } QByteArray RNNoiseFilter::process(const QByteArray& pcm24kStereo) @@ -162,7 +162,7 @@ QByteArray RNNoiseFilter::process(const QByteArray& pcm24kStereo) reinterpret_cast(&accumData[consumedSamples]), leftoverSamples * static_cast(sizeof(float))); } else { - m_inAccum[channel].clear(); + m_inAccum[channel].resize(0); } // 3. Scale RNNoise output back to [-1, 1], then downsample each @@ -236,9 +236,13 @@ QByteArray RNNoiseFilter::process48kStereo(const QByteArray& pcm48kStereo) int RNNoiseFilter::process48kStereo( const QByteArray& pcm48kStereo, QByteArray& output) { - output.clear(); + output.resize(0); if (!isValid() || pcm48kStereo.isEmpty()) { - output = pcm48kStereo; + output.resize(pcm48kStereo.size()); + if (!pcm48kStereo.isEmpty()) { + std::memcpy(output.data(), pcm48kStereo.constData(), + pcm48kStereo.size()); + } return output.size() / (2 * static_cast(sizeof(float))); } @@ -292,7 +296,7 @@ int RNNoiseFilter::process48kStereo( m_inAccum[channel].resize( leftover * static_cast(sizeof(float))); } else { - m_inAccum[channel].clear(); + m_inAccum[channel].resize(0); } } diff --git a/src/core/Resampler.cpp b/src/core/Resampler.cpp index 33963a4b5..8a86e1e23 100644 --- a/src/core/Resampler.cpp +++ b/src/core/Resampler.cpp @@ -26,7 +26,7 @@ QByteArray Resampler::process(const float* in, int numSamples) int Resampler::process(const float* in, int numSamples, QByteArray& output) { - output.clear(); + output.resize(0); if (!in || numSamples <= 0) { return 0; } diff --git a/src/core/TxVoiceProcessor.cpp b/src/core/TxVoiceProcessor.cpp index f0095deb1..d0d4d51bb 100644 --- a/src/core/TxVoiceProcessor.cpp +++ b/src/core/TxVoiceProcessor.cpp @@ -155,15 +155,15 @@ void TxVoiceProcessor::reset() if (m_rnnoiseEnabled && m_processors.rnnoise) { m_processors.rnnoise->reset(); } - m_work48.clear(); - m_resampledMono48.clear(); - m_resampledLeft24.clear(); - m_resampledRight24.clear(); - m_rnnoiseOutput48.clear(); - m_normalized48.clear(); - m_postStrip48.clear(); - m_transportFloat.clear(); - m_transportInt16.clear(); + m_work48.resize(0); + m_resampledMono48.resize(0); + m_resampledLeft24.resize(0); + m_resampledRight24.resize(0); + m_rnnoiseOutput48.resize(0); + m_normalized48.resize(0); + m_postStrip48.resize(0); + m_transportFloat.resize(0); + m_transportInt16.resize(0); } void TxVoiceProcessor::setProcessors(const Processors& processors) noexcept @@ -198,8 +198,8 @@ void TxVoiceProcessor::setMeasurementCaptureEnabled(bool enabled) noexcept { m_captureMeasurements = enabled; if (!enabled) { - m_normalized48.clear(); - m_postStrip48.clear(); + m_normalized48.resize(0); + m_postStrip48.resize(0); } } diff --git a/tests/rnnoise_filter_test.cpp b/tests/rnnoise_filter_test.cpp index 5a5ecc6e9..9565ea17f 100644 --- a/tests/rnnoise_filter_test.cpp +++ b/tests/rnnoise_filter_test.cpp @@ -665,6 +665,31 @@ bool testBinauralPhaseDifferenceSurvivesDenoising() return true; } +bool testNative48ReusableOutputPreservesCapacity() +{ + RNNoiseFilter filter( + RNNoiseFilter::OutputMode::ProcessedMono, + RNNoiseFilter::RateDomain::Native48k); + QByteArray input( + kBlockFrames * 2 * static_cast(sizeof(float)), '\0'); + QByteArray output; + output.reserve(input.size() * 4); + const qsizetype reservedCapacity = output.capacity(); + const int outputFrames = filter.process48kStereo(input, output); + + if (outputFrames != kBlockFrames || output.size() != input.size() + || output.capacity() < reservedCapacity) { + std::printf("native-48 reusable output lost capacity: " + "frames=%d bytes=%lld reserved=%lld capacity=%lld\n", + outputFrames, + static_cast(output.size()), + static_cast(reservedCapacity), + static_cast(output.capacity())); + return false; + } + return true; +} + } // namespace int main() @@ -674,7 +699,8 @@ int main() || !testProcessedMonoOutputIsDuplicated() || !testNoiseFloorDoesNotBreatheWithSpeech() || !testStereoChannelsStaySynchronizedWithConstantDryFloor() - || !testBinauralPhaseDifferenceSurvivesDenoising()) { + || !testBinauralPhaseDifferenceSurvivesDenoising() + || !testNative48ReusableOutputPreservesCapacity()) { return 1; } std::printf("rnnoise_filter_test passed\n"); diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp index 530d81580..896088545 100644 --- a/tests/tx_voice_processor_test.cpp +++ b/tests/tx_voice_processor_test.cpp @@ -1,6 +1,7 @@ #include "core/ClientTube.h" #include "core/ClientGate.h" #include "core/RNNoiseFilter.h" +#include "core/Resampler.h" #include "core/TxVoiceProcessor.h" #include @@ -17,6 +18,7 @@ using AetherSDR::ClientTube; using AetherSDR::ClientGate; using AetherSDR::RNNoiseFilter; +using AetherSDR::Resampler; using AetherSDR::TxVoiceProcessor; namespace { @@ -203,6 +205,53 @@ void testVoiceSrcLatencyBudgets() detail); } +void testReusableBuffersPreserveCapacity() +{ + constexpr int kFrames = 480; + std::vector input(kFrames, 0.0f); + Resampler resampler(48000, 24000, kFrames, + TxVoiceProcessor::kVoiceSrcTransitionBandPercent); + QByteArray output; + output.reserve(16384); + const qsizetype reservedCapacity = output.capacity(); + const int outputFrames = resampler.process(input.data(), kFrames, output); + + report("reusable SRC output preserves caller reservation", + outputFrames == kFrames / 2 + && output.capacity() >= reservedCapacity, + "reserved=" + std::to_string(reservedCapacity) + + " capacity=" + std::to_string(output.capacity())); + + constexpr int kPreparedFrames = 1024; + TxVoiceProcessor processor; + const bool prepared = processor.prepare(48000, kPreparedFrames); + const qsizetype normalizedCapacity = + processor.normalizedFloat48Stereo().capacity(); + const qsizetype postStripCapacity = + processor.postChannelStripFloat48Stereo().capacity(); + const qsizetype transportFloatCapacity = + processor.transportFloat32Stereo().capacity(); + const qsizetype transportInt16Capacity = + processor.transportInt16Stereo().capacity(); + const bool reservationsSurvivedPrepare = prepared + && normalizedCapacity >= kPreparedFrames * 2 * static_cast(sizeof(float)) + && postStripCapacity >= kPreparedFrames * 2 * static_cast(sizeof(float)) + && transportFloatCapacity > 0 + && transportInt16Capacity > 0; + + processor.reset(); + report("TX prepare and reset preserve realtime buffer reservations", + reservationsSurvivedPrepare + && processor.normalizedFloat48Stereo().capacity() + >= normalizedCapacity + && processor.postChannelStripFloat48Stereo().capacity() + >= postStripCapacity + && processor.transportFloat32Stereo().capacity() + >= transportFloatCapacity + && processor.transportInt16Stereo().capacity() + >= transportInt16Capacity); +} + void test48kBypassAndMeasurementBoundaries() { TxVoiceProcessor processor; @@ -578,6 +627,7 @@ int main() testFixedRateContract(); testVoiceSrcBandwidthAndAliasRejection(); testVoiceSrcLatencyBudgets(); + testReusableBuffersPreserveCapacity(); test48kBypassAndMeasurementBoundaries(); testDeviceRateNormalization(); testFloat48OfflineEntryAvoidsInputQuantization(); From 75fa4ee2bf9a6bc223950caadb9463da2e1bb57b Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 13:19:36 -0400 Subject: [PATCH 08/25] Corrects Stale RADE Resampler State --- src/core/AudioEngine.cpp | 26 +++++++++++++++++----- src/core/AudioEngine.h | 3 +++ src/core/Resampler.cpp | 9 +++++--- tests/tx_voice_processor_test.cpp | 37 +++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 4a0184730..d3ea58e9f 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -7789,7 +7789,14 @@ void AudioEngine::onTxAudioReady() // TxVoiceProcessor at the negotiated device rate. Do not call // processStereoToStereo() here: that helper would average raw mic L/R and // reintroduce the one-sided-channel 6.02 dB loss. - if (m_radeMode && m_radeTxNeedsResample && m_txResampler) { + const bool radeMode = m_radeMode.load(std::memory_order_acquire); + if (radeMode + && m_radeTxResamplerResetPending.exchange( + false, std::memory_order_acq_rel) + && m_txResampler) { + m_txResampler->reset(); + } + if (radeMode && m_radeTxNeedsResample && m_txResampler) { // Convert canonical duplicated int16 stereo → float32 mono for the // mono-to-stereo resampler. const auto* i16 = reinterpret_cast(data.constData()); @@ -7814,7 +7821,7 @@ void AudioEngine::onTxAudioReady() } // RADE mode: apply client-side gain + meter, then convert int16 → float32 - if (m_radeMode) { + if (radeMode) { // Apply client-side mic gain (same int16 gain path as SSB below) const float gain = m_pcMicGain.load(); if (gain < 0.999f) { @@ -8118,8 +8125,16 @@ void AudioEngine::setAllowBluetoothTelephonyOutput(bool on) void AudioEngine::setRadeMode(bool on) { - if (m_radeMode == on) return; - m_radeMode = on; + if (m_radeMode.load(std::memory_order_acquire) == on) { + return; + } + if (on) { + // Publish the reset request before RADE becomes visible to the audio + // callback. The callback owns the resampler state and consumes this + // request before processing the first new RADE block. + m_radeTxResamplerResetPending.store(true, std::memory_order_release); + } + m_radeMode.store(on, std::memory_order_release); // RADE TX: onTxAudioReady() emits txRawPcmReady (float32) then returns // early — the Opus voice TX path never runs. RADEEngine receives the // raw PCM, encodes it to a modem waveform, and emits it via @@ -8131,8 +8146,9 @@ void AudioEngine::setRadeMode(bool on) // use the physical mic and discard every dax_tx packet, producing no // TX waveform. feedDaxTxAudio/m_daxTxUseRadioRoute are irrelevant: // RADE bypasses feedDaxTxAudio entirely. - if (!on) + if (!on) { m_radeRxBuffer.clear(); + } clearTxAccumulators(); } diff --git a/src/core/AudioEngine.h b/src/core/AudioEngine.h index f82be7b99..6e35f5939 100644 --- a/src/core/AudioEngine.h +++ b/src/core/AudioEngine.h @@ -904,6 +904,9 @@ private slots: QByteArray m_txFloatAccumulator; // accumulate float32 PCM for RADE modem TX QByteArray m_daxPreTxBuffer; // short rolling pre-TX buffer for low-latency DAX mode std::atomic m_radeMode{false}; // RADE digital voice mode active (atomic: cross-thread) + // setRadeMode() publishes this before enabling RADE. The TX audio thread + // consumes it once so the RADE-only SRC is never reset concurrently. + std::atomic m_radeTxResamplerResetPending{false}; std::atomic m_pcMicGain{1.0f}; // client-side PC mic gain (0.0-1.0) std::atomic m_daxTxMode{false}; // DAX TX mode: VirtualAudioBridge handles TX QElapsedTimer m_txSourceStartTime; diff --git a/src/core/Resampler.cpp b/src/core/Resampler.cpp index 8a86e1e23..b38073b48 100644 --- a/src/core/Resampler.cpp +++ b/src/core/Resampler.cpp @@ -2,6 +2,8 @@ #include "CDSPResampler.h" +#include + namespace AetherSDR { Resampler::Resampler(double srcRate, double dstRate, int maxBlockSamples, double reqTransBand) @@ -174,9 +176,10 @@ void Resampler::prewarm() double* outPtr = nullptr; int lenRequired = m_groupDelayInputFrames; while (lenRequired > 0) { - int len = std::min(lenRequired, m_maxBlockSamples); - std::vector zeros(len, 0.0); - m_resampler->process(zeros.data(), len, outPtr); + const int len = std::min(lenRequired, m_maxBlockSamples); + m_inBuf.resize(static_cast(len)); + std::fill(m_inBuf.begin(), m_inBuf.end(), 0.0); + m_resampler->process(m_inBuf.data(), len, outPtr); lenRequired -= len; } } diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp index 896088545..9d8566c0d 100644 --- a/tests/tx_voice_processor_test.cpp +++ b/tests/tx_voice_processor_test.cpp @@ -252,6 +252,42 @@ void testReusableBuffersPreserveCapacity() >= transportInt16Capacity); } +void testResamplerResetRestoresFreshState() +{ + constexpr int kFrames = 960; + const std::vector history = makeFloatStereoTone( + kFrames, TxVoiceProcessor::kDspRate, 317.0f); + const std::vector probe = makeFloatStereoTone( + kFrames, TxVoiceProcessor::kDspRate, 997.0f); + std::vector historyMono(kFrames); + std::vector probeMono(kFrames); + for (int frame = 0; frame < kFrames; ++frame) { + historyMono[static_cast(frame)] = + history[static_cast(frame * 2)]; + probeMono[static_cast(frame)] = + probe[static_cast(frame * 2)]; + } + + Resampler resetInstance(48000, 24000, kFrames); + resetInstance.processMonoToStereo(historyMono.data(), kFrames); + resetInstance.reset(); + const QByteArray afterReset = resetInstance.processMonoToStereo( + probeMono.data(), kFrames); + + Resampler freshInstance(48000, 24000, kFrames); + const QByteArray freshOutput = freshInstance.processMonoToStereo( + probeMono.data(), kFrames); + constexpr int kStereoFloatFrameBytes = + 2 * static_cast(sizeof(float)); + const int resetFrames = afterReset.size() / kStereoFloatFrameBytes; + const int freshFrames = freshOutput.size() / kStereoFloatFrameBytes; + + report("resampler reset restores fresh deterministic stream state", + resetFrames == freshFrames && afterReset == freshOutput, + "resetFrames=" + std::to_string(resetFrames) + + " freshFrames=" + std::to_string(freshFrames)); +} + void test48kBypassAndMeasurementBoundaries() { TxVoiceProcessor processor; @@ -628,6 +664,7 @@ int main() testVoiceSrcBandwidthAndAliasRejection(); testVoiceSrcLatencyBudgets(); testReusableBuffersPreserveCapacity(); + testResamplerResetRestoresFreshState(); test48kBypassAndMeasurementBoundaries(); testDeviceRateNormalization(); testFloat48OfflineEntryAvoidsInputQuantization(); From aa7f104a134580207dd9bd15b7f9c3b4ce4c196e Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 13:34:43 -0400 Subject: [PATCH 09/25] Corrects Stereo Egress Misalignment via Safe Recovery from Frame-Count Mismatches. Principle XI. --- src/core/TxVoiceProcessor.cpp | 36 +++++++++++++++++++- src/core/TxVoiceProcessor.h | 5 +++ tests/tx_voice_processor_test.cpp | 56 +++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/core/TxVoiceProcessor.cpp b/src/core/TxVoiceProcessor.cpp index d0d4d51bb..d760de560 100644 --- a/src/core/TxVoiceProcessor.cpp +++ b/src/core/TxVoiceProcessor.cpp @@ -13,11 +13,17 @@ #include "RNNoiseFilter.h" #include "Resampler.h" +#include + #include #include namespace AetherSDR { +namespace { +Q_LOGGING_CATEGORY(lcTxVoiceProcessor, "aether.audio.tx.voice") +} + TxVoiceProcessor::TxVoiceProcessor() = default; TxVoiceProcessor::~TxVoiceProcessor() = default; @@ -113,6 +119,7 @@ void TxVoiceProcessor::prepareProcessors() void TxVoiceProcessor::reset() { m_ditherState = kDitherSeed; + m_warnedEgressFrameMismatch = false; if (m_inputResampler) { m_inputResampler->reset(); } @@ -313,9 +320,11 @@ bool TxVoiceProcessor::processWorkBuffer(int frames48) m_outputLeft.data(), frames48, m_resampledLeft24); const int rightOutputFrames = m_outputRightResampler->process( m_outputRight.data(), frames48, m_resampledRight24); - const int outputFrames = std::min( + const int outputFrames = reconcileEgressFrameCounts( leftOutputFrames, rightOutputFrames); if (outputFrames <= 0) { + m_transportFloat.resize(0); + m_transportInt16.resize(0); return false; } @@ -339,6 +348,31 @@ bool TxVoiceProcessor::processWorkBuffer(int frames48) return true; } +int TxVoiceProcessor::reconcileEgressFrameCounts( + int leftFrames, int rightFrames) +{ + if (leftFrames == rightFrames) { + m_warnedEgressFrameMismatch = false; + return leftFrames; + } + + if (!m_warnedEgressFrameMismatch) { + qCWarning(lcTxVoiceProcessor) + << "TX egress SRC channel frame mismatch: left=" << leftFrames + << "right=" << rightFrames + << "-- transmitting the common prefix and resetting both channels"; + m_warnedEgressFrameMismatch = true; + } + + // Both resamplers consumed the same input interval, so their common + // prefix belongs to the current aligned timeline. Preserve that prefix, + // discard only the unmatched tail, and reset both histories before the + // next callback so the dropped tail cannot become a permanent L/R offset. + m_outputLeftResampler->reset(); + m_outputRightResampler->reset(); + return std::min(leftFrames, rightFrames); +} + uint32_t TxVoiceProcessor::nextDitherRandom24() noexcept { // SplitMix64 is deterministic, allocation-free, and has no zero-state diff --git a/src/core/TxVoiceProcessor.h b/src/core/TxVoiceProcessor.h index 819a96c92..296be9e41 100644 --- a/src/core/TxVoiceProcessor.h +++ b/src/core/TxVoiceProcessor.h @@ -20,6 +20,7 @@ class ClientTube; class ClientTxTestTone; class RNNoiseFilter; class Resampler; +class TxVoiceProcessorTestAccess; // Headless, backend-independent TX voice rate-domain processor. AudioEngine // remains responsible for capture normalization, mode routing, metering, and @@ -109,10 +110,13 @@ class TxVoiceProcessor { int latencyFrames() const noexcept; private: + friend class TxVoiceProcessorTestAccess; + static constexpr uint64_t kDitherSeed = 0x6A09E667F3BCC909ULL; void processChannelStrip(QByteArray& float48Stereo) noexcept; bool processWorkBuffer(int frames48); + int reconcileEgressFrameCounts(int leftFrames, int rightFrames); void prepareProcessors(); uint32_t nextDitherRandom24() noexcept; float nextTpdfDitherLsb() noexcept; @@ -125,6 +129,7 @@ class TxVoiceProcessor { bool m_prepared{false}; bool m_rnnoiseEnabled{false}; bool m_captureMeasurements{false}; + bool m_warnedEgressFrameMismatch{false}; float m_micGain{1.0f}; uint64_t m_packedStages{0}; uint64_t m_ditherState{kDitherSeed}; diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp index 9d8566c0d..4689c544b 100644 --- a/tests/tx_voice_processor_test.cpp +++ b/tests/tx_voice_processor_test.cpp @@ -21,6 +21,31 @@ using AetherSDR::RNNoiseFilter; using AetherSDR::Resampler; using AetherSDR::TxVoiceProcessor; +namespace AetherSDR { + +class TxVoiceProcessorTestAccess +{ +public: + static int reconcileEgressFrameCounts( + TxVoiceProcessor& processor, int leftFrames, int rightFrames) + { + return processor.reconcileEgressFrameCounts(leftFrames, rightFrames); + } + + static void dirtyEgressResamplerHistories(TxVoiceProcessor& processor) + { + std::vector left(480, 0.25f); + std::vector right(960, -0.25f); + QByteArray discarded; + processor.m_outputLeftResampler->process( + left.data(), static_cast(left.size()), discarded); + processor.m_outputRightResampler->process( + right.data(), static_cast(right.size()), discarded); + } +}; + +} // namespace AetherSDR + namespace { int g_failed = 0; @@ -288,6 +313,36 @@ void testResamplerResetRestoresFreshState() + " freshFrames=" + std::to_string(freshFrames)); } +void testStereoEgressMismatchSalvagesAndRealigns() +{ + constexpr int kFrames = 480; + TxVoiceProcessor recovered; + recovered.prepare(48000, kFrames); + AetherSDR::TxVoiceProcessorTestAccess::dirtyEgressResamplerHistories( + recovered); + const int commonFrames = + AetherSDR::TxVoiceProcessorTestAccess::reconcileEgressFrameCounts( + recovered, 240, 239); + + report("stereo egress mismatch preserves the common aligned prefix", + commonFrames == 239, + "frames=" + std::to_string(commonFrames)); + + const std::vector probe = makeFloatStereoTone( + kFrames, TxVoiceProcessor::kDspRate, 997.0f); + const bool recoveredProcessed = recovered.processFloat48( + probe.data(), kFrames); + + TxVoiceProcessor fresh; + fresh.prepare(48000, kFrames); + const bool freshProcessed = fresh.processFloat48(probe.data(), kFrames); + + report("stereo egress mismatch resets both channels to fresh alignment", + recoveredProcessed && freshProcessed + && recovered.transportFloat32Stereo() + == fresh.transportFloat32Stereo()); +} + void test48kBypassAndMeasurementBoundaries() { TxVoiceProcessor processor; @@ -665,6 +720,7 @@ int main() testVoiceSrcLatencyBudgets(); testReusableBuffersPreserveCapacity(); testResamplerResetRestoresFreshState(); + testStereoEgressMismatchSalvagesAndRealigns(); test48kBypassAndMeasurementBoundaries(); testDeviceRateNormalization(); testFloat48OfflineEntryAvoidsInputQuantization(); From e83a4b22587f67491924dcd8d9146fe81a11a753 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 13:55:45 -0400 Subject: [PATCH 10/25] Implements RNNoise RateDomain Guard --- src/core/RNNoiseFilter.cpp | 10 ++++++++++ src/core/RNNoiseFilter.h | 3 ++- tests/rnnoise_filter_test.cpp | 27 ++++++++++++++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/core/RNNoiseFilter.cpp b/src/core/RNNoiseFilter.cpp index 56a014577..d5810cd29 100644 --- a/src/core/RNNoiseFilter.cpp +++ b/src/core/RNNoiseFilter.cpp @@ -237,6 +237,16 @@ int RNNoiseFilter::process48kStereo( const QByteArray& pcm48kStereo, QByteArray& output) { output.resize(0); + if (m_rateDomain != RateDomain::Native48k) { + qCWarning(lcRn2) + << "RNNoiseFilter: process48kStereo() requires native-48 kHz rate domain"; + output.resize(pcm48kStereo.size()); + if (!pcm48kStereo.isEmpty()) { + std::memcpy(output.data(), pcm48kStereo.constData(), + pcm48kStereo.size()); + } + return output.size() / (2 * static_cast(sizeof(float))); + } if (!isValid() || pcm48kStereo.isEmpty()) { output.resize(pcm48kStereo.size()); if (!pcm48kStereo.isEmpty()) { diff --git a/src/core/RNNoiseFilter.h b/src/core/RNNoiseFilter.h index 01bce2283..8d55c46e2 100644 --- a/src/core/RNNoiseFilter.h +++ b/src/core/RNNoiseFilter.h @@ -75,7 +75,8 @@ class RNNoiseFilter { std::array, 2> m_up; // 24kHz → 48kHz per channel std::array, 2> m_down; // 48kHz → 24kHz per channel std::array m_inAccum; // 48kHz mono float input - QByteArray m_outAccum; // 24kHz stereo float output + // Native 48 kHz or legacy 24 kHz stereo float output. + QByteArray m_outAccum; std::array, 2> m_input24k; std::array, 2> m_processed48k; std::array, 2> m_processed48kFloat; diff --git a/tests/rnnoise_filter_test.cpp b/tests/rnnoise_filter_test.cpp index 9565ea17f..b3a87717e 100644 --- a/tests/rnnoise_filter_test.cpp +++ b/tests/rnnoise_filter_test.cpp @@ -690,6 +690,30 @@ bool testNative48ReusableOutputPreservesCapacity() return true; } +bool testNative48EntryPointRejectsLegacyRateDomain() +{ + RNNoiseFilter filter( + RNNoiseFilter::OutputMode::ProcessedMono, + RNNoiseFilter::RateDomain::Legacy24k); + QByteArray input( + kBlockFrames * 2 * static_cast(sizeof(float)), Qt::Uninitialized); + auto* samples = reinterpret_cast(input.data()); + for (int frame = 0; frame < kBlockFrames; ++frame) { + samples[frame * 2] = 0.25f; + samples[frame * 2 + 1] = -0.125f; + } + + QByteArray output; + const int outputFrames = filter.process48kStereo(input, output); + if (outputFrames != kBlockFrames || output != input) { + std::printf("legacy rate domain did not pass through native-48 input: " + "frames=%d bytes=%lld\n", + outputFrames, static_cast(output.size())); + return false; + } + return true; +} + } // namespace int main() @@ -700,7 +724,8 @@ int main() || !testNoiseFloorDoesNotBreatheWithSpeech() || !testStereoChannelsStaySynchronizedWithConstantDryFloor() || !testBinauralPhaseDifferenceSurvivesDenoising() - || !testNative48ReusableOutputPreservesCapacity()) { + || !testNative48ReusableOutputPreservesCapacity() + || !testNative48EntryPointRejectsLegacyRateDomain()) { return 1; } std::printf("rnnoise_filter_test passed\n"); From 3b870e600dde90e8fa8d9ab0ce217ae28451a0dd Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 14:05:15 -0400 Subject: [PATCH 11/25] Removes duplicate hard-coded 48000, stale comment update --- src/core/AudioEngine.cpp | 5 +++++ src/core/AudioEngine.h | 2 +- src/gui/MainWindow_DspApplets.cpp | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index d3ea58e9f..4b3d74ada 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -2647,6 +2647,11 @@ QAudioFormat AudioEngine::makeFormat() const return fmt; } +bool AudioEngine::txInputNormalizationTo48k() const +{ + return m_txInputRate != TxVoiceProcessor::kDspRate; +} + QJsonArray AudioEngine::audioEndpointDiagnostics() const { QThread* const ownerThread = thread(); diff --git a/src/core/AudioEngine.h b/src/core/AudioEngine.h index 6e35f5939..ccc41397a 100644 --- a/src/core/AudioEngine.h +++ b/src/core/AudioEngine.h @@ -179,7 +179,7 @@ class AudioEngine : public QObject { bool hasKiwiSdrAudioSource(const QString& sourceId) const; int txInputSampleRate() const { return m_txInputRate; } int txInputChannelCount() const { return m_txInputChannels; } - bool txInputNormalizationTo48k() const { return m_txInputRate != 48000; } + bool txInputNormalizationTo48k() const; bool txRadeResamplingTo24k() const { return m_radeTxNeedsResample; } bool rxOutputResamplingActive() const { return m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE; } QJsonArray audioEndpointDiagnostics() const; diff --git a/src/gui/MainWindow_DspApplets.cpp b/src/gui/MainWindow_DspApplets.cpp index bd756ce25..91a2ecdbb 100644 --- a/src/gui/MainWindow_DspApplets.cpp +++ b/src/gui/MainWindow_DspApplets.cpp @@ -738,7 +738,7 @@ void MainWindow::applySpeechProcessorToClientComp(bool operatorIntent) // EqualizerModel emits `eq TXsc 63Hz=…` / `eq RXsc …`, which a radio with no // Flex command plane never receives. The equalizer those sliders are asking for // is ClientEq, already in both audio paths — TX through -// AudioEngine::applyClientTxDspInt16, RX through processMixedRxAudioData. +// TxVoiceProcessor, RX through AudioEngine::processMixedRxAudioData. // // THE OCTAVE BANDS OCCUPY ClientEq SLOTS 0..7, which are the same slots the // Aetherial strip's editor uses, because these are the same ClientEq objects the From 469adc2230767af899a86bb76dcd6c3450844259 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 15:47:42 -0400 Subject: [PATCH 12/25] Implements Caller-owned Transport Handoff --- docs/architecture/audio-pipeline.md | 9 +- src/core/AudioEngine.cpp | 1 - src/core/TxVoiceProcessor.cpp | 36 ++++--- src/core/TxVoiceProcessor.h | 19 ++-- tests/tx_voice_processor_test.cpp | 140 ++++++++++++++++++---------- 5 files changed, 125 insertions(+), 80 deletions(-) diff --git a/docs/architecture/audio-pipeline.md b/docs/architecture/audio-pipeline.md index e6e683fda..c09ede568 100644 --- a/docs/architecture/audio-pipeline.md +++ b/docs/architecture/audio-pipeline.md @@ -352,6 +352,10 @@ at the negotiated device rate. Rate conversion then depends on the route: `TxVoiceProcessor::processCapturedInt16()`. It converts the duplicated signal to mono float32 once, uses the stateful r8brain `Resampler::process()` path to reach 48 kHz when needed, and then duplicates the 48 kHz mono result to L/R. + After consuming the capture samples, it replaces that caller-owned block + in-place with the final 24 kHz stereo Int16 transport output. Queued monitor + consumers therefore retain an immutable completed block rather than sharing + a reusable processor-owned output buffer. - Native 48 kHz input skips the ingress SRC but still enters the same float32 processing domain. - RADE retains its separate conversion to 24 kHz before its early branch. DAX @@ -398,8 +402,9 @@ After capture channel canonicalization, `onTxAudioReady()` uses this ordering: 12. **Measurement seams**: when enabled for offline/tests, `normalizedFloat48Stereo()` exposes the normalized 48 kHz input and `postChannelStripFloat48Stereo()` exposes the 48 kHz post-strip/pre-gain - signal. `transportFloat32Stereo()` and `transportInt16Stereo()` expose the - final 24 kHz representations. + signal. `transportFloat32Stereo()` exposes the final 24 kHz float + representation; the caller-owned in/out block carries the corresponding + dithered Int16 transport representation. 13. **Monitor taps**: both legacy monitor pointers currently receive the stable post-limiter, post-SRC 24 kHz Int16 representation. The named 48 kHz measurement seam above is the accurate post-strip tap. diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 4b3d74ada..c9ce8dc15 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -7871,7 +7871,6 @@ void AudioEngine::onTxAudioReady() if (!m_txVoiceProcessor->processCapturedInt16(data)) { return; } - data = m_txVoiceProcessor->transportInt16Stereo(); // The legacy pre-tail monitor currently has no active GUI owner. Keep its // feed alive at the stable 24 kHz representation until it is replaced by diff --git a/src/core/TxVoiceProcessor.cpp b/src/core/TxVoiceProcessor.cpp index d760de560..c50ba12be 100644 --- a/src/core/TxVoiceProcessor.cpp +++ b/src/core/TxVoiceProcessor.cpp @@ -73,9 +73,6 @@ bool TxVoiceProcessor::prepare(int inputRate, int maxInputFrames) m_postStrip48.reserve(m_work48.capacity()); m_transportFloat.reserve( (m_maxDspFrames / 2 + 32) * kChannels * static_cast(sizeof(float))); - m_transportInt16.reserve( - (m_maxDspFrames / 2 + 32) * kChannels * static_cast(sizeof(int16_t))); - prepareProcessors(); m_prepared = true; reset(); @@ -170,7 +167,6 @@ void TxVoiceProcessor::reset() m_normalized48.resize(0); m_postStrip48.resize(0); m_transportFloat.resize(0); - m_transportInt16.resize(0); } void TxVoiceProcessor::setProcessors(const Processors& processors) noexcept @@ -210,12 +206,12 @@ void TxVoiceProcessor::setMeasurementCaptureEnabled(bool enabled) noexcept } } -bool TxVoiceProcessor::processCapturedInt16(const QByteArray& canonicalInput) +bool TxVoiceProcessor::processCapturedInt16(QByteArray& canonicalInputOutput) { - if (!m_prepared || canonicalInput.isEmpty()) { + if (!m_prepared || canonicalInputOutput.isEmpty()) { return false; } - const int inputFrames = canonicalInput.size() + const int inputFrames = canonicalInputOutput.size() / (kChannels * static_cast(sizeof(int16_t))); if (inputFrames <= 0) { return false; @@ -227,7 +223,8 @@ bool TxVoiceProcessor::processCapturedInt16(const QByteArray& canonicalInput) // bounded chunks, and the scratch buffers may grow for this exceptional // case. Process the delayed audio now instead of creating a silent hole. - const auto* input = reinterpret_cast(canonicalInput.constData()); + const auto* input = reinterpret_cast( + canonicalInputOutput.constData()); m_inputMono.resize(static_cast(inputFrames)); for (int frame = 0; frame < inputFrames; ++frame) { m_inputMono[static_cast(frame)] = input[frame * 2] / 32768.0f; @@ -251,10 +248,12 @@ bool TxVoiceProcessor::processCapturedInt16(const QByteArray& canonicalInput) work[frame * 2] = mono48Samples[frame]; work[frame * 2 + 1] = mono48Samples[frame]; } - return processWorkBuffer(frames48); + return processWorkBuffer(frames48, canonicalInputOutput); } -bool TxVoiceProcessor::processFloat48(const float* interleavedStereo, int frames) +bool TxVoiceProcessor::processFloat48(const float* interleavedStereo, + int frames, + QByteArray& transportInt16Output) { if (!m_prepared || !interleavedStereo || frames <= 0 || frames > m_maxDspFrames) { @@ -263,10 +262,11 @@ bool TxVoiceProcessor::processFloat48(const float* interleavedStereo, int frames m_work48.resize(frames * kChannels * static_cast(sizeof(float))); std::copy_n(interleavedStereo, frames * kChannels, reinterpret_cast(m_work48.data())); - return processWorkBuffer(frames); + return processWorkBuffer(frames, transportInt16Output); } -bool TxVoiceProcessor::processWorkBuffer(int frames48) +bool TxVoiceProcessor::processWorkBuffer(int frames48, + QByteArray& transportInt16Output) { if (m_captureMeasurements) { m_normalized48 = m_work48; @@ -324,16 +324,17 @@ bool TxVoiceProcessor::processWorkBuffer(int frames48) leftOutputFrames, rightOutputFrames); if (outputFrames <= 0) { m_transportFloat.resize(0); - m_transportInt16.resize(0); + transportInt16Output.resize(0); return false; } m_transportFloat.resize( outputFrames * kChannels * static_cast(sizeof(float))); - m_transportInt16.resize( + transportInt16Output.resize( outputFrames * kChannels * static_cast(sizeof(int16_t))); auto* outputFloat = reinterpret_cast(m_transportFloat.data()); - auto* outputInt16 = reinterpret_cast(m_transportInt16.data()); + auto* outputInt16 = reinterpret_cast( + transportInt16Output.data()); const auto* left = reinterpret_cast(m_resampledLeft24.constData()); const auto* right = reinterpret_cast(m_resampledRight24.constData()); for (int frame = 0; frame < outputFrames; ++frame) { @@ -460,11 +461,6 @@ void TxVoiceProcessor::processChannelStrip(QByteArray& float48Stereo) noexcept } } -const QByteArray& TxVoiceProcessor::transportInt16Stereo() const noexcept -{ - return m_transportInt16; -} - const QByteArray& TxVoiceProcessor::transportFloat32Stereo() const noexcept { return m_transportFloat; diff --git a/src/core/TxVoiceProcessor.h b/src/core/TxVoiceProcessor.h index 296be9e41..c100b3aee 100644 --- a/src/core/TxVoiceProcessor.h +++ b/src/core/TxVoiceProcessor.h @@ -87,14 +87,20 @@ class TxVoiceProcessor { void setMeasurementCaptureEnabled(bool enabled) noexcept; // Input must already be canonical duplicated-stereo int16. The channel - // selection/averaging policy remains in TxMicChannelNormalizer. - bool processCapturedInt16(const QByteArray& canonicalInput); + // selection/averaging policy remains in TxMicChannelNormalizer. On + // success, the consumed capture block is replaced in-place with the final + // 24 kHz stereo int16 transport block. The caller retains ownership, so a + // queued consumer can keep that immutable block without sharing storage + // that this processor will write on the next callback. + bool processCapturedInt16(QByteArray& canonicalInputOutput); // Offline/test entry point for audio already in the canonical DSP domain. - // Input is interleaved stereo float32 at exactly 48 kHz. - bool processFloat48(const float* interleavedStereo, int frames); + // Input is interleaved stereo float32 at exactly 48 kHz. The caller owns + // the returned 24 kHz stereo int16 transport block. + bool processFloat48(const float* interleavedStereo, + int frames, + QByteArray& transportInt16Output); - const QByteArray& transportInt16Stereo() const noexcept; const QByteArray& transportFloat32Stereo() const noexcept; const QByteArray& normalizedFloat48Stereo() const noexcept; const QByteArray& postChannelStripFloat48Stereo() const noexcept; @@ -115,7 +121,7 @@ class TxVoiceProcessor { static constexpr uint64_t kDitherSeed = 0x6A09E667F3BCC909ULL; void processChannelStrip(QByteArray& float48Stereo) noexcept; - bool processWorkBuffer(int frames48); + bool processWorkBuffer(int frames48, QByteArray& transportInt16Output); int reconcileEgressFrameCounts(int leftFrames, int rightFrames); void prepareProcessors(); uint32_t nextDitherRandom24() noexcept; @@ -149,7 +155,6 @@ class TxVoiceProcessor { QByteArray m_normalized48; QByteArray m_postStrip48; QByteArray m_transportFloat; - QByteArray m_transportInt16; }; } // namespace AetherSDR diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp index 4689c544b..08ccc5e6e 100644 --- a/tests/tx_voice_processor_test.cpp +++ b/tests/tx_voice_processor_test.cpp @@ -117,8 +117,10 @@ double transportToneRms(float frequencyHz) kInputFrames, TxVoiceProcessor::kDspRate, frequencyHz); TxVoiceProcessor processor; + QByteArray transportOutput; if (!processor.prepare(TxVoiceProcessor::kDspRate, kInputFrames) - || !processor.processFloat48(input.data(), kInputFrames)) { + || !processor.processFloat48( + input.data(), kInputFrames, transportOutput)) { return 0.0; } return leftChannelRms( @@ -256,13 +258,10 @@ void testReusableBuffersPreserveCapacity() processor.postChannelStripFloat48Stereo().capacity(); const qsizetype transportFloatCapacity = processor.transportFloat32Stereo().capacity(); - const qsizetype transportInt16Capacity = - processor.transportInt16Stereo().capacity(); const bool reservationsSurvivedPrepare = prepared && normalizedCapacity >= kPreparedFrames * 2 * static_cast(sizeof(float)) && postStripCapacity >= kPreparedFrames * 2 * static_cast(sizeof(float)) - && transportFloatCapacity > 0 - && transportInt16Capacity > 0; + && transportFloatCapacity > 0; processor.reset(); report("TX prepare and reset preserve realtime buffer reservations", @@ -272,9 +271,35 @@ void testReusableBuffersPreserveCapacity() && processor.postChannelStripFloat48Stereo().capacity() >= postStripCapacity && processor.transportFloat32Stereo().capacity() - >= transportFloatCapacity - && processor.transportInt16Stereo().capacity() - >= transportInt16Capacity); + >= transportFloatCapacity); +} + +void testCapturedTransportUsesCallerOwnedStorage() +{ + constexpr int kFrames = 480; + TxVoiceProcessor processor; + const bool prepared = processor.prepare(48000, kFrames); + + QByteArray firstBlock = makeCanonicalTone(kFrames, 48000, 997.0f); + const char* const firstInputStorage = firstBlock.constData(); + const bool firstProcessed = processor.processCapturedInt16(firstBlock); + const bool firstStorageReused = firstBlock.constData() == firstInputStorage; + + // Model the queued monitor/recorder consumers that retain the completed + // block after the audio callback returns. + const QByteArray retainedFirstBlock = firstBlock; + + QByteArray secondBlock = makeCanonicalTone(kFrames, 48000, 1201.0f); + const char* const secondInputStorage = secondBlock.constData(); + const bool secondProcessed = processor.processCapturedInt16(secondBlock); + const bool secondStorageReused = secondBlock.constData() == secondInputStorage; + + report("captured transport reuses unique caller-owned input storage", + prepared && firstProcessed && secondProcessed + && firstStorageReused && secondStorageReused); + report("retained transport output does not affect the next callback buffer", + !retainedFirstBlock.isEmpty() + && retainedFirstBlock.constData() != secondBlock.constData()); } void testResamplerResetRestoresFreshState() @@ -330,12 +355,15 @@ void testStereoEgressMismatchSalvagesAndRealigns() const std::vector probe = makeFloatStereoTone( kFrames, TxVoiceProcessor::kDspRate, 997.0f); + QByteArray recoveredOutput; const bool recoveredProcessed = recovered.processFloat48( - probe.data(), kFrames); + probe.data(), kFrames, recoveredOutput); TxVoiceProcessor fresh; fresh.prepare(48000, kFrames); - const bool freshProcessed = fresh.processFloat48(probe.data(), kFrames); + QByteArray freshOutput; + const bool freshProcessed = fresh.processFloat48( + probe.data(), kFrames, freshOutput); report("stereo egress mismatch resets both channels to fresh alignment", recoveredProcessed && freshProcessed @@ -348,8 +376,8 @@ void test48kBypassAndMeasurementBoundaries() TxVoiceProcessor processor; processor.setMeasurementCaptureEnabled(true); const bool prepared = processor.prepare(48000, 1024); - const bool processed = processor.processCapturedInt16( - makeCanonicalTone(480, 48000, 1000.0f)); + QByteArray transportOutput = makeCanonicalTone(480, 48000, 1000.0f); + const bool processed = processor.processCapturedInt16(transportOutput); report("48 kHz processor prepares", prepared); report("48 kHz block processes", processed); @@ -363,13 +391,13 @@ void test48kBypassAndMeasurementBoundaries() processor.transportFloat32Stereo().size() == 240 * 2 * static_cast(sizeof(float))); report("egress contains 240 stereo int16 frames", - processor.transportInt16Stereo().size() + transportOutput.size() == 240 * 2 * static_cast(sizeof(int16_t))); report("bypass output remains finite", finiteFloatBuffer(processor.transportFloat32Stereo())); report("mono voice remains duplicated stereo through egress", duplicatedStereo(processor.transportFloat32Stereo(), true) - && duplicatedStereo(processor.transportInt16Stereo(), false)); + && duplicatedStereo(transportOutput, false)); } void testDeviceRateNormalization() @@ -377,11 +405,11 @@ void testDeviceRateNormalization() TxVoiceProcessor processor; processor.setMeasurementCaptureEnabled(true); const bool prepared = processor.prepare(44100, 1024); - const bool processed = processor.processCapturedInt16( - makeCanonicalTone(441, 44100, 1000.0f)); + QByteArray transportOutput = makeCanonicalTone(441, 44100, 1000.0f); + const bool processed = processor.processCapturedInt16(transportOutput); const int normalizedFrames = processor.normalizedFloat48Stereo().size() / (2 * static_cast(sizeof(float))); - const int transportFrames = processor.transportInt16Stereo().size() + const int transportFrames = transportOutput.size() / (2 * static_cast(sizeof(int16_t))); report("44.1 kHz capture rate prepares", prepared); @@ -406,7 +434,9 @@ void testFloat48OfflineEntryAvoidsInputQuantization() TxVoiceProcessor processor; processor.setMeasurementCaptureEnabled(true); processor.prepare(48000, 480); - const bool processed = processor.processFloat48(input.data(), 480); + QByteArray transportOutput; + const bool processed = processor.processFloat48( + input.data(), 480, transportOutput); report("native float 48 kHz offline block processes", processed); report("native float entry preserves sub-int16 input values exactly", @@ -430,8 +460,8 @@ void testChannelStripRunsAt48k() processor.setStageOrder(packedSingleStage(TxVoiceProcessor::Stage::Tube)); processor.setMeasurementCaptureEnabled(true); processor.prepare(48000, 1024); - const bool processed = processor.processCapturedInt16( - makeCanonicalTone(480, 48000, 1000.0f)); + QByteArray transportOutput = makeCanonicalTone(480, 48000, 1000.0f); + const bool processed = processor.processCapturedInt16(transportOutput); report("channel-strip block processes", processed); report("tube is prepared at the canonical 48 kHz rate", @@ -450,7 +480,9 @@ void testNonFiniteSamplesCannotPoisonEgressSrc() TxVoiceProcessor processor; processor.prepare(48000, 480); - const bool processed = processor.processFloat48(input.data(), 480); + QByteArray transportOutput; + const bool processed = processor.processFloat48( + input.data(), 480, transportOutput); report("non-finite input block still processes", processed); report("non-finite samples cannot poison float transport output", @@ -462,14 +494,15 @@ void testMeasurementCaptureCanBeDisabled() TxVoiceProcessor processor; processor.setMeasurementCaptureEnabled(false); processor.prepare(48000, 1024); - processor.processCapturedInt16(makeCanonicalTone(480, 48000, 1000.0f)); + QByteArray transportOutput = makeCanonicalTone(480, 48000, 1000.0f); + processor.processCapturedInt16(transportOutput); report("disabled normalized tap holds no copied block", processor.normalizedFloat48Stereo().isEmpty()); report("disabled post-strip tap holds no copied block", processor.postChannelStripFloat48Stereo().isEmpty()); report("transport output remains available with taps disabled", - !processor.transportInt16Stereo().isEmpty()); + !transportOutput.isEmpty()); } void testBlockBoundaryContinuityAndReset() @@ -478,21 +511,21 @@ void testBlockBoundaryContinuityAndReset() TxVoiceProcessor whole; whole.prepare(48000, 4800); - whole.processCapturedInt16(input); - const QByteArray wholeOutput = whole.transportInt16Stereo(); + QByteArray wholeOutput = input; + whole.processCapturedInt16(wholeOutput); TxVoiceProcessor blocked; blocked.prepare(48000, 480); QByteArray blockedOutput; constexpr int kInputBlockBytes = 480 * 2 * static_cast(sizeof(int16_t)); for (int offset = 0; offset < input.size(); offset += kInputBlockBytes) { - const bool processed = blocked.processCapturedInt16( - input.mid(offset, kInputBlockBytes)); + QByteArray block = input.mid(offset, kInputBlockBytes); + const bool processed = blocked.processCapturedInt16(block); if (!processed) { report("streaming blocks all produce output", false); return; } - blockedOutput.append(blocked.transportInt16Stereo()); + blockedOutput.append(block); } report("48 -> 24 SRC is invariant to 10 ms block boundaries", @@ -503,8 +536,9 @@ void testBlockBoundaryContinuityAndReset() blocked.reset(); QByteArray afterReset; for (int offset = 0; offset < input.size(); offset += kInputBlockBytes) { - blocked.processCapturedInt16(input.mid(offset, kInputBlockBytes)); - afterReset.append(blocked.transportInt16Stereo()); + QByteArray block = input.mid(offset, kInputBlockBytes); + blocked.processCapturedInt16(block); + afterReset.append(block); } report("reset restores deterministic SRC stream state", afterReset == blockedOutput); @@ -519,18 +553,19 @@ void testOversizedCaptureBlockIsProcessed() TxVoiceProcessor oversized; oversized.prepare(48000, kPreparedInputFrames); - const bool oversizedProcessed = oversized.processCapturedInt16(input); - const QByteArray oversizedOutput = oversized.transportInt16Stereo(); + QByteArray oversizedOutput = input; + const bool oversizedProcessed = oversized.processCapturedInt16( + oversizedOutput); TxVoiceProcessor partitioned; partitioned.prepare(48000, kPreparedInputFrames); QByteArray partitionedOutput; - const bool firstProcessed = partitioned.processCapturedInt16( - input.left(kPreparedInputFrames * kFrameBytes)); - partitionedOutput.append(partitioned.transportInt16Stereo()); - const bool secondProcessed = partitioned.processCapturedInt16( - input.mid(kPreparedInputFrames * kFrameBytes)); - partitionedOutput.append(partitioned.transportInt16Stereo()); + QByteArray firstBlock = input.left(kPreparedInputFrames * kFrameBytes); + const bool firstProcessed = partitioned.processCapturedInt16(firstBlock); + partitionedOutput.append(firstBlock); + QByteArray secondBlock = input.mid(kPreparedInputFrames * kFrameBytes); + const bool secondProcessed = partitioned.processCapturedInt16(secondBlock); + partitionedOutput.append(secondBlock); report("capture block larger than prepared size is processed", oversizedProcessed); @@ -553,9 +588,9 @@ void testTransportTpdfDither() TxVoiceProcessor processor; processor.prepare(48000, kInputFrames); + QByteArray firstOutput; const bool processed = processor.processFloat48( - silence.data(), kInputFrames); - const QByteArray firstOutput = processor.transportInt16Stereo(); + silence.data(), kInputFrames, firstOutput); const QByteArray floatOutput = processor.transportFloat32Stereo(); const auto* quantized = reinterpret_cast( firstOutput.constData()); @@ -593,9 +628,10 @@ void testTransportTpdfDither() "sum=" + std::to_string(sampleSum)); processor.reset(); - processor.processFloat48(silence.data(), kInputFrames); + QByteArray afterReset; + processor.processFloat48(silence.data(), kInputFrames, afterReset); report("reset restores deterministic TPDF stream state", - processor.transportInt16Stereo() == firstOutput); + afterReset == firstOutput); } void testDitheredTransportSaturatesAtInt16Rails() @@ -605,12 +641,13 @@ void testDitheredTransportSaturatesAtInt16Rails() std::vector input(kInputFrames * 2, inputSample); TxVoiceProcessor processor; processor.prepare(48000, kInputFrames); - if (!processor.processFloat48(input.data(), kInputFrames)) { + QByteArray int16Bytes; + if (!processor.processFloat48( + input.data(), kInputFrames, int16Bytes)) { return false; } const QByteArray& floatBytes = processor.transportFloat32Stereo(); - const QByteArray& int16Bytes = processor.transportInt16Stereo(); const auto* floatSamples = reinterpret_cast( floatBytes.constData()); const auto* int16Samples = reinterpret_cast( @@ -654,12 +691,14 @@ void testRnnoiseNative48kIsland() bool allSized = true; bool allDuplicated = true; for (int block = 0; block < 12; ++block) { - allProcessed = processor.processCapturedInt16( - makeCanonicalTone(480, 48000, 700.0f)) && allProcessed; - allSized = processor.transportInt16Stereo().size() + QByteArray transportOutput = makeCanonicalTone( + 480, 48000, 700.0f); + allProcessed = processor.processCapturedInt16(transportOutput) + && allProcessed; + allSized = transportOutput.size() == 240 * 2 * static_cast(sizeof(int16_t)) && allSized; allDuplicated = duplicatedStereo( - processor.transportInt16Stereo(), false) && allDuplicated; + transportOutput, false) && allDuplicated; } report("RNNoise native 48 kHz island processes complete frames", allProcessed); @@ -681,8 +720,8 @@ void testDisabledRnnoiseIsNotDereferencedDuringPrepare() rnnoise.reset(); const bool prepared = processor.prepare(48000, 480); processor.setRnnoise(nullptr); - const bool processed = processor.processCapturedInt16( - makeCanonicalTone(480, 48000, 700.0f)); + QByteArray transportOutput = makeCanonicalTone(480, 48000, 700.0f); + const bool processed = processor.processCapturedInt16(transportOutput); report("disabled stale RNNoise association is not reset during prepare", prepared && processed); @@ -719,6 +758,7 @@ int main() testVoiceSrcBandwidthAndAliasRejection(); testVoiceSrcLatencyBudgets(); testReusableBuffersPreserveCapacity(); + testCapturedTransportUsesCallerOwnedStorage(); testResamplerResetRestoresFreshState(); testStereoEgressMismatchSalvagesAndRealigns(); test48kBypassAndMeasurementBoundaries(); From 4aa5b1d9588f8193f1da7ab094a56bf24f00cee2 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 17:37:00 -0400 Subject: [PATCH 13/25] Resets RADE TX resampler outside real-time callback --- src/core/AudioEngine.cpp | 37 +++++++++++++++++++++++++++---------- src/core/AudioEngine.h | 3 --- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index c9ce8dc15..24eb83721 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -7795,12 +7795,6 @@ void AudioEngine::onTxAudioReady() // processStereoToStereo() here: that helper would average raw mic L/R and // reintroduce the one-sided-channel 6.02 dB loss. const bool radeMode = m_radeMode.load(std::memory_order_acquire); - if (radeMode - && m_radeTxResamplerResetPending.exchange( - false, std::memory_order_acq_rel) - && m_txResampler) { - m_txResampler->reset(); - } if (radeMode && m_radeTxNeedsResample && m_txResampler) { // Convert canonical duplicated int16 stereo → float32 mono for the // mono-to-stereo resampler. @@ -8133,10 +8127,33 @@ void AudioEngine::setRadeMode(bool on) return; } if (on) { - // Publish the reset request before RADE becomes visible to the audio - // callback. The callback owns the resampler state and consumes this - // request before processing the first new RADE block. - m_radeTxResamplerResetPending.store(true, std::memory_order_release); + // Voice no longer advances RADE's 24 kHz SRC, so discard any history + // retained from the previous RADE session before publishing the new + // mode. The resampler belongs to the audio thread; run the reset there, + // between callbacks, and preserve setRadeMode()'s synchronous contract. + QThread* const ownerThread = thread(); + if (ownerThread && ownerThread != QThread::currentThread()) { + if (!ownerThread->isRunning()) { + qCWarning(lcAudio) + << "AudioEngine: cannot enable RADE while audio thread is stopped"; + return; + } + const bool invoked = QMetaObject::invokeMethod( + this, + [this]() { + if (m_txResampler) { + m_txResampler->reset(); + } + }, + Qt::BlockingQueuedConnection); + if (!invoked) { + qCWarning(lcAudio) + << "AudioEngine: failed to reset RADE TX resampler"; + return; + } + } else if (m_txResampler) { + m_txResampler->reset(); + } } m_radeMode.store(on, std::memory_order_release); // RADE TX: onTxAudioReady() emits txRawPcmReady (float32) then returns diff --git a/src/core/AudioEngine.h b/src/core/AudioEngine.h index ccc41397a..e19aac2b6 100644 --- a/src/core/AudioEngine.h +++ b/src/core/AudioEngine.h @@ -904,9 +904,6 @@ private slots: QByteArray m_txFloatAccumulator; // accumulate float32 PCM for RADE modem TX QByteArray m_daxPreTxBuffer; // short rolling pre-TX buffer for low-latency DAX mode std::atomic m_radeMode{false}; // RADE digital voice mode active (atomic: cross-thread) - // setRadeMode() publishes this before enabling RADE. The TX audio thread - // consumes it once so the RADE-only SRC is never reset concurrently. - std::atomic m_radeTxResamplerResetPending{false}; std::atomic m_pcMicGain{1.0f}; // client-side PC mic gain (0.0-1.0) std::atomic m_daxTxMode{false}; // DAX TX mode: VirtualAudioBridge handles TX QElapsedTimer m_txSourceStartTime; From 8b849e181cc0a2020f3cb50e3dd26a6a85457357 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 17:38:53 -0400 Subject: [PATCH 14/25] Correct Quindar TX Sample-Rate Comment --- src/core/ClientQuindarTone.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/ClientQuindarTone.h b/src/core/ClientQuindarTone.h index 78586b3a4..2e8399811 100644 --- a/src/core/ClientQuindarTone.h +++ b/src/core/ClientQuindarTone.h @@ -128,7 +128,7 @@ class ClientQuindarTone { // is hit — Quindar must always be locally audible whenever it's // overlaying the TX stream. Writes to the stereo float32 output // buffer using independent local-rate phase state (separate from - // process(), which runs at the radio's 24 kHz TX rate). When the + // process(), which runs in the fixed 48 kHz TX DSP domain). When the // atomic phase is Idle or Live, leaves the buffer as zeros. Never // mutates the atomic phase — the TX-path process() is the source // of truth for transitions; this path just mirrors them. From 6e9126caeb9c43a8b1550f38b94aaa81ab859e07 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 17:52:39 -0400 Subject: [PATCH 15/25] Adds complete Audio Pipeline SRC coverage --- tests/tx_voice_processor_test.cpp | 131 ++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp index 08ccc5e6e..0006bf6dd 100644 --- a/tests/tx_voice_processor_test.cpp +++ b/tests/tx_voice_processor_test.cpp @@ -127,6 +127,67 @@ double transportToneRms(float frequencyHz) processor.transportFloat32Stereo(), kSettlingOutputFrames); } +double capturedTransportToneRms(int inputRate, float frequencyHz) +{ + const int inputFrames = inputRate * 2; + TxVoiceProcessor processor; + QByteArray inputOutput = makeCanonicalTone( + inputFrames, inputRate, frequencyHz); + if (!processor.prepare(inputRate, inputFrames) + || !processor.processCapturedInt16(inputOutput)) { + return 0.0; + } + + // Measure the final second so both SRCs are in steady state and every + // integer-Hz test tone spans an exact number of cycles. + return leftChannelRms( + processor.transportFloat32Stereo(), + TxVoiceProcessor::kTransportRate); +} + +double capturedNormalizedToneRms(int inputRate, float frequencyHz) +{ + const int inputFrames = inputRate * 2; + TxVoiceProcessor processor; + processor.setMeasurementCaptureEnabled(true); + QByteArray inputOutput = makeCanonicalTone( + inputFrames, inputRate, frequencyHz); + if (!processor.prepare(inputRate, inputFrames) + || !processor.processCapturedInt16(inputOutput)) { + return 0.0; + } + + return leftChannelRms( + processor.normalizedFloat48Stereo(), TxVoiceProcessor::kDspRate); +} + +double leftChannelToneMagnitude(const QByteArray& floatStereo, + int sampleRate, + float frequencyHz, + int startFrame, + int analysisFrames) +{ + const auto* samples = reinterpret_cast( + floatStereo.constData()); + const int availableFrames = floatStereo.size() + / (2 * static_cast(sizeof(float))); + if (!samples || startFrame < 0 || analysisFrames <= 0 + || startFrame + analysisFrames > availableFrames) { + return 0.0; + } + + constexpr double kTwoPi = 6.28318530717958647692; + double inPhase = 0.0; + double quadrature = 0.0; + for (int frame = 0; frame < analysisFrames; ++frame) { + const double phase = kTwoPi * frequencyHz * frame / sampleRate; + const double sample = samples[(startFrame + frame) * 2]; + inPhase += sample * std::cos(phase); + quadrature += sample * std::sin(phase); + } + return 2.0 * std::hypot(inPhase, quadrature) / analysisFrames; +} + bool finiteFloatBuffer(const QByteArray& bytes) { const auto* samples = reinterpret_cast(bytes.constData()); @@ -201,6 +262,75 @@ void testVoiceSrcBandwidthAndAliasRejection() "worstAliasDb=" + std::to_string(worstAliasDb)); } +void testCapturedRateSrcBandwidthAndAliasRejection() +{ + const double reference24 = capturedTransportToneRms(24000, 1000.0f); + const double tenKhz24 = capturedTransportToneRms(24000, 10000.0f); + const double gain24Db = 20.0 * std::log10( + std::max(tenKhz24 / reference24, 1.0e-15)); + report("24 -> 48 -> 24 path remains within 0.1 dB through 10 kHz", + reference24 > 0.0 && std::abs(gain24Db) <= 0.1, + "gainDb=" + std::to_string(gain24Db)); + + const double reference441 = capturedTransportToneRms(44100, 1000.0f); + const double tenKhz441 = capturedTransportToneRms(44100, 10000.0f); + const double gain441Db = 20.0 * std::log10( + std::max(tenKhz441 / reference441, 1.0e-15)); + report("44.1 -> 48 -> 24 path remains within 0.1 dB through 10 kHz", + reference441 > 0.0 && std::abs(gain441Db) <= 0.1, + "gainDb=" + std::to_string(gain441Db)); + + const double normalizedReference441 = + capturedNormalizedToneRms(44100, 1000.0f); + const double normalizedTenKhz441 = + capturedNormalizedToneRms(44100, 10000.0f); + const double normalizedGain441Db = 20.0 * std::log10( + std::max(normalizedTenKhz441 / normalizedReference441, 1.0e-15)); + report("44.1 -> 48 normalization remains within 0.1 dB through 10 kHz", + normalizedReference441 > 0.0 + && std::abs(normalizedGain441Db) <= 0.1, + "gainDb=" + std::to_string(normalizedGain441Db)); + + // A 6 kHz tone sampled at 24 kHz has an interpolation image at 18 kHz + // after expansion to 48 kHz. Use the exact four-sample-period tone so + // int16 source quantization cannot set the rejection floor. + constexpr int kInputRate = 24000; + constexpr int kInputFrames = kInputRate * 2; + constexpr int kAnalysisFrames = TxVoiceProcessor::kDspRate; + TxVoiceProcessor imageProcessor; + imageProcessor.setMeasurementCaptureEnabled(true); + QByteArray imageInputOutput = makeCanonicalTone( + kInputFrames, kInputRate, 6000.0f); + const bool imageProcessed = imageProcessor.prepare( + kInputRate, kInputFrames) + && imageProcessor.processCapturedInt16(imageInputOutput); + const QByteArray& normalized = imageProcessor.normalizedFloat48Stereo(); + const int normalizedFrames = normalized.size() + / (2 * static_cast(sizeof(float))); + const int analysisStart = normalizedFrames - kAnalysisFrames; + const double fundamentalMagnitude = leftChannelToneMagnitude( + normalized, TxVoiceProcessor::kDspRate, 6000.0f, + analysisStart, kAnalysisFrames); + const double imageMagnitude = leftChannelToneMagnitude( + normalized, TxVoiceProcessor::kDspRate, 18000.0f, + analysisStart, kAnalysisFrames); + const double imageDb = 20.0 * std::log10( + std::max(imageMagnitude / fundamentalMagnitude, 1.0e-15)); + report("24 -> 48 interpolation image remains below -100 dB", + imageProcessed && fundamentalMagnitude > 0.0 && imageDb <= -100.0, + "imageDb=" + std::to_string(imageDb)); + + // 14.7 kHz is exactly one third of 44.1 kHz, giving an int16-periodic + // stop-band probe without broadband quantization residue. If it survived + // the serial SRCs it would fold to 9.3 kHz at the 24 kHz transport rate. + const double alias441 = capturedTransportToneRms(44100, 14700.0f); + const double alias441Db = 20.0 * std::log10( + std::max(alias441 / reference441, 1.0e-15)); + report("44.1 -> 48 -> 24 alias probe remains below -100 dB", + reference441 > 0.0 && alias441Db <= -100.0, + "aliasDb=" + std::to_string(alias441Db)); +} + void testVoiceSrcLatencyBudgets() { struct ExpectedLatency { @@ -756,6 +886,7 @@ int main() { testFixedRateContract(); testVoiceSrcBandwidthAndAliasRejection(); + testCapturedRateSrcBandwidthAndAliasRejection(); testVoiceSrcLatencyBudgets(); testReusableBuffersPreserveCapacity(); testCapturedTransportUsesCallerOwnedStorage(); From 33125d0fd7a7de7c7ef9333532542c97902cce06 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 19:01:00 -0400 Subject: [PATCH 16/25] Implements Frame-Level Exact Zero Preservation in Dither --- src/core/TxVoiceProcessor.cpp | 10 +++++ tests/tx_voice_processor_test.cpp | 72 ++++++++++++++++++++----------- 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/src/core/TxVoiceProcessor.cpp b/src/core/TxVoiceProcessor.cpp index c50ba12be..5410545e7 100644 --- a/src/core/TxVoiceProcessor.cpp +++ b/src/core/TxVoiceProcessor.cpp @@ -340,7 +340,17 @@ bool TxVoiceProcessor::processWorkBuffer(int frames48, for (int frame = 0; frame < outputFrames; ++frame) { outputFloat[frame * 2] = left[frame]; outputFloat[frame * 2 + 1] = right[frame]; + const bool exactDigitalSilence = left[frame] == 0.0f + && right[frame] == 0.0f; + // Advance the streaming PRNG for every frame so dither remains + // block-partition invariant. Exact zero has no quantization error to + // decorrelate, so preserve the transport's digital-silence code. const float ditherLsb = nextTpdfDitherLsb(); + if (exactDigitalSilence) { + outputInt16[frame * 2] = 0; + outputInt16[frame * 2 + 1] = 0; + continue; + } for (int channel = 0; channel < kChannels; ++channel) { outputInt16[frame * 2 + channel] = quantizeTransportSample( outputFloat[frame * 2 + channel], ditherLsb); diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp index 0006bf6dd..71fc6d69f 100644 --- a/tests/tx_voice_processor_test.cpp +++ b/tests/tx_voice_processor_test.cpp @@ -715,29 +715,25 @@ void testTransportTpdfDither() { constexpr int kInputFrames = 48000; std::vector silence(kInputFrames * 2, 0.0f); + std::vector subLsbProbe(kInputFrames * 2, 1.0e-9f); TxVoiceProcessor processor; processor.prepare(48000, kInputFrames); - QByteArray firstOutput; + QByteArray silentOutput; const bool processed = processor.processFloat48( - silence.data(), kInputFrames, firstOutput); + silence.data(), kInputFrames, silentOutput); const QByteArray floatOutput = processor.transportFloat32Stereo(); const auto* quantized = reinterpret_cast( - firstOutput.constData()); - const int sampleCount = firstOutput.size() + silentOutput.constData()); + const int sampleCount = silentOutput.size() / static_cast(sizeof(int16_t)); - bool bounded = true; - int nonZeroSamples = 0; - int64_t sampleSum = 0; + bool exactSilence = true; for (int sample = 0; sample < sampleCount; ++sample) { - bounded = quantized[sample] >= -1 && quantized[sample] <= 1 - && bounded; - nonZeroSamples += quantized[sample] != 0 ? 1 : 0; - sampleSum += quantized[sample]; + exactSilence = quantized[sample] == 0 && exactSilence; } - report("silent float transport block processes with dither", processed); + report("silent float transport block processes", processed); report("dither does not alter the float transport measurement tap", finiteFloatBuffer(floatOutput) && std::all_of( @@ -745,23 +741,49 @@ void testTransportTpdfDither() reinterpret_cast(floatOutput.constData()) + floatOutput.size() / static_cast(sizeof(float)), [](float sample) { return sample == 0.0f; })); - report("linked TPDF preserves duplicated mono transport channels", - duplicatedStereo(firstOutput, false)); - report("silent-input TPDF quantization remains within one int16 LSB", - bounded); - report("TPDF decorrelates digital silence from the zero code", - nonZeroSamples > sampleCount / 10, - "nonZero=" + std::to_string(nonZeroSamples) - + " samples=" + std::to_string(sampleCount)); - report("silent-input TPDF has near-zero DC bias", - std::abs(sampleSum) < sampleCount / 100, - "sum=" + std::to_string(sampleSum)); + report("exact digital silence remains the zero int16 code", exactSilence); + report("digital silence remains duplicated stereo", + duplicatedStereo(silentOutput, false)); + + QByteArray advancedProbeOutput; + const bool probeProcessed = processor.processFloat48( + subLsbProbe.data(), kInputFrames, advancedProbeOutput); + const auto* probeSamples = reinterpret_cast( + advancedProbeOutput.constData()); + const int probeSampleCount = advancedProbeOutput.size() + / static_cast(sizeof(int16_t)); + int nonZeroProbeSamples = 0; + int64_t probeSampleSum = 0; + for (int sample = 0; sample < probeSampleCount; ++sample) { + nonZeroProbeSamples += probeSamples[sample] != 0 ? 1 : 0; + probeSampleSum += probeSamples[sample]; + } + report("nonzero sub-LSB signal is still TPDF dithered", + probeProcessed && nonZeroProbeSamples > probeSampleCount / 10, + "nonZero=" + std::to_string(nonZeroProbeSamples) + + " samples=" + std::to_string(probeSampleCount)); + report("sub-LSB TPDF remains linked across duplicated channels", + duplicatedStereo(advancedProbeOutput, false)); + report("sub-LSB TPDF retains near-zero DC bias", + std::abs(probeSampleSum) < probeSampleCount / 100, + "sum=" + std::to_string(probeSampleSum)); + + TxVoiceProcessor freshProcessor; + freshProcessor.prepare(48000, kInputFrames); + QByteArray freshProbeOutput; + freshProcessor.processFloat48( + subLsbProbe.data(), kInputFrames, freshProbeOutput); + report("silent frames continue advancing the dither PRNG", + advancedProbeOutput != freshProbeOutput); processor.reset(); + QByteArray resetSilentOutput; + processor.processFloat48(silence.data(), kInputFrames, resetSilentOutput); QByteArray afterReset; - processor.processFloat48(silence.data(), kInputFrames, afterReset); + processor.processFloat48(subLsbProbe.data(), kInputFrames, afterReset); report("reset restores deterministic TPDF stream state", - afterReset == firstOutput); + resetSilentOutput == silentOutput + && afterReset == advancedProbeOutput); } void testDitheredTransportSaturatesAtInt16Rails() From e0acc25dd868ebcc150e3728966f0e4c18498aaf Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 19:11:35 -0400 Subject: [PATCH 17/25] Implements Route-Aware Resampling Diagnostics --- src/core/AudioEngine.cpp | 17 ++++++++++- src/core/DeviceDiagnostics.cpp | 18 +++++++++-- src/core/DeviceDiagnostics.h | 32 ++++++++++++++++++++ src/gui/SliceTroubleshootingDialog.cpp | 8 ++++- tests/device_diagnostics_test.cpp | 42 ++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 24eb83721..18c94c084 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -2754,8 +2754,23 @@ QJsonArray AudioEngine::audioEndpointDiagnostics() const tx["sample_rate_hz"] = txRunning ? QJsonValue(m_txInputRate) : QJsonValue(); tx["channel_count"] = txRunning ? QJsonValue(m_txInputChannels) : QJsonValue(); tx["sample_format"] = txRunning ? QStringLiteral("Int16") : QString(); + const DeviceDiagnostics::TxAudioResamplingRoute txResampling = + DeviceDiagnostics::txAudioResamplingRoute( + m_radeMode.load(std::memory_order_acquire), + m_daxTxMode.load(std::memory_order_acquire), + m_txInputRate != TxVoiceProcessor::kDspRate, + m_radeTxNeedsResample); tx["resampling_active"] = txRunning - ? QJsonValue(m_txInputRate != TxVoiceProcessor::kDspRate) + ? QJsonValue(txResampling.active) + : QJsonValue(); + tx["voice_input_normalizing_to_48k"] = txRunning + ? QJsonValue(txResampling.voiceInputNormalizingTo48k) + : QJsonValue(); + tx["voice_egress_resampling_to_24k"] = txRunning + ? QJsonValue(txResampling.voiceEgressResamplingTo24k) + : QJsonValue(); + tx["rade_resampling_to_24k"] = txRunning + ? QJsonValue(txResampling.radeResamplingTo24k) : QJsonValue(); tx["note"] = m_txInputMono ? QStringLiteral("mono input promoted to stereo for radio TX") : QString(); const TxCaptureHealthTracker::Snapshot txHealth = diff --git a/src/core/DeviceDiagnostics.cpp b/src/core/DeviceDiagnostics.cpp index d831089b1..566bf1ee6 100644 --- a/src/core/DeviceDiagnostics.cpp +++ b/src/core/DeviceDiagnostics.cpp @@ -276,11 +276,23 @@ QJsonObject buildAudioDevicesSnapshot(const AudioEngine* audio, const QJsonObjec txRoute["actual_sample_format"] = (audio && audio->isTxStreaming()) ? QJsonValue(QStringLiteral("Int16")) : QJsonValue(); - txRoute["voice_normalizing_to_48k"] = (audio && audio->isTxStreaming()) - ? QJsonValue(audio->txInputNormalizationTo48k()) + const TxAudioResamplingRoute txResampling = audio + ? txAudioResamplingRoute( + audio->isRadeMode(), + audio->isDaxTxMode(), + audio->txInputNormalizationTo48k(), + audio->txRadeResamplingTo24k()) + : TxAudioResamplingRoute{}; + txRoute["voice_input_normalizing_to_48k"] = + (audio && audio->isTxStreaming()) + ? QJsonValue(txResampling.voiceInputNormalizingTo48k) + : QJsonValue(); + txRoute["voice_egress_resampling_to_24k"] = + (audio && audio->isTxStreaming()) + ? QJsonValue(txResampling.voiceEgressResamplingTo24k) : QJsonValue(); txRoute["rade_resampling_to_24k"] = (audio && audio->isTxStreaming()) - ? QJsonValue(audio->txRadeResamplingTo24k()) + ? QJsonValue(txResampling.radeResamplingTo24k) : QJsonValue(); // Surface the active TX slice's id, mode, and per-slice DAX channel here // so the bundle's TX route summary has the same context a triager would diff --git a/src/core/DeviceDiagnostics.h b/src/core/DeviceDiagnostics.h index 8eda4b509..844ba319f 100644 --- a/src/core/DeviceDiagnostics.h +++ b/src/core/DeviceDiagnostics.h @@ -15,6 +15,38 @@ struct AudioBusType { QString source; }; +struct TxAudioResamplingRoute { + bool active{false}; + bool voiceInputNormalizingTo48k{false}; + bool voiceEgressResamplingTo24k{false}; + bool radeResamplingTo24k{false}; +}; + +// Mirrors AudioEngine::onTxAudioReady() route priority without requiring an +// audio device in diagnostics tests. RADE consumes the mic buffer first, DAX +// bypasses it, and normal voice always performs the 48 -> 24 kHz egress SRC. +inline constexpr TxAudioResamplingRoute txAudioResamplingRoute( + bool radeMode, + bool daxTxMode, + bool voiceInputNeedsNormalization, + bool radeInputNeedsResampling) noexcept +{ + if (radeMode) { + return { + .active = radeInputNeedsResampling, + .radeResamplingTo24k = radeInputNeedsResampling, + }; + } + if (daxTxMode) { + return {}; + } + return { + .active = true, + .voiceInputNormalizingTo48k = voiceInputNeedsNormalization, + .voiceEgressResamplingTo24k = true, + }; +} + inline AudioBusType inferAudioBusType(const QString& description, const QByteArray& id) { const QString text = (description + QLatin1Char(' ') + QString::fromLatin1(id)).toLower(); diff --git a/src/gui/SliceTroubleshootingDialog.cpp b/src/gui/SliceTroubleshootingDialog.cpp index cc2f43408..d7bb0caa6 100644 --- a/src/gui/SliceTroubleshootingDialog.cpp +++ b/src/gui/SliceTroubleshootingDialog.cpp @@ -246,13 +246,19 @@ QString formatAudioEndpointBullet(const QJsonObject& endpoint) const QString backend = orPlaceholder(endpoint["backend"].toString()); const QString state = orPlaceholder(endpoint["state"].toString(), "n/a"); const QString error = orPlaceholder(endpoint["error"].toString(), "n/a"); - const QString details = QString("rate `%1`, channels `%2`, format `%3`, resampling `%4`") + QString details = QString("rate `%1`, channels `%2`, format `%3`, resampling `%4`") .arg(formatHzValue(endpoint["sample_rate_hz"])) .arg(endpoint["channel_count"].isDouble() ? QString::number(endpoint["channel_count"].toInt()) : QStringLiteral("n/a")) .arg(orPlaceholder(endpoint["sample_format"].toString())) .arg(formatBoolValue(endpoint["resampling_active"])); + if (endpoint.contains("voice_input_normalizing_to_48k")) { + details += QString(", voice input normalization to 48 kHz `%1`, voice egress resampling to 24 kHz `%2`, RADE resampling to 24 kHz `%3`") + .arg(formatBoolValue(endpoint["voice_input_normalizing_to_48k"])) + .arg(formatBoolValue(endpoint["voice_egress_resampling_to_24k"])) + .arg(formatBoolValue(endpoint["rade_resampling_to_24k"])); + } QString line = QString("- `%1` [%2 %3]: operational `%4`, running `%5`, state `%6`, error `%7`, backend `%8`, device `%9`, %10") .arg(orPlaceholder(endpoint["name"].toString())) diff --git a/tests/device_diagnostics_test.cpp b/tests/device_diagnostics_test.cpp index 6a423a219..b9ff11d43 100644 --- a/tests/device_diagnostics_test.cpp +++ b/tests/device_diagnostics_test.cpp @@ -7,6 +7,8 @@ #include using AetherSDR::DeviceDiagnostics::inferAudioBusType; +using AetherSDR::DeviceDiagnostics::TxAudioResamplingRoute; +using AetherSDR::DeviceDiagnostics::txAudioResamplingRoute; namespace { @@ -62,6 +64,46 @@ int main() QByteArray("coreaudio-default-output"), QStringLiteral("Unknown")); + const TxAudioResamplingRoute nativeVoice = txAudioResamplingRoute( + false, false, false, false); + report("48 kHz voice reports its egress SRC", + nativeVoice.active + && !nativeVoice.voiceInputNormalizingTo48k + && nativeVoice.voiceEgressResamplingTo24k + && !nativeVoice.radeResamplingTo24k); + + const TxAudioResamplingRoute normalizedVoice = txAudioResamplingRoute( + false, false, true, true); + report("non-48 kHz voice reports both serial SRCs", + normalizedVoice.active + && normalizedVoice.voiceInputNormalizingTo48k + && normalizedVoice.voiceEgressResamplingTo24k + && !normalizedVoice.radeResamplingTo24k); + + const TxAudioResamplingRoute nativeRade = txAudioResamplingRoute( + true, false, true, false); + report("native-rate RADE reports no active SRC", + !nativeRade.active + && !nativeRade.voiceInputNormalizingTo48k + && !nativeRade.voiceEgressResamplingTo24k + && !nativeRade.radeResamplingTo24k); + + const TxAudioResamplingRoute resampledRade = txAudioResamplingRoute( + true, true, true, true); + report("RADE route priority reports only its active SRC", + resampledRade.active + && !resampledRade.voiceInputNormalizingTo48k + && !resampledRade.voiceEgressResamplingTo24k + && resampledRade.radeResamplingTo24k); + + const TxAudioResamplingRoute daxOnly = txAudioResamplingRoute( + false, true, true, true); + report("DAX mic bypass reports no SRC", + !daxOnly.active + && !daxOnly.voiceInputNormalizingTo48k + && !daxOnly.voiceEgressResamplingTo24k + && !daxOnly.radeResamplingTo24k); + std::printf("\n%s\n", g_failed == 0 ? "All tests passed." : "Some tests failed."); return g_failed == 0 ? 0 : 1; } From 5e4b4fa4577850f28e4b479cc28116955fe9cef0 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 19:31:21 -0400 Subject: [PATCH 18/25] Mid-flight Documentation Updates --- docs/architecture/audio-pipeline.md | 85 +++++++++++++++++++---- docs/architecture/pipelines.md | 21 +++--- docs/architecture/tx-audio-signal-path.md | 21 +++++- src/core/AudioEngine.cpp | 6 +- src/core/ClientFinalLimiter.h | 22 +++--- src/core/ClientReverb.cpp | 11 ++- src/core/ClientReverb.h | 13 ++-- src/core/TxVoiceProcessor.h | 4 +- 8 files changed, 130 insertions(+), 53 deletions(-) diff --git a/docs/architecture/audio-pipeline.md b/docs/architecture/audio-pipeline.md index c09ede568..acd4f7789 100644 --- a/docs/architecture/audio-pipeline.md +++ b/docs/architecture/audio-pipeline.md @@ -24,7 +24,10 @@ all run through the same DSP chain: negotiated device rate. After channel canonicalization, `TxVoiceProcessor` converts to float once, normalizes to a fixed 48 kHz DSP domain, runs optional TX RN2 and the complete voice strip, then performs one 48-to-24 kHz conversion - and one TPDF-dithered Int16 quantization at the unchanged transport boundary. + and one TPDF-dithered Int16 quantization at the existing 24 kHz voice-output + boundary. That boundary is final for Flex `remote_audio_tx`; HL2 and Icom + currently consume the same 24 kHz Int16 seam and convert onward inside their + backends. - **Opus `remote_audio_tx` path**: the normal PC mic voice path sends 24 kHz stereo Int16 frames as 10 ms Opus packets over VITA-49 PCC `0x8005`. - **RADE TX/RX path**: RADE branches early from PC mic capture and bypasses the @@ -45,7 +48,8 @@ interleaved L/R samples. Important cases: signal using Auto Left/Right/Average selection. `TxVoiceProcessor` converts that signal to float, resamples mono to 48 kHz when needed, and duplicates it to L/R for continuous float32 processing. -- Opus `remote_audio_tx` packets are always 24 kHz stereo Int16 frames. +- Opus `remote_audio_tx` encoder input is always 24 kHz stereo Int16 in 10 ms + frames. - RADE modem/speech processing is logically mono, but AudioEngine handoff and VITA packetization use 24 kHz stereo float32 frames. - DAX radio-native TX packets are mono Int16, but the DAX bridge and TCI hand @@ -305,9 +309,12 @@ flowchart TD N --> O["Independent L/R egress SRC
48 kHz -> 24 kHz float32"] O --> P["Linked TPDF dither + round-to-nearest
quantize once to 24 kHz stereo Int16"] P --> Q["Final monitor, scopes, PC mic meter"] - Q --> R{"Opus enabled?"} - R -->|yes| S["10 ms Opus remote_audio_tx
PCC 0x8005"] - R -->|no| T["Uncompressed VITA fallback
PCC 0x03E3"] + Q --> R{"Backend consumes TX audio seam?"} + R -->|yes| U["IRadioBackend::submitTxAudio()
24 kHz stereo Int16"] + U --> V["HL2 / Icom backend
convert for backend-native processing"] + R -->|no| S{"Opus enabled?"} + S -->|yes| T["10 ms Opus remote_audio_tx
PCC 0x8005"] + S -->|no| W["Uncompressed VITA fallback
PCC 0x03E3"] ``` ### QAudioSource format negotiation @@ -358,10 +365,36 @@ at the negotiated device rate. Rate conversion then depends on the route: a reusable processor-owned output buffer. - Native 48 kHz input skips the ingress SRC but still enters the same float32 processing domain. +- The voice SRCs use a 12% transition-band profile selected to retain the + supported modulation bandwidth through 10 kHz while avoiding the much larger + group delay of the general-purpose 2% profile. Their deterministic serial SRC + delay, expressed in the 48 kHz DSP domain, is 394 frames (about 8.2 ms) for + 48 kHz capture, 615 frames (about 12.8 ms) for 44.1 kHz capture, and 788 + frames (about 16.4 ms) for 24 kHz capture. RNNoise and enabled gate lookahead + add their separately reported delays. `TxVoiceProcessor::latencyFrames()` + reports the combined enabled-path delay in 48 kHz frames. - RADE retains its separate conversion to 24 kHz before its early branch. DAX TX does not consume this mic buffer; DAX/TCI audio arrives separately through `feedDaxTxAudio()`. +### TX rate diagnostics + +Support snapshots retain `resampling_active` as the route-level statement that +the selected TX path currently performs any SRC. The explicit fields identify +which conversion is responsible: + +- `voice_input_normalizing_to_48k`: the normal voice path converts the + negotiated capture rate to the fixed 48 kHz DSP rate. +- `voice_egress_resampling_to_24k`: the normal voice path performs its required + 48-to-24 kHz output conversion. This is true even for native 48 kHz capture. +- `rade_resampling_to_24k`: the separate RADE path converts the capture rate to + its fixed 24 kHz input rate. + +Route priority mirrors `onTxAudioReady()`: RADE reports only its own SRC, DAX +mic bypass reports no PC-mic SRC, and normal voice reports its unconditional +egress SRC plus ingress normalization when needed. This prevents the historical +generic field from changing meaning when the capture rate changes. + ### Voice TX ordering after capture/resampling After capture channel canonicalization, `onTxAudioReady()` uses this ordering: @@ -392,13 +425,19 @@ After capture channel canonicalization, `onTxAudioReady()` uses this ordering: voice-strip work and Quindar insertion. Non-finite samples are replaced with silence before they can enter the stateful egress SRC. 10. **Egress SRC**: independent left and right `Resampler` instances convert - 48 kHz float32 to 24 kHz float32, preserving the stereo representation. + 48 kHz float32 to 24 kHz float32, preserving the stereo representation. The + matched instances must return equal frame counts. If they ever diverge, the + common aligned prefix is retained, the mismatch is logged, and both + resamplers reset before the next block so the channels cannot remain offset. 11. **Transport quantization**: one unshaped TPDF value with a 2-LSB peak-to-peak range is added per frame, identically to L/R so the duplicated mono voice representation remains exact. Samples are then rounded to the nearest Int16 code and saturated at the Int16 rails. The allocation-free deterministic PRNG has persistent streaming state and is returned to its fixed seed by `TxVoiceProcessor::reset()` for reproducible offline tests. + When both float samples in a frame are exactly zero, the PRNG still advances + but the frame remains the exact Int16 digital-silence code. Nonzero samples, + including sub-LSB values, retain normal TPDF treatment. 12. **Measurement seams**: when enabled for offline/tests, `normalizedFloat48Stereo()` exposes the normalized 48 kHz input and `postChannelStripFloat48Stereo()` exposes the 48 kHz post-strip/pre-gain @@ -415,10 +454,15 @@ After capture channel canonicalization, `onTxAudioReady()` uses this ordering: 16. **Main scope**: `scopeSamplesReady(..., true)` receives a mono scope signal made by averaging L/R. 17. **Packetization**: - - If Opus TX is enabled, the path encodes 10 ms Opus packets for + - Backends with `takesTxAudioOverSeam` receive the completed 24 kHz stereo + Int16 block through `IRadioBackend::submitTxAudio()`. HL2 converts it to + its 48 kHz host-modulator domain, while Icom converts to its configured + 48 kHz radio-audio stream. These conversions mean the voice processor's + quantization is final only for the Flex path today. + - For Flex, if Opus TX is enabled, the path encodes 10 ms Opus packets for `remote_audio_tx`. - - Otherwise, the fallback path packetizes 128 stereo frames as float32 VITA - PCC `0x03E3`. + - Otherwise, the Flex fallback path packetizes 128 stereo frames as + float32 VITA PCC `0x03E3`. ### PC mic gain and final limiter @@ -437,6 +481,13 @@ The limiter is channel-linked and prepared at 48 kHz. It optionally DC-blocks each channel, applies output trim as a pre-limiter drive stage, and then limits peaks against the ceiling with attack/release smoothing. +The limiter precedes the 48-to-24 kHz egress SRC. Although it bounds samples in +the 48 kHz domain, the SRC's band-limited reconstruction can overshoot that +sample ceiling. The final Int16 conversion saturates any resulting over-range +samples, so the limiter ceiling is not currently a guaranteed post-SRC or true- +peak ceiling. Changing that policy requires measured headroom or a separately +specified post-SRC safeguard; it must not silently add lookahead latency. + ### Passband authority, SRC filtering, and oversampling The client does not add a selectable TX passband filter in this path. The radio @@ -742,8 +793,9 @@ Radio-provided taps: | PC mic gain | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | float32 stereo | 48 kHz | 2 | 0..100 maps to 0.0..1.0 attenuation | | Quindar TX insertion | `ClientQuindarTone::process()` | float32 stereo | float32 stereo | 48 kHz | 2 | Inserts tones before final limiter | | Final voice limiter | `ClientFinalLimiter::process()` | float32 stereo | float32 stereo | 48 kHz | 2 | DC block, output trim, linked peak limiting | -| Voice egress SRC | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | float32 stereo | 48 kHz -> 24 kHz | 2 | Independent matched L/R r8brain instances preserve stereo; state persists across blocks | -| Voice transport quantization | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | Int16 stereo | 24 kHz | 2 | Linked unshaped TPDF (2 LSB peak-to-peak), round-to-nearest, and Int16 saturation; deterministic state persists across blocks | +| Voice egress SRC | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | float32 stereo | 48 kHz -> 24 kHz | 2 | Independent matched L/R r8brain instances preserve stereo; 12% transition profile; state persists across blocks; group delay is included by `latencyFrames()` | +| Voice transport quantization | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | Int16 stereo | 24 kHz | 2 | Linked unshaped TPDF (2 LSB peak-to-peak), round-to-nearest, and Int16 saturation; exact-zero frames remain digital zero while the PRNG advances | +| Backend TX audio seam | `RadioModel::submitTxAudio()` / `IRadioBackend::submitTxAudio()` | Int16 stereo | backend-dependent | 24 kHz at seam | 2 | Used by host-modulating backends; HL2 and Icom currently convert the 24 kHz seam output to 48 kHz backend processing | | Opus TX packetization | `AudioEngine::onTxAudioReady()` | Int16 stereo | VITA PCC `0x8005` Opus | 24 kHz | 2 | 10 ms packets, paced queue | | Uncompressed voice fallback | `AudioEngine::onTxAudioReady()` | Int16 stereo | VITA PCC `0x03E3` float32 stereo | 24 kHz | 2 | 128 stereo frames per packet | | RADE TX branch | `AudioEngine::onTxAudioReady()` | Int16 stereo | float32 stereo | 24 kHz | 2 | Applies PC mic gain, canonical meter, emits `txRawPcmReady()` | @@ -780,7 +832,8 @@ warning before it is discarded and regenerated. | `TxMicChannelNormalizer`, stereo input | Canonicalize and duplicate | Int16 stereo | Int16 stereo | Auto Left/Right/Average avoids one-sided stereo 6.02 dB loss; retains device rate | | `TxVoiceProcessor`, ingress | Format conversion, resample, duplicate | canonical Int16 stereo at device rate | float32 stereo 48 kHz | Takes one canonical channel, converts to float once, uses mono `Resampler::process()` when needed, then duplicates | | `TxVoiceProcessor`, egress | Preserve stereo and downsample | float32 stereo 48 kHz | float32 stereo 24 kHz | Separate L/R `Resampler` instances; no downmix | -| `TxVoiceProcessor`, transport boundary | Dither and quantize | float32 stereo 24 kHz | Int16 stereo 24 kHz | Single linked-channel TPDF-dithered conversion using round-to-nearest and Int16 saturation | +| `TxVoiceProcessor`, transport boundary | Dither and quantize | float32 stereo 24 kHz | Int16 stereo 24 kHz | Single linked-channel TPDF-dithered conversion using round-to-nearest and Int16 saturation; exact-zero frames preserve digital silence | +| `IRadioBackend::submitTxAudio()` | Backend TX handoff | Int16 stereo 24 kHz | backend-dependent | Flex does not use this seam for normal voice; HL2 and Icom currently convert the shared 24 kHz output for 48 kHz backend processing | | `AudioEngine::onTxAudioReady()`, RADE branch | Format conversion | Int16 stereo | float32 stereo | After PC mic gain and canonical meter | | `AudioEngine::onTxAudioReady()`, Opus TX | Encoding | Int16 stereo | Opus payload | 10 ms / 240 frame packets | | `AudioEngine::onTxAudioReady()`, VITA fallback | Format conversion | Int16 stereo | float32 stereo VITA | 128 stereo frames per packet | @@ -831,8 +884,12 @@ warning before it is discarded and regenerated. - PC mic capture is canonicalized before resampling and metering, so one-sided stereo microphones keep full level and right-only microphones meter correctly. - Keep the normal voice strip in the fixed 48 kHz float domain. The 24 kHz rate - is the existing transport boundary, not the voice DSP engine rate. Apply - dither and quantize only once after the final 48-to-24 kHz SRC. + is the existing voice-output boundary, not the voice DSP engine rate. For + Flex `remote_audio_tx`, apply dither and quantize only once after the final + 48-to-24 kHz SRC. The shared HL2/Icom seam currently consumes that same + Int16 block and performs additional backend-specific conversion; removing + that legacy round trip requires a separately reviewed backend-capability and + transport-contract change. - DAX/TCI and RADE intentionally bypass the client voice strip. Do not move them through voice EQ/compression/limiting unless the digital-mode behavior is deliberately being redesigned. diff --git a/docs/architecture/pipelines.md b/docs/architecture/pipelines.md index 32b19ea09..3c04af239 100644 --- a/docs/architecture/pipelines.md +++ b/docs/architecture/pipelines.md @@ -89,17 +89,18 @@ TX AUDIO ROUTING SUMMARY: ◄── AUDIO THREAD │ └─→ radio-native VITA PCC 0x0123 │ ▼ - test tone → client TX DSP chain → post-DSP monitor - → PC mic gain → Quindar → final limiter → meters/scopes + device-rate Int16 → canonical mono → 48 kHz float normalization + → RN2 → test tone → client TX DSP chain → PC mic gain + → Quindar → final limiter → 48-to-24 kHz SRC + → linked TPDF + Int16 quantization → monitors/meters/scopes │ - ├─→ Opus remote_audio_tx VITA PCC 0x8005 - └─→ uncompressed VITA fallback PCC 0x03E3 - │ - ▼ [queued to NETWORK] - PanadapterStream.sendToRadio() - │ - ▼ - Radio UDP 4991 + ├─→ backend TX seam (HL2/Icom, 24 kHz Int16) + │ └─→ backend-native conversion/modulation + │ + └─→ Flex Opus PCC 0x8005 / uncompressed PCC 0x03E3 + └─→ [queued to NETWORK] + PanadapterStream.sendToRadio() + └─→ Radio UDP 4991 The PSK Reporter WSPR beacon is a one-shot, operator-armed digital source. A precise timer on the AudioEngine worker thread generates sample-accurate 4-FSK diff --git a/docs/architecture/tx-audio-signal-path.md b/docs/architecture/tx-audio-signal-path.md index 2ed9e5776..431e744d9 100644 --- a/docs/architecture/tx-audio-signal-path.md +++ b/docs/architecture/tx-audio-signal-path.md @@ -18,6 +18,14 @@ PC mic voice TX stream before VITA/Opus packetization. The radio receives the already-shaped voice signal and treats it identically to any other PC-mic input (enters at SC_MIC, meter 26). +Mic hardware remains Int16 at its negotiated device rate. The normal voice +path canonicalizes the selected channel, converts to float once, normalizes to +a fixed 48 kHz processing domain, and remains float through RN2 and the complete +channel strip. A 12%-transition-band SRC then converts 48 kHz to the existing +24 kHz Flex transport rate before linked TPDF dither and the sole Int16 +quantization on the Flex path. See `audio-pipeline.md` for measured SRC delay +and the separate HL2/Icom backend seam behavior. + DAX/TCI TX and RADE are intentionally not part of this voice strip. DAX/TCI bypasses client voice DSP in `AudioEngine::feedDaxTxAudio()`. RADE branches early from `AudioEngine::onTxAudioReady()` and bypasses @@ -29,10 +37,16 @@ to bypass, double-click to open the floating editor. ``` PC mic capture (QAudioSource) + │ + ▼ Int16 at negotiated device rate +Canonical channel selection → float conversion → 48 kHz normalization + │ + ▼ +Optional native-48 kHz RN2 → 48 kHz test tone │ ▼ ┌───────────────────────────────────────────────────────────────────┐ -│ CHAIN widget — drag-drop ordered TX DSP pipeline │ +│ CHAIN widget — drag-drop ordered 48 kHz float TX DSP pipeline │ │ │ │ [GATE] → [EQ] → [DESS] → [COMP] → [TUBE] → [PUDU] → [VERB] │ │ │ @@ -45,7 +59,7 @@ PC mic capture (QAudioSource) │ ClientReverb — Freeverb (disabled by default) │ │ │ │ Audio thread loads the packed chain order once per block and │ -│ dispatches each stage to its per-stage apply helper. │ +│ TxVoiceProcessor dispatches each enabled processor directly. │ └─────────┬──────────────────────────────────────────────────────────┘ │ ▼ (meters: per-stage inputPeak/outputPeak/GR, ClientEq FFT @@ -55,6 +69,9 @@ PC mic capture (QAudioSource) PC mic gain → Quindar → final limiter → meters/scopes │ ▼ + 48-to-24 kHz SRC → linked TPDF dither → Int16 quantization + │ + ▼ Opus remote_audio_tx / VITA encode → UDP → radio ``` diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 18c94c084..c7abc75fc 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -7869,8 +7869,10 @@ void AudioEngine::onTxAudioReady() // Canonical mic input enters this seam at the negotiated device rate, // becomes float once, and stays float through RN2, the user-orderable // channel strip, mic gain, Quindar, and the final limiter. A matched - // stereo r8brain pair performs the sole 48 -> 24 kHz egress conversion; - // quantization happens once at the unchanged Opus/VITA boundary. + // stereo r8brain pair performs the sole 48 -> 24 kHz conversion inside + // this voice processor; quantization happens once at its 24 kHz output + // boundary. That is final for Flex Opus/VITA. Host-modulating backends + // currently consume the same Int16 seam and may convert onward. // Radio-authoritative mode/passband filtering remains downstream. m_txVoiceProcessor->setStageOrder( m_txChainPacked.load(std::memory_order_acquire)); diff --git a/src/core/ClientFinalLimiter.h b/src/core/ClientFinalLimiter.h index e672bf247..2b772f660 100644 --- a/src/core/ClientFinalLimiter.h +++ b/src/core/ClientFinalLimiter.h @@ -7,12 +7,11 @@ namespace AetherSDR { -// Final-stage brickwall limiter for the TX audio chain — sits at the -// very tail of the chain, after every user-configurable stage (Gate, -// EQ, Comp, DeEss, Tube, PUDU, Reverb) AND after the PC mic gain -// scaling. Its job is to ensure no sample escapes louder than the -// configured ceiling, regardless of what the upstream chain does (a -// reverb tail spike, an over-driven PUDU, or a mic-gain user error). +// Final nonlinear processor in the 48 kHz TX audio chain. It runs after every +// user-configurable stage (Gate, EQ, Comp, DeEss, Tube, PUDU, Reverb) and after +// PC mic gain, but before the 48-to-24 kHz egress SRC. It bounds its own 48 kHz +// output samples against the configured ceiling; downstream SRC reconstruction +// can overshoot that sample ceiling before final Int16 saturation. // // Topology: feed-forward peak limiter with a per-block smoothed // envelope (fast attack, moderately fast release) applied as a single @@ -43,9 +42,9 @@ class ClientFinalLimiter { void setCeilingDb(float db) noexcept; float ceilingDb() const noexcept; - // Master output trim applied AFTER the limiter. Useful for - // setting average level independently of the brickwall ceiling - // (ceiling caps peaks; trim sets RMS). Range [-12, +12] dB. + // Drive trim applied before the limiter. Positive values increase limiter + // activity; negative values lower the signal below the ceiling. Range + // [-12, +12] dB. void setOutputTrimDb(float db) noexcept; float outputTrimDb() const noexcept; @@ -63,9 +62,8 @@ class ClientFinalLimiter { // UI-thread meter snapshots. float inputPeakDb() const noexcept; // pre-limiter peak - float outputPeakDb() const noexcept; // post-limiter peak (the - // value the radio actually - // sees) + float outputPeakDb() const noexcept; // post-limiter 48 kHz peak; + // egress SRC may overshoot it float outputRmsDb() const noexcept; // ~300 ms post-limiter RMS float gainReductionDb() const noexcept; // ≤ 0 dB bool active() const noexcept; // true while limiter is diff --git a/src/core/ClientReverb.cpp b/src/core/ClientReverb.cpp index c55ba5b1d..6e76f95e5 100644 --- a/src/core/ClientReverb.cpp +++ b/src/core/ClientReverb.cpp @@ -38,13 +38,10 @@ void ClientReverb::prepare(double sampleRate) { m_sampleRate = sampleRate; - // Allocate comb + allpass delay buffers at max (Size=1) lengths, - // per channel. Stereo-spread adds kStereoSpread samples on the - // right channel. - // Allocate both channels at the SAME max length — L's stereo-spread - // variant is the upper bound for both so a single cached active- - // length works without overrunning either buffer. Trades ~23 - // unused samples per comb per channel for a simpler index loop. + // Allocate comb + allpass delay buffers at max (Size=1) lengths. The + // historical Freeverb spread constant remains only as common headroom: + // both channels use the same maximum and active lengths. This trades about + // 23 unused samples per delay per channel for a simpler index loop. for (int i = 0; i < kNumCombs; ++i) { const int baseLen = scaleLenForRate(kCombTuningsL44k[i], sampleRate); const int maxLen = baseLen + kStereoSpread; diff --git a/src/core/ClientReverb.h b/src/core/ClientReverb.h index 7d9bd7e5c..5b80de228 100644 --- a/src/core/ClientReverb.h +++ b/src/core/ClientReverb.h @@ -8,9 +8,11 @@ namespace AetherSDR { // Client-side reverb — TX DSP chain Phase 6 (Freeverb). Eight parallel // lowpass-feedback comb filters in parallel summed through four series -// allpass filters, stereo-spread by 23 samples between L and R. A -// pre-delay ring buffer sits in front of the reverb core. Voice- -// oriented knob set; no "studio" parameters. +// allpass filters per channel. The current implementation uses matched +// active delay lengths for L and R; the historical 23-sample spread constant +// is allocation headroom, not active stereo decorrelation. A pre-delay ring +// buffer sits in front of the reverb core. Voice-oriented knob set; no +// "studio" parameters. // // Thread model mirrors ClientTube / ClientGate / ClientDeEss: UI // thread writes atomics + bumps a version counter; the audio thread @@ -19,8 +21,9 @@ namespace AetherSDR { // // Buffer sizes are fixed in prepare() based on sample rate — max comb // length + stereo-spread headroom for Size=1, plus max pre-delay of -// 100 ms. Typical TX path runs at 24 kHz; total buffer budget per -// ClientReverb instance is ~12 kB of float samples. +// 100 ms. The TX voice strip prepares this processor at 48 kHz; its delay +// buffers contain about 147 KiB of float storage at that rate, excluding +// vector bookkeeping. class ClientReverb { public: ClientReverb(); diff --git a/src/core/TxVoiceProcessor.h b/src/core/TxVoiceProcessor.h index c100b3aee..00e1dd524 100644 --- a/src/core/TxVoiceProcessor.h +++ b/src/core/TxVoiceProcessor.h @@ -25,7 +25,9 @@ class TxVoiceProcessorTestAccess; // Headless, backend-independent TX voice rate-domain processor. AudioEngine // remains responsible for capture normalization, mode routing, metering, and // transport. This class makes the canonical 48 kHz float DSP island explicit -// and returns the unchanged 24 kHz stereo int16 transport representation. +// and returns the existing 24 kHz stereo int16 voice-output representation. +// It is the final PCM boundary for Flex remote_audio_tx; host-modulating +// backends currently consume the same seam and may convert onward. class TxVoiceProcessor { public: static constexpr int kDspRate = 48000; From 51c9031e05f612602ac91f447f890a3e71b0c22c Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 19:44:24 -0400 Subject: [PATCH 19/25] Registers new diagnostics --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index e663b0554..274c6438e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5673,6 +5673,7 @@ add_executable(device_diagnostics_test ) target_include_directories(device_diagnostics_test PRIVATE src) target_link_libraries(device_diagnostics_test PRIVATE Qt6::Core) +add_test(NAME device_diagnostics_test COMMAND device_diagnostics_test) add_executable(midi_settings_test tests/midi_settings_test.cpp From 68bddc400a3f83e531b6107fc4df2d3b07ae29a4 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 20:22:05 -0400 Subject: [PATCH 20/25] Removes redundant per-block call --- src/core/AudioEngine.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index c7abc75fc..183a4b5fb 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -7878,7 +7878,6 @@ void AudioEngine::onTxAudioReady() m_txChainPacked.load(std::memory_order_acquire)); m_txVoiceProcessor->setMicGain(m_pcMicGain.load()); m_txVoiceProcessor->setRnnoiseEnabled(m_rn2TxEnabled.load()); - m_txVoiceProcessor->setMeasurementCaptureEnabled(false); if (!m_txVoiceProcessor->processCapturedInt16(data)) { return; } From 1411105cbae306913278ef250e56b4213a68992d Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 20:26:00 -0400 Subject: [PATCH 21/25] Removes unused process48kStereo() overload --- src/core/RNNoiseFilter.cpp | 14 ++++---------- src/core/RNNoiseFilter.h | 1 - 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/core/RNNoiseFilter.cpp b/src/core/RNNoiseFilter.cpp index d5810cd29..99e6c10d5 100644 --- a/src/core/RNNoiseFilter.cpp +++ b/src/core/RNNoiseFilter.cpp @@ -226,13 +226,6 @@ QByteArray RNNoiseFilter::process(const QByteArray& pcm24kStereo) return QByteArray(needed, '\0'); } -QByteArray RNNoiseFilter::process48kStereo(const QByteArray& pcm48kStereo) -{ - QByteArray output; - process48kStereo(pcm48kStereo, output); - return output; -} - int RNNoiseFilter::process48kStereo( const QByteArray& pcm48kStereo, QByteArray& output) { @@ -289,13 +282,14 @@ int RNNoiseFilter::process48kStereo( m_processed48k[channel].resize(consumedSamples); auto* accum = reinterpret_cast(m_inAccum[channel].data()); for (int frame = 0; frame < completeFrames; ++frame) { - float* output = &m_processed48k[channel][frame * FRAME_SIZE]; + float* frameOutput = + &m_processed48k[channel][frame * FRAME_SIZE]; float* input = &accum[frame * FRAME_SIZE]; if (m_dryMix > 0.0f) { rnnoise_process_frame_with_dry_mix( - m_states[channel], output, input, m_dryMix); + m_states[channel], frameOutput, input, m_dryMix); } else { - rnnoise_process_frame(m_states[channel], output, input); + rnnoise_process_frame(m_states[channel], frameOutput, input); } } diff --git a/src/core/RNNoiseFilter.h b/src/core/RNNoiseFilter.h index 8d55c46e2..268e4f469 100644 --- a/src/core/RNNoiseFilter.h +++ b/src/core/RNNoiseFilter.h @@ -52,7 +52,6 @@ class RNNoiseFilter { // resamplers. Input and output are interleaved 48 kHz stereo float32 with // an identical frame count. This is the TX voice path's fixed-rate seam; // the existing process() entry point remains the 24 kHz RX-compatible API. - QByteArray process48kStereo(const QByteArray& pcm48kStereo); int process48kStereo(const QByteArray& pcm48kStereo, QByteArray& output); // Fraction of the original spectrum retained in each RX frame, clamped to From 9acab148339e28aa193c54f109a379e657b1b75f Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 20:31:27 -0400 Subject: [PATCH 22/25] Documentation Update --- docs/architecture/audio-pipeline.md | 31 +++++++++++++++++++++++------ src/core/TxVoiceProcessor.h | 10 ++++++---- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/architecture/audio-pipeline.md b/docs/architecture/audio-pipeline.md index acd4f7789..1d196f777 100644 --- a/docs/architecture/audio-pipeline.md +++ b/docs/architecture/audio-pipeline.md @@ -363,6 +363,10 @@ at the negotiated device rate. Rate conversion then depends on the route: in-place with the final 24 kHz stereo Int16 transport output. Queued monitor consumers therefore retain an immutable completed block rather than sharing a reusable processor-owned output buffer. +- `prepare(..., maxInputFrames)` sizes the normal realtime working set; it is + not an input ceiling. A capture callback larger than that reservation is + still processed, with scratch buffers allowed to grow for the exceptional + block, rather than dropping the delayed microphone audio. - Native 48 kHz input skips the ingress SRC but still enters the same float32 processing domain. - The voice SRCs use a 12% transition-band profile selected to retain the @@ -370,9 +374,13 @@ at the negotiated device rate. Rate conversion then depends on the route: group delay of the general-purpose 2% profile. Their deterministic serial SRC delay, expressed in the 48 kHz DSP domain, is 394 frames (about 8.2 ms) for 48 kHz capture, 615 frames (about 12.8 ms) for 44.1 kHz capture, and 788 - frames (about 16.4 ms) for 24 kHz capture. RNNoise and enabled gate lookahead - add their separately reported delays. `TxVoiceProcessor::latencyFrames()` - reports the combined enabled-path delay in 48 kHz frames. + frames (about 16.4 ms) for 24 kHz capture. Enabled gate lookahead and the + nominal 480-frame RNNoise contribution are added by + `TxVoiceProcessor::latencyFrames()`, which reports the configured-path delay + in 48 kHz frames. The SRC and gate terms are deterministic. RNNoise's current + callback-sized streaming adapter can emit additional startup silence for + irregular callback partitions while waiting for complete 480-frame output, + so the RNNoise term is nominal rather than a universal wall-clock guarantee. - RADE retains its separate conversion to 24 kHz before its early branch. DAX TX does not consume this mic buffer; DAX/TCI audio arrives separately through `feedDaxTxAudio()`. @@ -411,7 +419,9 @@ After capture channel canonicalization, `onTxAudioReady()` uses this ordering: stateful r8brain SRC converts it to the fixed DSP rate; the result is then duplicated to float32 stereo. 4. **Optional TX RN2**: the mic-preamp RNNoise instance runs directly in the - native 48 kHz domain. RX RN2 retains its existing legacy rate wrapper. + native 48 kHz domain. `TxVoiceProcessor` supplies a reusable caller-owned + output buffer to `RNNoiseFilter::process48kStereo(input, output)`; RX RN2 + retains its existing legacy rate wrapper. 5. **Test tone**: `ClientTxTestTone` can replace the mic signal with a generated 48 kHz float32 stereo tone before the user voice strip. 6. **Voice DSP strip**: `TxVoiceProcessor::processChannelStrip()` runs float32 @@ -438,7 +448,9 @@ After capture channel canonicalization, `onTxAudioReady()` uses this ordering: When both float samples in a frame are exactly zero, the PRNG still advances but the frame remains the exact Int16 digital-silence code. Nonzero samples, including sub-LSB values, retain normal TPDF treatment. -12. **Measurement seams**: when enabled for offline/tests, +12. **Measurement seams**: capture is disabled by default and production + `AudioEngine` does not toggle it per block. Offline/tests enable it + explicitly when they need the named intermediate buffers. `normalizedFloat48Stereo()` exposes the normalized 48 kHz input and `postChannelStripFloat48Stereo()` exposes the 48 kHz post-strip/pre-gain signal. `transportFloat32Stereo()` exposes the final 24 kHz float @@ -579,6 +591,13 @@ When `m_radeMode` is active, `AudioEngine::onTxAudioReady()` branches before the normal Opus voice TX path. RADE receives float32 PCM and bypasses the Opus `remote_audio_tx` encoder entirely. +On the transition into RADE, `AudioEngine::setRadeMode(true)` resets the +RADE-only device-to-24 kHz resampler on the audio thread before publishing the +new mode. The reset therefore discards history from a previous RADE session +without running resampler reset/prewarm work inside the realtime capture +callback. This changes neither RADE's rate domain nor its steady-state signal +latency. + `RADEEngine::feedTxAudio()` expects 24 kHz stereo float32 PCM. It averages L/R and downsamples to 16 kHz mono for LPCNet feature extraction, encodes the RADE modem data, converts the 8 kHz modem waveform back to 24 kHz stereo float32, and @@ -788,7 +807,7 @@ Radio-provided taps: | PC mic capture | `AudioEngine::startTxStream()` | device Int16 | Int16 from `QAudioSource` | negotiated device rate | 1 or 2 | macOS push-buffer polling; Linux/Windows pull mode | | PC mic channel canonicalization | `TxMicChannelNormalizer::canonicalizeInt16ToMonoStereo()` | Int16 mono/stereo | duplicated-stereo Int16 | negotiated device rate | 1 or 2 -> 1 -> 2 | Auto selects stronger one-sided stereo channel or averages balanced stereo; no SRC here | | Voice float conversion / ingress SRC | `TxVoiceProcessor::processCapturedInt16()` | duplicated-stereo Int16 | float32 stereo | device rate -> 48 kHz when needed | 2 -> 1 -> 2 | Converts to float once; stateful mono r8brain SRC; native 48 kHz skips SRC | -| TX RN2 | `RNNoiseFilter::process48kStereo()` | float32 stereo | float32 stereo | 48 kHz | 2 -> 1 -> 2 | Optional mic denoiser in `Native48k` rate domain | +| TX RN2 | `RNNoiseFilter::process48kStereo(input, output)` | float32 stereo | float32 stereo | 48 kHz | 2 -> 1 -> 2 | Optional mic denoiser in `Native48k` rate domain; reuses caller-owned output storage | | PC mic voice strip | `TxVoiceProcessor::processChannelStrip()` | float32 stereo | float32 stereo | 48 kHz | 2 | Ordered Gate/EQ/DeEss/Comp/Tube/PUDU/Reverb | | PC mic gain | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | float32 stereo | 48 kHz | 2 | 0..100 maps to 0.0..1.0 attenuation | | Quindar TX insertion | `ClientQuindarTone::process()` | float32 stereo | float32 stereo | 48 kHz | 2 | Inserts tones before final limiter | diff --git a/src/core/TxVoiceProcessor.h b/src/core/TxVoiceProcessor.h index 00e1dd524..edcc728df 100644 --- a/src/core/TxVoiceProcessor.h +++ b/src/core/TxVoiceProcessor.h @@ -110,11 +110,13 @@ class TxVoiceProcessor { int inputRate() const noexcept { return m_inputRate; } bool isPrepared() const noexcept { return m_prepared; } - // Deterministic end-to-end delay expressed in 48 kHz DSP frames. Includes - // serial ingress/egress SRC group delay, RNNoise's one-frame WOLA delay, + // Configured-path delay expressed in 48 kHz DSP frames. Includes exact + // serial ingress/egress SRC group delay, RNNoise's nominal one-frame delay, // and enabled gate lookahead. The matched L/R egress SRCs run in parallel - // and therefore contribute one delay. Reverb pre-delay is an artistic - // wet-path parameter rather than whole-signal latency. + // and therefore contribute one delay. RNNoise's callback-sized streaming + // adapter can add startup silence for irregular block partitions, so its + // term is not an exact wall-clock guarantee. Reverb pre-delay is an + // artistic wet-path parameter rather than whole-signal latency. int latencyFrames() const noexcept; private: From 3263d82eaaeeaf16130280b02a69cca0c58af0c2 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 23:04:11 -0400 Subject: [PATCH 23/25] Adds Coverage for Lower Sample Rate Edge Cases --- docs/architecture/audio-pipeline.md | 9 ++++++--- tests/tx_voice_processor_test.cpp | 30 ++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/docs/architecture/audio-pipeline.md b/docs/architecture/audio-pipeline.md index 1d196f777..a795b732b 100644 --- a/docs/architecture/audio-pipeline.md +++ b/docs/architecture/audio-pipeline.md @@ -325,7 +325,7 @@ flowchart TD - macOS prefers sample rates in this order for general devices: 48 kHz, 44.1 kHz, then 24 kHz. For Bluetooth headset inputs that CoreAudio reports as native 8, 16, or 24 kHz-only, AetherSDR opens the mic at that native rate - first and then uses its normal radio-native conversion when needed. + first and then applies the conversion required by the selected TX route. - Linux and other non-Windows platforms prefer 24 kHz, then 48 kHz, then 44.1 kHz. - Stereo is tried before mono for each sample rate. @@ -374,8 +374,11 @@ at the negotiated device rate. Rate conversion then depends on the route: group delay of the general-purpose 2% profile. Their deterministic serial SRC delay, expressed in the 48 kHz DSP domain, is 394 frames (about 8.2 ms) for 48 kHz capture, 615 frames (about 12.8 ms) for 44.1 kHz capture, and 788 - frames (about 16.4 ms) for 24 kHz capture. Enabled gate lookahead and the - nominal 480-frame RNNoise contribution are added by + frames (about 16.4 ms) for 24 kHz capture. Supported low-rate capture has a + longer interpolation filter: 1,240 frames (about 25.8 ms) at 16 kHz and + 2,104 frames (about 43.8 ms) at 8 kHz. These figures include the serial + ingress and egress SRCs but not optional DSP latency. Enabled gate lookahead + and the nominal 480-frame RNNoise contribution are added by `TxVoiceProcessor::latencyFrames()`, which reports the configured-path delay in 48 kHz frames. The SRC and gate terms are deterministic. RNNoise's current callback-sized streaming adapter can emit additional startup silence for diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp index 71fc6d69f..b95f5cbdf 100644 --- a/tests/tx_voice_processor_test.cpp +++ b/tests/tx_voice_processor_test.cpp @@ -336,29 +336,45 @@ void testVoiceSrcLatencyBudgets() struct ExpectedLatency { int inputRate; int frames48; + int budgetFrames48; }; constexpr ExpectedLatency kExpected[] = { - {48000, 394}, - {44100, 615}, - {24000, 788}, + {48000, 394, 960}, + {44100, 615, 960}, + {24000, 788, 960}, + // Low-rate capture remains supported for macOS Bluetooth/HFP and the + // Windows fallback ladder. These ratios require longer interpolation + // filters than the common 24/44.1/48 kHz device rates. + {16000, 1240, 1440}, + {8000, 2104, 2160}, }; bool exact = true; - bool withinTwentyMs = true; + bool commonRatesWithinTwentyMs = true; + bool withinRateSpecificBudgets = true; std::string detail; for (const ExpectedLatency expected : kExpected) { TxVoiceProcessor processor; const bool prepared = processor.prepare(expected.inputRate, 1024); const int frames = processor.latencyFrames(); exact = prepared && frames == expected.frames48 && exact; - withinTwentyMs = prepared && frames <= 960 && withinTwentyMs; + if (expected.inputRate >= 24000) { + commonRatesWithinTwentyMs = prepared && frames <= 960 + && commonRatesWithinTwentyMs; + } + withinRateSpecificBudgets = prepared + && frames <= expected.budgetFrames48 + && withinRateSpecificBudgets; detail += std::to_string(expected.inputRate) + "Hz=" + std::to_string(frames) + " "; } report("SRC group delay is reported for every capture rate", exact, detail); - report("worst-case serial SRC group delay remains below 20 ms", - withinTwentyMs, + report("24/44.1/48 kHz serial SRC delay remains below 20 ms", + commonRatesWithinTwentyMs, + detail); + report("supported low-rate SRC delays remain within documented budgets", + withinRateSpecificBudgets, detail); } From a6eebe6a00991ec90dd0047f69eb70ebd1db3002 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 23:10:26 -0400 Subject: [PATCH 24/25] Defer Stereo Egress SRC Recover Outside Realtime Callback. Principle XI --- docs/architecture/audio-pipeline.md | 2 +- src/core/AudioEngine.cpp | 19 ++++++++++++++++++- src/core/AudioEngine.h | 3 +++ src/core/TxVoiceProcessor.cpp | 22 ++++++++++++++++++---- src/core/TxVoiceProcessor.h | 11 +++++++++++ tests/tx_voice_processor_test.cpp | 9 +++++++++ 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/architecture/audio-pipeline.md b/docs/architecture/audio-pipeline.md index a795b732b..d4313f444 100644 --- a/docs/architecture/audio-pipeline.md +++ b/docs/architecture/audio-pipeline.md @@ -815,7 +815,7 @@ Radio-provided taps: | PC mic gain | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | float32 stereo | 48 kHz | 2 | 0..100 maps to 0.0..1.0 attenuation | | Quindar TX insertion | `ClientQuindarTone::process()` | float32 stereo | float32 stereo | 48 kHz | 2 | Inserts tones before final limiter | | Final voice limiter | `ClientFinalLimiter::process()` | float32 stereo | float32 stereo | 48 kHz | 2 | DC block, output trim, linked peak limiting | -| Voice egress SRC | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | float32 stereo | 48 kHz -> 24 kHz | 2 | Independent matched L/R r8brain instances preserve stereo; 12% transition profile; state persists across blocks; group delay is included by `latencyFrames()` | +| Voice egress SRC | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | float32 stereo | 48 kHz -> 24 kHz | 2 | Independent matched L/R r8brain instances preserve stereo; 12% transition profile; state persists across blocks; group delay is included by `latencyFrames()`. An impossible frame-count mismatch preserves the aligned common prefix and queues both SRC histories for reset on the audio event loop between callbacks. | | Voice transport quantization | `TxVoiceProcessor::processWorkBuffer()` | float32 stereo | Int16 stereo | 24 kHz | 2 | Linked unshaped TPDF (2 LSB peak-to-peak), round-to-nearest, and Int16 saturation; exact-zero frames remain digital zero while the PRNG advances | | Backend TX audio seam | `RadioModel::submitTxAudio()` / `IRadioBackend::submitTxAudio()` | Int16 stereo | backend-dependent | 24 kHz at seam | 2 | Used by host-modulating backends; HL2 and Icom currently convert the 24 kHz seam output to 48 kHz backend processing | | Opus TX packetization | `AudioEngine::onTxAudioReady()` | Int16 stereo | VITA PCC `0x8005` Opus | 24 kHz | 2 | 10 ms packets, paced queue | diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 183a4b5fb..bb40fda07 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -7878,7 +7878,24 @@ void AudioEngine::onTxAudioReady() m_txChainPacked.load(std::memory_order_acquire)); m_txVoiceProcessor->setMicGain(m_pcMicGain.load()); m_txVoiceProcessor->setRnnoiseEnabled(m_rn2TxEnabled.load()); - if (!m_txVoiceProcessor->processCapturedInt16(data)) { + const bool voiceProcessed = m_txVoiceProcessor->processCapturedInt16(data); + if (m_txVoiceProcessor->egressRecoveryPending() + && !m_txVoiceEgressRecoveryQueued) { + m_txVoiceEgressRecoveryQueued = true; + const bool queued = QMetaObject::invokeMethod( + this, + [this]() { + m_txVoiceProcessor->recoverEgressAfterMismatch(); + m_txVoiceEgressRecoveryQueued = false; + }, + Qt::QueuedConnection); + if (!queued) { + m_txVoiceEgressRecoveryQueued = false; + qCWarning(lcAudio) + << "AudioEngine: failed to queue TX egress SRC recovery"; + } + } + if (!voiceProcessed) { return; } diff --git a/src/core/AudioEngine.h b/src/core/AudioEngine.h index e19aac2b6..ccc594969 100644 --- a/src/core/AudioEngine.h +++ b/src/core/AudioEngine.h @@ -1083,6 +1083,9 @@ private slots: std::unique_ptr m_wsprBeacon; std::unique_ptr m_clientQuindarTone; std::unique_ptr m_txVoiceProcessor; + // Audio-thread only: prevents duplicate queued recovery work after the + // impossible matched-egress frame-count mismatch. + bool m_txVoiceEgressRecoveryQueued{false}; // Audio-thread-loaded pointer for the post-final-limiter monitor // (final-output recording). Same lock-free atomic pointer pattern // as m_txPostDspMonitor. diff --git a/src/core/TxVoiceProcessor.cpp b/src/core/TxVoiceProcessor.cpp index 5410545e7..80dd61a2c 100644 --- a/src/core/TxVoiceProcessor.cpp +++ b/src/core/TxVoiceProcessor.cpp @@ -117,6 +117,7 @@ void TxVoiceProcessor::reset() { m_ditherState = kDitherSeed; m_warnedEgressFrameMismatch = false; + m_egressRecoveryPending = false; if (m_inputResampler) { m_inputResampler->reset(); } @@ -371,17 +372,30 @@ int TxVoiceProcessor::reconcileEgressFrameCounts( qCWarning(lcTxVoiceProcessor) << "TX egress SRC channel frame mismatch: left=" << leftFrames << "right=" << rightFrames - << "-- transmitting the common prefix and resetting both channels"; + << "-- transmitting the common prefix and requesting recovery" + " between callbacks"; m_warnedEgressFrameMismatch = true; } // Both resamplers consumed the same input interval, so their common // prefix belongs to the current aligned timeline. Preserve that prefix, - // discard only the unmatched tail, and reset both histories before the - // next callback so the dropped tail cannot become a permanent L/R offset. + // discard only the unmatched tail, and ask the owner to reset both + // histories between callbacks so the dropped tail cannot become a + // permanent L/R offset. Resampler::reset() performs prewarming and must not + // run from this realtime processing call. + m_egressRecoveryPending = true; + return std::min(leftFrames, rightFrames); +} + +void TxVoiceProcessor::recoverEgressAfterMismatch() +{ + if (!m_egressRecoveryPending) { + return; + } m_outputLeftResampler->reset(); m_outputRightResampler->reset(); - return std::min(leftFrames, rightFrames); + m_egressRecoveryPending = false; + m_warnedEgressFrameMismatch = false; } uint32_t TxVoiceProcessor::nextDitherRandom24() noexcept diff --git a/src/core/TxVoiceProcessor.h b/src/core/TxVoiceProcessor.h index edcc728df..a86009653 100644 --- a/src/core/TxVoiceProcessor.h +++ b/src/core/TxVoiceProcessor.h @@ -103,6 +103,16 @@ class TxVoiceProcessor { int frames, QByteArray& transportInt16Output); + // A matched egress pair should always return equal frame counts. If it + // does not, processing preserves the aligned common prefix and raises this + // request. The owner must service it between realtime callbacks; resetting + // r8brain from processWorkBuffer() would violate Resampler's contract. + bool egressRecoveryPending() const noexcept + { + return m_egressRecoveryPending; + } + void recoverEgressAfterMismatch(); + const QByteArray& transportFloat32Stereo() const noexcept; const QByteArray& normalizedFloat48Stereo() const noexcept; const QByteArray& postChannelStripFloat48Stereo() const noexcept; @@ -140,6 +150,7 @@ class TxVoiceProcessor { bool m_rnnoiseEnabled{false}; bool m_captureMeasurements{false}; bool m_warnedEgressFrameMismatch{false}; + bool m_egressRecoveryPending{false}; float m_micGain{1.0f}; uint64_t m_packedStages{0}; uint64_t m_ditherState{kDitherSeed}; diff --git a/tests/tx_voice_processor_test.cpp b/tests/tx_voice_processor_test.cpp index b95f5cbdf..0331f07ad 100644 --- a/tests/tx_voice_processor_test.cpp +++ b/tests/tx_voice_processor_test.cpp @@ -498,6 +498,15 @@ void testStereoEgressMismatchSalvagesAndRealigns() report("stereo egress mismatch preserves the common aligned prefix", commonFrames == 239, "frames=" + std::to_string(commonFrames)); + report("stereo egress mismatch defers expensive SRC recovery", + recovered.egressRecoveryPending()); + + // AudioEngine services this request on its event loop after the realtime + // callback returns. The headless test performs the same control-boundary + // action explicitly. + recovered.recoverEgressAfterMismatch(); + report("deferred stereo egress recovery clears the request", + !recovered.egressRecoveryPending()); const std::vector probe = makeFloatStereoTone( kFrames, TxVoiceProcessor::kDspRate, 997.0f); From 9c77126e6851e6f6e244a820ba8d4baaa41cd9d1 Mon Sep 17 00:00:00 2001 From: Silent-Gloves Date: Tue, 11 Aug 2026 23:13:43 -0400 Subject: [PATCH 25/25] Documentation Updates --- docs/architecture/audio-pipeline.md | 4 +++- docs/audio-sink-factory.md | 7 ++++--- src/core/AudioFormatNegotiator.h | 14 +++++++++----- tests/audio_format_negotiation_test.cpp | 5 +++-- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/architecture/audio-pipeline.md b/docs/architecture/audio-pipeline.md index d4313f444..ead6d2f1b 100644 --- a/docs/architecture/audio-pipeline.md +++ b/docs/architecture/audio-pipeline.md @@ -325,7 +325,9 @@ flowchart TD - macOS prefers sample rates in this order for general devices: 48 kHz, 44.1 kHz, then 24 kHz. For Bluetooth headset inputs that CoreAudio reports as native 8, 16, or 24 kHz-only, AetherSDR opens the mic at that native rate - first and then applies the conversion required by the selected TX route. + first. Normal voice then normalizes the captured signal to its fixed 48 kHz + DSP domain; the separate RADE route instead converts it to RADE's 24 kHz + handoff rate. DAX/TCI TX does not consume the PC-mic capture buffer. - Linux and other non-Windows platforms prefer 24 kHz, then 48 kHz, then 44.1 kHz. - Stereo is tried before mono for each sample rate. diff --git a/docs/audio-sink-factory.md b/docs/audio-sink-factory.md index d3c81b7da..6ecf54799 100644 --- a/docs/audio-sink-factory.md +++ b/docs/audio-sink-factory.md @@ -9,8 +9,9 @@ Every audio sink and source in AetherSDR currently answers two questions on its own: -1. **"What rate / sample format does this device want, and how do I get my - 24 kHz canonical audio onto it?"** — reimplemented ~9 times with ~6 divergent +1. **"What rate / sample format does this device want, and how do I bridge it + to the selected route's canonical device-boundary rate?"** — reimplemented + ~9 times with ~6 divergent fallback ladders and per-OS `#ifdef` branches that have drifted apart. This is the root of a recurring class of platform bugs (44.1k-only devices silently failing on some sinks, WASAPI Float32-only devices rejecting Int16, @@ -84,7 +85,7 @@ The ladder, in one place, owns: | Int16 ↔ Float32 | both tried per rate; Float-first output, Int16-first input | [#2669] / [#1090] | | `preferredFormat()` catch-all | final rung for Float32-only virtual drivers / WASAPI | [#3231] | | macOS mic preferred-rate-first | avoids silent 48 k open on 16 k-native mics | PR [#2930] | -| macOS Bluetooth-HFP mic | native 8/16/24 k first, never forced to 48 k | [#2615] | +| macOS Bluetooth-HFP mic | open native 8/16/24 k first; normal voice then normalizes to 48 k, while RADE converts separately to 24 k | [#2615] | | Windows probe-at-open | skip unreliable `isFormatSupported`, try at open | [#2120] / [#2929] | ### Layer 2 — `AudioDeviceNegotiator` (live wrapper, Qt Multimedia) — next diff --git a/src/core/AudioFormatNegotiator.h b/src/core/AudioFormatNegotiator.h index 081ec5780..5dd4cd242 100644 --- a/src/core/AudioFormatNegotiator.h +++ b/src/core/AudioFormatNegotiator.h @@ -3,8 +3,8 @@ // ─── Audio format / sample-rate negotiation policy ─────────────────────────── // // One ladder, one set of per-OS rules — the single home for "what rate and -// sample format does this device want, and how do I get my 24 kHz canonical -// audio onto it" (issue #3306). +// sample format does this device want, and how do I bridge between that rate +// and the caller's canonical device-boundary rate" (issue #3306). // // Historically each audio sink/source re-implemented this with its own // divergent fallback ladder and per-OS `#ifdef` branches, which is the root of @@ -30,8 +30,11 @@ namespace AetherSDR { namespace AudioFormatNegotiator { -// Canonical internal rate: the radio VITA-49 narrowband audio rate. Everything -// resamples to/from this single value (AudioEngine::DEFAULT_SAMPLE_RATE). +// Default device-boundary rate: the radio VITA-49 narrowband audio rate and +// the canonical rate for RX and several digital/legacy routes +// (AudioEngine::DEFAULT_SAMPLE_RATE). This is not a universal DSP rate: normal +// PC-mic voice is normalized to TxVoiceProcessor's fixed 48 kHz float domain, +// then returns to 24 kHz at its current transport/backend seam. constexpr int kInternalRate = 24000; // Target OS is data, not an #ifdef, so every runner tests every ladder. @@ -87,7 +90,8 @@ struct DeviceCaps { int channels = 2; // macOS Bluetooth hands-free/SCO capture route: caps out at 8/16/24k and - // must be opened at its native low rate, NOT forced to 48k (#2615). + // must be opened at its native low rate (#2615). After capture, normal + // voice normalizes to 48k; the separate RADE route converts to 24k. bool isBluetoothHfp = false; // False => isFormatSupported() is not trustworthy for this backend, so the diff --git a/tests/audio_format_negotiation_test.cpp b/tests/audio_format_negotiation_test.cpp index 59c9cb584..59630253f 100644 --- a/tests/audio_format_negotiation_test.cpp +++ b/tests/audio_format_negotiation_test.cpp @@ -157,8 +157,9 @@ int main() Mac, Out, Pan, dev({48000}, {F}), true, 48000, F, ResamplerKind::PreservePan, 2, true, FormatPreference::Int16First}); - // ── macOS Bluetooth-HFP mic: native low rate first, NOT forced to 48k - // (#2615). preferred-first puts 16k ahead of the 48k ladder. ─────────── + // ── macOS Bluetooth-HFP mic: open the native low rate first (#2615). + // preferred-first puts 16k ahead of the 48k device ladder; downstream + // voice DSP still normalizes the captured signal to its own 48k domain. ─ { DeviceCaps c = dev({8000, 16000, 24000}, {I}); c.isBluetoothHfp = true;