#include "AudioEngine.h"
#include "AppSettings.h"
#include "AudioSummaryLogger.h"
#include "AudioDeviceNegotiator.h"
#include "ClientEq.h"
#include "ClientComp.h"
#include "ClientGate.h"
#include "ClientDeEss.h"
#include "ClientTube.h"
#include "ClientPudu.h"
#include "ClientPuduMonitor.h"
#include "ClientReverb.h"
#include "ClientFinalLimiter.h"
#include "ClientTxTestTone.h"
#include "ClientQuindarTone.h"
#include "QuindarLocalSink.h"
#include "CwSidetoneGenerator.h"
#include "CwSidetoneQAudioSink.h"
#include "CwSidetoneSinkBackend.h"
#include "DeviceDiagnostics.h"
#ifdef HAVE_PORTAUDIO
#include "CwSidetonePortAudioSink.h"
#endif
#include "LogManager.h"
#include "OpusCodec.h"
#include "SpectralNR.h"
#ifdef HAVE_SPECBLEACH
#include "SpecbleachFilter.h"
#endif
#include "RNNoiseFilter.h"
#include "NvidiaBnrFilter.h"
#ifdef HAVE_DFNR
#include "DeepFilterFilter.h"
#endif
#ifdef __APPLE__
#include "MacNRFilter.h"
#endif
#include "Resampler.h"

#ifdef Q_OS_MAC
#include <CoreAudio/CoreAudio.h>
#include <CoreFoundation/CoreFoundation.h>
#endif

#include <cmath>
#include <limits>
#include <QIODevice>
#include <QFile>
#include <QFileInfo>
#include <QMediaDevices>
#include <QAudioDevice>
#include <QDir>
#include <QDateTime>
#include <QtEndian>
#include <QThread>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStringList>
#include <algorithm>
#include <cstring>
#include <optional>

namespace AetherSDR {

static QString wisdomDir();
static void logNr2WisdomSummary(const QString& context);
static void logNr2WisdomGenerationSummary(SpectralNR::WisdomResult result);
static void applyNr2SettingsFromAppSettings(SpectralNR& nr2);

namespace {
constexpr qint64 kTxAutoRestartMinRuntimeMs = 60000;
constexpr qint64 kScopeEmitMinIntervalMs = 25;  // ~40 fps, per RX/TX source
// The strip's "Waveform CE-SSB" panel renders at higher refresh than
// the floating Waveform applet, so its dedicated post-chain emit
// path uses a shorter throttle so the widget actually has fresh data
// to draw on every frame.  ~120 Hz max — emissions over the strip
// widget's repaint rate are simply ignored by the panel.
constexpr qint64 kTxPostChainEmitMinIntervalMs = 8;
// RX strip-panel mirror — same 8 ms throttle so the strip's "Aetherial
// Waveform — RX" panel sees one emission per audio callback (no dropped
// blocks).  The shared scopeSamplesReady throttle stays at 25 ms for
// the floating WaveApplet which doesn't need this fidelity.
constexpr qint64 kRxPostChainEmitMinIntervalMs = 8;

bool devicePresent(const QList<QAudioDevice>& devices, const QAudioDevice& target)
{
    if (target.isNull()) {
        return false;
    }

    return std::any_of(devices.begin(), devices.end(), [&target](const QAudioDevice& device) {
        return device.id() == target.id();
    });
}

QString formatAudioAttempt(int sampleRate,
                           int channelCount,
                           QAudioFormat::SampleFormat sampleFormat)
{
    return QStringLiteral("%1Hz %2ch %3")
        .arg(sampleRate)
        .arg(channelCount)
        .arg(AudioSummaryLogger::sampleFormatName(sampleFormat));
}

qsizetype queuedAudioBytes(const std::deque<QByteArray>& packets)
{
    qsizetype total = 0;
    for (const QByteArray& packet : packets) {
        total += packet.size();
    }
    return total;
}

void trimAudioPacketQueue(std::deque<QByteArray>& packets, qsizetype maxBytes)
{
    qsizetype total = queuedAudioBytes(packets);
    while (total > maxBytes && !packets.empty()) {
        total -= packets.front().size();
        packets.pop_front();
    }
}
QString audioErrorName(QAudio::Error error)
{
    switch (error) {
    case QAudio::NoError: return QStringLiteral("NoError");
    case QAudio::OpenError: return QStringLiteral("OpenError");
    case QAudio::IOError: return QStringLiteral("IOError");
#if QT_VERSION < QT_VERSION_CHECK(6, 11, 0)
    case QAudio::UnderrunError: return QStringLiteral("UnderrunError");
#endif
    case QAudio::FatalError: return QStringLiteral("FatalError");
    default: return QStringLiteral("UnknownError");
    }
}

QString audioStateName(QAudio::State state)
{
    switch (state) {
    case QAudio::ActiveState: return QStringLiteral("Active");
    case QAudio::SuspendedState: return QStringLiteral("Suspended");
    case QAudio::StoppedState: return QStringLiteral("Stopped");
    case QAudio::IdleState: return QStringLiteral("Idle");
    default: return QStringLiteral("Unknown");
    }
}

void logAudioOpenFailure(const QString& path,
                         const QString& backend,
                         const QAudioDevice& device,
                         const QStringList& attemptedFormats,
                         const QString& failureReason,
                         const QStringList& fallbackReasons = {})
{
    AudioSummaryLogger::OpenFailureSummary summary;
    summary.path = path;
    summary.backend = backend;
    summary.deviceDescription = device.description();
    summary.attemptedFormats = attemptedFormats.join(QStringLiteral("; "));
    summary.failureReason = failureReason;
    summary.fallbackReason = fallbackReasons.join(QStringLiteral("; "));
    AudioSummaryLogger::logOpenFailure(summary);
}

#ifdef Q_OS_MAC
AudioObjectPropertyAddress macAudioAddress(AudioObjectPropertySelector selector,
                                           AudioObjectPropertyScope scope = kAudioObjectPropertyScopeGlobal)
{
    return AudioObjectPropertyAddress{selector, scope, kAudioObjectPropertyElementMain};
}

template <typename T>
std::optional<T> readMacAudioScalar(AudioObjectID object,
                                    AudioObjectPropertySelector selector,
                                    AudioObjectPropertyScope scope = kAudioObjectPropertyScopeGlobal)
{
    AudioObjectPropertyAddress address = macAudioAddress(selector, scope);
    if (!AudioObjectHasProperty(object, &address)) {
        return std::nullopt;
    }

    T value{};
    UInt32 size = sizeof(value);
    const OSStatus status = AudioObjectGetPropertyData(object, &address, 0, nullptr, &size, &value);
    if (status != noErr || size != sizeof(value)) {
        return std::nullopt;
    }

    return value;
}

template <typename T>
QList<T> readMacAudioArray(AudioObjectID object,
                           AudioObjectPropertySelector selector,
                           AudioObjectPropertyScope scope = kAudioObjectPropertyScopeGlobal)
{
    AudioObjectPropertyAddress address = macAudioAddress(selector, scope);
    if (!AudioObjectHasProperty(object, &address)) {
        return {};
    }

    UInt32 size = 0;
    OSStatus status = AudioObjectGetPropertyDataSize(object, &address, 0, nullptr, &size);
    if (status != noErr || size == 0 || (size % sizeof(T)) != 0) {
        return {};
    }

    QList<T> values(size / sizeof(T));
    status = AudioObjectGetPropertyData(object, &address, 0, nullptr, &size, values.data());
    if (status != noErr) {
        return {};
    }

    values.resize(size / sizeof(T));
    return values;
}

std::optional<AudioDeviceID> macAudioDeviceForUid(const QByteArray& uid)
{
    if (uid.isEmpty()) {
        return std::nullopt;
    }

    CFStringRef uidString = CFStringCreateWithBytes(kCFAllocatorDefault,
                                                    reinterpret_cast<const UInt8*>(uid.constData()),
                                                    uid.size(),
                                                    kCFStringEncodingUTF8,
                                                    false);
    if (!uidString) {
        return std::nullopt;
    }

    AudioDeviceID deviceId = kAudioObjectUnknown;
    AudioValueTranslation translation{};
    translation.mInputData = &uidString;
    translation.mInputDataSize = sizeof(uidString);
    translation.mOutputData = &deviceId;
    translation.mOutputDataSize = sizeof(deviceId);

    AudioObjectPropertyAddress address = macAudioAddress(kAudioHardwarePropertyDeviceForUID);
    UInt32 size = sizeof(translation);
    const OSStatus status = AudioObjectGetPropertyData(kAudioObjectSystemObject,
                                                       &address,
                                                       0,
                                                       nullptr,
                                                       &size,
                                                       &translation);
    CFRelease(uidString);

    if (status != noErr || deviceId == kAudioObjectUnknown) {
        return std::nullopt;
    }

    return deviceId;
}

bool isMacBluetoothLowRate(int rate)
{
    return rate == 8000 || rate == 16000 || rate == AudioEngine::DEFAULT_SAMPLE_RATE;
}

std::optional<int> macBluetoothNativeInputRate(const QAudioDevice& qtDevice)
{
    const auto deviceId = macAudioDeviceForUid(qtDevice.id());
    if (!deviceId) {
        return std::nullopt;
    }

    const auto transport = readMacAudioScalar<UInt32>(*deviceId, kAudioDevicePropertyTransportType);
    if (!transport
        || (*transport != kAudioDeviceTransportTypeBluetooth
            && *transport != kAudioDeviceTransportTypeBluetoothLE)) {
        return std::nullopt;
    }

    bool hasHighRate = false;
    int exactLowRate = 0;
    const QList<AudioValueRange> nominalRanges = readMacAudioArray<AudioValueRange>(
        *deviceId,
        kAudioDevicePropertyAvailableNominalSampleRates);
    for (const AudioValueRange& range : nominalRanges) {
        if ((range.mMinimum <= 44100.0 && range.mMaximum >= 44100.0)
            || (range.mMinimum <= 48000.0 && range.mMaximum >= 48000.0)) {
            hasHighRate = true;
        }

        const int minRate = static_cast<int>(std::lround(range.mMinimum));
        const int maxRate = static_cast<int>(std::lround(range.mMaximum));
        if (minRate == maxRate && isMacBluetoothLowRate(minRate)) {
            exactLowRate = std::max(exactLowRate, minRate);
        }
    }

    if (hasHighRate) {
        return std::nullopt;
    }

    const auto nominalRate = readMacAudioScalar<Float64>(*deviceId, kAudioDevicePropertyNominalSampleRate);
    const int roundedNominal = nominalRate ? static_cast<int>(std::lround(*nominalRate)) : 0;
    if (isMacBluetoothLowRate(roundedNominal)) {
        return roundedNominal;
    }

    if (exactLowRate > 0) {
        return exactLowRate;
    }

    return std::nullopt;
}
// (macTxInputRateCandidates removed — TX mic rate negotiation now goes through
//  the consolidated AudioFormatNegotiator ladder; #2930's preferred-rate-first
//  and #2615's Bluetooth-HFP native rate are encoded there, fed by
//  macBluetoothNativeInputRate above. #3306)
#endif
}

void AudioEngine::emitScopeFromFloat32Stereo(const QByteArray& pcm,
                                             int sampleRate,
                                             bool tx)
{
    const int floatSamples = pcm.size() / static_cast<int>(sizeof(float));
    if (floatSamples <= 0)
        return;

    QElapsedTimer& throttle = tx ? m_lastTxScopeEmit : m_lastRxScopeEmit;
    if (throttle.isValid() && throttle.elapsed() < kScopeEmitMinIntervalMs)
        return;

    const bool stereo = (floatSamples % 2) == 0;
    const int monoSamples = stereo ? floatSamples / 2 : floatSamples;
    QByteArray& mono = tx ? m_scopeTxScratch : m_scopeRxScratch;
    mono.resize(monoSamples * static_cast<int>(sizeof(float)));

    const auto* src = reinterpret_cast<const float*>(pcm.constData());
    auto* dst = reinterpret_cast<float*>(mono.data());
    if (stereo) {
        for (int i = 0; i < monoSamples; ++i) {
            const float avg = (src[i * 2] + src[i * 2 + 1]) * 0.5f;
            dst[i] = std::clamp(std::isfinite(avg) ? avg : 0.0f, -1.0f, 1.0f);
        }
    } else {
        for (int i = 0; i < monoSamples; ++i) {
            const float s = src[i];
            dst[i] = std::clamp(std::isfinite(s) ? s : 0.0f, -1.0f, 1.0f);
        }
    }

    if (throttle.isValid())
        throttle.restart();
    else
        throttle.start();
    emit scopeSamplesReady(mono, sampleRate > 0 ? sampleRate : DEFAULT_SAMPLE_RATE, tx);
}

void AudioEngine::emitScopeFromInt16Stereo(const QByteArray& pcm,
                                           int sampleRate,
                                           bool tx)
{
    const int intSamples = pcm.size() / static_cast<int>(sizeof(int16_t));
    if (intSamples <= 0)
        return;

    QElapsedTimer& throttle = tx ? m_lastTxScopeEmit : m_lastRxScopeEmit;
    if (throttle.isValid() && throttle.elapsed() < kScopeEmitMinIntervalMs)
        return;

    const bool stereo = (intSamples % 2) == 0;
    const int monoSamples = stereo ? intSamples / 2 : intSamples;
    QByteArray& mono = tx ? m_scopeTxScratch : m_scopeRxScratch;
    mono.resize(monoSamples * static_cast<int>(sizeof(float)));

    const auto* src = reinterpret_cast<const int16_t*>(pcm.constData());
    auto* dst = reinterpret_cast<float*>(mono.data());
    if (stereo) {
        for (int i = 0; i < monoSamples; ++i) {
            const float l = src[i * 2] / 32768.0f;
            const float r = src[i * 2 + 1] / 32768.0f;
            dst[i] = std::clamp((l + r) * 0.5f, -1.0f, 1.0f);
        }
    } else {
        for (int i = 0; i < monoSamples; ++i)
            dst[i] = std::clamp(src[i] / 32768.0f, -1.0f, 1.0f);
    }

    if (throttle.isValid())
        throttle.restart();
    else
        throttle.start();
    emit scopeSamplesReady(mono, sampleRate > 0 ? sampleRate : DEFAULT_SAMPLE_RATE, tx);
}

void AudioEngine::emitTxPostChainScopeFromInt16Stereo(const QByteArray& pcm,
                                                      int sampleRate)
{
    // Same int16-stereo -> mono-float collapse as emitScopeFromInt16Stereo,
    // but emits on the dedicated high-rate TX scope used by the waveform
    // displays. PC mic voice reaches this point after the user DSP chain,
    // PC mic gain, and final limiter.
    const int intSamples = pcm.size() / static_cast<int>(sizeof(int16_t));
    if (intSamples <= 0)
        return;

    if (m_lastTxPostChainScopeEmit.isValid()
        && m_lastTxPostChainScopeEmit.elapsed() < kTxPostChainEmitMinIntervalMs)
        return;

    const bool stereo = (intSamples % 2) == 0;
    const int monoSamples = stereo ? intSamples / 2 : intSamples;
    m_scopeTxPostChainScratch.resize(monoSamples * static_cast<int>(sizeof(float)));

    const auto* src = reinterpret_cast<const int16_t*>(pcm.constData());
    auto* dst = reinterpret_cast<float*>(m_scopeTxPostChainScratch.data());
    if (stereo) {
        for (int i = 0; i < monoSamples; ++i) {
            const float l = src[i * 2] / 32768.0f;
            const float r = src[i * 2 + 1] / 32768.0f;
            dst[i] = std::clamp((l + r) * 0.5f, -1.0f, 1.0f);
        }
    } else {
        for (int i = 0; i < monoSamples; ++i)
            dst[i] = std::clamp(src[i] / 32768.0f, -1.0f, 1.0f);
    }

    if (m_lastTxPostChainScopeEmit.isValid())
        m_lastTxPostChainScopeEmit.restart();
    else
        m_lastTxPostChainScopeEmit.start();
    emit txPostChainScopeReady(m_scopeTxPostChainScratch,
                               sampleRate > 0 ? sampleRate : DEFAULT_SAMPLE_RATE);
}

void AudioEngine::emitTxPostChainScopeFromFloat32Stereo(const QByteArray& pcm,
                                                        int sampleRate)
{
    const int floatSamples = pcm.size() / static_cast<int>(sizeof(float));
    if (floatSamples <= 0)
        return;

    if (m_lastTxPostChainScopeEmit.isValid()
        && m_lastTxPostChainScopeEmit.elapsed() < kTxPostChainEmitMinIntervalMs)
        return;

    const bool stereo = (floatSamples % 2) == 0;
    const int monoSamples = stereo ? floatSamples / 2 : floatSamples;
    m_scopeTxPostChainScratch.resize(monoSamples * static_cast<int>(sizeof(float)));

    const auto* src = reinterpret_cast<const float*>(pcm.constData());
    auto* dst = reinterpret_cast<float*>(m_scopeTxPostChainScratch.data());
    if (stereo) {
        for (int i = 0; i < monoSamples; ++i) {
            const float avg = (src[i * 2] + src[i * 2 + 1]) * 0.5f;
            dst[i] = std::clamp(std::isfinite(avg) ? avg : 0.0f, -1.0f, 1.0f);
        }
    } else {
        for (int i = 0; i < monoSamples; ++i) {
            const float s = src[i];
            dst[i] = std::clamp(std::isfinite(s) ? s : 0.0f, -1.0f, 1.0f);
        }
    }

    if (m_lastTxPostChainScopeEmit.isValid())
        m_lastTxPostChainScopeEmit.restart();
    else
        m_lastTxPostChainScopeEmit.start();
    emit txPostChainScopeReady(m_scopeTxPostChainScratch,
                               sampleRate > 0 ? sampleRate : DEFAULT_SAMPLE_RATE);
}

void AudioEngine::emitRxPostChainScopeFromFloat32Stereo(const QByteArray& pcm,
                                                         int sampleRate)
{
    // RX-side mirror of emitTxPostChainScopeFromInt16Stereo.  Same
    // float32-stereo → mono-float collapse as emitScopeFromFloat32Stereo,
    // but uses a dedicated 8 ms throttle and emits on rxPostChainScopeReady
    // so the channel strip's RX scope tracks wall clock at short windows.
    const int floatSamples = pcm.size() / static_cast<int>(sizeof(float));
    if (floatSamples <= 0)
        return;

    if (m_lastRxPostChainScopeEmit.isValid()
        && m_lastRxPostChainScopeEmit.elapsed() < kRxPostChainEmitMinIntervalMs)
        return;

    const bool stereo = (floatSamples % 2) == 0;
    const int monoSamples = stereo ? floatSamples / 2 : floatSamples;
    m_scopeRxPostChainScratch.resize(monoSamples * static_cast<int>(sizeof(float)));

    const auto* src = reinterpret_cast<const float*>(pcm.constData());
    auto* dst = reinterpret_cast<float*>(m_scopeRxPostChainScratch.data());
    if (stereo) {
        for (int i = 0; i < monoSamples; ++i) {
            const float avg = (src[i * 2] + src[i * 2 + 1]) * 0.5f;
            dst[i] = std::clamp(std::isfinite(avg) ? avg : 0.0f, -1.0f, 1.0f);
        }
    } else {
        for (int i = 0; i < monoSamples; ++i) {
            const float s = src[i];
            dst[i] = std::clamp(std::isfinite(s) ? s : 0.0f, -1.0f, 1.0f);
        }
    }

    if (m_lastRxPostChainScopeEmit.isValid())
        m_lastRxPostChainScopeEmit.restart();
    else
        m_lastRxPostChainScopeEmit.start();
    emit rxPostChainScopeReady(m_scopeRxPostChainScratch,
                               sampleRate > 0 ? sampleRate : DEFAULT_SAMPLE_RATE);
}

void AudioEngine::emitTncRxTapFromFloat32Stereo(const QByteArray& pcm, int sampleRate)
{
    const int floatSamples = pcm.size() / static_cast<int>(sizeof(float));
    if (floatSamples <= 0)
        return;

    const bool stereo = (floatSamples % 2) == 0;
    const int monoSamples = stereo ? floatSamples / 2 : floatSamples;
    m_tncRxTapScratch.resize(monoSamples * static_cast<int>(sizeof(float)));

    const auto* src = reinterpret_cast<const float*>(pcm.constData());
    auto* dst = reinterpret_cast<float*>(m_tncRxTapScratch.data());
    if (stereo) {
        for (int i = 0; i < monoSamples; ++i) {
            const float avg = (src[i * 2] + src[i * 2 + 1]) * 0.5f;
            dst[i] = std::clamp(std::isfinite(avg) ? avg : 0.0f, -1.0f, 1.0f);
        }
    } else {
        for (int i = 0; i < monoSamples; ++i) {
            const float s = src[i];
            dst[i] = std::clamp(std::isfinite(s) ? s : 0.0f, -1.0f, 1.0f);
        }
    }

    emit tncRxAudioReady(m_tncRxTapScratch,
                         sampleRate > 0 ? sampleRate : DEFAULT_SAMPLE_RATE);
}

void AudioEngine::updateRxBufferStats()
{
    qsizetype externalTotal = 0;
    for (const auto& source : m_externalKiwiSources) {
        if (!source) {
            continue;
        }
        externalTotal += source->rxBuffer.size()
                       + queuedAudioBytes(source->rxPackets)
                       + source->outputBuffer.size();
    }

    const qsizetype total =
        m_rxBuffer.size() + queuedAudioBytes(m_rxPackets)
        + m_kiwiSdrRxBuffer.size() + queuedAudioBytes(m_kiwiSdrRxPackets)
        + m_rxOutputBuffer.size() + m_kiwiSdrOutputBuffer.size()
        + m_radeRxBuffer.size() + externalTotal;
    m_rxBufferBytes.store(total);
    m_rxBufferPeakBytes.store(std::max(m_rxBufferPeakBytes.load(), total));
}

AudioEngine::ExternalRxAudioSourceState*
AudioEngine::externalKiwiSource(const QString& sourceId, bool create)
{
    const QString id = sourceId.trimmed();
    if (id.isEmpty()) {
        return nullptr;
    }

    std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->id == id) {
            return source.get();
        }
    }

    if (!create) {
        return nullptr;
    }

    auto source = std::make_unique<ExternalRxAudioSourceState>();
    source->id = id;
    source->prebuffering = true;
    if (m_nr2Enabled.load(std::memory_order_relaxed) && m_kiwiSdrNr2) {
        source->nr2 = std::make_unique<SpectralNR>(256, DEFAULT_SAMPLE_RATE);
        if (source->nr2->hasPlanFailed()) {
            qCWarning(lcAudio) << "AudioEngine: external Kiwi NR2 plan failed for"
                               << id;
            source->nr2.reset();
        } else {
            applyNr2SettingsFromAppSettings(*source->nr2);
        }
    }
    m_externalKiwiSources.push_back(std::move(source));
    return m_externalKiwiSources.back().get();
}

bool AudioEngine::anyExternalKiwiAudioEnabled() const
{
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->enabled && !source->muted) {
            return true;
        }
    }
    return false;
}

bool AudioEngine::anyExternalKiwiBufferQueued() const
{
    for (const auto& source : m_externalKiwiSources) {
        if (source && !source->muted
            && (!source->rxBuffer.isEmpty() || !source->rxPackets.empty()
                || !source->outputBuffer.isEmpty())) {
            return true;
        }
    }
    return false;
}

qsizetype AudioEngine::externalKiwiOutputBufferBytes() const
{
    qsizetype maxBytes = 0;
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->enabled && !source->muted
            && !source->prebuffering) {
            maxBytes = std::max(maxBytes, source->outputBuffer.size());
        }
    }
    return maxBytes;
}

AudioEngine::AudioEngine(QObject* parent)
    : QObject(parent)
    , m_clientEqRx(std::make_unique<ClientEq>())
    , m_clientEqTx(std::make_unique<ClientEq>())
    , m_clientCompTx(std::make_unique<ClientComp>())
    , m_clientCompRx(std::make_unique<ClientComp>())
    , m_clientGateTx(std::make_unique<ClientGate>())
    , m_clientGateRx(std::make_unique<ClientGate>())
    , m_clientDeEssTx(std::make_unique<ClientDeEss>())
    , m_clientDeEssRx(std::make_unique<ClientDeEss>())
    , m_clientTubeTx(std::make_unique<ClientTube>())
    , m_clientTubeRx(std::make_unique<ClientTube>())
    , m_clientPuduTx(std::make_unique<ClientPudu>())
    , m_clientPuduRx(std::make_unique<ClientPudu>())
    , m_clientReverbTx(std::make_unique<ClientReverb>())
    , m_clientFinalLimiterTx(std::make_unique<ClientFinalLimiter>())
    , m_clientTxTestTone(std::make_unique<ClientTxTestTone>())
    , m_cwSidetone(std::make_unique<CwSidetoneGenerator>(48000))
    , m_clientQuindarTone(std::make_unique<ClientQuindarTone>())
{
    // TX-side CW decode mirror (#2417).  Plug the sidetone generator's
    // per-block tap into a downsampler + signal emitter; gated on the
    // m_cwDecodeTxTapEnabled atomic so MainWindow can flip TX-decode on
    // and off without rebuilding any audio plumbing.  Runs on the
    // sidetone audio thread.
    m_cwSidetone->setSampleTap(
        [this](const float* mono, int frames, int sampleRateHz) {
            if (!m_cwDecodeTxTapEnabled.load(std::memory_order_relaxed))
                return;
            if (frames <= 0 || sampleRateHz <= 0) return;
            // CwDecoder::feedAudio expects 24 kHz stereo float32 — the
            // same shape PanadapterStream::audioDataReady() emits on
            // the RX side.  Decimate 48→24 by averaging consecutive
            // pairs; the sidetone is a single sine well below 12 kHz
            // so the cheap two-tap LPF is sufficient for ggmorse.  For
            // sample rates that are not an integer multiple of 24 kHz
            // (rare — only when the device forced a 44.1 kHz negotiation),
            // fall back to nearest-neighbour stepping.
            constexpr int kTargetHz = 24000;
            QByteArray buf;
            if (sampleRateHz == 48000) {
                const int outFrames = frames / 2;
                if (outFrames <= 0) return;
                buf.resize(outFrames * 2 * static_cast<int>(sizeof(float)));
                auto* out = reinterpret_cast<float*>(buf.data());
                for (int i = 0; i < outFrames; ++i) {
                    const float s = 0.5f * (mono[2 * i] + mono[2 * i + 1]);
                    out[2 * i]     = s;  // L
                    out[2 * i + 1] = s;  // R
                }
            } else {
                const double step =
                    static_cast<double>(sampleRateHz) / kTargetHz;
                const int outFrames =
                    static_cast<int>(static_cast<double>(frames) / step);
                if (outFrames <= 0) return;
                buf.resize(outFrames * 2 * static_cast<int>(sizeof(float)));
                auto* out = reinterpret_cast<float*>(buf.data());
                for (int i = 0; i < outFrames; ++i) {
                    const int srcIdx = static_cast<int>(i * step);
                    const float s = mono[std::min(srcIdx, frames - 1)];
                    out[2 * i]     = s;
                    out[2 * i + 1] = s;
                }
            }
            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.
    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_clientQuindarTone->prepare(DEFAULT_SAMPLE_RATE);
    loadClientEqSettings();      // restore persisted bands before first audio
    loadClientCompSettings();    // restore persisted comp params + chain order
    loadClientGateSettings();    // restore persisted gate params
    loadClientGateRxSettings();  // restore persisted RX gate params
    loadClientCompRxSettings();  // restore persisted RX comp params
    loadClientTubeRxSettings();  // restore persisted RX tube params
    loadClientPuduRxSettings();  // restore persisted RX PUDU params
    loadClientDeEssSettings();   // restore persisted de-esser params
    loadClientDeEssRxSettings(); // restore persisted RX de-esser params
    loadClientTubeSettings();    // restore persisted tube params
    loadClientPuduSettings();    // restore persisted PUDU params
    loadClientReverbSettings();  // restore persisted reverb params
    loadClientFinalLimiterSettings();  // restore persisted final-limiter params
    loadClientQuindarSettings();       // restore persisted Quindar tone params
    loadClientRxChainOrder();    // restore persisted RX chain order (Phase 0+)
    loadAetherialTubePreampTxSettings(); // restore TX mic pre-amp toggles (#2813)

    // Restore saved audio device selections
    auto& s = AppSettings::instance();
    QByteArray savedOutId = s.value("AudioOutputDeviceId", "").toByteArray();
    QByteArray savedInId  = s.value("AudioInputDeviceId",  "").toByteArray();

    if (!savedOutId.isEmpty()) {
        for (const auto& dev : QMediaDevices::audioOutputs()) {
            if (dev.id() == savedOutId) { m_outputDevice = dev; break; }
        }
    }
    if (!savedInId.isEmpty()) {
        for (const auto& dev : QMediaDevices::audioInputs()) {
            if (dev.id() == savedInId) { m_inputDevice = dev; break; }
        }
    }

    AudioSummaryLogger::logStartupEnvironment(
        DeviceDiagnostics::buildAudioStartupSnapshot(this, QJsonObject{}));
    logNr2WisdomSummary(QStringLiteral("startup"));

    // Opus TX pacing timer — sends one queued packet every 10ms for even
    // delivery timing. Without this, QAudioSource delivers bursts of samples
    // that get Opus-encoded and sent back-to-back, causing jitter-induced
    // crackling on SmartLink/WAN connections.
    m_opusTxPaceTimer = new QTimer(this);
    m_opusTxPaceTimer->setTimerType(Qt::PreciseTimer);
    m_opusTxPaceTimer->setInterval(10);
    connect(m_opusTxPaceTimer, &QTimer::timeout, this, [this]() {
        if (m_opusTxQueue.isEmpty()) return;
        emit txPacketReady(m_opusTxQueue.takeFirst());
    });
    m_opusTxPaceTimer->start();

    // RX pacing timer -- processes source queues through their RX DSP paths
    // and drains speaker-ready output into QAudioSink at regular intervals.
    // Includes latency management: caps buffer at ~200ms to prevent unbounded
    // growth when network packets arrive in bursts (common on Windows WASAPI
    // with virtual audio routers like Voicemeeter).
    m_rxTimer = new QTimer(this);
    m_rxTimer->setTimerType(Qt::PreciseTimer);
    m_rxTimer->setInterval(10);
    connect(m_rxTimer, &QTimer::timeout, this, [this]() {
        if (!m_audioSink || !m_audioDevice || !m_audioDevice->isOpen() || m_audioSink->state() == QAudio::StoppedState) return;

        // Cap buffer to bound latency. Default 200ms, user-adjustable for
        // high-jitter connections (VPN, SmartLink) where drops cause choppy audio.
        const int sampleRate = m_rxOutputRate.load();
        const bool kiwiAudio =
            m_kiwiSdrAudioEnabled.load(std::memory_order_relaxed);
        const bool externalKiwiAudio = anyExternalKiwiAudioEnabled();
        const bool anyKiwiAudio = kiwiAudio || externalKiwiAudio;
        const int configuredBufMs = m_rxBufferCapMs.load();
        const int effectiveBufMs = anyKiwiAudio
            ? std::max(configuredBufMs, kKiwiSdrBufferCapMs)
            : configuredBufMs;
        const qsizetype sourceMaxBufBytes =
            DEFAULT_SAMPLE_RATE * 2 * static_cast<qsizetype>(sizeof(float))
            * effectiveBufMs / 1000;
        const qsizetype outputMaxBufBytes =
            sampleRate * 2 * static_cast<qsizetype>(sizeof(float))
            * effectiveBufMs / 1000;
        trimAudioPacketQueue(m_rxPackets, sourceMaxBufBytes);
        if (m_rxBuffer.size() > sourceMaxBufBytes) {
            // Drop oldest samples to keep latency bounded
            m_rxBuffer.remove(0, m_rxBuffer.size() - sourceMaxBufBytes);
        }
        if (m_kiwiSdrRxBuffer.size() > sourceMaxBufBytes) {
            m_kiwiSdrRxBuffer.remove(0, m_kiwiSdrRxBuffer.size() - sourceMaxBufBytes);
        }
        if (m_rxOutputBuffer.size() > outputMaxBufBytes) {
            m_rxOutputBuffer.remove(0, m_rxOutputBuffer.size() - outputMaxBufBytes);
        }
        if (m_kiwiSdrOutputBuffer.size() > outputMaxBufBytes) {
            m_kiwiSdrOutputBuffer.remove(
                0, m_kiwiSdrOutputBuffer.size() - outputMaxBufBytes);
        }
        for (const auto& source : m_externalKiwiSources) {
            if (!source) {
                continue;
            }
            if (source->rxBuffer.size() > sourceMaxBufBytes) {
                source->rxBuffer.remove(0, source->rxBuffer.size() - sourceMaxBufBytes);
            }
            if (source->outputBuffer.size() > outputMaxBufBytes) {
                source->outputBuffer.remove(
                    0, source->outputBuffer.size() - outputMaxBufBytes);
            }
        }
        if (m_radeRxBuffer.size() > outputMaxBufBytes) {
            m_radeRxBuffer.remove(0, m_radeRxBuffer.size() - outputMaxBufBytes);
        }

        const qsizetype freeBytes = m_audioSink->bytesFree();
        if (freeBytes > 0 && m_rxBuffer.isEmpty()
            && m_rxPackets.empty()
            && m_kiwiSdrRxBuffer.isEmpty() && m_kiwiSdrRxPackets.empty()
            && m_rxOutputBuffer.isEmpty()
            && m_kiwiSdrOutputBuffer.isEmpty()
            && m_radeRxBuffer.isEmpty()
            && !anyExternalKiwiBufferQueued()) {
            if (anyKiwiAudio) {
                m_kiwiSdrPrebuffering = true;
                for (const auto& source : m_externalKiwiSources) {
                    if (source && source->enabled) {
                        source->prebuffering = true;
                    }
                }
            } else {
                m_rxBufferUnderrunCount.fetch_add(1);
            }
        }

        // Zombie sink watchdog: if we have data waiting but the sink reports
        // zero bytes free for ~2 seconds, the WASAPI handle is likely stale
        // (e.g. after screensaver/idle on Windows with USB audio). (#1361)
        if (freeBytes == 0 && (!m_rxBuffer.isEmpty()
                               || !m_rxPackets.empty()
                               || !m_radeRxBuffer.isEmpty()
                               || !m_kiwiSdrRxBuffer.isEmpty()
                               || !m_kiwiSdrRxPackets.empty()
                               || !m_rxOutputBuffer.isEmpty()
                               || !m_kiwiSdrOutputBuffer.isEmpty()
                               || anyExternalKiwiBufferQueued())) {
            if (++m_rxZombieTickCount >= kZombieTickThreshold) {
                m_rxZombieTickCount = 0;
                qCWarning(lcAudio) << "AudioEngine: sink appears zombie (bytesFree stuck at 0 for"
                                   << kZombieTickThreshold * 10 << "ms), restarting RX (#1361)";
                QMetaObject::invokeMethod(this, [this]() {
                    if (!m_audioSink) return;
                    stopRxStream();
                    startRxStream();
                }, Qt::QueuedConnection);
                return;
            }
        } else {
            m_rxZombieTickCount = 0;
        }

        // Audio liveness watchdog: if no audio data has arrived via
        // feedAudioData() for ~15 seconds while the sink is still running,
        // the audio backend may have silently stopped (CoreAudio after
        // extended idle, or the radio stopped sending VITA-49 packets).
        // Restart the sink to re-acquire a fresh handle. (#1411)
        if (m_lastAudioFeedTime.isValid()
            && m_lastAudioFeedTime.elapsed() > kAudioLivenessTimeoutMs
            && m_rxBuffer.isEmpty()
            && m_rxPackets.empty()
            && m_rxOutputBuffer.isEmpty()
            && m_radeRxBuffer.isEmpty()
            && m_kiwiSdrOutputBuffer.isEmpty()
            && m_kiwiSdrRxBuffer.isEmpty()
            && m_kiwiSdrRxPackets.empty()
            && !anyExternalKiwiBufferQueued()) {
            qCWarning(lcAudio) << "AudioEngine: no audio data received for"
                               << m_lastAudioFeedTime.elapsed() << "ms, restarting RX (#1411)";
            m_lastAudioFeedTime.start();  // prevent repeated rapid restarts
            QMetaObject::invokeMethod(this, [this]() {
                if (!m_audioSink) return;
                stopRxStream();
                startRxStream();
            }, Qt::QueuedConnection);
            return;
        }

        if (m_nr2Enabled.load(std::memory_order_relaxed)) {
            std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
            while (!m_rxPackets.empty()) {
                QByteArray packet = std::move(m_rxPackets.front());
                m_rxPackets.pop_front();
                processMixedRxAudioData(packet, RxDspSource::Main);
            }
            while (kiwiAudio && !m_kiwiSdrRxPackets.empty()) {
                QByteArray packet = std::move(m_kiwiSdrRxPackets.front());
                m_kiwiSdrRxPackets.pop_front();
                processMixedRxAudioData(packet, RxDspSource::KiwiSdr);
            }
            for (const auto& source : m_externalKiwiSources) {
                if (!source || !source->enabled || source->muted) {
                    continue;
                }
                while (!source->rxPackets.empty()) {
                    QByteArray packet = std::move(source->rxPackets.front());
                    source->rxPackets.pop_front();
                    processMixedRxAudioData(packet, RxDspSource::KiwiSdr, source.get());
                }
            }
        }

        if (kiwiAudio && m_kiwiSdrPrebuffering) {
            // KiwiSDR uncompressed audio is observed as 512-sample 12 kHz
            // blocks (~43 ms), but WebSocket delivery bunches frames with
            // >100 ms gaps. Hold only the Kiwi jitter buffer before mixing;
            // the normal Flex RX buffer must keep draining while Kiwi fills.
            const int prebufferMs = std::min(
                kKiwiSdrJitterTargetMs,
                std::max(50, effectiveBufMs / 2));
            const qsizetype prebufferBytes =
                (m_nr2Enabled.load(std::memory_order_relaxed)
                     ? sampleRate
                     : DEFAULT_SAMPLE_RATE)
                * 2 * static_cast<qsizetype>(sizeof(float)) * prebufferMs / 1000;
            const qsizetype bufferedBytes =
                m_nr2Enabled.load(std::memory_order_relaxed)
                    ? m_kiwiSdrOutputBuffer.size()
                    : m_kiwiSdrRxBuffer.size();
            if (bufferedBytes >= prebufferBytes) {
                m_kiwiSdrPrebuffering = false;
            }
        }
        for (const auto& source : m_externalKiwiSources) {
            if (!source || !source->enabled || !source->prebuffering) {
                continue;
            }
            const int prebufferMs = std::min(
                kKiwiSdrJitterTargetMs,
                std::max(50, effectiveBufMs / 2));
            const qsizetype prebufferBytes =
                (m_nr2Enabled.load(std::memory_order_relaxed)
                     ? sampleRate
                     : DEFAULT_SAMPLE_RATE)
                * 2 * static_cast<qsizetype>(sizeof(float)) * prebufferMs / 1000;
            const qsizetype bufferedBytes =
                m_nr2Enabled.load(std::memory_order_relaxed)
                    ? source->outputBuffer.size()
                    : source->rxBuffer.size();
            if (bufferedBytes >= prebufferBytes) {
                source->prebuffering = false;
            }
        }

        // Align to stereo float32 frame boundaries before any arithmetic.
        const qsizetype floatBytes = static_cast<qsizetype>(sizeof(float));
        const qsizetype frameBytes = 2 * floatBytes;
        const bool nr2PacketMode = m_nr2Enabled.load(std::memory_order_relaxed);
        const bool kiwiNr2PacketMode = kiwiAudio && nr2PacketMode;
        if (kiwiNr2PacketMode && !m_kiwiSdrPrebuffering
            && m_kiwiSdrOutputBuffer.isEmpty()) {
            m_kiwiSdrPrebuffering = true;
        }
        const bool kiwiMixActive =
            kiwiAudio && !kiwiNr2PacketMode && !m_kiwiSdrPrebuffering;
        for (const auto& source : m_externalKiwiSources) {
            if (!source || !source->enabled || source->muted || source->prebuffering) {
                continue;
            }
            const bool sourceEmpty =
                nr2PacketMode ? source->outputBuffer.isEmpty()
                              : source->rxBuffer.isEmpty();
            if (sourceEmpty) {
                source->prebuffering = true;
            }
        }
        const qsizetype kiwiMixBytes =
            kiwiMixActive ? m_kiwiSdrRxBuffer.size() : 0;
        const qsizetype freeFrames = freeBytes / frameBytes;
        // Fill each post-DSP FIFO independently. A prebuffered Kiwi FIFO must
        // not make the timer skip Flex processing, otherwise Flex only leaks
        // into the final mix when the Kiwi FIFO briefly drains.
        const qsizetype queuedMainFrames = m_rxOutputBuffer.size() / frameBytes;
        const qsizetype wantedMainOutputFrames =
            freeFrames > queuedMainFrames ? freeFrames - queuedMainFrames : 0;
        const qsizetype wantedMainNativeFrames =
            sampleRate > 0
                ? (wantedMainOutputFrames * DEFAULT_SAMPLE_RATE) / sampleRate
                : wantedMainOutputFrames;
        const qsizetype wantedMainNativeBytes = wantedMainNativeFrames * frameBytes;
        const qsizetype mainBytes =
            nr2PacketMode
                ? 0
                : (std::min(wantedMainNativeBytes, m_rxBuffer.size()) / frameBytes)
                    * frameBytes;
        if (mainBytes > 0) {
            const QByteArray mainPcm = m_rxBuffer.left(mainBytes);
            m_rxBuffer.remove(0, mainBytes);
            processMixedRxAudioData(mainPcm, RxDspSource::Main);
        }

        // NR2 regression guard:
        // With NR2 enabled, Kiwi packets stay whole until this timer processes
        // them through their Kiwi-only NR2 state into post-DSP Kiwi FIFOs.
        // Do not chop raw Kiwi into timer-sized pieces and feed NR2 here; that
        // reintroduced speech-correlated static. Raw Kiwi draining below is
        // only used while NR2 is off.
        const qsizetype queuedKiwiFrames = m_kiwiSdrOutputBuffer.size() / frameBytes;
        const qsizetype wantedKiwiOutputFrames =
            freeFrames > queuedKiwiFrames ? freeFrames - queuedKiwiFrames : 0;
        const qsizetype wantedKiwiNativeFrames =
            sampleRate > 0
                ? (wantedKiwiOutputFrames * DEFAULT_SAMPLE_RATE) / sampleRate
                : wantedKiwiOutputFrames;
        const qsizetype wantedKiwiNativeBytes = wantedKiwiNativeFrames * frameBytes;
        const qsizetype kiwiBytes =
            (std::min(wantedKiwiNativeBytes, kiwiMixBytes) / frameBytes)
            * frameBytes;
        if (kiwiBytes > 0) {
            const QByteArray kiwiPcm = m_kiwiSdrRxBuffer.left(kiwiBytes);
            m_kiwiSdrRxBuffer.remove(0, kiwiBytes);
            processMixedRxAudioData(kiwiPcm, RxDspSource::KiwiSdr);
        }

        // Managed Kiwi RX antennas must keep the same per-source output FIFO
        // boundary with NR2 off as they do with NR2 on. If they are collapsed
        // into the legacy applet Kiwi buffer here, the final mixer ignores
        // them unless the applet-level Kiwi Audio toggle is also enabled.
        if (!nr2PacketMode) {
            for (const auto& source : m_externalKiwiSources) {
                if (!source || !source->enabled || source->muted
                    || source->prebuffering) {
                    continue;
                }

                const qsizetype queuedSourceFrames =
                    source->outputBuffer.size() / frameBytes;
                const qsizetype wantedSourceOutputFrames =
                    freeFrames > queuedSourceFrames
                        ? freeFrames - queuedSourceFrames
                        : 0;
                const qsizetype wantedSourceNativeFrames =
                    sampleRate > 0
                        ? (wantedSourceOutputFrames * DEFAULT_SAMPLE_RATE) / sampleRate
                        : wantedSourceOutputFrames;
                const qsizetype wantedSourceNativeBytes =
                    wantedSourceNativeFrames * frameBytes;
                const qsizetype sourceBytes =
                    (std::min(wantedSourceNativeBytes, source->rxBuffer.size())
                     / frameBytes) * frameBytes;
                if (sourceBytes <= 0) {
                    continue;
                }

                const QByteArray sourcePcm = source->rxBuffer.left(sourceBytes);
                source->rxBuffer.remove(0, sourceBytes);
                processMixedRxAudioData(
                    sourcePcm, RxDspSource::KiwiSdr, source.get());
            }
        }

        const qsizetype kiwiOutputBytes =
            (kiwiAudio && !m_kiwiSdrPrebuffering)
                ? m_kiwiSdrOutputBuffer.size()
                : 0;
        const qsizetype externalKiwiOutputBytes = externalKiwiOutputBufferBytes();
        const qsizetype aggregateKiwiOutputBytes =
            std::max(kiwiOutputBytes, externalKiwiOutputBytes);
        qsizetype len = (freeBytes / frameBytes) * frameBytes;
        len = std::min(len, std::max({m_rxOutputBuffer.size(),
                                      aggregateKiwiOutputBytes,
                                      m_radeRxBuffer.size()}));
        len = (len / frameBytes) * frameBytes;
        if (len > 0)
        {
            QByteArray chunk;
            if (m_radeRxBuffer.isEmpty() && aggregateKiwiOutputBytes <= 0) {
                // Fast path: no decoded overlay active -- write the
                // already-processed RX output directly.
                chunk = m_rxOutputBuffer.left(len);
                m_rxOutputBuffer.remove(0, chunk.size());
            } else if (m_rxOutputBuffer.isEmpty() && m_radeRxBuffer.isEmpty()
                       && kiwiOutputBytes > 0 && externalKiwiOutputBytes <= 0) {
                // Fast path: only Kiwi decoded audio is active.
                chunk = m_kiwiSdrOutputBuffer.left(len);
                m_kiwiSdrOutputBuffer.remove(0, chunk.size());
            } else {
                // Mix path: add post-DSP Flex, every post-DSP Kiwi stream,
                // and decoded RADE sample-wise at the output device rate.
                chunk = QByteArray(len, '\0');
                auto* out = reinterpret_cast<float*>(chunk.data());
                int activeOutputSources = 0;
                constexpr float kOutputSilenceThreshold = 1.0e-6f;

                const qsizetype rxTake =
                    (std::min(len, m_rxOutputBuffer.size()) / floatBytes)
                    * floatBytes;
                if (rxTake > 0) {
                    const auto* rx =
                        reinterpret_cast<const float*>(m_rxOutputBuffer.constData());
                    const qsizetype rxSamples = rxTake / floatBytes;
                    bool sourceActive = false;
                    for (qsizetype i = 0; i < rxSamples; ++i) {
                        sourceActive = sourceActive
                            || std::fabs(rx[i]) > kOutputSilenceThreshold;
                        out[i] += rx[i];
                    }
                    if (sourceActive) {
                        ++activeOutputSources;
                    }
                    m_rxOutputBuffer.remove(0, rxTake);
                }

                const qsizetype kiwiTake =
                    (std::min(len, kiwiOutputBytes) / floatBytes)
                    * floatBytes;
                if (kiwiTake > 0) {
                    const auto* kiwi =
                        reinterpret_cast<const float*>(m_kiwiSdrOutputBuffer.constData());
                    const qsizetype kiwiSamples = kiwiTake / floatBytes;
                    bool sourceActive = false;
                    for (qsizetype i = 0; i < kiwiSamples; ++i) {
                        sourceActive = sourceActive
                            || std::fabs(kiwi[i]) > kOutputSilenceThreshold;
                        out[i] += kiwi[i];
                    }
                    if (sourceActive) {
                        ++activeOutputSources;
                    }
                    m_kiwiSdrOutputBuffer.remove(0, kiwiTake);
                }

                for (const auto& source : m_externalKiwiSources) {
                    if (!source || !source->enabled || source->muted
                        || source->prebuffering) {
                        continue;
                    }
                    const qsizetype sourceTake =
                        (std::min(len, source->outputBuffer.size()) / floatBytes)
                        * floatBytes;
                    if (sourceTake <= 0) {
                        continue;
                    }
                    const auto* kiwi =
                        reinterpret_cast<const float*>(source->outputBuffer.constData());
                    const qsizetype kiwiSamples = sourceTake / floatBytes;
                    bool sourceActive = false;
                    for (qsizetype i = 0; i < kiwiSamples; ++i) {
                        const float sample = kiwi[i] * source->gain;
                        sourceActive = sourceActive
                            || std::fabs(sample) > kOutputSilenceThreshold;
                        out[i] += sample;
                    }
                    if (sourceActive) {
                        ++activeOutputSources;
                    }
                    source->outputBuffer.remove(0, sourceTake);
                }

                const qsizetype radeTake = (std::min(len, m_radeRxBuffer.size()) / floatBytes) * floatBytes;
                if (radeTake > 0) {
                    const auto* rade = reinterpret_cast<const float*>(m_radeRxBuffer.constData());
                    const qsizetype radeSamples = radeTake / floatBytes;
                    bool sourceActive = false;
                    for (qsizetype i = 0; i < radeSamples; ++i) {
                        sourceActive = sourceActive
                            || std::fabs(rade[i]) > kOutputSilenceThreshold;
                        out[i] += rade[i];
                    }
                    if (sourceActive) {
                        ++activeOutputSources;
                    }
                    m_radeRxBuffer.remove(0, radeTake);
                }

                // Single gain/clamp pass after all sources are mixed. Use
                // strict 1/N active-source scaling here: 1/sqrt(N) preserves
                // more loudness but still lets three speech streams hard-clip
                // and sound like NR2 static.
                const qsizetype totalSamples = len / floatBytes;
                const float mixGain = activeOutputSources > 1
                    ? 1.0f / static_cast<float>(activeOutputSources)
                    : 1.0f;
                for (qsizetype i = 0; i < totalSamples; ++i) {
                    out[i] = std::clamp(out[i] * mixGain, -1.0f, 1.0f);
                }
            }

            len = m_audioDevice->write(chunk);

            // Stale session watchdog: if we're writing data but processedUSecs()
            // hasn't advanced, the WASAPI session is silently discarding audio
            // (e.g. after Teams/Zoom reconfigures the audio endpoint). (#1569)
            qint64 processed = m_audioSink->processedUSecs();
            if (processed == m_lastProcessedUSecs) {
                if (++m_rxStaleTickCount >= kStaleTickThreshold) {
                    m_rxStaleTickCount = 0;
                    qCWarning(lcAudio) << "AudioEngine: sink appears stale (processedUSecs stuck at"
                                       << processed << "for" << kStaleTickThreshold * 10
                                       << "ms), restarting RX (#1569)";
                    QMetaObject::invokeMethod(this, [this]() {
                        if (!m_audioSink) return;
                        stopRxStream();
                        startRxStream();
                    }, Qt::QueuedConnection);
                    return;
                }
            } else {
                m_rxStaleTickCount = 0;
                m_lastProcessedUSecs = processed;
            }
        }

        updateRxBufferStats();
    });
    m_rxTimer->start();
}

AudioEngine::~AudioEngine()
{
    stopRxStream();
    stopTxStream();
}

QAudioFormat AudioEngine::makeFormat() const
{
    QAudioFormat fmt;
    fmt.setSampleRate(DEFAULT_SAMPLE_RATE);
    fmt.setChannelCount(2);                        // stereo
    fmt.setSampleFormat(QAudioFormat::Float);
    return fmt;
}

QJsonArray AudioEngine::audioEndpointDiagnostics() const
{
    const auto outputDescription = [this]() {
        const QAudioDevice dev = m_outputDevice.isNull()
            ? QMediaDevices::defaultAudioOutput()
            : m_outputDevice;
        return dev.isNull() ? QStringLiteral("Unavailable") : dev.description();
    };
    const auto inputDescription = [this]() {
        const QAudioDevice dev = m_inputDevice.isNull()
            ? QMediaDevices::defaultAudioInput()
            : m_inputDevice;
        return dev.isNull() ? QStringLiteral("Unavailable") : dev.description();
    };

    QJsonArray endpoints;

    const bool rxRunning = m_audioSink != nullptr;
    const bool rxDeviceOpen = !m_audioDevice.isNull() && m_audioDevice->isOpen();
    QJsonObject rx;
    rx["name"] = QStringLiteral("RX output");
    rx["direction"] = QStringLiteral("rx");
    rx["kind"] = QStringLiteral("sink");
    rx["backend"] = QStringLiteral("QAudioSink");
    rx["device"] = outputDescription();
    rx["running"] = rxRunning;
    rx["operational"] = rxRunning && rxDeviceOpen;
    rx["device_open"] = rxDeviceOpen;
    rx["state"] = rxRunning ? audioStateName(m_audioSink->state()) : QStringLiteral("Stopped");
    rx["error"] = rxRunning ? audioErrorName(m_audioSink->error()) : QStringLiteral("NoError");
    rx["sample_rate_hz"] = rxRunning ? QJsonValue(m_rxBufferSampleRate.load()) : QJsonValue();
    rx["channel_count"] = rxRunning ? QJsonValue(2) : QJsonValue();
    rx["sample_format"] = rxRunning ? QStringLiteral("Float") : QString();
    rx["resampling_active"] = rxRunning ? QJsonValue(m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE) : QJsonValue();
    rx["buffer_bytes"] = static_cast<double>(m_rxBufferBytes.load());
    rx["buffer_peak_bytes"] = static_cast<double>(m_rxBufferPeakBytes.load());
    rx["underrun_count"] = static_cast<double>(m_rxBufferUnderrunCount.load());
    endpoints.append(rx);

    const bool txRunning = m_audioSource != nullptr;
#ifdef Q_OS_MAC
    const bool txDeviceOpen = m_micBuffer && m_micBuffer->isOpen();
#else
    const bool txDeviceOpen = !m_micDevice.isNull() && m_micDevice->isOpen();
#endif
    QJsonObject tx;
    tx["name"] = QStringLiteral("TX input");
    tx["direction"] = QStringLiteral("tx");
    tx["kind"] = QStringLiteral("source");
    tx["backend"] = QStringLiteral("QAudioSource");
    tx["device"] = inputDescription();
    tx["running"] = txRunning;
    tx["operational"] = txRunning && txDeviceOpen;
    tx["device_open"] = txDeviceOpen;
    tx["state"] = txRunning ? audioStateName(m_audioSource->state()) : QStringLiteral("Stopped");
    tx["error"] = txRunning ? audioErrorName(m_audioSource->error()) : QStringLiteral("NoError");
    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["note"] = m_txInputMono ? QStringLiteral("mono input promoted to stereo for radio TX") : QString();
    endpoints.append(tx);

    const bool sidetoneRunning = m_sidetoneSink && m_sidetoneSink->isRunning();
    QJsonObject sidetone;
    sidetone["name"] = QStringLiteral("CW sidetone");
    sidetone["direction"] = QStringLiteral("tx");
    sidetone["kind"] = QStringLiteral("sink");
    sidetone["backend"] = m_sidetoneSink
        ? QString::fromLatin1(m_sidetoneSink->name())
        : QStringLiteral("not initialized");
    sidetone["device"] = m_sidetoneSink && !m_sidetoneSink->deviceDescription().trimmed().isEmpty()
        ? m_sidetoneSink->deviceDescription()
        : outputDescription();
    sidetone["running"] = sidetoneRunning;
    sidetone["operational"] = sidetoneRunning;
    sidetone["device_open"] = sidetoneRunning;
    sidetone["state"] = sidetoneRunning ? QStringLiteral("Active") : QStringLiteral("Stopped");
    sidetone["error"] = QStringLiteral("NoError");
    sidetone["sample_rate_hz"] = sidetoneRunning ? QJsonValue(m_sidetoneSink->actualRateHz()) : QJsonValue();
    sidetone["channel_count"] = sidetoneRunning ? QJsonValue(2) : QJsonValue();
    sidetone["sample_format"] = QString();
    sidetone["resampling_active"] = QJsonValue();
    sidetone["note"] = m_sidetoneSink && m_sidetoneSink->fallbackOccurred()
        ? m_sidetoneSink->fallbackReason()
        : QString();
    endpoints.append(sidetone);

    const bool quindarRunning = m_quindarLocalSink && m_quindarLocalSink->isRunning();
    QJsonObject quindar;
    quindar["name"] = QStringLiteral("Quindar local monitor");
    quindar["direction"] = QStringLiteral("tx");
    quindar["kind"] = QStringLiteral("sink");
    quindar["backend"] = QStringLiteral("QAudioSink");
    quindar["device"] = outputDescription();
    quindar["running"] = quindarRunning;
    quindar["operational"] = quindarRunning;
    quindar["device_open"] = quindarRunning;
    quindar["state"] = quindarRunning ? QStringLiteral("Active") : QStringLiteral("Stopped");
    quindar["error"] = QStringLiteral("NoError");
    quindar["sample_rate_hz"] = quindarRunning ? QJsonValue(m_quindarLocalSink->actualRateHz()) : QJsonValue();
    quindar["channel_count"] = quindarRunning ? QJsonValue(2) : QJsonValue();
    quindar["sample_format"] = quindarRunning ? QStringLiteral("Float") : QString();
    quindar["resampling_active"] = quindarRunning
        ? QJsonValue(m_quindarLocalSink->actualRateHz() != 48000)
        : QJsonValue();
    endpoints.append(quindar);

    return endpoints;
}

// ─── RX stream ───────────────────────────────────────────────────────────────

bool AudioEngine::startRxStream()
{
    if (m_audioSink) return true;   // already running

    m_rxBuffer.clear();
    m_rxPackets.clear();
    m_kiwiSdrRxBuffer.clear();
    m_kiwiSdrRxPackets.clear();
    m_rxOutputBuffer.clear();
    m_kiwiSdrOutputBuffer.clear();
    m_kiwiSdrBnrOutBuf.clear();
    m_kiwiSdrBnrPrimed = false;
    m_radeRxBuffer.clear();
    for (const auto& source : m_externalKiwiSources) {
        if (!source) {
            continue;
        }
        source->rxBuffer.clear();
        source->rxPackets.clear();
        source->outputBuffer.clear();
        source->nr2Output.clear();
        source->rxResampler.reset();
        source->rxResamplerR.reset();
        source->bnrUp.reset();
        source->bnrDown.reset();
        source->bnrOutBuf.clear();
        source->bnrPrimed = false;
        source->prebuffering = source->enabled;
    }
    m_rxBufferBytes.store(0);
    m_rxBufferPeakBytes.store(0);
    m_rxBufferUnderrunCount.store(0);
    m_rxBufferSampleRate.store(DEFAULT_SAMPLE_RATE);
    m_rxZombieTickCount = 0;
    m_rxStaleTickCount = 0;
    m_lastProcessedUSecs = 0;
    m_lastAudioFeedTime.start();  // initialize liveness watchdog (#1411)

    QAudioDevice dev = QMediaDevices::defaultAudioOutput();
    bool rxFallbackOccurred = false;
    QStringList rxFallbackReasons;
    QStringList rxFormatAttempts;
    const auto noteRxFallback = [&rxFallbackOccurred, &rxFallbackReasons](const QString& reason) {
        rxFallbackOccurred = true;
        if (!reason.isEmpty() && !rxFallbackReasons.contains(reason)) {
            rxFallbackReasons << reason;
        }
    };
    const auto noteRxAttempt = [&rxFormatAttempts](const QAudioFormat& format) {
        const QString attempt = formatAudioAttempt(format.sampleRate(),
                                                  format.channelCount(),
                                                  format.sampleFormat());
        if (!rxFormatAttempts.contains(attempt)) {
            rxFormatAttempts << attempt;
        }
    };
    if (!m_outputDevice.isNull()) {
        const auto outputs = QMediaDevices::audioOutputs();
        if (devicePresent(outputs, m_outputDevice)) {
            dev = m_outputDevice;
        } else {
            qCWarning(lcAudio) << "AudioEngine: saved output device is unavailable, using the system default output instead";
            noteRxFallback(QStringLiteral("saved output unavailable -> system default"));
            m_outputDevice = QAudioDevice{};
        }
    }

#ifdef Q_OS_MAC
    if (!m_allowBluetoothTelephonyOutput.load()) {
        // Only override devices that look like Bluetooth telephony routes.
        // Telephony-only (HFP/SCO) routes cap out at 8-16 kHz and cannot
        // handle our native 24 kHz Float stereo format.  If the device
        // supports 24 kHz it's a normal output and should not be replaced,
        // even if 48 kHz is unsupported (happens on some CoreAudio device
        // types with newer Qt versions) (#1705).
        QAudioFormat nativeFmt = makeFormat();          // 24 kHz Float stereo
        const bool looksLikeTelephony = !dev.isFormatSupported(nativeFmt);

        QAudioFormat preferredFmt = makeFormat();
        preferredFmt.setSampleRate(48000);
        if (looksLikeTelephony && !dev.isFormatSupported(preferredFmt)) {
            const auto supportsPreferredOutput = [&preferredFmt](const QAudioDevice& candidate) {
                return !candidate.isNull() && candidate.isFormatSupported(preferredFmt);
            };

            const QAudioDevice defaultDev = QMediaDevices::defaultAudioOutput();
            if (supportsPreferredOutput(defaultDev)) {
                qCWarning(lcAudio) << "AudioEngine: selected output route looks telephony-only, using default 48k-capable output instead:"
                                   << defaultDev.description();
                noteRxFallback(QStringLiteral("telephony output substituted with default output"));
                dev = defaultDev;
            } else {
                const QString selectedDescription = dev.description();
                for (const QAudioDevice& candidate : QMediaDevices::audioOutputs()) {
                    if (candidate.id() == dev.id()) {
                        continue;
                    }
                    if (candidate.description() == selectedDescription
                        && supportsPreferredOutput(candidate)) {
                        qCWarning(lcAudio) << "AudioEngine: selected output route looks telephony-only, using sibling 48k-capable output instead:"
                                           << candidate.description();
                        noteRxFallback(QStringLiteral("telephony output substituted with sibling output"));
                        dev = candidate;
                        break;
                    }
                }
            }
        }
    }
#endif

    // Negotiate the output format via the consolidated factory (#3306). RX audio
    // is written as Float PCM, so we walk only the Float rungs of the ladder —
    // but the ladder supplies, in ONE place with no per-OS #ifdef: the preferred
    // rate (Windows/macOS 48k to dodge the WASAPI 24k resampler artifacts #2120
    // and keep macOS A2DP devices off the HFP/telephony route; Linux native 24k),
    // the universal 44.1 kHz fallback (#3385), and the device preferredFormat
    // catch-all. Each rung is tried with a real start(), so reliable backends and
    // WASAPI's probe-at-open are handled identically.
    const QList<QAudioFormat> rxLadder = AudioDeviceNegotiator::formatLadder(
        dev, AudioFormatNegotiator::Direction::Output,
        AudioFormatNegotiator::ResamplerPolicy::PreservePan);

    m_audioSink = nullptr;
    m_audioDevice = nullptr;
    QString lastRxError;
    bool triedFloatRung = false;
    for (const QAudioFormat& candidate : rxLadder) {
        if (candidate.sampleFormat() != QAudioFormat::Float)
            continue;   // RX drain writes Float PCM; Int16 rungs are for other sinks
        noteRxAttempt(candidate);
        auto* sink = new QAudioSink(dev, candidate, this);
        sink->setVolume(m_muted.load() ? 0.0f : m_rxVolume.load());
        QIODevice* io = sink->start();   // push-mode
        if (io) {
            m_audioSink = sink;
            m_audioDevice = io;
            m_rxOutputRate.store(candidate.sampleRate());
            if (triedFloatRung) {
                noteRxFallback(QStringLiteral("preferred RX format unavailable -> %1 Hz")
                                   .arg(candidate.sampleRate()));
            }
            break;
        }
        lastRxError = audioErrorName(sink->error());
        delete sink;
        triedFloatRung = true;
    }

    if (!m_audioDevice) {
        qCWarning(lcAudio) << "AudioEngine: failed to open RX audio sink on any negotiated format";
        logAudioOpenFailure(QStringLiteral("RX sink"),
                            QStringLiteral("QAudioSink"),
                            dev,
                            rxFormatAttempts,
                            QStringLiteral("QAudioSink::start failed on all negotiated formats (%1)")
                                .arg(lastRxError),
                            rxFallbackReasons);
        m_audioSink = nullptr;
        return false;
    }

    // Rebuild cached resamplers if the device rate changed since they were built
    // (e.g. a device swap 48k -> 44.1k), so they target the new device rate.
    if (m_rxResampler && static_cast<int>(m_rxResampler->dstRate()) != m_rxOutputRate.load()) {
        m_rxResampler.reset();
        m_rxResamplerR.reset();
    }
    if (m_radeRxResampler && static_cast<int>(m_radeRxResampler->dstRate()) != m_rxOutputRate.load()) {
        m_radeRxResampler.reset();
    }

    // Guard against the audio backend silently stopping the sink after idle/sleep
    // (#1149 / #1303). IdleState restart removed — it looped on Windows (#1405);
    // the zombie-sink watchdog handles stale WASAPI sessions after idle/sleep.
    connect(m_audioSink, &QAudioSink::stateChanged, this,
            [this](QAudio::State state) {
        if (state != QAudio::StoppedState) {
            return;
        }
        m_audioDevice = nullptr;
        if (!m_audioSink) {
            return;   // intentional stop (stopRxStream nulls this)
        }
        const QAudio::Error error = m_audioSink->error();
        if (error != QAudio::NoError) {
            qCWarning(lcAudio) << "AudioEngine: QAudioSink stopped with error, not auto-restarting RX"
                               << error;
            return;
        }
        QMetaObject::invokeMethod(this, [this]() {
            if (!m_audioSink) return;
            qCWarning(lcAudio) << "AudioEngine: QAudioSink stopped unexpectedly, restarting RX (#1303)";
            stopRxStream();
            startRxStream();
        }, Qt::QueuedConnection);
    });
    qCWarning(lcAudio) << "AudioEngine: RX stream started at" << m_rxOutputRate.load() << "Hz"
                       << "device:" << dev.description();
    m_rxBufferSampleRate.store(m_rxOutputRate.load());
    AudioSummaryLogger::RxSinkSummary summary;
    summary.deviceDescription = dev.description();
    summary.sampleRate = m_rxOutputRate.load();
    summary.channelCount = 2;
    summary.sampleFormat = QAudioFormat::Float;
    summary.resamplingActive = (m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE);
    summary.fallbackOccurred = rxFallbackOccurred;
    summary.fallbackReason = rxFallbackReasons.join(QStringLiteral("; "));
    AudioSummaryLogger::logRxSink(summary);
    // Open the dedicated sidetone + Quindar local sinks alongside RX. Cheap when
    // disabled (timers write silence to a tiny primed buffer). NOTE: the old
    // Windows branch returned before startQuindarLocalSink(), so the Quindar
    // local monitor never opened on Windows — unifying the path fixes that.
    startSidetoneStream();
    startQuindarLocalSink();
    emit rxStarted();
    return true;
}

void AudioEngine::stopRxStream()
{
    stopSidetoneStream();
    stopQuindarLocalSink();
    m_rxBuffer.clear();
    m_rxPackets.clear();
    m_kiwiSdrRxBuffer.clear();
    m_kiwiSdrRxPackets.clear();
    m_rxOutputBuffer.clear();
    m_kiwiSdrOutputBuffer.clear();
    m_kiwiSdrBnrOutBuf.clear();
    m_kiwiSdrBnrPrimed = false;
    m_radeRxBuffer.clear();
    for (const auto& source : m_externalKiwiSources) {
        if (!source) {
            continue;
        }
        source->rxBuffer.clear();
        source->rxPackets.clear();
        source->outputBuffer.clear();
        source->nr2Output.clear();
        source->rxResampler.reset();
        source->rxResamplerR.reset();
        source->bnrUp.reset();
        source->bnrDown.reset();
        source->bnrOutBuf.clear();
        source->bnrPrimed = false;
        source->prebuffering = source->enabled;
    }
    m_rxBufferBytes.store(0);
    m_rxBufferPeakBytes.store(0);
    m_rxBufferSampleRate.store(DEFAULT_SAMPLE_RATE);

    if (m_audioSink) {
        // Null out m_audioSink BEFORE stopping so that the stateChanged
        // handler's "if (!m_audioSink) return" guard prevents a cascading
        // restart loop.  Without this, stop() emits stateChanged(StoppedState)
        // synchronously while m_audioSink is still non-null, causing the
        // handler to queue another stopRx+startRx — which repeats
        // indefinitely and prevents audio from ever playing. (#1441)
        auto* sink = m_audioSink;
        m_audioSink   = nullptr;
        m_audioDevice = nullptr;
        // Guard: same stale-device-handle crash can occur on the RX side (#1059).
        if (sink->state() != QAudio::StoppedState)
            sink->stop();
        delete sink;
    }
    emit rxStopped();
}

void AudioEngine::setRxVolume(float v)
{
    m_rxVolume.store(qBound(0.0f, v, 1.0f));
    if (m_audioSink)
        m_audioSink->setVolume(m_muted.load() ? 0.0f : m_rxVolume.load());
}

void AudioEngine::setMuted(bool muted)
{
    const bool prev = m_muted.load();
    m_muted.store(muted);
    if (m_audioSink)
        m_audioSink->setVolume(muted ? 0.0f : m_rxVolume.load());
    if (prev != muted)
        emit mutedChanged(muted);
}

// Pick the sidetone backend based on build flag + AppSettings override.
// PortAudio when available (lower latency on Linux/macOS); QAudioSink
// fallback otherwise or when explicitly requested by the user.
static std::unique_ptr<CwSidetoneSinkBackend> makeSidetoneBackend(QObject* qparent)
{
    const QString pref =
        AppSettings::instance().value("CwSidetoneBackend", "PortAudio").toString();

#ifdef HAVE_PORTAUDIO
    if (pref != "QAudioSink") {
        return std::unique_ptr<CwSidetoneSinkBackend>(
            new CwSidetonePortAudioSink());
    }
#endif
    return std::unique_ptr<CwSidetoneSinkBackend>(
        new CwSidetoneQAudioSink(qparent));
}

bool AudioEngine::startSidetoneStream()
{
    if (m_sidetoneSink && m_sidetoneSink->isRunning()) return true;
    if (!m_cwSidetone) return false;

    QAudioDevice dev = QMediaDevices::defaultAudioOutput();
    if (!m_outputDevice.isNull()) {
        const auto outputs = QMediaDevices::audioOutputs();
        for (const auto& d : outputs) {
            // Use the freshly enumerated Qt device object so backend-specific
            // handles follow the selected endpoint after hotplug/default churn.
            if (d.id() == m_outputDevice.id()) { dev = d; break; }
        }
        if (dev.id() != m_outputDevice.id()) {
            qCWarning(lcAudio) << "AudioEngine: saved sidetone output device is unavailable, using the system default output instead";
        }
    }

    m_sidetoneSink = makeSidetoneBackend(this);
    bool sidetoneFallbackOccurred = false;
    QStringList sidetoneFallbackReasons;
    QStringList sidetoneAttempts;
    const QString portAudioAttempt = QStringLiteral("PortAudio 48000Hz 2ch Float, native-rate fallback if needed");
    const QString qAudioSinkAttempt = QStringLiteral("QAudioSink 48000Hz/44100Hz/24000Hz 2ch Float, then Int16");
    sidetoneAttempts << (qstrcmp(m_sidetoneSink->name(), "PortAudio") == 0
        ? portAudioAttempt
        : qAudioSinkAttempt);
    if (!m_sidetoneSink->start(dev, 48000, m_cwSidetone.get())) {
        // Backend failed — try the other one before giving up.  Most likely
        // path: PortAudio init failed on a quirky device, fall back to Qt.
#ifdef HAVE_PORTAUDIO
        if (qstrcmp(m_sidetoneSink->name(), "PortAudio") == 0) {
            qCWarning(lcAudio) << "AudioEngine: PortAudio sidetone failed, falling back to QAudioSink";
            sidetoneFallbackOccurred = true;
            sidetoneFallbackReasons << QStringLiteral("PortAudio failed -> QAudioSink");
            m_sidetoneSink.reset(new CwSidetoneQAudioSink(this));
            sidetoneAttempts << qAudioSinkAttempt;
            if (!m_sidetoneSink->start(dev, 48000, m_cwSidetone.get())) {
                logAudioOpenFailure(QStringLiteral("CW sidetone"),
                                    QStringLiteral("PortAudio -> QAudioSink"),
                                    dev,
                                    sidetoneAttempts,
                                    QStringLiteral("all sidetone backends failed"),
                                    sidetoneFallbackReasons);
                m_sidetoneSink.reset();
                return false;
            }
        } else {
            logAudioOpenFailure(QStringLiteral("CW sidetone"),
                                QString::fromLatin1(m_sidetoneSink->name()),
                                dev,
                                sidetoneAttempts,
                                QStringLiteral("sidetone backend failed"),
                                sidetoneFallbackReasons);
            m_sidetoneSink.reset();
            return false;
        }
#else
        logAudioOpenFailure(QStringLiteral("CW sidetone"),
                            QString::fromLatin1(m_sidetoneSink->name()),
                            dev,
                            sidetoneAttempts,
                            QStringLiteral("sidetone backend failed"),
                            sidetoneFallbackReasons);
        m_sidetoneSink.reset();
        return false;
#endif
    }

    qCInfo(lcAudio) << "AudioEngine: sidetone running on" << m_sidetoneSink->name()
                    << "rate=" << m_sidetoneSink->actualRateHz() << "Hz";
    if (m_sidetoneSink->fallbackOccurred()) {
        sidetoneFallbackOccurred = true;
        if (!m_sidetoneSink->fallbackReason().isEmpty()) {
            sidetoneFallbackReasons << m_sidetoneSink->fallbackReason();
        }
    }
    AudioSummaryLogger::CwSidetoneSummary summary;
    summary.backend = QString::fromLatin1(m_sidetoneSink->name());
    summary.deviceDescription = m_sidetoneSink->deviceDescription();
    summary.sampleRate = m_sidetoneSink->actualRateHz();
    summary.fallbackOccurred = sidetoneFallbackOccurred;
    summary.fallbackReason = sidetoneFallbackReasons.join(QStringLiteral("; "));
    AudioSummaryLogger::logCwSidetone(summary);
    return true;
}

void AudioEngine::stopSidetoneStream()
{
    if (m_sidetoneSink) {
        m_sidetoneSink->stop();
        m_sidetoneSink.reset();
    }
    if (m_cwSidetone) m_cwSidetone->reset();
}

bool AudioEngine::startQuindarLocalSink()
{
    if (m_quindarLocalSink && m_quindarLocalSink->isRunning()) return true;
    if (!m_clientQuindarTone) return false;

    QAudioDevice dev = QMediaDevices::defaultAudioOutput();
    if (!m_outputDevice.isNull()) {
        const auto outputs = QMediaDevices::audioOutputs();
        for (const auto& d : outputs) {
            if (d.id() == m_outputDevice.id()) { dev = m_outputDevice; break; }
        }
    }

    if (!m_quindarLocalSink) {
        m_quindarLocalSink = std::make_unique<QuindarLocalSink>(this);
    }
    if (!m_quindarLocalSink->start(dev, m_clientQuindarTone.get())) {
        m_quindarLocalSink.reset();
        return false;
    }
    return true;
}

void AudioEngine::stopQuindarLocalSink()
{
    if (m_quindarLocalSink) {
        m_quindarLocalSink->stop();
        m_quindarLocalSink.reset();
    }
}

void AudioEngine::setRxPan(int v)
{
    m_rxPan.store(qBound(0, v, 100));
}

// Apply the stored RX pan to a stereo float32 buffer in-place.
// Only called on NR output — the radio itself handles pan when NR is off.
// Pan law: linear, symmetric around centre (50).
//   pan 0-50  → L=1.0,           R=pan/50
//   pan 50-100→ L=(100-pan)/50,  R=1.0
// At pan=50 both gains are 1.0, so it is a true no-op when centred.
// Safety: if nFrames==0 (e.g. empty or partial buffer on an error path),
// the loop body never executes — no UB.
static void applyRxPanInPlace(float* stereo, int nFrames, int pan)
{
    if (pan == 50 || nFrames <= 0) return;
    const float lGain = (pan >= 50) ? (100 - pan) / 50.0f : 1.0f;
    const float rGain = (pan <= 50) ? pan        / 50.0f : 1.0f;
    for (int i = 0; i < nFrames; ++i) {
        stereo[2 * i    ] *= lGain;
        stereo[2 * i + 1] *= rGain;
    }
}

// Resample 24kHz stereo float32 → 48kHz stereo float32 via r8brain.
// L and R are processed through separate Resampler instances so that any
// per-channel difference (radio-applied audio_pan) is preserved.
// processStereoToStereo() collapses L+R to mono — do NOT use it here.
QByteArray AudioEngine::resampleStereo(const QByteArray& pcm,
                                       RxDspSource source,
                                       ExternalRxAudioSourceState* externalSource)
{
    // Two independent L/R instances preserve VITA-49 per-channel pan (PreservePan
    // strategy — never collapse to mono here, #2403/#2459). Target the negotiated
    // device rate so 44.1k / 48k devices both work (#3306).
    std::unique_ptr<Resampler>& leftResampler = externalSource
        ? externalSource->rxResampler
        : (source == RxDspSource::KiwiSdr ? m_kiwiSdrRxResampler : m_rxResampler);
    std::unique_ptr<Resampler>& rightResampler = externalSource
        ? externalSource->rxResamplerR
        : (source == RxDspSource::KiwiSdr ? m_kiwiSdrRxResamplerR : m_rxResamplerR);
    if (!leftResampler) {
        leftResampler = std::make_unique<Resampler>(24000, m_rxOutputRate.load());
    }
    if (!rightResampler) {
        rightResampler = std::make_unique<Resampler>(24000, m_rxOutputRate.load());
    }

    const int frames = pcm.size() / (2 * static_cast<int>(sizeof(float)));
    if (frames <= 0) return {};

    const auto* src = reinterpret_cast<const float*>(pcm.constData());

    std::vector<float> lBuf(frames), rBuf(frames);
    for (int i = 0; i < frames; ++i) {
        lBuf[i] = src[2 * i];
        rBuf[i] = src[2 * i + 1];
    }

    QByteArray lOut = leftResampler->process(lBuf.data(), frames);
    QByteArray rOut = rightResampler->process(rBuf.data(), frames);

    const int outFrames = lOut.size() / static_cast<int>(sizeof(float));
    const int rFrames   = rOut.size() / static_cast<int>(sizeof(float));
    const int commonFrames = std::min(outFrames, rFrames);
    if (commonFrames <= 0) return {};

    QByteArray result(commonFrames * 2 * static_cast<int>(sizeof(float)), Qt::Uninitialized);
    auto*       dst  = reinterpret_cast<float*>(result.data());
    const auto* lSrc = reinterpret_cast<const float*>(lOut.constData());
    const auto* rSrc = reinterpret_cast<const float*>(rOut.constData());
    for (int i = 0; i < commonFrames; ++i) {
        dst[2 * i]     = lSrc[i];
        dst[2 * i + 1] = rSrc[i];
    }
    return result;
}

void AudioEngine::feedAudioData(const QByteArray& pcm)
{
    processRxAudioData(pcm, true);
}

void AudioEngine::feedKiwiSdrAudioData(const QByteArray& pcm24kStereoFloat)
{
    if (!m_kiwiSdrAudioEnabled.load(std::memory_order_relaxed)) {
        return;
    }
    m_lastAudioFeedTime.start();

    constexpr qsizetype kFrameBytes =
        2 * static_cast<qsizetype>(sizeof(float));
    const qsizetype alignedBytes =
        (pcm24kStereoFloat.size() / kFrameBytes) * kFrameBytes;
    if (alignedBytes <= 0) {
        return;
    }

    const QByteArray alignedPcm =
        alignedBytes == pcm24kStereoFloat.size()
            ? pcm24kStereoFloat
            : pcm24kStereoFloat.left(alignedBytes);

    if (m_nr2Enabled.load(std::memory_order_relaxed)) {
        std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
        m_kiwiSdrRxPackets.push_back(alignedPcm);
        updateRxBufferStats();
        return;
    }

    processRxAudioData(alignedPcm, false, RxAudioBuffer::KiwiSdr);
}

void AudioEngine::feedKiwiSdrAudioData(const QString& sourceId,
                                       const QByteArray& pcm24kStereoFloat)
{
    ExternalRxAudioSourceState* source = externalKiwiSource(sourceId, true);
    if (!source || !source->enabled || source->muted) {
        return;
    }
    m_lastAudioFeedTime.start();

    constexpr qsizetype kFrameBytes =
        2 * static_cast<qsizetype>(sizeof(float));
    const qsizetype alignedBytes =
        (pcm24kStereoFloat.size() / kFrameBytes) * kFrameBytes;
    if (alignedBytes <= 0) {
        return;
    }

    const QByteArray alignedPcm =
        alignedBytes == pcm24kStereoFloat.size()
            ? pcm24kStereoFloat
            : pcm24kStereoFloat.left(alignedBytes);

    if (m_nr2Enabled.load(std::memory_order_relaxed)) {
        std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
        source->rxPackets.push_back(alignedPcm);
        updateRxBufferStats();
        return;
    }

    source->rxBuffer.append(alignedPcm);
    updateRxBufferStats();
}

void AudioEngine::setKiwiSdrAudioEnabled(bool on)
{
    if (m_kiwiSdrAudioEnabled.exchange(on, std::memory_order_relaxed) == on) {
        return;
    }

    std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
    m_kiwiSdrRxBuffer.clear();
    m_kiwiSdrRxPackets.clear();
    m_kiwiSdrOutputBuffer.clear();
    m_kiwiSdrNr2Mono.clear();
    m_kiwiSdrNr2Processed.clear();
    m_kiwiSdrNr2Output.clear();
    m_kiwiSdrRxResampler.reset();
    m_kiwiSdrRxResamplerR.reset();
    if (m_nr2Enabled && m_kiwiSdrNr2) {
        m_kiwiSdrNr2->reset();
    }
    m_kiwiSdrPrebuffering = on;
    updateRxBufferStats();
}

void AudioEngine::setKiwiSdrAudioSourceEnabled(const QString& sourceId, bool on)
{
    std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
    ExternalRxAudioSourceState* source = externalKiwiSource(sourceId, on);
    if (!source || source->enabled == on) {
        return;
    }

    source->enabled = on;
    qCDebug(lcKiwiSdrAudio).noquote()
        << "Audio source" << (on ? "enabled" : "disabled") << source->id;
    source->rxBuffer.clear();
    source->rxPackets.clear();
    source->outputBuffer.clear();
    source->nr2Mono.clear();
    source->nr2Processed.clear();
    source->nr2Output.clear();
    source->rxResampler.reset();
    source->rxResamplerR.reset();
    source->bnrUp.reset();
    source->bnrDown.reset();
    source->bnrOutBuf.clear();
    source->bnrPrimed = false;
    if (on && m_nr2Enabled.load(std::memory_order_relaxed) && !source->nr2) {
        source->nr2 = std::make_unique<SpectralNR>(256, DEFAULT_SAMPLE_RATE);
        if (source->nr2->hasPlanFailed()) {
            qCWarning(lcAudio) << "AudioEngine: external Kiwi NR2 plan failed for"
                               << source->id;
            source->nr2.reset();
        } else {
            applyNr2SettingsFromAppSettings(*source->nr2);
        }
    }
    source->prebuffering = on;
    updateRxBufferStats();
}

void AudioEngine::setKiwiSdrAudioSourceGain(const QString& sourceId,
                                            float gainPercent)
{
    std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
    ExternalRxAudioSourceState* source = externalKiwiSource(sourceId, true);
    if (!source) {
        return;
    }

    source->gain = std::clamp(gainPercent, 0.0f, 100.0f) / 100.0f;
}

void AudioEngine::setKiwiSdrAudioSourceMuted(const QString& sourceId,
                                             bool muted)
{
    std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
    ExternalRxAudioSourceState* source = externalKiwiSource(sourceId, true);
    if (!source || source->muted == muted) {
        return;
    }

    source->muted = muted;
    source->rxBuffer.clear();
    source->rxPackets.clear();
    source->outputBuffer.clear();
    source->nr2Mono.clear();
    source->nr2Processed.clear();
    source->nr2Output.clear();
    source->bnrUp.reset();
    source->bnrDown.reset();
    source->bnrOutBuf.clear();
    source->bnrPrimed = false;
    source->prebuffering = !muted && source->enabled;
    updateRxBufferStats();
}

void AudioEngine::setKiwiSdrAudioSourcePan(const QString& sourceId, int pan)
{
    std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
    ExternalRxAudioSourceState* source = externalKiwiSource(sourceId, true);
    if (!source) {
        return;
    }

    source->pan = qBound(0, pan, 100);
}

void AudioEngine::removeKiwiSdrAudioSource(const QString& sourceId)
{
    const QString id = sourceId.trimmed();
    if (id.isEmpty()) {
        return;
    }

    std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
    const auto it = std::remove_if(
        m_externalKiwiSources.begin(), m_externalKiwiSources.end(),
        [&id](const std::unique_ptr<ExternalRxAudioSourceState>& source) {
            return source && source->id == id;
        });
    if (it != m_externalKiwiSources.end()) {
        m_externalKiwiSources.erase(it, m_externalKiwiSources.end());
        qCDebug(lcKiwiSdrAudio).noquote() << "Audio source removed" << id;
        updateRxBufferStats();
    }
}

void AudioEngine::resetRxChainStateForSourceSwitch()
{
    std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);

    m_rxResampler.reset();
    m_rxResamplerR.reset();
    m_rxPackets.clear();
    m_kiwiSdrRxResampler.reset();
    m_kiwiSdrRxResamplerR.reset();
    m_clientEqRxScratch.clear();
    m_clientGateRxScratch.clear();
    m_clientCompRxScratch.clear();
    m_clientDeEssRxScratch.clear();
    m_clientTubeRxScratch.clear();
    m_clientPuduRxScratch.clear();
    m_nr2Mono.clear();
    m_nr2Processed.clear();
    m_nr2Output.clear();
    m_kiwiSdrNr2Mono.clear();
    m_kiwiSdrNr2Processed.clear();
    m_kiwiSdrNr2Output.clear();
    for (const auto& source : m_externalKiwiSources) {
        if (!source) {
            continue;
        }
        source->rxBuffer.clear();
        source->rxPackets.clear();
        source->outputBuffer.clear();
        source->nr2Mono.clear();
        source->nr2Processed.clear();
        source->nr2Output.clear();
        source->rxResampler.reset();
        source->rxResamplerR.reset();
        source->bnrUp.reset();
        source->bnrDown.reset();
        source->bnrOutBuf.clear();
        source->bnrPrimed = false;
        if (m_nr2Enabled && source->nr2) {
            source->nr2->reset();
        }
        source->prebuffering = source->enabled && !source->muted;
    }

    if (m_clientEqRx) {
        m_clientEqRx->reset();
    }
    if (m_clientGateRx) {
        m_clientGateRx->reset();
    }
    if (m_clientCompRx) {
        m_clientCompRx->reset();
    }
    if (m_clientDeEssRx) {
        m_clientDeEssRx->reset();
    }
    if (m_clientTubeRx) {
        m_clientTubeRx->reset();
    }
    if (m_clientPuduRx) {
        m_clientPuduRx->reset();
    }
    if (m_nr2Enabled && m_nr2) {
        m_nr2->reset();
    }
    if (m_nr2Enabled && m_kiwiSdrNr2) {
        m_kiwiSdrNr2->reset();
    }
    if (m_rn2Enabled && m_rn2) {
        m_rn2->reset();
    }
#ifdef HAVE_SPECBLEACH
    if (m_nr4Enabled && m_nr4) {
        m_nr4->reset();
    }
#endif
#ifdef HAVE_DFNR
    if (m_dfnrEnabled && m_dfnr) {
        m_dfnr->reset();
    }
#endif
#ifdef __APPLE__
    if (m_mnrEnabled && m_mnr) {
        m_mnr->reset();
    }
#endif
    if (m_bnrEnabled) {
        m_bnrUp = std::make_unique<Resampler>(24000, 48000, 16384);
        m_bnrDown = std::make_unique<Resampler>(48000, 24000, 16384);
        m_kiwiSdrBnrUp = std::make_unique<Resampler>(24000, 48000, 16384);
        m_kiwiSdrBnrDown = std::make_unique<Resampler>(48000, 24000, 16384);
        m_bnrOutBuf.clear();
        m_kiwiSdrBnrOutBuf.clear();
        m_bnrPrimed = false;
        m_kiwiSdrBnrPrimed = false;
    }
}

void AudioEngine::processRxAudioData(const QByteArray& pcm, bool emitTncTap,
                                     RxAudioBuffer targetBuffer)
{
    if (!m_audioSink) return;  // PC audio disabled
    m_lastAudioFeedTime.start();  // reset liveness watchdog (#1411)

    // Source callbacks queue native 24 kHz stereo PCM only. With NR2 enabled,
    // each receive source keeps whole packet-sized blocks until the timer
    // processes that source through its own NR2/output path. The speaker drain
    // mixes post-DSP output FIFOs at the sink.
    if (emitTncTap && m_tncRxTapEnabled.load(std::memory_order_relaxed)) {
        emitTncRxTapFromFloat32Stereo(pcm, DEFAULT_SAMPLE_RATE);
    }

    constexpr qsizetype kFrameBytes = 2 * static_cast<qsizetype>(sizeof(float));
    const qsizetype alignedBytes = (pcm.size() / kFrameBytes) * kFrameBytes;
    if (alignedBytes <= 0) {
        return;
    }

    const QByteArray alignedPcm =
        alignedBytes == pcm.size() ? pcm : pcm.left(alignedBytes);

    if (targetBuffer == RxAudioBuffer::Main
        && m_nr2Enabled.load(std::memory_order_relaxed)) {
        std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
        m_rxPackets.push_back(alignedPcm);
        updateRxBufferStats();
        return;
    }

    QByteArray& target =
        targetBuffer == RxAudioBuffer::KiwiSdr ? m_kiwiSdrRxBuffer
                                               : m_rxBuffer;
    target.append(alignedPcm);
    updateRxBufferStats();
}

void AudioEngine::processMixedRxAudioData(const QByteArray& pcm,
                                          RxDspSource source,
                                          ExternalRxAudioSourceState* externalSource)
{
    if (!m_audioSink) return;  // PC audio disabled

    const auto sourcePan = [this, externalSource]() {
        return externalSource ? externalSource->pan : m_rxPan.load();
    };

    // feedAudioData() handles all remote_audio_rx paths: SSB/CW/digital on any
    // pan, and the zero-filled frames the radio sends for muted slices
    // (audio_mute=1 zeroes the payload; it does NOT suppress packets).
    // The caller supplies exactly one native 24 kHz stereo source stream:
    // Flex audio, the legacy Kiwi stream, or one virtual Kiwi antenna stream.
    // Stateful NR/output resamplers must never see alternating Flex/Kiwi or
    // different Kiwi endpoints on the same DSP state.
    auto writeAudio = [this, source, externalSource, sourcePan](
                          const QByteArray& data,
                          bool applyOutputPan = false) {
        if (!m_audioDevice || !m_audioDevice->isOpen()) return;

        // Client-side parametric EQ runs at the native 24 kHz rate, after
        // any NR chain, before resample-to-48k and soft boost. Copy-then-
        // process because the caller owns `data`. Skip when disabled or
        // during TX (matches the NR-chain TX bypass policy).
        const QByteArray* eqSource = &data;
        if (m_clientEqRx && m_clientEqRx->isEnabled() && !m_radioTransmitting) {
            m_clientEqRxScratch = data;
            const int frames = m_clientEqRxScratch.size()
                             / (2 * static_cast<int>(sizeof(float)));
            m_clientEqRx->process(
                reinterpret_cast<float*>(m_clientEqRxScratch.data()),
                frames, 2);
            eqSource = &m_clientEqRxScratch;
        }

        // Tap post-EQ audio into the ring buffer for the editor's FFT
        // analyzer. Runs whether EQ is active or bypassed — the tap shows
        // the signal actually heading to the sink at native 24 kHz.
        const int tapFrames = eqSource->size() / (2 * static_cast<int>(sizeof(float)));
        if (tapFrames > 0) {
            tapClientEqRxStereo(
                reinterpret_cast<const float*>(eqSource->constData()),
                tapFrames);
        }

        // RX chain stage: GATE — runs after EQ, in place on a scratch
        // buffer so the EQ tap above sees the post-EQ / pre-gate signal
        // (matches the user's signal-flow expectation).  Skip during TX
        // for the same reason as EQ.
        const QByteArray* gateSource = eqSource;
        if (m_clientGateRx && m_clientGateRx->isEnabled() && !m_radioTransmitting) {
            m_clientGateRxScratch = *eqSource;
            applyClientGateRxFloat32(m_clientGateRxScratch);
            gateSource = &m_clientGateRxScratch;
        }

        // RX chain stage: COMP — runs after GATE.  Same scratch-copy
        // pattern.
        const QByteArray* compSource = gateSource;
        if (m_clientCompRx && m_clientCompRx->isEnabled() && !m_radioTransmitting) {
            m_clientCompRxScratch = *gateSource;
            applyClientCompRxFloat32(m_clientCompRxScratch);
            compSource = &m_clientCompRxScratch;
        }

        // RX chain stage: DESS — runs after COMP, before TUBE.  Same
        // scratch-copy pattern as the surrounding stages.
        const QByteArray* deEssSource = compSource;
        if (m_clientDeEssRx && m_clientDeEssRx->isEnabled() && !m_radioTransmitting) {
            m_clientDeEssRxScratch = *compSource;
            applyClientDeEssRxFloat32(m_clientDeEssRxScratch);
            deEssSource = &m_clientDeEssRxScratch;
        }

        // RX chain stage: TUBE — runs after DESS.
        const QByteArray* tubeSource = deEssSource;
        if (m_clientTubeRx && m_clientTubeRx->isEnabled() && !m_radioTransmitting) {
            m_clientTubeRxScratch = *deEssSource;
            applyClientTubeRxFloat32(m_clientTubeRxScratch);
            tubeSource = &m_clientTubeRxScratch;
        }

        // RX chain stage: PUDU — runs after TUBE.
        const QByteArray* puduSource = tubeSource;
        if (m_clientPuduRx && m_clientPuduRx->isEnabled() && !m_radioTransmitting) {
            m_clientPuduRxScratch = *tubeSource;
            applyClientPuduRxFloat32(m_clientPuduRxScratch);
            puduSource = &m_clientPuduRxScratch;
        }

        const int scopeSampleRate = m_rxOutputRate.load();
        const QByteArray& resampled =
            (m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE)
                ? resampleStereo(*puduSource, source, externalSource)
                : *puduSource;
        const QByteArray* output = &resampled;
        QByteArray boosted;
        if (m_rxBoost.load()) {
            // Soft-knee boost — increases perceived loudness without hard clipping.
            // Uses tanh compression: loud signals are gently limited while quiet
            // signals get ~2x gain.  tanh(2*x) ≈ 2*x for small x, ≈ 1.0 for large x.
            boosted.resize(resampled.size());
            const auto* src = reinterpret_cast<const float*>(resampled.constData());
            auto* dst = reinterpret_cast<float*>(boosted.data());
            const int nSamples = resampled.size() / static_cast<int>(sizeof(float));
            for (int i = 0; i < nSamples; ++i) {
                dst[i] = std::tanh(src[i] * 2.0f);
            }
            output = &boosted;
        }
        QByteArray trimmed;
        const float trimDb = m_rxOutputTrimDb.load();
        if (std::fabs(trimDb) > 0.01f) {
            const float gain = std::pow(10.0f, trimDb / 20.0f);
            trimmed.resize(output->size());
            const auto* src = reinterpret_cast<const float*>(output->constData());
            auto* dst = reinterpret_cast<float*>(trimmed.data());
            const int nSamples = output->size() / static_cast<int>(sizeof(float));
            for (int i = 0; i < nSamples; ++i) dst[i] = src[i] * gain;
            output = &trimmed;
        }
        QByteArray panned;
        if (applyOutputPan && sourcePan() != 50) {
            panned = *output;
            applyRxPanInPlace(
                reinterpret_cast<float*>(panned.data()),
                panned.size() / (2 * static_cast<int>(sizeof(float))),
                sourcePan());
            output = &panned;
        }
        QByteArray& outputBuffer = externalSource
            ? externalSource->outputBuffer
            : (source == RxDspSource::KiwiSdr ? m_kiwiSdrOutputBuffer
                                               : m_rxOutputBuffer);
        outputBuffer.append(*output);
        emitScopeFromFloat32Stereo(*output, scopeSampleRate, false);
        emitRxPostChainScopeFromFloat32Stereo(*output, scopeSampleRate);
        updateRxBufferStats();
    };
    const auto writeAudioAndLevel = [this, externalSource, &writeAudio](
                                        const QByteArray& data) {
        writeAudio(data, externalSource != nullptr);
        emit levelChanged(computeRMS(data));
    };

    // Bypass client-side DSP during TX (#367, #1505). NR2/RN2/BNR adapt
    // their internal state to silence during TX, causing distorted audio
    // after returning to RX. Use m_radioTransmitting (raw interlock state)
    // so bypass kicks in even when an external app triggers PTT.
    // DSP mutex: prevents use-after-free if enable/disable runs concurrently (#502)
    {
        std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
        if (m_radioTransmitting) {
            writeAudioAndLevel(pcm);
        } else if (m_rn2Enabled && m_rn2) {
            QByteArray processed = m_rn2->process(pcm);
            // Re-apply pan lost during NR mono-mix (#1460)
            applyRxPanInPlace(reinterpret_cast<float*>(processed.data()),
                              processed.size() / (2 * static_cast<int>(sizeof(float))),
                              sourcePan());
            writeAudio(processed);
            emit levelChanged(computeRMS(processed));
        } else if (m_nr2Enabled && m_nr2) {
            processNr2(pcm, source, externalSource);  // applyRxPanInPlace called inside processNr2
            const QByteArray& nr2Output = externalSource
                ? externalSource->nr2Output
                : (source == RxDspSource::KiwiSdr ? m_kiwiSdrNr2Output
                                                   : m_nr2Output);
            writeAudio(nr2Output);
            emit levelChanged(computeRMS(nr2Output));

#ifdef HAVE_SPECBLEACH
        } else if (m_nr4Enabled && m_nr4) {
            QByteArray processed = m_nr4->process(pcm);
            // Re-apply pan lost during NR mono-mix (#1460)
            applyRxPanInPlace(reinterpret_cast<float*>(processed.data()),
                              processed.size() / (2 * static_cast<int>(sizeof(float))),
                              sourcePan());
            writeAudio(processed);
            emit levelChanged(computeRMS(processed));
#endif
#ifdef HAVE_DFNR
        } else if (m_dfnrEnabled && m_dfnr) {
            QByteArray processed = m_dfnr->process(pcm);
            // Re-apply pan lost during NR mono-mix (#1460)
            applyRxPanInPlace(reinterpret_cast<float*>(processed.data()),
                              processed.size() / (2 * static_cast<int>(sizeof(float))),
                              sourcePan());
            writeAudio(processed);
            emit levelChanged(computeRMS(processed));
#endif
#ifdef __APPLE__
        } else if (m_mnrEnabled && m_mnr) {
            QByteArray processed = m_mnr->process(pcm);
            // Re-apply pan lost during NR mono-mix (#1460)
            applyRxPanInPlace(reinterpret_cast<float*>(processed.data()),
                              processed.size() / (2 * static_cast<int>(sizeof(float))),
                              sourcePan());
            writeAudio(processed);
            emit levelChanged(computeRMS(processed));
#endif
        } else if (m_bnrEnabled && m_bnr && m_bnr->isConnected()) {
            processBnr(pcm, source, externalSource);
            // processBnr writes audio and emits level internally
        } else {
            writeAudioAndLevel(pcm);
        }
    }
}

namespace {

// Key builders kept local — settings namespace lives inside AudioEngine.cpp
// so the applet never reaches past these functions to form keys directly.
QString ceqKey(const char* pathTag, const char* leaf)
{
    return QStringLiteral("ClientEq%1%2").arg(pathTag, leaf);
}

QString ceqBandKey(const char* pathTag, int band, const char* leaf)
{
    return QStringLiteral("ClientEq%1_Band%2_%3")
        .arg(pathTag).arg(band).arg(leaf);
}

void loadOne(ClientEq& eq, const char* tag)
{
    auto& s = AppSettings::instance();
    const bool enabled = s.value(ceqKey(tag, "Enabled"), "False").toString() == "True";
    const int savedCount = std::clamp(
        s.value(ceqKey(tag, "BandCount"), "0").toString().toInt(),
        0, ClientEq::kMaxBands);
    const float masterGain = std::clamp(
        s.value(ceqKey(tag, "MasterGain"), "1.0").toString().toFloat(),
        0.0f, 4.0f);
    const int familyIdx = std::clamp(
        s.value(ceqKey(tag, "FilterFamily"), "0").toString().toInt(), 0, 3);
    eq.setEnabled(enabled);
    eq.setMasterGain(masterGain);
    eq.setFilterFamily(static_cast<ClientEq::FilterFamily>(familyIdx));

    // Fixed 8-slot layout.  If the user's saved state has fewer bands,
    // we keep their saved ones in slots [0, savedCount) and pad the
    // remaining slots with the default Logic-Pro-style templates, all
    // disabled.  Existing users migrate in place — their configured
    // bands survive, they just gain a few untouched defaults next to them.
    const int activeCount = ClientEq::kDefaultBandCount;
    eq.setActiveBandCount(activeCount);

    for (int i = 0; i < activeCount; ++i) {
        ClientEq::BandParams p;
        if (i < savedCount) {
            p.freqHz  = s.value(ceqBandKey(tag, i, "Freq"), "1000").toString().toFloat();
            p.gainDb  = s.value(ceqBandKey(tag, i, "Gain"), "0").toString().toFloat();
            p.q       = s.value(ceqBandKey(tag, i, "Q"),    "0.707").toString().toFloat();
            p.type    = static_cast<ClientEq::FilterType>(
                s.value(ceqBandKey(tag, i, "Type"), "0").toString().toInt());
            p.enabled = s.value(ceqBandKey(tag, i, "BandEn"), "True").toString() == "True";
            p.slopeDbPerOct = std::clamp(
                s.value(ceqBandKey(tag, i, "Slope"), "12").toString().toInt(),
                12, 48);
        } else {
            p = ClientEq::defaultBand(i);  // disabled by default
        }
        eq.setBand(i, p);
    }
}

void saveOne(const ClientEq& eq, const char* tag)
{
    auto& s = AppSettings::instance();
    s.setValue(ceqKey(tag, "Enabled"),
               eq.isEnabled() ? "True" : "False");
    s.setValue(ceqKey(tag, "MasterGain"),
               QString::number(eq.masterGain(), 'f', 3));
    s.setValue(ceqKey(tag, "FilterFamily"),
               QString::number(static_cast<int>(eq.filterFamily())));
    const int count = eq.activeBandCount();
    s.setValue(ceqKey(tag, "BandCount"), QString::number(count));
    for (int i = 0; i < count; ++i) {
        const ClientEq::BandParams p = eq.band(i);
        s.setValue(ceqBandKey(tag, i, "Freq"),
                   QString::number(p.freqHz, 'f', 2));
        s.setValue(ceqBandKey(tag, i, "Gain"),
                   QString::number(p.gainDb, 'f', 2));
        s.setValue(ceqBandKey(tag, i, "Q"),
                   QString::number(p.q, 'f', 3));
        s.setValue(ceqBandKey(tag, i, "Type"),
                   QString::number(static_cast<int>(p.type)));
        s.setValue(ceqBandKey(tag, i, "BandEn"),
                   p.enabled ? "True" : "False");
        s.setValue(ceqBandKey(tag, i, "Slope"),
                   QString::number(p.slopeDbPerOct));
    }
}

} // namespace

void AudioEngine::loadClientEqSettings()
{
    if (!m_clientEqRx || !m_clientEqTx) return;
    loadOne(*m_clientEqRx, "Rx");
    loadOne(*m_clientEqTx, "Tx");
}

void AudioEngine::saveClientEqSettings() const
{
    if (!m_clientEqRx || !m_clientEqTx) return;
    saveOne(*m_clientEqRx, "Rx");
    saveOne(*m_clientEqTx, "Tx");
    AppSettings::instance().save();
}

void AudioEngine::tapClientEqRxStereo(const float* stereoInterleaved, int frames)
{
    if (frames <= 0) return;
    // Audio-thread writer: skip silently if UI thread holds the lock —
    // dropping a block of tap samples just produces a one-frame stutter
    // on the FFT display, never an audio glitch.
    std::unique_lock<std::mutex> lk(m_clientEqTapMutex, std::try_to_lock);
    if (!lk.owns_lock()) return;
    int w = m_clientEqTapRxWrite;
    for (int i = 0; i < frames; ++i) {
        const float mono = 0.5f * (stereoInterleaved[i * 2]
                                 + stereoInterleaved[i * 2 + 1]);
        m_clientEqTapRx[w] = mono;
        w = (w + 1) & (kClientEqTapSize - 1);
    }
    m_clientEqTapRxWrite = w;
}

void AudioEngine::tapClientEqTxInt16(const int16_t* int16stereo, int frames)
{
    if (frames <= 0) return;
    std::unique_lock<std::mutex> 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;
    std::unique_lock<std::mutex> lk(m_clientEqTapMutex, std::try_to_lock);
    if (!lk.owns_lock()) return;
    int w = m_clientEqTapTxWrite;
    const int frames = samples / channels;
    for (int i = 0; i < frames; ++i) {
        float mono;
        if (channels == 2) {
            mono = 0.5f * (f32[i * 2] + f32[i * 2 + 1]);
        } else {
            mono = f32[i];
        }
        m_clientEqTapTx[w] = mono;
        w = (w + 1) & (kClientEqTapSize - 1);
    }
    m_clientEqTapTxWrite = w;
}

bool AudioEngine::copyRecentClientEqRxSamples(float* out, int count) const
{
    if (!out || count <= 0 || count > kClientEqTapSize) return false;
    std::lock_guard<std::mutex> lk(m_clientEqTapMutex);
    int w = m_clientEqTapRxWrite;
    for (int i = 0; i < count; ++i) {
        // Fill newest-last: out[count-1] is the most recent sample.
        const int idx = (w - count + i + kClientEqTapSize) & (kClientEqTapSize - 1);
        out[i] = m_clientEqTapRx[idx];
    }
    return true;
}

bool AudioEngine::copyRecentClientEqTxSamples(float* out, int count) const
{
    if (!out || count <= 0 || count > kClientEqTapSize) return false;
    std::lock_guard<std::mutex> lk(m_clientEqTapMutex);
    int w = m_clientEqTapTxWrite;
    for (int i = 0; i < count; ++i) {
        const int idx = (w - count + i + kClientEqTapSize) & (kClientEqTapSize - 1);
        out[i] = m_clientEqTapTx[idx];
    }
    return true;
}

void AudioEngine::applyClientEqTxInt16(QByteArray& int16stereo)
{
    if (int16stereo.isEmpty()) return;
    const int samples = int16stereo.size() / static_cast<int>(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<int>(sizeof(float)));
        auto* f32 = reinterpret_cast<float*>(m_clientEqTxScratch.data());
        const auto* i16 = reinterpret_cast<const int16_t*>(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<int16_t*>(int16stereo.data());
        for (int i = 0; i < samples; ++i) {
            out[i] = static_cast<int16_t>(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<const int16_t*>(int16stereo.constData()),
                       frames);
}

void AudioEngine::applyClientEqTxFloat32(QByteArray& float32)
{
    if (float32.isEmpty()) return;
    const int samples = float32.size() / static_cast<int>(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<float*>(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<const float*>(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<int>(sizeof(int16_t));
    if ((samples & 1) != 0) return;
    const int frames = samples / 2;

    m_clientCompTxScratch.resize(samples * static_cast<int>(sizeof(float)));
    auto* f32 = reinterpret_cast<float*>(m_clientCompTxScratch.data());
    const auto* i16 = reinterpret_cast<const int16_t*>(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<int16_t*>(int16stereo.data());
    for (int i = 0; i < samples; ++i) {
        out[i] = static_cast<int16_t>(
            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<int>(sizeof(float));
    const int channels = (samples % 2 == 0) ? 2 : 1;
    const int frames   = samples / channels;
    m_clientCompTx->process(reinterpret_cast<float*>(float32.data()),
                            frames, channels);
}

void AudioEngine::applyClientCompRxFloat32(QByteArray& float32)
{
    if (!m_clientCompRx || !m_clientCompRx->isEnabled()) return;
    if (float32.isEmpty()) return;
    const int samples = float32.size() / static_cast<int>(sizeof(float));
    if ((samples & 1) != 0) return;
    const int frames = samples / 2;
    m_clientCompRx->process(reinterpret_cast<float*>(float32.data()),
                            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<int>(sizeof(int16_t));
    if ((samples & 1) != 0) return;
    const int frames = samples / 2;

    m_clientGateTxScratch.resize(samples * static_cast<int>(sizeof(float)));
    auto* f32 = reinterpret_cast<float*>(m_clientGateTxScratch.data());
    const auto* i16 = reinterpret_cast<const int16_t*>(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<int16_t*>(int16stereo.data());
    for (int i = 0; i < samples; ++i) {
        out[i] = static_cast<int16_t>(
            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<int>(sizeof(float));
    const int channels = (samples % 2 == 0) ? 2 : 1;
    const int frames   = samples / channels;
    m_clientGateTx->process(reinterpret_cast<float*>(float32.data()),
                            frames, channels);
}

void AudioEngine::applyClientGateRxFloat32(QByteArray& float32)
{
    if (!m_clientGateRx || !m_clientGateRx->isEnabled()) return;
    if (float32.isEmpty()) return;
    const int samples = float32.size() / static_cast<int>(sizeof(float));
    if ((samples & 1) != 0) return;
    const int frames = samples / 2;  // RX path is always stereo
    m_clientGateRx->process(reinterpret_cast<float*>(float32.data()),
                            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<int>(sizeof(int16_t));
    if ((samples & 1) != 0) return;
    const int frames = samples / 2;

    m_clientDeEssTxScratch.resize(samples * static_cast<int>(sizeof(float)));
    auto* f32 = reinterpret_cast<float*>(m_clientDeEssTxScratch.data());
    const auto* i16 = reinterpret_cast<const int16_t*>(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<int16_t*>(int16stereo.data());
    for (int i = 0; i < samples; ++i) {
        out[i] = static_cast<int16_t>(
            std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f));
    }
}

void AudioEngine::applyClientDeEssRxFloat32(QByteArray& float32)
{
    if (!m_clientDeEssRx || !m_clientDeEssRx->isEnabled()) return;
    const int frames = float32.size() / static_cast<int>(sizeof(float)) / 2;
    if (frames <= 0) return;

    m_clientDeEssRx->process(reinterpret_cast<float*>(float32.data()),
                             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<int>(sizeof(float));
    const int channels = (samples % 2 == 0) ? 2 : 1;
    const int frames   = samples / channels;
    m_clientDeEssTx->process(reinterpret_cast<float*>(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<int>(sizeof(int16_t));
    if ((samples & 1) != 0) return;
    const int frames = samples / 2;

    m_clientTubeTxScratch.resize(samples * static_cast<int>(sizeof(float)));
    auto* f32 = reinterpret_cast<float*>(m_clientTubeTxScratch.data());
    const auto* i16 = reinterpret_cast<const int16_t*>(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<int16_t*>(int16stereo.data());
    for (int i = 0; i < samples; ++i) {
        out[i] = static_cast<int16_t>(
            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<int>(sizeof(float));
    const int channels = (samples % 2 == 0) ? 2 : 1;
    const int frames   = samples / channels;
    m_clientTubeTx->process(reinterpret_cast<float*>(float32.data()),
                            frames, channels);
}

void AudioEngine::applyClientTubeRxFloat32(QByteArray& float32)
{
    if (!m_clientTubeRx || !m_clientTubeRx->isEnabled()) return;
    if (float32.isEmpty()) return;
    const int samples = float32.size() / static_cast<int>(sizeof(float));
    if ((samples & 1) != 0) return;
    const int frames = samples / 2;
    m_clientTubeRx->process(reinterpret_cast<float*>(float32.data()),
                            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<int>(sizeof(int16_t));
    if ((samples & 1) != 0) return;
    const int frames = samples / 2;

    m_clientPuduTxScratch.resize(samples * static_cast<int>(sizeof(float)));
    auto* f32 = reinterpret_cast<float*>(m_clientPuduTxScratch.data());
    const auto* i16 = reinterpret_cast<const int16_t*>(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<int16_t*>(int16stereo.data());
    for (int i = 0; i < samples; ++i) {
        out[i] = static_cast<int16_t>(
            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<int>(sizeof(float));
    const int channels = (samples % 2 == 0) ? 2 : 1;
    const int frames   = samples / channels;
    m_clientPuduTx->process(reinterpret_cast<float*>(float32.data()),
                            frames, channels);
}

void AudioEngine::applyClientPuduRxFloat32(QByteArray& float32)
{
    if (!m_clientPuduRx || !m_clientPuduRx->isEnabled()) return;
    if (float32.isEmpty()) return;
    const int samples = float32.size() / static_cast<int>(sizeof(float));
    if ((samples & 1) != 0) return;
    const int frames = samples / 2;
    m_clientPuduRx->process(reinterpret_cast<float*>(float32.data()),
                            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<int>(sizeof(int16_t));
    if ((samples & 1) != 0) return;
    const int frames = samples / 2;

    m_clientReverbTxScratch.resize(samples * static_cast<int>(sizeof(float)));
    auto* f32 = reinterpret_cast<float*>(m_clientReverbTxScratch.data());
    const auto* i16 = reinterpret_cast<const int16_t*>(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<int16_t*>(int16stereo.data());
    for (int i = 0; i < samples; ++i) {
        out[i] = static_cast<int16_t>(
            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<int>(sizeof(int16_t));
    if ((samples & 1) != 0) return;
    const int frames = samples / 2;

    m_clientFinalLimiterTxScratch.resize(samples * static_cast<int>(sizeof(float)));
    auto* f32 = reinterpret_cast<float*>(m_clientFinalLimiterTxScratch.data());
    const auto* i16 = reinterpret_cast<const int16_t*>(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<int16_t*>(int16stereo.data());
    for (int i = 0; i < samples; ++i) {
        out[i] = static_cast<int16_t>(
            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<int>(sizeof(float));
    const int channels = (samples % 2 == 0) ? 2 : 1;
    const int frames   = samples / channels;
    m_clientFinalLimiterTx->process(reinterpret_cast<float*>(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<int>(sizeof(float));
    const int channels = (samples % 2 == 0) ? 2 : 1;
    const int frames   = samples / channels;
    m_clientReverbTx->process(reinterpret_cast<float*>(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<TxChainStage>((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<TxChainStage>((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
    // its per-stage apply helper.  Phase 0 ships with no implemented
    // stages — every entry is a no-op until Phase 1+ slot in the DSP
    // classes (RX EQ first).  Same atomic-load pattern as TX so the
    // audio thread reads the entire chain order in one access.
    const uint64_t packed = m_rxChainPacked.load(std::memory_order_acquire);
    for (int i = 0; i < kMaxRxChainStages; ++i) {
        const auto stage = static_cast<RxChainStage>((packed >> (i * 8)) & 0xFF);
        switch (stage) {
            case RxChainStage::None:  return;     // end-of-list marker
            case RxChainStage::Eq:    /* TODO Phase 1 */ break;
            case RxChainStage::Gate:  /* TODO Phase 2 */ break;
            case RxChainStage::Comp:  /* TODO Phase 3 */ break;
            case RxChainStage::Tube:  /* TODO Phase 4 */ break;
            case RxChainStage::Pudu:  /* TODO Phase 5 */ break;
            case RxChainStage::DeEss: /* TODO Phase 6 */ break;
        }
    }
    (void)float32;  // unused until first stage lands
}

namespace {

// Pack a stage list into the uint64_t atomic format used by the audio
// thread.  Unused slots are TxChainStage::None (0).
uint64_t packChain(const QVector<AudioEngine::TxChainStage>& stages)
{
    uint64_t v = 0;
    const int n = std::min(static_cast<int>(stages.size()),
                           AudioEngine::kMaxTxChainStages);
    for (int i = 0; i < n; ++i) {
        v |= static_cast<uint64_t>(static_cast<uint8_t>(stages[i])) << (i * 8);
    }
    return v;
}

QVector<AudioEngine::TxChainStage> unpackChain(uint64_t v)
{
    QVector<AudioEngine::TxChainStage> out;
    out.reserve(AudioEngine::kMaxTxChainStages);
    for (int i = 0; i < AudioEngine::kMaxTxChainStages; ++i) {
        const auto s = static_cast<AudioEngine::TxChainStage>((v >> (i * 8)) & 0xFF);
        if (s == AudioEngine::TxChainStage::None) break;
        out.append(s);
    }
    return out;
}

// Map persisted stage names (human-readable in the XML settings) to
// the enum and back.  Keeping names textual means a settings file can
// be inspected and edited without decoding byte values.
QString stageName(AudioEngine::TxChainStage s)
{
    switch (s) {
        case AudioEngine::TxChainStage::Gate:   return "Gate";
        case AudioEngine::TxChainStage::Eq:     return "Eq";
        case AudioEngine::TxChainStage::DeEss:  return "DeEss";
        case AudioEngine::TxChainStage::Comp:   return "Comp";
        case AudioEngine::TxChainStage::Tube:   return "Tube";
        case AudioEngine::TxChainStage::Enh:    return "Enh";
        case AudioEngine::TxChainStage::Reverb: return "Reverb";
        case AudioEngine::TxChainStage::None:   return "";
    }
    return "";
}

AudioEngine::TxChainStage stageFromName(const QString& name)
{
    if (name == "Gate")   return AudioEngine::TxChainStage::Gate;
    if (name == "Eq")     return AudioEngine::TxChainStage::Eq;
    if (name == "DeEss")  return AudioEngine::TxChainStage::DeEss;
    if (name == "Comp")   return AudioEngine::TxChainStage::Comp;
    if (name == "Tube")   return AudioEngine::TxChainStage::Tube;
    if (name == "Enh")    return AudioEngine::TxChainStage::Enh;
    if (name == "Reverb") return AudioEngine::TxChainStage::Reverb;
    return AudioEngine::TxChainStage::None;
}

// 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.
QVector<AudioEngine::TxChainStage> defaultChain()
{
    return {
        AudioEngine::TxChainStage::Gate,
        AudioEngine::TxChainStage::Eq,
        AudioEngine::TxChainStage::DeEss,
        AudioEngine::TxChainStage::Comp,
        AudioEngine::TxChainStage::Tube,
        AudioEngine::TxChainStage::Enh,
        AudioEngine::TxChainStage::Reverb,
    };
}

// ── RX chain helpers — parallel to the TX functions above ───────────────

uint64_t packRxChain(const QVector<AudioEngine::RxChainStage>& stages)
{
    uint64_t v = 0;
    const int n = std::min(static_cast<int>(stages.size()),
                           AudioEngine::kMaxRxChainStages);
    for (int i = 0; i < n; ++i) {
        v |= static_cast<uint64_t>(static_cast<uint8_t>(stages[i])) << (i * 8);
    }
    return v;
}

QVector<AudioEngine::RxChainStage> unpackRxChain(uint64_t v)
{
    QVector<AudioEngine::RxChainStage> out;
    out.reserve(AudioEngine::kMaxRxChainStages);
    for (int i = 0; i < AudioEngine::kMaxRxChainStages; ++i) {
        const auto s = static_cast<AudioEngine::RxChainStage>((v >> (i * 8)) & 0xFF);
        if (s == AudioEngine::RxChainStage::None) break;
        out.append(s);
    }
    return out;
}

QString rxStageName(AudioEngine::RxChainStage s)
{
    switch (s) {
        case AudioEngine::RxChainStage::Eq:    return "Eq";
        case AudioEngine::RxChainStage::Gate:  return "Gate";
        case AudioEngine::RxChainStage::Comp:  return "Comp";
        case AudioEngine::RxChainStage::Tube:  return "Tube";
        case AudioEngine::RxChainStage::Pudu:  return "Pudu";
        case AudioEngine::RxChainStage::DeEss: return "DeEss";
        case AudioEngine::RxChainStage::None:  return "";
    }
    return "";
}

AudioEngine::RxChainStage rxStageFromName(const QString& name)
{
    if (name == "Eq")    return AudioEngine::RxChainStage::Eq;
    if (name == "Gate")  return AudioEngine::RxChainStage::Gate;
    if (name == "Comp")  return AudioEngine::RxChainStage::Comp;
    if (name == "Tube")  return AudioEngine::RxChainStage::Tube;
    if (name == "Pudu")  return AudioEngine::RxChainStage::Pudu;
    if (name == "DeEss") return AudioEngine::RxChainStage::DeEss;
    return AudioEngine::RxChainStage::None;
}

// Canonical RX chain order (#2425):
//   [RADIO]→[ADSP]→[AGC-T]→[EQ]→[AGC-C]→[DESS]→[TUBE]→[EVO]→[SPEAK]
// RADIO / ADSP / SPEAK are status/launcher tiles handled by the chain
// widget; the audio path only sees the six user-controllable stages
// between them, in the order: Gate, Eq, Comp, DeEss, Tube, Pudu.
QVector<AudioEngine::RxChainStage> defaultRxChain()
{
    return {
        AudioEngine::RxChainStage::Gate,
        AudioEngine::RxChainStage::Eq,
        AudioEngine::RxChainStage::Comp,
        AudioEngine::RxChainStage::DeEss,
        AudioEngine::RxChainStage::Tube,
        AudioEngine::RxChainStage::Pudu,
    };
}

} // namespace

void AudioEngine::setTxChainStages(const QVector<TxChainStage>& stages)
{
    m_txChainPacked.store(packChain(stages), std::memory_order_release);
    QStringList names;
    for (auto s : stages) {
        const QString n = stageName(s);
        if (!n.isEmpty()) names.append(n);
    }
    AppSettings::instance().setValue(
        "ClientCompTxChainStages", names.join(","));
}

QVector<AudioEngine::TxChainStage> AudioEngine::txChainStages() const
{
    return unpackChain(m_txChainPacked.load(std::memory_order_acquire));
}

bool AudioEngine::isTxBypassed() const
{
    return m_txBypassActive;
}

void AudioEngine::setTxBypassed(bool on)
{
    if (on == isTxBypassed()) return;

    auto setStageEnabled = [this](TxChainStage s, bool enabled) {
        switch (s) {
            case TxChainStage::Eq:
                if (m_clientEqTx) {
                    m_clientEqTx->setEnabled(enabled);
                    saveClientEqSettings();
                }
                break;
            case TxChainStage::Comp:
                if (m_clientCompTx) {
                    m_clientCompTx->setEnabled(enabled);
                    saveClientCompSettings();
                }
                break;
            case TxChainStage::Gate:
                if (m_clientGateTx) {
                    m_clientGateTx->setEnabled(enabled);
                    saveClientGateSettings();
                }
                break;
            case TxChainStage::DeEss:
                if (m_clientDeEssTx) {
                    m_clientDeEssTx->setEnabled(enabled);
                    saveClientDeEssSettings();
                }
                break;
            case TxChainStage::Tube:
                if (m_clientTubeTx) {
                    m_clientTubeTx->setEnabled(enabled);
                    saveClientTubeSettings();
                }
                break;
            case TxChainStage::Enh:   // PUDU
                if (m_clientPuduTx) {
                    m_clientPuduTx->setEnabled(enabled);
                    saveClientPuduSettings();
                }
                break;
            case TxChainStage::Reverb:
                if (m_clientReverbTx) {
                    m_clientReverbTx->setEnabled(enabled);
                    saveClientReverbSettings();
                }
                break;
            case TxChainStage::None:
                break;
        }
    };

    auto isEnabled = [this](TxChainStage s) -> bool {
        switch (s) {
            case TxChainStage::Eq:     return m_clientEqTx     && m_clientEqTx->isEnabled();
            case TxChainStage::Comp:   return m_clientCompTx   && m_clientCompTx->isEnabled();
            case TxChainStage::Gate:   return m_clientGateTx   && m_clientGateTx->isEnabled();
            case TxChainStage::DeEss:  return m_clientDeEssTx  && m_clientDeEssTx->isEnabled();
            case TxChainStage::Tube:   return m_clientTubeTx   && m_clientTubeTx->isEnabled();
            case TxChainStage::Enh:    return m_clientPuduTx   && m_clientPuduTx->isEnabled();
            case TxChainStage::Reverb: return m_clientReverbTx && m_clientReverbTx->isEnabled();
            case TxChainStage::None:   return false;
        }
        return false;
    };

    static const QVector<TxChainStage> kAllStages{
        TxChainStage::Eq,
        TxChainStage::Comp,
        TxChainStage::Gate,
        TxChainStage::DeEss,
        TxChainStage::Tube,
        TxChainStage::Enh,
        TxChainStage::Reverb,
    };

    if (on) {
        m_txBypassSnapshot.clear();
        for (auto s : kAllStages) {
            if (isEnabled(s)) {
                m_txBypassSnapshot.append(s);
                setStageEnabled(s, false);
            }
        }
        // RN2 TX is not in TxChainStage but is conceptually part of the
        // chain — it runs on the voice path ahead of the user DSP chain
        // (AudioEngine.cpp onTxAudioReady, #2813).  Without snapshotting
        // it here, BYPASS leaves RN2 actively denoising while every
        // visible stage is off, which makes BYPASS appear to almost
        // work — voice passes (RN2 was trained on it) but other audio
        // is suppressed.  See #3054.
        m_txBypassSnapshotRn2 = m_rn2TxEnabled.load();
        if (m_txBypassSnapshotRn2) setRn2TxEnabled(false);
    } else {
        for (auto s : m_txBypassSnapshot) setStageEnabled(s, true);
        m_txBypassSnapshot.clear();
        if (m_txBypassSnapshotRn2) setRn2TxEnabled(true);
        m_txBypassSnapshotRn2 = false;
    }

    m_txBypassActive = on;
    emit txBypassChanged(on);
}

bool AudioEngine::isRxBypassed() const
{
    return m_rxBypassActive;
}

void AudioEngine::setRxBypassed(bool on)
{
    if (on == isRxBypassed()) return;

    auto setStageEnabled = [this](RxChainStage s, bool enabled) {
        switch (s) {
            case RxChainStage::Eq:
                if (m_clientEqRx) {
                    m_clientEqRx->setEnabled(enabled);
                    saveClientEqSettings();
                }
                break;
            case RxChainStage::Gate:
                if (m_clientGateRx) {
                    m_clientGateRx->setEnabled(enabled);
                    saveClientGateRxSettings();
                }
                break;
            case RxChainStage::Comp:
                if (m_clientCompRx) {
                    m_clientCompRx->setEnabled(enabled);
                    saveClientCompRxSettings();
                }
                break;
            case RxChainStage::Tube:
                if (m_clientTubeRx) {
                    m_clientTubeRx->setEnabled(enabled);
                    saveClientTubeRxSettings();
                }
                break;
            case RxChainStage::Pudu:
                if (m_clientPuduRx) {
                    m_clientPuduRx->setEnabled(enabled);
                    saveClientPuduRxSettings();
                }
                break;
            case RxChainStage::DeEss:
                if (m_clientDeEssRx) {
                    m_clientDeEssRx->setEnabled(enabled);
                    saveClientDeEssRxSettings();
                }
                break;
            case RxChainStage::None:
                break;
        }
    };

    auto isEnabled = [this](RxChainStage s) -> bool {
        switch (s) {
            case RxChainStage::Eq:    return m_clientEqRx    && m_clientEqRx->isEnabled();
            case RxChainStage::Gate:  return m_clientGateRx  && m_clientGateRx->isEnabled();
            case RxChainStage::Comp:  return m_clientCompRx  && m_clientCompRx->isEnabled();
            case RxChainStage::Tube:  return m_clientTubeRx  && m_clientTubeRx->isEnabled();
            case RxChainStage::Pudu:  return m_clientPuduRx  && m_clientPuduRx->isEnabled();
            case RxChainStage::DeEss: return m_clientDeEssRx && m_clientDeEssRx->isEnabled();
            case RxChainStage::None:  return false;
        }
        return false;
    };

    static const QVector<RxChainStage> kAllStages{
        RxChainStage::Eq,
        RxChainStage::Gate,
        RxChainStage::Comp,
        RxChainStage::Tube,
        RxChainStage::Pudu,
        RxChainStage::DeEss,
    };

    if (on) {
        m_rxBypassSnapshot.clear();
        for (auto s : kAllStages) {
            if (isEnabled(s)) {
                m_rxBypassSnapshot.append(s);
                setStageEnabled(s, false);
            }
        }
        // RX RN2 lives in the NR cluster — not in RxChainStage — but
        // BYPASS must still suppress it so the bypassed RX path is
        // genuinely transparent rather than "everything except the
        // neural denoiser".  Mirrors the TX-side fix above (#3054).
        m_rxBypassSnapshotRn2 = m_rn2Enabled.load();
        if (m_rxBypassSnapshotRn2) setRn2Enabled(false);
    } else {
        for (auto s : m_rxBypassSnapshot) setStageEnabled(s, true);
        m_rxBypassSnapshot.clear();
        if (m_rxBypassSnapshotRn2) setRn2Enabled(true);
        m_rxBypassSnapshotRn2 = false;
    }

    m_rxBypassActive = on;
    emit rxBypassChanged(on);
}

void AudioEngine::setRxChainStages(const QVector<RxChainStage>& stages)
{
    m_rxChainPacked.store(packRxChain(stages), std::memory_order_release);
    QStringList names;
    for (auto s : stages) {
        const QString n = rxStageName(s);
        if (!n.isEmpty()) names.append(n);
    }
    AppSettings::instance().setValue(
        "ClientRxChainStages", names.join(","));
}

QVector<AudioEngine::RxChainStage> AudioEngine::rxChainStages() const
{
    return unpackRxChain(m_rxChainPacked.load(std::memory_order_acquire));
}

void AudioEngine::loadClientRxChainOrder()
{
    auto& s = AppSettings::instance();
    QVector<RxChainStage> stages;
    bool sawUnknown = false;
    const QString stored = s.value("ClientRxChainStages", "").toString();
    if (!stored.isEmpty()) {
        for (const QString& name : stored.split(',', Qt::SkipEmptyParts)) {
            const auto stage = rxStageFromName(name.trimmed());
            if (stage != RxChainStage::None) stages.append(stage);
            else                              sawUnknown = true;
        }
    }
    // Any unknown name in the stored list is a strong signal that the
    // settings file is from a different (or old) version of AetherSDR.
    // Reset to the canonical default rather than silently filtering the
    // unknown entries — that filtering shuffles the remaining stages
    // into a misleading order.
    const bool resetFromStale = sawUnknown;
    if (sawUnknown || stages.isEmpty()) stages = defaultRxChain();

    // Append any canonical stages missing from the loaded list so future
    // phases slot in without a migration.
    for (auto canon : defaultRxChain()) {
        if (!stages.contains(canon)) stages.append(canon);
    }
    m_rxChainPacked.store(packRxChain(stages), std::memory_order_release);

    // Overwrite the stale value on disk so the user's settings file
    // doesn't keep showing names from a previous build.
    if (resetFromStale) {
        QStringList names;
        for (auto st : stages) {
            const QString n = rxStageName(st);
            if (!n.isEmpty()) names.append(n);
        }
        s.setValue("ClientRxChainStages", names.join(","));
    }
}

void AudioEngine::saveClientRxChainOrder() const
{
    QStringList names;
    for (auto s : rxChainStages()) {
        const QString n = rxStageName(s);
        if (!n.isEmpty()) names.append(n);
    }
    AppSettings::instance().setValue("ClientRxChainStages", names.join(","));
}

void AudioEngine::setTxChainOrder(TxChainOrder order)
{
    // Legacy two-stage API used by the existing ClientCompEditor combo.
    // Find Eq and Comp in the current chain; swap their relative
    // positions to match the requested order, preserving every other
    // stage's slot.  Falls back to just [Eq, Comp] / [Comp, Eq] if
    // the chain is empty.
    auto stages = txChainStages();
    if (stages.isEmpty()) stages = defaultChain();

    const int eqIdx   = stages.indexOf(TxChainStage::Eq);
    const int compIdx = stages.indexOf(TxChainStage::Comp);
    if (eqIdx >= 0 && compIdx >= 0) {
        const bool compFirst = compIdx < eqIdx;
        const bool wantCompFirst = (order == TxChainOrder::CompThenEq);
        if (compFirst != wantCompFirst) stages.swapItemsAt(eqIdx, compIdx);
    }
    setTxChainStages(stages);
}

AudioEngine::TxChainOrder AudioEngine::txChainOrder() const
{
    const auto stages = txChainStages();
    const int eqIdx   = stages.indexOf(TxChainStage::Eq);
    const int compIdx = stages.indexOf(TxChainStage::Comp);
    if (eqIdx >= 0 && compIdx >= 0 && compIdx < eqIdx) {
        return TxChainOrder::CompThenEq;
    }
    return (eqIdx >= 0 && compIdx >= 0) ? TxChainOrder::EqThenComp
                                        : TxChainOrder::CompThenEq;
}

void AudioEngine::loadClientCompSettings()
{
    if (!m_clientCompTx) return;
    auto& s = AppSettings::instance();
    m_clientCompTx->setEnabled(
        s.value("ClientCompTxEnabled", "False").toString() == "True");
    m_clientCompTx->setThresholdDb(
        s.value("ClientCompTxThresholdDb", "-18.0").toFloat());
    m_clientCompTx->setRatio(
        s.value("ClientCompTxRatio", "3.0").toFloat());
    m_clientCompTx->setAttackMs(
        s.value("ClientCompTxAttackMs", "20.0").toFloat());
    m_clientCompTx->setReleaseMs(
        s.value("ClientCompTxReleaseMs", "200.0").toFloat());
    m_clientCompTx->setKneeDb(
        s.value("ClientCompTxKneeDb", "6.0").toFloat());
    m_clientCompTx->setMakeupDb(
        s.value("ClientCompTxMakeupDb", "0.0").toFloat());
    m_clientCompTx->setLimiterEnabled(
        s.value("ClientCompTxLimEnabled", "True").toString() == "True");
    m_clientCompTx->setLimiterCeilingDb(
        s.value("ClientCompTxLimCeilingDb", "-1.0").toFloat());
    m_clientCompTx->setDriveDb(
        s.value("ClientCompTxDriveDb", "0.0").toFloat());
    m_clientCompTx->setPhaseRotatorStages(
        s.value("ClientCompTxPhaseRotatorStages", "0").toInt());

    // Load the generalised chain — stored as a comma-separated list of
    // stage names (e.g. "Gate,Eq,DeEss,Comp,Tube,Enh").  Migrate from
    // the older two-state ClientCompTxChainOrder (0 = CompThenEq,
    // 1 = EqThenComp) if present.
    QVector<TxChainStage> stages;
    const QString stored = s.value("ClientCompTxChainStages", "").toString();
    if (!stored.isEmpty()) {
        for (const QString& name : stored.split(',', Qt::SkipEmptyParts)) {
            const auto stage = stageFromName(name.trimmed());
            if (stage != TxChainStage::None) stages.append(stage);
        }
    } else if (s.contains("ClientCompTxChainOrder")) {
        const int legacy = s.value("ClientCompTxChainOrder", "0").toInt();
        // Preserve the user's Comp-vs-Eq preference from the old two-
        // option setting — bracket it with the default canonical
        // layout for the not-yet-implemented stages.
        stages = (legacy == 1)
            ? QVector<TxChainStage>{TxChainStage::Gate, TxChainStage::Eq,
                                     TxChainStage::DeEss, TxChainStage::Comp,
                                     TxChainStage::Tube, TxChainStage::Enh}
            : QVector<TxChainStage>{TxChainStage::Gate, TxChainStage::Comp,
                                     TxChainStage::Eq, TxChainStage::DeEss,
                                     TxChainStage::Tube, TxChainStage::Enh};
    }
    if (stages.isEmpty()) stages = defaultChain();

    // Append any canonical stages that are missing from the loaded
    // list — guarantees all 6 processor boxes are always visible in
    // the chain widget so users can reorder them ahead of time and
    // future phases slot in automatically without a second migration.
    for (auto canon : defaultChain()) {
        if (!stages.contains(canon)) stages.append(canon);
    }

    m_txChainPacked.store(packChain(stages), std::memory_order_release);
}

void AudioEngine::saveClientCompSettings() const
{
    if (!m_clientCompTx) return;
    auto& s = AppSettings::instance();
    auto toBool = [](bool on) { return on ? QString("True") : QString("False"); };
    s.setValue("ClientCompTxEnabled",     toBool(m_clientCompTx->isEnabled()));
    s.setValue("ClientCompTxThresholdDb", QString::number(m_clientCompTx->thresholdDb()));
    s.setValue("ClientCompTxRatio",       QString::number(m_clientCompTx->ratio()));
    s.setValue("ClientCompTxAttackMs",    QString::number(m_clientCompTx->attackMs()));
    s.setValue("ClientCompTxReleaseMs",   QString::number(m_clientCompTx->releaseMs()));
    s.setValue("ClientCompTxKneeDb",      QString::number(m_clientCompTx->kneeDb()));
    s.setValue("ClientCompTxMakeupDb",    QString::number(m_clientCompTx->makeupDb()));
    s.setValue("ClientCompTxLimEnabled",  toBool(m_clientCompTx->limiterEnabled()));
    s.setValue("ClientCompTxLimCeilingDb",
               QString::number(m_clientCompTx->limiterCeilingDb()));
    s.setValue("ClientCompTxDriveDb",
               QString::number(m_clientCompTx->driveDb()));
    s.setValue("ClientCompTxPhaseRotatorStages",
               QString::number(m_clientCompTx->phaseRotatorStages()));
    // Chain stages persist as a comma-separated name list — already
    // written live by setTxChainStages() but re-emitted here so a
    // saveClientCompSettings() call dumps everything in sync.
    QStringList names;
    for (auto st : txChainStages()) {
        const QString n = stageName(st);
        if (!n.isEmpty()) names.append(n);
    }
    s.setValue("ClientCompTxChainStages", names.join(","));
}

void AudioEngine::loadClientCompRxSettings()
{
    if (!m_clientCompRx) return;
    auto& s = AppSettings::instance();
    m_clientCompRx->setEnabled(
        s.value("ClientCompRxEnabled", "False").toString() == "True");
    m_clientCompRx->setThresholdDb(
        s.value("ClientCompRxThresholdDb", "-18.0").toFloat());
    m_clientCompRx->setRatio(
        s.value("ClientCompRxRatio", "3.0").toFloat());
    m_clientCompRx->setAttackMs(
        s.value("ClientCompRxAttackMs", "20.0").toFloat());
    m_clientCompRx->setReleaseMs(
        s.value("ClientCompRxReleaseMs", "200.0").toFloat());
    m_clientCompRx->setKneeDb(
        s.value("ClientCompRxKneeDb", "6.0").toFloat());
    m_clientCompRx->setMakeupDb(
        s.value("ClientCompRxMakeupDb", "0.0").toFloat());
    m_clientCompRx->setLimiterEnabled(
        s.value("ClientCompRxLimEnabled", "True").toString() == "True");
    m_clientCompRx->setLimiterCeilingDb(
        s.value("ClientCompRxLimCeilingDb", "-1.0").toFloat());
}

void AudioEngine::saveClientCompRxSettings() const
{
    if (!m_clientCompRx) return;
    auto& s = AppSettings::instance();
    auto toBool = [](bool on) { return on ? QString("True") : QString("False"); };
    s.setValue("ClientCompRxEnabled",     toBool(m_clientCompRx->isEnabled()));
    s.setValue("ClientCompRxThresholdDb", QString::number(m_clientCompRx->thresholdDb()));
    s.setValue("ClientCompRxRatio",       QString::number(m_clientCompRx->ratio()));
    s.setValue("ClientCompRxAttackMs",    QString::number(m_clientCompRx->attackMs()));
    s.setValue("ClientCompRxReleaseMs",   QString::number(m_clientCompRx->releaseMs()));
    s.setValue("ClientCompRxKneeDb",      QString::number(m_clientCompRx->kneeDb()));
    s.setValue("ClientCompRxMakeupDb",    QString::number(m_clientCompRx->makeupDb()));
    s.setValue("ClientCompRxLimEnabled",  toBool(m_clientCompRx->limiterEnabled()));
    s.setValue("ClientCompRxLimCeilingDb",
               QString::number(m_clientCompRx->limiterCeilingDb()));
}

void AudioEngine::loadClientGateSettings()
{
    if (!m_clientGateTx) return;
    auto& s = AppSettings::instance();
    m_clientGateTx->setEnabled(
        s.value("ClientGateTxEnabled", "False").toString() == "True");
    // Mode first — it snaps ratio + floor to presets, so apply before
    // those two so a persisted mode doesn't overwrite a custom ratio.
    const int modeInt = s.value("ClientGateTxMode", "0").toInt();
    m_clientGateTx->setMode(modeInt == 1
        ? ClientGate::Mode::Gate
        : ClientGate::Mode::Expander);
    m_clientGateTx->setThresholdDb(
        s.value("ClientGateTxThresholdDb", "-40.0").toFloat());
    m_clientGateTx->setReturnDb(
        s.value("ClientGateTxReturnDb", "2.0").toFloat());
    m_clientGateTx->setRatio(
        s.value("ClientGateTxRatio", "2.0").toFloat());
    m_clientGateTx->setAttackMs(
        s.value("ClientGateTxAttackMs", "0.5").toFloat());
    m_clientGateTx->setHoldMs(
        s.value("ClientGateTxHoldMs", "20.0").toFloat());
    m_clientGateTx->setReleaseMs(
        s.value("ClientGateTxReleaseMs", "100.0").toFloat());
    m_clientGateTx->setFloorDb(
        s.value("ClientGateTxFloorDb", "-15.0").toFloat());
    m_clientGateTx->setLookaheadMs(
        s.value("ClientGateTxLookaheadMs", "0.0").toFloat());
}

void AudioEngine::saveClientGateSettings() const
{
    if (!m_clientGateTx) return;
    auto& s = AppSettings::instance();
    auto toBool = [](bool on) { return on ? QString("True") : QString("False"); };
    s.setValue("ClientGateTxEnabled", toBool(m_clientGateTx->isEnabled()));
    s.setValue("ClientGateTxMode",
        QString::number(static_cast<int>(m_clientGateTx->mode())));
    s.setValue("ClientGateTxThresholdDb",
        QString::number(m_clientGateTx->thresholdDb()));
    s.setValue("ClientGateTxReturnDb",
        QString::number(m_clientGateTx->returnDb()));
    s.setValue("ClientGateTxRatio",
        QString::number(m_clientGateTx->ratio()));
    s.setValue("ClientGateTxAttackMs",
        QString::number(m_clientGateTx->attackMs()));
    s.setValue("ClientGateTxHoldMs",
        QString::number(m_clientGateTx->holdMs()));
    s.setValue("ClientGateTxReleaseMs",
        QString::number(m_clientGateTx->releaseMs()));
    s.setValue("ClientGateTxFloorDb",
        QString::number(m_clientGateTx->floorDb()));
    s.setValue("ClientGateTxLookaheadMs",
        QString::number(m_clientGateTx->lookaheadMs()));
}

void AudioEngine::loadClientGateRxSettings()
{
    if (!m_clientGateRx) return;
    auto& s = AppSettings::instance();
    m_clientGateRx->setEnabled(
        s.value("ClientGateRxEnabled", "False").toString() == "True");
    const int modeInt = s.value("ClientGateRxMode", "0").toInt();
    m_clientGateRx->setMode(modeInt == 1
        ? ClientGate::Mode::Gate
        : ClientGate::Mode::Expander);
    m_clientGateRx->setThresholdDb(
        s.value("ClientGateRxThresholdDb", "-40.0").toFloat());
    m_clientGateRx->setReturnDb(
        s.value("ClientGateRxReturnDb", "2.0").toFloat());
    m_clientGateRx->setRatio(
        s.value("ClientGateRxRatio", "2.0").toFloat());
    m_clientGateRx->setAttackMs(
        s.value("ClientGateRxAttackMs", "0.5").toFloat());
    m_clientGateRx->setHoldMs(
        s.value("ClientGateRxHoldMs", "20.0").toFloat());
    m_clientGateRx->setReleaseMs(
        s.value("ClientGateRxReleaseMs", "100.0").toFloat());
    m_clientGateRx->setFloorDb(
        s.value("ClientGateRxFloorDb", "-15.0").toFloat());
    m_clientGateRx->setLookaheadMs(
        s.value("ClientGateRxLookaheadMs", "0.0").toFloat());
}

void AudioEngine::saveClientGateRxSettings() const
{
    if (!m_clientGateRx) return;
    auto& s = AppSettings::instance();
    auto toBool = [](bool on) { return on ? QString("True") : QString("False"); };
    s.setValue("ClientGateRxEnabled", toBool(m_clientGateRx->isEnabled()));
    s.setValue("ClientGateRxMode",
        QString::number(static_cast<int>(m_clientGateRx->mode())));
    s.setValue("ClientGateRxThresholdDb",
        QString::number(m_clientGateRx->thresholdDb()));
    s.setValue("ClientGateRxReturnDb",
        QString::number(m_clientGateRx->returnDb()));
    s.setValue("ClientGateRxRatio",
        QString::number(m_clientGateRx->ratio()));
    s.setValue("ClientGateRxAttackMs",
        QString::number(m_clientGateRx->attackMs()));
    s.setValue("ClientGateRxHoldMs",
        QString::number(m_clientGateRx->holdMs()));
    s.setValue("ClientGateRxReleaseMs",
        QString::number(m_clientGateRx->releaseMs()));
    s.setValue("ClientGateRxFloorDb",
        QString::number(m_clientGateRx->floorDb()));
    s.setValue("ClientGateRxLookaheadMs",
        QString::number(m_clientGateRx->lookaheadMs()));
}

void AudioEngine::loadClientDeEssSettings()
{
    if (!m_clientDeEssTx) return;
    auto& s = AppSettings::instance();
    m_clientDeEssTx->setEnabled(
        s.value("ClientDeEssTxEnabled", "False").toString() == "True");
    m_clientDeEssTx->setFrequencyHz(
        s.value("ClientDeEssTxFrequencyHz", "6000.0").toFloat());
    m_clientDeEssTx->setQ(
        s.value("ClientDeEssTxQ", "2.0").toFloat());
    m_clientDeEssTx->setThresholdDb(
        s.value("ClientDeEssTxThresholdDb", "-30.0").toFloat());
    m_clientDeEssTx->setAmountDb(
        s.value("ClientDeEssTxAmountDb", "-6.0").toFloat());
    m_clientDeEssTx->setAttackMs(
        s.value("ClientDeEssTxAttackMs", "1.0").toFloat());
    m_clientDeEssTx->setReleaseMs(
        s.value("ClientDeEssTxReleaseMs", "100.0").toFloat());
    m_clientDeEssTx->setSlopeStages(
        s.value("ClientDeEssTxSlopeStages", "2").toInt());
}

void AudioEngine::saveClientDeEssSettings() const
{
    if (!m_clientDeEssTx) return;
    auto& s = AppSettings::instance();
    auto toBool = [](bool on) { return on ? QString("True") : QString("False"); };
    s.setValue("ClientDeEssTxEnabled",
        toBool(m_clientDeEssTx->isEnabled()));
    s.setValue("ClientDeEssTxFrequencyHz",
        QString::number(m_clientDeEssTx->frequencyHz()));
    s.setValue("ClientDeEssTxQ",
        QString::number(m_clientDeEssTx->q()));
    s.setValue("ClientDeEssTxThresholdDb",
        QString::number(m_clientDeEssTx->thresholdDb()));
    s.setValue("ClientDeEssTxAmountDb",
        QString::number(m_clientDeEssTx->amountDb()));
    s.setValue("ClientDeEssTxAttackMs",
        QString::number(m_clientDeEssTx->attackMs()));
    s.setValue("ClientDeEssTxReleaseMs",
        QString::number(m_clientDeEssTx->releaseMs()));
    s.setValue("ClientDeEssTxSlopeStages",
        QString::number(m_clientDeEssTx->slopeStages()));
}

void AudioEngine::loadClientDeEssRxSettings()
{
    if (!m_clientDeEssRx) return;
    auto& s = AppSettings::instance();
    m_clientDeEssRx->setEnabled(
        s.value("ClientDeEssRxEnabled", "False").toString() == "True");
    m_clientDeEssRx->setFrequencyHz(
        s.value("ClientDeEssRxFrequencyHz", "6000.0").toFloat());
    m_clientDeEssRx->setQ(
        s.value("ClientDeEssRxQ", "2.0").toFloat());
    m_clientDeEssRx->setThresholdDb(
        s.value("ClientDeEssRxThresholdDb", "-30.0").toFloat());
    m_clientDeEssRx->setAmountDb(
        s.value("ClientDeEssRxAmountDb", "-6.0").toFloat());
    m_clientDeEssRx->setAttackMs(
        s.value("ClientDeEssRxAttackMs", "1.0").toFloat());
    m_clientDeEssRx->setReleaseMs(
        s.value("ClientDeEssRxReleaseMs", "100.0").toFloat());
    m_clientDeEssRx->setSlopeStages(
        s.value("ClientDeEssRxSlopeStages", "2").toInt());
}

void AudioEngine::saveClientDeEssRxSettings() const
{
    if (!m_clientDeEssRx) return;
    auto& s = AppSettings::instance();
    auto toBool = [](bool on) { return on ? QString("True") : QString("False"); };
    s.setValue("ClientDeEssRxEnabled",
        toBool(m_clientDeEssRx->isEnabled()));
    s.setValue("ClientDeEssRxFrequencyHz",
        QString::number(m_clientDeEssRx->frequencyHz()));
    s.setValue("ClientDeEssRxQ",
        QString::number(m_clientDeEssRx->q()));
    s.setValue("ClientDeEssRxThresholdDb",
        QString::number(m_clientDeEssRx->thresholdDb()));
    s.setValue("ClientDeEssRxAmountDb",
        QString::number(m_clientDeEssRx->amountDb()));
    s.setValue("ClientDeEssRxAttackMs",
        QString::number(m_clientDeEssRx->attackMs()));
    s.setValue("ClientDeEssRxReleaseMs",
        QString::number(m_clientDeEssRx->releaseMs()));
    s.setValue("ClientDeEssRxSlopeStages",
        QString::number(m_clientDeEssRx->slopeStages()));
}

void AudioEngine::loadClientTubeSettings()
{
    if (!m_clientTubeTx) return;
    auto& s = AppSettings::instance();
    m_clientTubeTx->setEnabled(
        s.value("ClientTubeTxEnabled", "False").toString() == "True");
    const int modelInt = s.value("ClientTubeTxModel", "0").toInt();
    m_clientTubeTx->setModel(
        modelInt == 1 ? ClientTube::Model::B :
        modelInt == 2 ? ClientTube::Model::C :
                        ClientTube::Model::A);
    m_clientTubeTx->setDriveDb(
        s.value("ClientTubeTxDriveDb", "0.0").toFloat());
    m_clientTubeTx->setBiasAmount(
        s.value("ClientTubeTxBias", "0.0").toFloat());
    m_clientTubeTx->setTone(
        s.value("ClientTubeTxTone", "0.0").toFloat());
    m_clientTubeTx->setOutputGainDb(
        s.value("ClientTubeTxOutputDb", "0.0").toFloat());
    m_clientTubeTx->setDryWet(
        s.value("ClientTubeTxDryWet", "1.0").toFloat());
    m_clientTubeTx->setEnvelopeAmount(
        s.value("ClientTubeTxEnvelope", "0.0").toFloat());
    m_clientTubeTx->setAttackMs(
        s.value("ClientTubeTxAttackMs", "5.0").toFloat());
    m_clientTubeTx->setReleaseMs(
        s.value("ClientTubeTxReleaseMs", "35.0").toFloat());
}

void AudioEngine::saveClientTubeSettings() const
{
    if (!m_clientTubeTx) return;
    auto& s = AppSettings::instance();
    auto toBool = [](bool on) { return on ? QString("True") : QString("False"); };
    s.setValue("ClientTubeTxEnabled",  toBool(m_clientTubeTx->isEnabled()));
    s.setValue("ClientTubeTxModel",
        QString::number(static_cast<int>(m_clientTubeTx->model())));
    s.setValue("ClientTubeTxDriveDb",
        QString::number(m_clientTubeTx->driveDb()));
    s.setValue("ClientTubeTxBias",
        QString::number(m_clientTubeTx->biasAmount()));
    s.setValue("ClientTubeTxTone",
        QString::number(m_clientTubeTx->tone()));
    s.setValue("ClientTubeTxOutputDb",
        QString::number(m_clientTubeTx->outputGainDb()));
    s.setValue("ClientTubeTxDryWet",
        QString::number(m_clientTubeTx->dryWet()));
    s.setValue("ClientTubeTxEnvelope",
        QString::number(m_clientTubeTx->envelopeAmount()));
    s.setValue("ClientTubeTxAttackMs",
        QString::number(m_clientTubeTx->attackMs()));
    s.setValue("ClientTubeTxReleaseMs",
        QString::number(m_clientTubeTx->releaseMs()));
}

void AudioEngine::loadClientTubeRxSettings()
{
    if (!m_clientTubeRx) return;
    auto& s = AppSettings::instance();
    m_clientTubeRx->setEnabled(
        s.value("ClientTubeRxEnabled", "False").toString() == "True");
    const int modelInt = s.value("ClientTubeRxModel", "0").toInt();
    m_clientTubeRx->setModel(
        modelInt == 1 ? ClientTube::Model::B :
        modelInt == 2 ? ClientTube::Model::C :
                        ClientTube::Model::A);
    m_clientTubeRx->setDriveDb(
        s.value("ClientTubeRxDriveDb", "0.0").toFloat());
    m_clientTubeRx->setBiasAmount(
        s.value("ClientTubeRxBias", "0.0").toFloat());
    m_clientTubeRx->setTone(
        s.value("ClientTubeRxTone", "0.0").toFloat());
    m_clientTubeRx->setOutputGainDb(
        s.value("ClientTubeRxOutputDb", "0.0").toFloat());
    m_clientTubeRx->setDryWet(
        s.value("ClientTubeRxDryWet", "1.0").toFloat());
    m_clientTubeRx->setEnvelopeAmount(
        s.value("ClientTubeRxEnvelope", "0.0").toFloat());
    m_clientTubeRx->setAttackMs(
        s.value("ClientTubeRxAttackMs", "5.0").toFloat());
    m_clientTubeRx->setReleaseMs(
        s.value("ClientTubeRxReleaseMs", "35.0").toFloat());
}

void AudioEngine::saveClientTubeRxSettings() const
{
    if (!m_clientTubeRx) return;
    auto& s = AppSettings::instance();
    auto toBool = [](bool on) { return on ? QString("True") : QString("False"); };
    s.setValue("ClientTubeRxEnabled",  toBool(m_clientTubeRx->isEnabled()));
    s.setValue("ClientTubeRxModel",
        QString::number(static_cast<int>(m_clientTubeRx->model())));
    s.setValue("ClientTubeRxDriveDb",
        QString::number(m_clientTubeRx->driveDb()));
    s.setValue("ClientTubeRxBias",
        QString::number(m_clientTubeRx->biasAmount()));
    s.setValue("ClientTubeRxTone",
        QString::number(m_clientTubeRx->tone()));
    s.setValue("ClientTubeRxOutputDb",
        QString::number(m_clientTubeRx->outputGainDb()));
    s.setValue("ClientTubeRxDryWet",
        QString::number(m_clientTubeRx->dryWet()));
    s.setValue("ClientTubeRxEnvelope",
        QString::number(m_clientTubeRx->envelopeAmount()));
    s.setValue("ClientTubeRxAttackMs",
        QString::number(m_clientTubeRx->attackMs()));
    s.setValue("ClientTubeRxReleaseMs",
        QString::number(m_clientTubeRx->releaseMs()));
}

void AudioEngine::loadClientPuduSettings()
{
    if (!m_clientPuduTx) return;
    auto& s = AppSettings::instance();
    m_clientPuduTx->setEnabled(
        s.value("ClientPuduTxEnabled", "False").toString() == "True");
    const int modeInt = s.value("ClientPuduTxMode", "0").toInt();
    m_clientPuduTx->setMode(modeInt == 1
        ? ClientPudu::Mode::Behringer
        : ClientPudu::Mode::Aphex);
    m_clientPuduTx->setPooDriveDb(
        s.value("ClientPuduTxPooDriveDb", "6.0").toFloat());
    m_clientPuduTx->setPooTuneHz(
        s.value("ClientPuduTxPooTuneHz", "100.0").toFloat());
    m_clientPuduTx->setPooMix(
        s.value("ClientPuduTxPooMix", "0.3").toFloat());
    m_clientPuduTx->setDooTuneHz(
        s.value("ClientPuduTxDooTuneHz", "5000.0").toFloat());
    m_clientPuduTx->setDooHarmonicsDb(
        s.value("ClientPuduTxDooHarmonicsDb", "6.0").toFloat());
    m_clientPuduTx->setDooMix(
        s.value("ClientPuduTxDooMix", "0.3").toFloat());
}

void AudioEngine::setTxPostDspMonitor(ClientPuduMonitor* m) noexcept
{
    // Release-store so the audio thread sees the new pointer on its
    // next block via matching acquire-load at the tap site.
    m_txPostDspMonitor.store(m, std::memory_order_release);
}

void AudioEngine::setTxFinalMonitor(ClientPuduMonitor* m) noexcept
{
    m_txFinalMonitor.store(m, std::memory_order_release);
}

void AudioEngine::saveClientPuduSettings() const
{
    if (!m_clientPuduTx) return;
    auto& s = AppSettings::instance();
    auto toBool = [](bool on) { return on ? QString("True") : QString("False"); };
    s.setValue("ClientPuduTxEnabled", toBool(m_clientPuduTx->isEnabled()));
    s.setValue("ClientPuduTxMode",
        QString::number(static_cast<int>(m_clientPuduTx->mode())));
    s.setValue("ClientPuduTxPooDriveDb",
        QString::number(m_clientPuduTx->pooDriveDb()));
    s.setValue("ClientPuduTxPooTuneHz",
        QString::number(m_clientPuduTx->pooTuneHz()));
    s.setValue("ClientPuduTxPooMix",
        QString::number(m_clientPuduTx->pooMix()));
    s.setValue("ClientPuduTxDooTuneHz",
        QString::number(m_clientPuduTx->dooTuneHz()));
    s.setValue("ClientPuduTxDooHarmonicsDb",
        QString::number(m_clientPuduTx->dooHarmonicsDb()));
    s.setValue("ClientPuduTxDooMix",
        QString::number(m_clientPuduTx->dooMix()));
}

void AudioEngine::loadClientPuduRxSettings()
{
    if (!m_clientPuduRx) return;
    auto& s = AppSettings::instance();
    m_clientPuduRx->setEnabled(
        s.value("ClientPuduRxEnabled", "False").toString() == "True");
    const int modeInt = s.value("ClientPuduRxMode", "0").toInt();
    m_clientPuduRx->setMode(modeInt == 1
        ? ClientPudu::Mode::Behringer
        : ClientPudu::Mode::Aphex);
    m_clientPuduRx->setPooDriveDb(
        s.value("ClientPuduRxPooDriveDb", "6.0").toFloat());
    m_clientPuduRx->setPooTuneHz(
        s.value("ClientPuduRxPooTuneHz", "100.0").toFloat());
    m_clientPuduRx->setPooMix(
        s.value("ClientPuduRxPooMix", "0.3").toFloat());
    m_clientPuduRx->setDooTuneHz(
        s.value("ClientPuduRxDooTuneHz", "5000.0").toFloat());
    m_clientPuduRx->setDooHarmonicsDb(
        s.value("ClientPuduRxDooHarmonicsDb", "6.0").toFloat());
    m_clientPuduRx->setDooMix(
        s.value("ClientPuduRxDooMix", "0.3").toFloat());
}

void AudioEngine::saveClientPuduRxSettings() const
{
    if (!m_clientPuduRx) return;
    auto& s = AppSettings::instance();
    auto toBool = [](bool on) { return on ? QString("True") : QString("False"); };
    s.setValue("ClientPuduRxEnabled", toBool(m_clientPuduRx->isEnabled()));
    s.setValue("ClientPuduRxMode",
        QString::number(static_cast<int>(m_clientPuduRx->mode())));
    s.setValue("ClientPuduRxPooDriveDb",
        QString::number(m_clientPuduRx->pooDriveDb()));
    s.setValue("ClientPuduRxPooTuneHz",
        QString::number(m_clientPuduRx->pooTuneHz()));
    s.setValue("ClientPuduRxPooMix",
        QString::number(m_clientPuduRx->pooMix()));
    s.setValue("ClientPuduRxDooTuneHz",
        QString::number(m_clientPuduRx->dooTuneHz()));
    s.setValue("ClientPuduRxDooHarmonicsDb",
        QString::number(m_clientPuduRx->dooHarmonicsDb()));
    s.setValue("ClientPuduRxDooMix",
        QString::number(m_clientPuduRx->dooMix()));
}

void AudioEngine::loadClientReverbSettings()
{
    if (!m_clientReverbTx) return;
    auto& s = AppSettings::instance();
    m_clientReverbTx->setEnabled(
        s.value("ClientReverbTxEnabled", "False").toString() == "True");
    m_clientReverbTx->setSize(
        s.value("ClientReverbTxSize", "0.5").toFloat());
    m_clientReverbTx->setDecayS(
        s.value("ClientReverbTxDecayS", "1.2").toFloat());
    m_clientReverbTx->setDamping(
        s.value("ClientReverbTxDamping", "0.5").toFloat());
    m_clientReverbTx->setPreDelayMs(
        s.value("ClientReverbTxPreDelayMs", "20.0").toFloat());
    m_clientReverbTx->setMix(
        s.value("ClientReverbTxMix", "0.15").toFloat());
}

void AudioEngine::saveClientReverbSettings()
{
    if (!m_clientReverbTx) return;
    auto& s = AppSettings::instance();
    auto toBool = [](bool on) { return on ? QString("True") : QString("False"); };
    s.setValue("ClientReverbTxEnabled",    toBool(m_clientReverbTx->isEnabled()));
    s.setValue("ClientReverbTxSize",
        QString::number(m_clientReverbTx->size()));
    s.setValue("ClientReverbTxDecayS",
        QString::number(m_clientReverbTx->decayS()));
    s.setValue("ClientReverbTxDamping",
        QString::number(m_clientReverbTx->damping()));
    s.setValue("ClientReverbTxPreDelayMs",
        QString::number(m_clientReverbTx->preDelayMs()));
    s.setValue("ClientReverbTxMix",
        QString::number(m_clientReverbTx->mix()));
    emit clientReverbStateChanged();
}

void AudioEngine::loadClientFinalLimiterSettings()
{
    if (!m_clientFinalLimiterTx) return;
    auto& s = AppSettings::instance();
    // Default OFF: SmartSDR has no client-side brickwall limiter, so a fresh
    // install with the limiter on at a -1 dBFS ceiling produces noticeably
    // less forward power than SmartSDR for the same mic level (radio's SW ALC
    // sees ~1 dB less peak to set its working point off of).  The limiter is
    // still available for users who want headroom protection when running
    // hot Comp/Tube/PUDU/Reverb settings — they can flip LIM on in the
    // Aetherial Final Output Stage panel.  Existing users whose setting was
    // already persisted keep their previous behavior.
    m_clientFinalLimiterTx->setEnabled(
        s.value("ClientFinalLimiterTxEnabled", "False").toString() == "True");
    m_clientFinalLimiterTx->setCeilingDb(
        s.value("ClientFinalLimiterTxCeilingDb", "-1.0").toFloat());
    m_clientFinalLimiterTx->setOutputTrimDb(
        s.value("ClientFinalLimiterTxOutputTrimDb", "0.0").toFloat());
    m_clientFinalLimiterTx->setDcBlockEnabled(
        s.value("ClientFinalLimiterTxDcBlock", "True").toString() == "True");
}

void AudioEngine::saveClientFinalLimiterSettings() const
{
    if (!m_clientFinalLimiterTx) return;
    auto& s = AppSettings::instance();
    s.setValue("ClientFinalLimiterTxEnabled",
        m_clientFinalLimiterTx->isEnabled() ? QString("True") : QString("False"));
    s.setValue("ClientFinalLimiterTxCeilingDb",
        QString::number(m_clientFinalLimiterTx->ceilingDb()));
    s.setValue("ClientFinalLimiterTxOutputTrimDb",
        QString::number(m_clientFinalLimiterTx->outputTrimDb()));
    s.setValue("ClientFinalLimiterTxDcBlock",
        m_clientFinalLimiterTx->dcBlockEnabled() ? QString("True") : QString("False"));
}

// Aetherial Tube Pre-Amp TX — nested-JSON persistence (Principle V).
// One AppSettings key holds a JSON object so future mic-preamp toggles
// (high-pass, phase invert, polarity, etc.) can be added without further
// migration.  Shape today: {"rn2": bool}.  (#2813)

void AudioEngine::loadAetherialTubePreampTxSettings()
{
    auto& s = AppSettings::instance();
    const QString raw = s.value("AetherialTubePreampTx", "{}").toString();
    QJsonParseError err;
    const auto doc = QJsonDocument::fromJson(raw.toUtf8(), &err);
    if (err.error != QJsonParseError::NoError || !doc.isObject()) {
        // Bad JSON — treat as empty, all defaults off.
        return;
    }
    const auto obj = doc.object();
    if (obj.value("rn2").toBool(false)) {
        // Route through the setter so the lazy-allocation + signal
        // emission both happen exactly as on a user toggle.
        setRn2TxEnabled(true);
    }
}

void AudioEngine::saveAetherialTubePreampTxSettings() const
{
    QJsonObject obj;
    obj["rn2"] = m_rn2TxEnabled.load();
    const QString raw = QString::fromUtf8(
        QJsonDocument(obj).toJson(QJsonDocument::Compact));
    auto& s = AppSettings::instance();
    s.setValue("AetherialTubePreampTx", raw);
    s.save();
}

void AudioEngine::loadClientQuindarSettings()
{
    if (!m_clientQuindarTone) return;
    auto& s = AppSettings::instance();
    m_clientQuindarTone->setEnabled(
        s.value("QuindarEnabled", "False").toString() == "True");
    const QString styleStr = s.value("QuindarStyle", "Tone").toString();
    m_clientQuindarTone->setStyle(styleStr == "Morse"
        ? ClientQuindarTone::Style::Morse
        : ClientQuindarTone::Style::Tone);
    m_clientQuindarTone->setLevelDb(
        s.value("QuindarLevelDb", "-6.0").toFloat());
    m_clientQuindarTone->setIntroFreqHz(
        s.value("QuindarIntroFreqHz", "2525.0").toFloat());
    m_clientQuindarTone->setOutroFreqHz(
        s.value("QuindarOutroFreqHz", "2475.0").toFloat());
    m_clientQuindarTone->setDurationMs(
        s.value("QuindarDurationMs", "250").toInt());
    m_clientQuindarTone->setMorseWpm(
        s.value("QuindarMorseWpm", "45").toInt());
    m_clientQuindarTone->setMorsePitchHz(
        s.value("QuindarMorsePitchHz", "750.0").toFloat());
}

void AudioEngine::saveClientQuindarSettings() const
{
    if (!m_clientQuindarTone) return;
    auto& s = AppSettings::instance();
    s.setValue("QuindarEnabled",
        m_clientQuindarTone->isEnabled() ? QString("True") : QString("False"));
    s.setValue("QuindarStyle",
        m_clientQuindarTone->style() == ClientQuindarTone::Style::Morse
            ? QString("Morse") : QString("Tone"));
    s.setValue("QuindarLevelDb",
        QString::number(m_clientQuindarTone->levelDb()));
    s.setValue("QuindarIntroFreqHz",
        QString::number(m_clientQuindarTone->introFreqHz()));
    s.setValue("QuindarOutroFreqHz",
        QString::number(m_clientQuindarTone->outroFreqHz()));
    s.setValue("QuindarDurationMs",
        QString::number(m_clientQuindarTone->durationMs()));
    s.setValue("QuindarMorseWpm",
        QString::number(m_clientQuindarTone->morseWpm()));
    s.setValue("QuindarMorsePitchHz",
        QString::number(m_clientQuindarTone->morsePitchHz()));
}

static QString wisdomDir()
{
#ifdef _WIN32
    // Windows: use %APPDATA%/AetherSDR/
    QString dir = QDir::homePath() + "/AppData/Roaming/AetherSDR/";
#else
    // Singular ~/.config/AetherSDR/ — matches AppSettings, the log dir,
    // and the other ConfigLocation users.  Pre-fix this was the
    // double-nested ~/.config/AetherSDR/AetherSDR/ path, which forced an
    // FFTW wisdom regeneration on first launch after the dir unified.
    QString dir = QDir::homePath() + "/.config/AetherSDR/";
#endif
    QDir().mkpath(dir);
    return dir;
}

QString AudioEngine::wisdomFilePath()
{
    return wisdomDir() + "aethersdr_fftw_wisdom";
}

static QString wisdomFileDetailText(const QFileInfo& info)
{
    if (!info.exists()) {
        return QStringLiteral("path=\"%1\"")
            .arg(QDir::toNativeSeparators(info.absoluteFilePath()));
    }

    return QStringLiteral("path=\"%1\" size=%2B modified=\"%3\"")
        .arg(QDir::toNativeSeparators(info.absoluteFilePath()))
        .arg(info.size())
        .arg(info.lastModified().toString(Qt::ISODateWithMs));
}

static QString wisdomResultText(SpectralNR::WisdomResult result)
{
    switch (result) {
    case SpectralNR::WisdomResult::Ready:     return QStringLiteral("ready");
    case SpectralNR::WisdomResult::Generated: return QStringLiteral("generated");
    case SpectralNR::WisdomResult::Cancelled: return QStringLiteral("cancelled");
    case SpectralNR::WisdomResult::Failed:    return QStringLiteral("failed");
    }
    return QStringLiteral("unknown");
}

static void logNr2WisdomSummary(const QString& context)
{
#ifndef HAVE_FFTW3
    QStringList lines;
    lines << QStringLiteral("Audio NR2 wisdom summary:")
          << QStringLiteral("  context=%1 status=unavailable action=runtime-plans reason=\"built without FFTW3\"")
                 .arg(context);
    qCInfo(lcAudioSummary).noquote() << lines.join(QLatin1Char('\n'));
#else
    const QString directory = wisdomDir();
    const QString path = directory + "aethersdr_fftw_wisdom";
    const QFileInfo info(path);

    QString status;
    QString action;
    bool warn = false;

    if (!info.exists()) {
        status = QStringLiteral("missing");
        action = QStringLiteral("train-on-first-enable");
    } else if (!info.isFile()) {
        status = QStringLiteral("invalid");
        action = QStringLiteral("discard-and-regenerate-on-first-enable");
        warn = true;
    } else if (SpectralNR::loadWisdom(directory.toStdString())) {
        status = QStringLiteral("valid");
        action = QStringLiteral("use-cached-wisdom");
    } else {
        status = QStringLiteral("invalid-or-stale");
        action = QStringLiteral("discard-and-regenerate-on-first-enable");
        warn = true;
    }

    QStringList lines;
    lines << QStringLiteral("Audio NR2 wisdom summary:")
          << QStringLiteral("  context=%1 status=%2 action=%3")
                 .arg(context, status, action)
          << QStringLiteral("  %1").arg(wisdomFileDetailText(info));

    const QString summary = lines.join(QLatin1Char('\n'));
    if (warn) {
        qCWarning(lcAudioSummary).noquote() << summary;
        qCWarning(lcAudio).noquote()
            << QStringLiteral("AudioEngine: NR2 FFTW wisdom %1; %2 %3")
                   .arg(status, action, wisdomFileDetailText(info));
    } else {
        qCInfo(lcAudioSummary).noquote() << summary;
    }
#endif
}

static void logNr2WisdomGenerationSummary(SpectralNR::WisdomResult result)
{
    const QFileInfo info(AudioEngine::wisdomFilePath());
    QStringList lines;
    lines << QStringLiteral("Audio NR2 wisdom generation summary:")
          << QStringLiteral("  result=%1").arg(wisdomResultText(result))
          << QStringLiteral("  %1").arg(wisdomFileDetailText(info));

    const QString summary = lines.join(QLatin1Char('\n'));
    if (result == SpectralNR::WisdomResult::Failed) {
        qCWarning(lcAudioSummary).noquote() << summary;
    } else {
        qCInfo(lcAudioSummary).noquote() << summary;
    }
}

static void applyNr2SettingsFromAppSettings(SpectralNR& nr2)
{
    auto& s = AppSettings::instance();
    nr2.setGainMax(s.value("NR2GainMax", "1.00").toFloat());  // default 1.0 = no amplification (#1507)
    nr2.setGainSmooth(s.value("NR2GainSmooth", "0.85").toFloat());
    nr2.setQspp(s.value("NR2Qspp", "0.20").toFloat());
    nr2.setGainMethod(s.value("NR2GainMethod", "2").toInt());
    nr2.setNpeMethod(s.value("NR2NpeMethod", "0").toInt());
    nr2.setAeFilter(s.value("NR2AeFilter", "True").toString() == "True");
}

bool AudioEngine::needsWisdomGeneration()
{
#ifndef HAVE_FFTW3
    return false;
#else
    const QString path = wisdomFilePath();
    if (!QFile::exists(path)) {
        logNr2WisdomSummary(QStringLiteral("NR2 enable preflight"));
        return true;
    }

    if (!SpectralNR::loadWisdom(wisdomDir().toStdString())) {
        logNr2WisdomSummary(QStringLiteral("NR2 enable preflight"));
        return true;
    }

    return false;
#endif
}

SpectralNR::WisdomResult AudioEngine::generateWisdom(
    SpectralNR::WisdomProgressCb progress,
    SpectralNR::WisdomCancelCb shouldCancel)
{
    const auto result = SpectralNR::generateWisdom(wisdomDir().toStdString(),
                                                   std::move(progress),
                                                   std::move(shouldCancel));
    logNr2WisdomGenerationSummary(result);
    return result;
}

void AudioEngine::setNr2Enabled(bool on)
{
    if (m_nr2Enabled == on) return;
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    m_rxBuffer.clear();
    m_rxPackets.clear();
    m_rxOutputBuffer.clear();
    m_kiwiSdrRxBuffer.clear();
    m_kiwiSdrRxPackets.clear();
    m_kiwiSdrOutputBuffer.clear();
    m_kiwiSdrRxResampler.reset();
    m_kiwiSdrRxResamplerR.reset();
    m_nr2Mono.clear();
    m_nr2Processed.clear();
    m_nr2Output.clear();
    m_kiwiSdrNr2Mono.clear();
    m_kiwiSdrNr2Processed.clear();
    m_kiwiSdrNr2Output.clear();
    m_kiwiSdrPrebuffering = m_kiwiSdrAudioEnabled.load(std::memory_order_relaxed);
    for (const auto& source : m_externalKiwiSources) {
        if (!source) {
            continue;
        }
        source->rxBuffer.clear();
        source->rxPackets.clear();
        source->outputBuffer.clear();
        source->nr2Mono.clear();
        source->nr2Processed.clear();
        source->nr2Output.clear();
        source->rxResampler.reset();
        source->rxResamplerR.reset();
        source->prebuffering = source->enabled && !source->muted;
    }
    if (on) {
        // Disable all other NR modes — they're mutually exclusive
        if (m_rn2Enabled)  setRn2Enabled(false);
        if (m_bnrEnabled)  setBnrEnabled(false);
        if (m_nr4Enabled)  setNr4Enabled(false);
        if (m_dfnrEnabled) setDfnrEnabled(false);
        if (m_mnrEnabled)  setMnrEnabled(false);
        // Wisdom should already be generated by MainWindow::enableNr2WithWisdom().
        // Import only here: full wisdom generation can take minutes and must
        // never run on the audio worker thread.
#ifdef HAVE_FFTW3
        if (!SpectralNR::loadWisdom(wisdomDir().toStdString()))
            qCWarning(lcAudio) << "AudioEngine: NR2 FFTW wisdom unavailable on enable;"
                               << "using runtime FFTW_MEASURE plans";
#endif
        m_nr2 = std::make_unique<SpectralNR>(256, DEFAULT_SAMPLE_RATE);
        m_kiwiSdrNr2 = std::make_unique<SpectralNR>(256, DEFAULT_SAMPLE_RATE);
        if (m_nr2->hasPlanFailed() || m_kiwiSdrNr2->hasPlanFailed()) {
            qCWarning(lcAudio) << "AudioEngine: NR2 FFTW plan creation failed — disabling";
            m_nr2.reset();
            m_kiwiSdrNr2.reset();
            emit nr2EnabledChanged(false);
            return;
        }
        // Restore user-adjusted parameters from AppSettings
        applyNr2SettingsFromAppSettings(*m_nr2);
        applyNr2SettingsFromAppSettings(*m_kiwiSdrNr2);
        for (const auto& source : m_externalKiwiSources) {
            if (!source) {
                continue;
            }
            source->nr2 = std::make_unique<SpectralNR>(256, DEFAULT_SAMPLE_RATE);
            if (source->nr2->hasPlanFailed()) {
                qCWarning(lcAudio) << "AudioEngine: external Kiwi NR2 plan failed for"
                                   << source->id;
                source->nr2.reset();
            } else {
                applyNr2SettingsFromAppSettings(*source->nr2);
            }
        }
        m_nr2Enabled = true;
    } else {
        m_nr2Enabled = false;
        m_nr2.reset();
        m_kiwiSdrNr2.reset();
        for (const auto& source : m_externalKiwiSources) {
            if (!source) {
                continue;
            }
            source->nr2.reset();
            source->nr2Mono.clear();
            source->nr2Processed.clear();
            source->nr2Output.clear();
            source->outputBuffer.clear();
            source->rxResampler.reset();
            source->rxResamplerR.reset();
            source->prebuffering = source->enabled && !source->muted;
        }
    }
    qCDebug(lcAudio) << "AudioEngine: NR2" << (on ? "enabled" : "disabled");
    emit nr2EnabledChanged(on);
}

void AudioEngine::setNr2GainMax(float v)
{
    if (m_nr2) m_nr2->setGainMax(v);
    if (m_kiwiSdrNr2) m_kiwiSdrNr2->setGainMax(v);
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr2) {
            source->nr2->setGainMax(v);
        }
    }
}

void AudioEngine::setNr2Qspp(float v)
{
    if (m_nr2) m_nr2->setQspp(v);
    if (m_kiwiSdrNr2) m_kiwiSdrNr2->setQspp(v);
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr2) {
            source->nr2->setQspp(v);
        }
    }
}

void AudioEngine::setNr2GainSmooth(float v)
{
    if (m_nr2) m_nr2->setGainSmooth(v);
    if (m_kiwiSdrNr2) m_kiwiSdrNr2->setGainSmooth(v);
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr2) {
            source->nr2->setGainSmooth(v);
        }
    }
}

void AudioEngine::setNr2GainMethod(int m)
{
    if (m_nr2) m_nr2->setGainMethod(m);
    if (m_kiwiSdrNr2) m_kiwiSdrNr2->setGainMethod(m);
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr2) {
            source->nr2->setGainMethod(m);
        }
    }
}

void AudioEngine::setNr2NpeMethod(int m)
{
    if (m_nr2) m_nr2->setNpeMethod(m);
    if (m_kiwiSdrNr2) m_kiwiSdrNr2->setNpeMethod(m);
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr2) {
            source->nr2->setNpeMethod(m);
        }
    }
}

void AudioEngine::setNr2AeFilter(bool on)
{
    if (m_nr2) m_nr2->setAeFilter(on);
    if (m_kiwiSdrNr2) m_kiwiSdrNr2->setAeFilter(on);
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr2) {
            source->nr2->setAeFilter(on);
        }
    }
}


#ifdef HAVE_SPECBLEACH

void AudioEngine::setNr4Enabled(bool on)
{
    if (m_nr4Enabled == on) return;
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (on) {
        if (m_nr2Enabled)  setNr2Enabled(false);
        if (m_rn2Enabled)  setRn2Enabled(false);
        if (m_bnrEnabled)  setBnrEnabled(false);
        if (m_dfnrEnabled) setDfnrEnabled(false);
        if (m_mnrEnabled)  setMnrEnabled(false);
        m_nr4 = std::make_unique<SpecbleachFilter>();
        if (!m_nr4->isValid()) {
            qCWarning(lcAudio) << "AudioEngine: NR4 initialization failed";
            m_nr4.reset();
            emit nr4EnabledChanged(false);
            return;
        }
        // Restore all saved params
        auto& s = AppSettings::instance();
        m_nr4->setReductionAmount(s.value("NR4ReductionAmount", "10.0").toFloat());
        m_nr4->setSmoothingFactor(s.value("NR4SmoothingFactor", "0.0").toFloat());
        m_nr4->setWhiteningFactor(s.value("NR4WhiteningFactor", "0.0").toFloat());
        m_nr4->setAdaptiveNoise(s.value("NR4AdaptiveNoise", "True").toString() == "True");
        m_nr4->setNoiseEstimationMethod(s.value("NR4NoiseEstimationMethod", "0").toInt());
        m_nr4->setMaskingDepth(s.value("NR4MaskingDepth", "0.50").toFloat());
        m_nr4->setSuppressionStrength(s.value("NR4SuppressionStrength", "0.50").toFloat());
        m_nr4Enabled = true;
    } else {
        m_nr4Enabled = false;
        m_nr4.reset();
    }
    qCDebug(lcAudio) << "AudioEngine: NR4" << (on ? "enabled" : "disabled");
    emit nr4EnabledChanged(on);
}

void AudioEngine::setNr4ReductionAmount(float dB) { if (m_nr4) m_nr4->setReductionAmount(dB); }
void AudioEngine::setNr4SmoothingFactor(float pct) { if (m_nr4) m_nr4->setSmoothingFactor(pct); }
void AudioEngine::setNr4WhiteningFactor(float pct) { if (m_nr4) m_nr4->setWhiteningFactor(pct); }
void AudioEngine::setNr4AdaptiveNoise(bool on) { if (m_nr4) m_nr4->setAdaptiveNoise(on); }
void AudioEngine::setNr4NoiseEstimationMethod(int m) { if (m_nr4) m_nr4->setNoiseEstimationMethod(m); }
void AudioEngine::setNr4MaskingDepth(float v) { if (m_nr4) m_nr4->setMaskingDepth(v); }
void AudioEngine::setNr4SuppressionStrength(float v) { if (m_nr4) m_nr4->setSuppressionStrength(v); }
#else // !HAVE_SPECBLEACH — stubs
void AudioEngine::setNr4Enabled(bool on) { if (on) emit nr4EnabledChanged(false); }
void AudioEngine::setNr4ReductionAmount(float) {}
void AudioEngine::setNr4SmoothingFactor(float) {}
void AudioEngine::setNr4WhiteningFactor(float) {}
void AudioEngine::setNr4AdaptiveNoise(bool) {}
void AudioEngine::setNr4NoiseEstimationMethod(int) {}
void AudioEngine::setNr4MaskingDepth(float) {}
void AudioEngine::setNr4SuppressionStrength(float) {}
#endif // HAVE_SPECBLEACH

// MNR (macOS MMSE-Wiener noise reduction)
void AudioEngine::setMnrEnabled(bool on)
{
    if (m_mnrEnabled == on) return;
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
#ifdef __APPLE__
    if (on) {
        // Disable all other noise-reduction modes — they're mutually exclusive
        if (m_nr2Enabled)  setNr2Enabled(false);
        if (m_rn2Enabled)  setRn2Enabled(false);
        if (m_bnrEnabled)  setBnrEnabled(false);
        if (m_nr4Enabled)  setNr4Enabled(false);
        if (m_dfnrEnabled) setDfnrEnabled(false);
        m_mnr = std::make_unique<MacNRFilter>();
        if (!m_mnr->isValid()) {
            qCWarning(lcAudio) << "AudioEngine: MNR vDSP setup failed — disabling";
            m_mnr.reset();
            return;
        }
        // Restore strength from settings (default 1.0 = full suppression)
        m_mnrStrength.store(std::clamp(
            AppSettings::instance().value("MnrStrength", "1.00").toFloat(), 0.0f, 1.0f));
        m_mnr->setStrength(m_mnrStrength.load());
    } else {
        m_mnr.reset();
    }
#endif
    m_mnrEnabled = on;
    emit mnrEnabledChanged(on);
}

void AudioEngine::setMnrStrength(float normalized)
{
    m_mnrStrength.store(std::clamp(normalized, 0.0f, 1.0f));
    AppSettings::instance().setValue("MnrStrength",
        QString::number(m_mnrStrength.load(), 'f', 2));
#ifdef __APPLE__
    if (m_mnr) m_mnr->setStrength(m_mnrStrength.load());
#endif
}

float AudioEngine::mnrStrength() const
{
    return m_mnrStrength.load();
}

void AudioEngine::setRn2Enabled(bool on)
{
    if (m_rn2Enabled == on) return;
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (on) {
        // Disable all other NR modes — they're mutually exclusive
        if (m_nr2Enabled)  setNr2Enabled(false);
        if (m_bnrEnabled)  setBnrEnabled(false);
        if (m_nr4Enabled)  setNr4Enabled(false);
        if (m_dfnrEnabled) setDfnrEnabled(false);
        if (m_mnrEnabled)  setMnrEnabled(false);
        m_rn2 = std::make_unique<RNNoiseFilter>();
        if (!m_rn2->isValid()) {
            qCWarning(lcAudio) << "AudioEngine: RN2 rnnoise_create() failed — disabling";
            m_rn2.reset();
            emit rn2EnabledChanged(false);
            return;
        }
        // Set flag AFTER object is fully constructed
        m_rn2Enabled = true;
    } else {
        m_rn2Enabled = false;
        m_rn2.reset();
    }
    qCDebug(lcAudio) << "AudioEngine: RN2 (RNNoise)" << (on ? "enabled" : "disabled");
    emit rn2EnabledChanged(on);
}

// ─── RN2 — TX path (mic pre-amp) ──────────────────────────────────────────────
// Mirrors the RX RN2 setter above (lazy-alloc under m_dspMutex, atomic guard
// for the audio-thread read).  No mutual-exclusion with other TX-side NR
// because there is none — RN2 is the only neural denoiser on the mic path
// today.  Persistence is via the AetherialTubePreampTx nested-JSON key.

void AudioEngine::setRn2TxEnabled(bool on)
{
    if (m_rn2TxEnabled.load() == on) return;
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (on) {
        m_rn2Tx = std::make_unique<RNNoiseFilter>();
        if (!m_rn2Tx->isValid()) {
            qCWarning(lcAudio) << "AudioEngine: RN2 TX rnnoise_create() failed — disabling";
            m_rn2Tx.reset();
            emit rn2TxEnabledChanged(false);
            return;
        }
        m_rn2TxEnabled.store(true);
    } else {
        m_rn2TxEnabled.store(false);
        m_rn2Tx.reset();
    }
    saveAetherialTubePreampTxSettings();
    qCDebug(lcAudio) << "AudioEngine: RN2 TX (RNNoise mic pre-amp)" << (on ? "enabled" : "disabled");
    emit rn2TxEnabledChanged(on);
}

// ─── BNR (NVIDIA NIM GPU noise removal) ──────────────────────────────────────

void AudioEngine::setBnrEnabled(bool on)
{
    if (m_bnrEnabled == on) return;
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (on) {
        // Mutual exclusion with all other NR modes
        if (m_nr2Enabled)  setNr2Enabled(false);
        if (m_rn2Enabled)  setRn2Enabled(false);
        if (m_nr4Enabled)  setNr4Enabled(false);
        if (m_dfnrEnabled) setDfnrEnabled(false);
        if (m_mnrEnabled)  setMnrEnabled(false);

        m_bnr = std::make_unique<NvidiaBnrFilter>(this);
        connect(m_bnr.get(), &NvidiaBnrFilter::connectionChanged,
                this, &AudioEngine::bnrConnectionChanged);

        // Resamplers: 24kHz mono ↔ 48kHz mono
        // BNR returns variable-sized chunks (up to 200ms = 9600 samples at 48kHz),
        // so use a large maxBlockSamples to avoid r8brain buffer overflow.
        m_bnrUp   = std::make_unique<Resampler>(24000, 48000, 16384);
        m_bnrDown = std::make_unique<Resampler>(48000, 24000, 16384);
        m_kiwiSdrBnrUp = std::make_unique<Resampler>(24000, 48000, 16384);
        m_kiwiSdrBnrDown = std::make_unique<Resampler>(48000, 24000, 16384);
        m_bnrOutBuf.clear();
        m_kiwiSdrBnrOutBuf.clear();
        m_bnrPrimed = false;
        m_kiwiSdrBnrPrimed = false;
        // Set flag AFTER objects are fully constructed
        m_bnrEnabled = true;

        // Try connecting — if the container is still booting, retry with a timer.
        if (!m_bnr->connectToServer(m_bnrAddress)) {
            // Retry up to 5 times, 2s apart
            auto* retryTimer = new QTimer(this);
            retryTimer->setInterval(2000);
            auto retryCount = std::make_shared<int>(0);
            connect(retryTimer, &QTimer::timeout, this,
                    [this, retryTimer, retryCount]() {
                if (!m_bnr || *retryCount >= 5) {
                    retryTimer->stop();
                    retryTimer->deleteLater();
                    if (m_bnr && !m_bnr->isConnected()) {
                        qCWarning(lcAudio) << "AudioEngine: BNR connect failed after retries";
                        m_bnr.reset();
                        m_bnrUp.reset();
                        m_bnrDown.reset();
                        m_kiwiSdrBnrUp.reset();
                        m_kiwiSdrBnrDown.reset();
                        m_kiwiSdrBnrOutBuf.clear();
                        m_kiwiSdrBnrPrimed = false;
                        m_bnrEnabled = false;
                        emit bnrEnabledChanged(false);
                    }
                    return;
                }
                ++(*retryCount);
                qDebug() << "AudioEngine: BNR connect retry" << *retryCount << "of 5";
                if (m_bnr->connectToServer(m_bnrAddress)) {
                    retryTimer->stop();
                    retryTimer->deleteLater();
                }
            });
            retryTimer->start();
        }
    } else {
        m_bnrEnabled = false;
        if (m_bnr) m_bnr->disconnect();
        m_bnr.reset();
        m_bnrUp.reset();
        m_bnrDown.reset();
        m_kiwiSdrBnrUp.reset();
        m_kiwiSdrBnrDown.reset();
        m_kiwiSdrBnrOutBuf.clear();
        m_kiwiSdrBnrPrimed = false;
    }
    qCDebug(lcAudio) << "AudioEngine: BNR (NVIDIA NIM)" << (on ? "enabled" : "disabled");
    emit bnrEnabledChanged(on);
}

void AudioEngine::setBnrAddress(const QString& addr)
{
    m_bnrAddress = addr;
}

void AudioEngine::setBnrIntensity(float ratio)
{
    if (m_bnr) m_bnr->setIntensityRatio(ratio);
}

float AudioEngine::bnrIntensity() const
{
    return m_bnr ? m_bnr->intensityRatio() : 1.0f;
}

bool AudioEngine::bnrConnected() const
{
    return m_bnr && m_bnr->isConnected();
}

void AudioEngine::processBnr(const QByteArray& stereoPcm,
                             RxDspSource source,
                             ExternalRxAudioSourceState* externalSource)
{
    // ── Feed input to BNR container (non-blocking) ───────────────────────
    std::vector<float>& monoScratch =
        externalSource ? externalSource->nr2Mono : m_nr2Mono;
    std::unique_ptr<Resampler>& bnrUp =
        externalSource
            ? externalSource->bnrUp
            : (source == RxDspSource::KiwiSdr ? m_kiwiSdrBnrUp : m_bnrUp);
    std::unique_ptr<Resampler>& bnrDown =
        externalSource
            ? externalSource->bnrDown
            : (source == RxDspSource::KiwiSdr ? m_kiwiSdrBnrDown : m_bnrDown);
    QByteArray& bnrOutBuf =
        externalSource
            ? externalSource->bnrOutBuf
            : (source == RxDspSource::KiwiSdr ? m_kiwiSdrBnrOutBuf : m_bnrOutBuf);
    bool& bnrPrimed =
        externalSource
            ? externalSource->bnrPrimed
            : (source == RxDspSource::KiwiSdr ? m_kiwiSdrBnrPrimed : m_bnrPrimed);

    if (!bnrUp) {
        bnrUp = std::make_unique<Resampler>(24000, 48000, 16384);
    }
    if (!bnrDown) {
        bnrDown = std::make_unique<Resampler>(48000, 24000, 16384);
    }

    // 1. 24kHz stereo float32 → 24kHz mono float32 (average L+R)
    const auto* src = reinterpret_cast<const float*>(stereoPcm.constData());
    const int stereoFrames = stereoPcm.size() / (2 * static_cast<int>(sizeof(float)));

    if (static_cast<int>(monoScratch.size()) < stereoFrames) {
        monoScratch.resize(stereoFrames);
    }
    for (int i = 0; i < stereoFrames; ++i) {
        monoScratch[i] = (src[2 * i] + src[2 * i + 1]) * 0.5f;
    }

    // 2. 24kHz mono float32 → 48kHz mono float32 (r8brain)
    QByteArray mono48k = bnrUp->process(monoScratch.data(), stereoFrames);

    // 3. Already float32 — pass directly to BNR
    const auto* mono48kSrc = reinterpret_cast<const float*>(mono48k.constData());
    const int mono48kSamples = mono48k.size() / static_cast<int>(sizeof(float));

    // 4. Push to BNR container (non-blocking), pull any denoised data
    QByteArray denoised = m_bnr->process(mono48kSrc, mono48kSamples);

    // ── Convert denoised data and add to jitter buffer ───────────────────

    if (!denoised.isEmpty()) {
        // 5. BNR returns float32 48kHz mono — downsample to 24kHz mono float32
        const auto* df = reinterpret_cast<const float*>(denoised.constData());
        const int dn = denoised.size() / static_cast<int>(sizeof(float));

        QByteArray mono24k = bnrDown->process(df, dn);

        // 6. Mono float32 → stereo float32 (duplicate L=R)
        const auto* m24 = reinterpret_cast<const float*>(mono24k.constData());
        const int n24 = mono24k.size() / static_cast<int>(sizeof(float));
        QByteArray stereo(n24 * 2 * static_cast<int>(sizeof(float)), Qt::Uninitialized);
        auto* ds = reinterpret_cast<float*>(stereo.data());
        for (int i = 0; i < n24; ++i) {
            ds[2 * i]     = m24[i];
            ds[2 * i + 1] = m24[i];
        }

        bnrOutBuf.append(stereo);

        // Cap jitter buffer at ~500ms (24kHz stereo float32 = 192000 bytes/sec)
        constexpr int maxBufBytes = 96000;  // 500ms
        if (bnrOutBuf.size() > maxBufBytes) {
            bnrOutBuf.remove(0, bnrOutBuf.size() - maxBufBytes);
        }
    }

    // ── Play from jitter buffer ──────────────────────────────────────────

    // Wait for ~50ms of buffered audio before starting playback (priming)
    constexpr int primeBytes = 9600;  // 50ms of 24kHz stereo float32
    if (!bnrPrimed) {
        if (bnrOutBuf.size() >= primeBytes) {
            bnrPrimed = true;
        } else {
            return;  // still priming — silence (no audio output)
        }
    }

    // Play the same amount of audio as the incoming chunk to maintain sync
    const int wantBytes = stereoPcm.size();
    if (bnrOutBuf.size() >= wantBytes) {
        QByteArray chunk = bnrOutBuf.left(wantBytes);
        bnrOutBuf.remove(0, wantBytes);

        const int pan = externalSource ? externalSource->pan : m_rxPan.load();
        applyRxPanInPlace(
            reinterpret_cast<float*>(chunk.data()),
            chunk.size() / (2 * static_cast<int>(sizeof(float))),
            pan);

        if (m_audioDevice && m_audioDevice->isOpen()) {
            const int scopeSampleRate = m_rxOutputRate.load();
            const QByteArray& resampled =
                (m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE)
                    ? resampleStereo(chunk, source, externalSource)
                    : chunk;
            const QByteArray* output = &resampled;
            QByteArray trimmed;
            const float trimDb = m_rxOutputTrimDb.load();
            if (std::fabs(trimDb) > 0.01f) {
                const float gain = std::pow(10.0f, trimDb / 20.0f);
                trimmed.resize(resampled.size());
                const auto* src = reinterpret_cast<const float*>(resampled.constData());
                auto* dst = reinterpret_cast<float*>(trimmed.data());
                const int nSamples = resampled.size() / static_cast<int>(sizeof(float));
                for (int i = 0; i < nSamples; ++i) {
                    dst[i] = src[i] * gain;
                }
                output = &trimmed;
            }
            QByteArray& outputBuffer =
                externalSource
                    ? externalSource->outputBuffer
                    : (source == RxDspSource::KiwiSdr ? m_kiwiSdrOutputBuffer
                                                       : m_rxOutputBuffer);
            outputBuffer.append(*output);
            emitScopeFromFloat32Stereo(*output, scopeSampleRate, false);
            emitRxPostChainScopeFromFloat32Stereo(*output, scopeSampleRate);
            updateRxBufferStats();
        }
        emit levelChanged(computeRMS(chunk));
    }
    // If buffer underrun, skip this callback (brief silence, not choppy)
}

// ─── DFNR (DeepFilterNet3 neural noise reduction) ────────────────────────────

#ifdef HAVE_DFNR

void AudioEngine::setDfnrEnabled(bool on)
{
    if (m_dfnrEnabled == on) return;
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (on) {
        // Mutual exclusion with all other NR modes
        if (m_nr2Enabled)  setNr2Enabled(false);
        if (m_rn2Enabled)  setRn2Enabled(false);
        if (m_nr4Enabled)  setNr4Enabled(false);
        if (m_bnrEnabled)  setBnrEnabled(false);
        if (m_mnrEnabled)  setMnrEnabled(false);
        m_dfnr = std::make_unique<DeepFilterFilter>();
        if (!m_dfnr->isValid()) {
            qCWarning(lcAudio) << "AudioEngine: DFNR df_create() failed — disabling";
            m_dfnr.reset();
            emit dfnrEnabledChanged(false);
            return;
        }
        // Restore saved attenuation limit
        auto& s = AppSettings::instance();
        m_dfnr->setAttenLimit(s.value("DfnrAttenLimit", "100").toFloat());
        m_dfnr->setPostFilterBeta(s.value("DfnrPostFilterBeta", "0.0").toFloat());
        // Set flag AFTER object is fully constructed
        m_dfnrEnabled = true;
    } else {
        m_dfnrEnabled = false;
        m_dfnr.reset();
    }
    qCDebug(lcAudio) << "AudioEngine: DFNR (DeepFilterNet3)" << (on ? "enabled" : "disabled");
    emit dfnrEnabledChanged(on);
}

void AudioEngine::setDfnrAttenLimit(float db)
{
    if (m_dfnr) m_dfnr->setAttenLimit(db);
}

float AudioEngine::dfnrAttenLimit() const
{
    return m_dfnr ? m_dfnr->attenLimit() : 100.0f;
}

void AudioEngine::setDfnrPostFilterBeta(float beta)
{
    if (m_dfnr) m_dfnr->setPostFilterBeta(beta);
}

#else // !HAVE_DFNR — stubs
void AudioEngine::setDfnrEnabled(bool) {}
void AudioEngine::setDfnrAttenLimit(float) {}
float AudioEngine::dfnrAttenLimit() const { return 100.0f; }
void AudioEngine::setDfnrPostFilterBeta(float) {}
#endif // HAVE_DFNR

void AudioEngine::processNr2(const QByteArray& stereoPcm,
                             RxDspSource source,
                             ExternalRxAudioSourceState* externalSource)
{
    const int totalFloats = stereoPcm.size() / static_cast<int>(sizeof(float));
    const int stereoFrames = totalFloats / 2;
    const auto* src = reinterpret_cast<const float*>(stereoPcm.constData());
    SpectralNR* nr2 = externalSource
        ? externalSource->nr2.get()
        : (source == RxDspSource::KiwiSdr ? m_kiwiSdrNr2.get() : m_nr2.get());
    std::vector<float>& mono = externalSource
        ? externalSource->nr2Mono
        : (source == RxDspSource::KiwiSdr ? m_kiwiSdrNr2Mono : m_nr2Mono);
    std::vector<float>& processed = externalSource
        ? externalSource->nr2Processed
        : (source == RxDspSource::KiwiSdr ? m_kiwiSdrNr2Processed : m_nr2Processed);
    QByteArray& output = externalSource
        ? externalSource->nr2Output
        : (source == RxDspSource::KiwiSdr ? m_kiwiSdrNr2Output : m_nr2Output);
    if (!nr2) {
        output = stereoPcm;
        return;
    }

    // Resize pre-allocated buffers if needed
    if (static_cast<int>(mono.size()) < stereoFrames) {
        mono.resize(stereoFrames);
        processed.resize(stereoFrames);
    }

    // Stereo float32 → mono float32 (average L+R)
    for (int i = 0; i < stereoFrames; ++i)
        mono[i] = (src[2 * i] + src[2 * i + 1]) * 0.5f;

    // Process through SpectralNR (float32 I/O)
    nr2->process(mono.data(), processed.data(), stereoFrames);

    // Mono float32 → stereo float32, then re-apply the pan the radio had set
    // before NR mono-mixed it away (#1460).
    // Hard-clamp to ±1.0: if gainMax was tuned above 1.0 (not recommended),
    // unclamped samples would cause digital crackling at the audio sink (#1507).
    const int outBytes = stereoFrames * 2 * static_cast<int>(sizeof(float));
    output.resize(outBytes);
    auto* dst = reinterpret_cast<float*>(output.data());
    for (int i = 0; i < stereoFrames; ++i) {
        const float s = std::clamp(processed[i], -1.0f, 1.0f);
        dst[2 * i]     = s;
        dst[2 * i + 1] = s;
    }
    const int pan = externalSource ? externalSource->pan : m_rxPan.load();
    applyRxPanInPlace(dst, stereoFrames, pan);
}

QByteArray AudioEngine::applyBoost(const QByteArray& pcm, float gain) const
{
    const int nSamples = pcm.size() / sizeof(int16_t);
    const auto* src = reinterpret_cast<const int16_t*>(pcm.constData());
    QByteArray out(pcm.size(), Qt::Uninitialized);
    auto* dst = reinterpret_cast<int16_t*>(out.data());
    for (int i = 0; i < nSamples; ++i) {
        float s = src[i] * gain;
        // Soft clamp to avoid harsh digital clipping
        if (s > 32767.0f) s = 32767.0f;
        else if (s < -32767.0f) s = -32767.0f;
        dst[i] = static_cast<int16_t>(s);
    }
    return out;
}

float AudioEngine::computeRMS(const QByteArray& pcm) const
{
    const int samples = pcm.size() / static_cast<int>(sizeof(float));
    if (samples == 0) return 0.0f;

    const float* data = reinterpret_cast<const float*>(pcm.constData());
    double sum = 0.0;
    for (int i = 0; i < samples; ++i) {
        sum += static_cast<double>(data[i]) * data[i];
    }
    return static_cast<float>(std::sqrt(sum / samples));
}

void AudioEngine::accumulatePcMicMeterInt16Stereo(const QByteArray& int16stereo)
{
    const auto block = TxMicChannelNormalizer::measureInt16StereoLevelBlock(int16stereo);
    if (block.frames <= 0) {
        return;
    }

    m_pcMicPeak = std::max(m_pcMicPeak, block.peak);
    m_pcMicSumSq += block.sumSq;
    m_pcMicSampleCount += block.frames;
    if (m_pcMicSampleCount >= kMicMeterWindowSamples) {
        const float rms = static_cast<float>(std::sqrt(m_pcMicSumSq / m_pcMicSampleCount));
        emit pcMicLevelChanged(TxMicChannelNormalizer::dbfs(m_pcMicPeak),
                               TxMicChannelNormalizer::dbfs(rms));
        m_pcMicPeak = 0.0f;
        m_pcMicSumSq = 0.0;
        m_pcMicSampleCount = 0;
    }
}

void AudioEngine::logTxInputChannelDiagnostics(const TxMicChannelNormalizer::Diagnostics& diagnostics,
                                               const char* route)
{
    if (!diagnostics.oneSidedStereo) {
        return;
    }

    QElapsedTimer& throttle = (route && std::strcmp(route, "DAX radio") == 0)
        ? m_lastDaxRadioChannelLog
        : m_lastTxMicChannelLog;
    if (throttle.isValid() && throttle.elapsed() < 1000) {
        return;
    }

    if (throttle.isValid())
        throttle.restart();
    else
        throttle.start();

    qCDebug(lcAudio) << "AudioEngine:" << (route ? route : "TX mic")
                     << "one-sided stereo input"
                     << "leftRmsDbfs:" << TxMicChannelNormalizer::dbfs(diagnostics.leftRms)
                     << "rightRmsDbfs:" << TxMicChannelNormalizer::dbfs(diagnostics.rightRms)
                     << "leftPeakDbfs:" << TxMicChannelNormalizer::dbfs(diagnostics.leftPeak)
                     << "rightPeakDbfs:" << TxMicChannelNormalizer::dbfs(diagnostics.rightPeak)
                     << "selected:"
                     << TxMicChannelNormalizer::channelModeName(diagnostics.selectedMode);
}

// ─── TX stream ────────────────────────────────────────────────────────────────

bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioPort)
{
    if (m_audioSource) return true;  // already running

    // WASAPI silent-open recovery (#2929). If the previous open was driven
    // by the silence watchdog, m_txForceMonoOnNextOpen is true; consume it
    // here. A fresh (non-watchdog) start re-enables the one-shot retry budget.
    const bool isWatchdogRetry = m_txForceMonoOnNextOpen;
    m_txForceMonoOnNextOpen = false;
    if (!isWatchdogRetry) {
        m_txSilenceRetryDone = false;
    }
    m_txReceivedAnyBytes = false;

    m_txAddress = radioAddress;
    m_txPort    = radioPort;
    m_txPacketCount = 0;
    m_txAccumulator.clear();
    m_txMicChannelState.reset();
    m_lastTxMicChannelLog.invalidate();

    // TX mic capture uses Int16 — we convert to float32 after capture.
    // (makeFormat() returns Float for the RX sink, but mic hardware is Int16.)
    QAudioFormat fmt;
    fmt.setSampleRate(DEFAULT_SAMPLE_RATE);
    fmt.setChannelCount(2);
    fmt.setSampleFormat(QAudioFormat::Int16);
    QAudioDevice dev = QMediaDevices::defaultAudioInput();
    bool txFallbackOccurred = false;
    QStringList txFallbackReasons;
    QStringList txFormatAttempts;
    const auto noteTxFallback = [&txFallbackOccurred, &txFallbackReasons](const QString& reason) {
        txFallbackOccurred = true;
        if (!reason.isEmpty() && !txFallbackReasons.contains(reason)) {
            txFallbackReasons << reason;
        }
    };
    const auto noteTxAttempt = [&txFormatAttempts](const QAudioFormat& format) {
        const QString attempt = formatAudioAttempt(format.sampleRate(),
                                                  format.channelCount(),
                                                  format.sampleFormat());
        if (!txFormatAttempts.contains(attempt)) {
            txFormatAttempts << attempt;
        }
    };
    if (!m_inputDevice.isNull()) {
        const auto inputs = QMediaDevices::audioInputs();
        if (devicePresent(inputs, m_inputDevice)) {
            dev = m_inputDevice;
        } else {
            qCWarning(lcAudio) << "AudioEngine: saved input device is unavailable, using the system default input instead";
            noteTxFallback(QStringLiteral("saved input unavailable -> system default"));
            m_inputDevice = QAudioDevice{};
        }
    }

    if (dev.isNull()) {
        qCWarning(lcAudio) << "AudioEngine: no audio input device available";
        return false;
    }

    qCDebug(lcAudio) << "AudioEngine: input device caps:"
        << dev.minimumSampleRate() << "-" << dev.maximumSampleRate() << "Hz"
        << dev.minimumChannelCount() << "-" << dev.maximumChannelCount() << "ch";

    // Negotiate the TX mic input format via the consolidated factory (#3306).
    // The mic is captured as Int16; the factory supplies the per-OS rate ladder
    // in ONE place (macOS preferred/HAL-native-rate-first to dodge the silent
    // 48k-open trap #2930 and the Bluetooth-HFP native rate #2615; Linux native
    // 24k). We walk it preferring stereo across all rates then mono, preserving
    // the existing channel fallback.
    bool formatFound = false;
#ifdef Q_OS_WIN
    // Windows WASAPI shared mode handles rate conversion transparently, but Qt's
    // isFormatSupported() returns false for many valid devices (Voicemeeter,
    // FlexRadio DAX). Default to 48kHz and let WASAPI handle the rate. Clamp the
    // channel count to the device's maximumChannelCount() so mono-only USB PnP
    // mics open as mono on the first attempt — opening them stereo silently
    // returns a non-null QIODevice that delivers zero bytes (#2929). This path
    // already matches the factory's Windows policy (force 48k + probe-at-open);
    // migrating its mono-clamp onto the wrapper is a separate, soakable step.
    constexpr int preferredTxRate = 48000;
    fmt.setSampleRate(48000);
    const int maxCh = dev.maximumChannelCount();
    const int initialCh = (isWatchdogRetry || (maxCh > 0 && maxCh < 2)) ? 1 : 2;
    fmt.setChannelCount(initialCh);
    noteTxAttempt(fmt);
    formatFound = true;
#else
    bool txBluetoothHfp = false;
    int  txPreferredOverride = 0;
#ifdef Q_OS_MAC
    // CoreAudio-HAL detection the factory can't derive from QAudioDevice: if this
    // is a Bluetooth-HFP capture route, put its native low rate first (#2615).
    if (const auto nativeRate = macBluetoothNativeInputRate(dev)) {
        txBluetoothHfp = true;
        txPreferredOverride = *nativeRate;
    }
#endif
    const QList<QAudioFormat> txLadder = AudioDeviceNegotiator::formatLadder(
        dev, AudioFormatNegotiator::Direction::Input,
        AudioFormatNegotiator::ResamplerPolicy::PreservePan,
        AudioFormatNegotiator::hostTargetOs(), DEFAULT_SAMPLE_RATE,
        txBluetoothHfp, txPreferredOverride);
    const int preferredTxRate = txLadder.isEmpty() ? 48000 : txLadder.first().sampleRate();
    for (int channels : {2, 1}) {
        for (const QAudioFormat& cand : txLadder) {
            if (cand.sampleFormat() != QAudioFormat::Int16)
                continue;   // mic is captured as Int16
            fmt.setChannelCount(channels);
            fmt.setSampleRate(cand.sampleRate());
            noteTxAttempt(fmt);
            if (dev.isFormatSupported(fmt)) {
                formatFound = true;
                break;
            }
        }
        if (formatFound) break;
    }
#endif

    if (!formatFound) {
        qCWarning(lcAudio) << "AudioEngine: input device supports no usable format"
            << "(tried preferred platform rates, stereo and mono)";
        logAudioOpenFailure(QStringLiteral("TX source"),
                            QStringLiteral("QAudioSource"),
                            dev,
                            txFormatAttempts,
                            QStringLiteral("input device supports no usable TX format"),
                            txFallbackReasons);
        return false;
    }
    if (fmt.sampleRate() != preferredTxRate || fmt.channelCount() != 2) {
        noteTxFallback(QStringLiteral("negotiated %1Hz %2ch instead of preferred %3Hz stereo")
                           .arg(fmt.sampleRate())
                           .arg(fmt.channelCount())
                           .arg(preferredTxRate));
    }

    qCInfo(lcAudio) << "AudioEngine: selected TX input format:"
        << fmt.sampleRate() << "Hz" << fmt.channelCount() << "ch";

    // Record actual negotiated input format for resampling in onTxAudioReady
    m_txInputRate = fmt.sampleRate();
    m_txInputChannels = fmt.channelCount();
    m_txInputMono = (m_txInputChannels == 1);
    m_txNeedsResample = (m_txInputRate != 24000);

    // Create polyphase resampler for high-quality rate conversion
    if (m_txNeedsResample)
        m_txResampler = std::make_unique<Resampler>(m_txInputRate, DEFAULT_SAMPLE_RATE, 16384);
    else
        m_txResampler.reset();

    qCDebug(lcAudio) << "AudioEngine: TX input device:" << dev.description()
             << "id:" << dev.id()
             << "rate:" << fmt.sampleRate() << "ch:" << fmt.channelCount()
             << "resample:" << m_txNeedsResample;

#ifdef Q_OS_MAC
    // macOS: QAudioSource pull mode broken — use push mode with QBuffer
    const quint64 txLifecycleGeneration = ++m_txLifecycleGeneration;
    m_micBuffer = new QBuffer(this);
    m_micBuffer->open(QIODevice::ReadWrite);
    m_audioSource = new QAudioSource(dev, fmt, this);
    m_audioSource->start(m_micBuffer);

    if (m_audioSource->state() == QAudio::StoppedState) {
        const QString error = audioErrorName(m_audioSource->error());
        qCWarning(lcAudio) << "AudioEngine: failed to start audio source";
        logAudioOpenFailure(QStringLiteral("TX source"),
                            QStringLiteral("QAudioSource"),
                            dev,
                            txFormatAttempts,
                            QStringLiteral("QAudioSource stopped immediately after start (%1)").arg(error),
                            txFallbackReasons);
        delete m_audioSource; m_audioSource = nullptr;
        delete m_micBuffer; m_micBuffer = nullptr;
        return false;
    }

    // Poll push-mode buffer
    m_txPollTimer = new QTimer(this);
    m_txPollTimer->setInterval(5);
    connect(m_txPollTimer, &QTimer::timeout, this, &AudioEngine::onTxAudioReady);
    m_txPollTimer->start();

    // Guard against CoreAudio silently stopping the source after extended
    // runtime (~16h). Detect the silent stop, pause the timer, and restart
    // cleanly so onTxAudioReady never touches a stale m_micBuffer. (#1149)
    connect(m_audioSource, &QAudioSource::stateChanged, this,
            [this, txLifecycleGeneration](QAudio::State state) {
        if (state != QAudio::StoppedState) {
            return;
        }
        if (txLifecycleGeneration != m_txLifecycleGeneration) {
            return;
        }
        if (!m_audioSource || !m_txPollTimer) {
            return;  // intentional stop already handled
        }

        const QAudio::Error error = m_audioSource->error();
        m_txPollTimer->stop();
        if (error != QAudio::NoError) {
            qCWarning(lcAudio) << "AudioEngine: QAudioSource stopped with error, not auto-restarting TX"
                               << error;
            QMetaObject::invokeMethod(this, [this]() {
                if (m_audioSource) {
                    stopTxStream();
                }
            }, Qt::QueuedConnection);
            return;
        }

        const qint64 runtimeMs = m_txSourceStartTime.isValid() ? m_txSourceStartTime.elapsed() : 0;
        if (!m_txSourceStartTime.isValid() || runtimeMs < kTxAutoRestartMinRuntimeMs) {
            qCWarning(lcAudio) << "AudioEngine: QAudioSource stopped too soon, not auto-restarting TX"
                               << runtimeMs << "ms";
            QMetaObject::invokeMethod(this, [this]() {
                if (m_audioSource) {
                    stopTxStream();
                }
            }, Qt::QueuedConnection);
            return;
        }

        QHostAddress addr = m_txAddress;
        quint16 port = m_txPort;
        QMetaObject::invokeMethod(this, [this, addr, port]() {
            qCWarning(lcAudio) << "AudioEngine: QAudioSource stopped silently (#1149), restarting TX";
            stopTxStream();
            startTxStream(addr, port);
        }, Qt::QueuedConnection);
    }, Qt::QueuedConnection);
#else
    // Linux/Windows: pull mode works fine
    m_audioSource = new QAudioSource(dev, fmt, this);
    m_micDevice = m_audioSource->start();
    if (!m_micDevice) {
        const QString firstError = audioErrorName(m_audioSource->error());
        qCWarning(lcAudio) << "AudioEngine: failed to open audio source at"
                           << fmt.sampleRate() << "Hz" << fmt.channelCount() << "ch"
                           << "error:" << m_audioSource->error()
                           << "device:" << dev.description();
#ifdef Q_OS_WIN
        // Windows: WASAPI may reject our negotiated format at open time.
        // Try additional rates before giving up.
        delete m_audioSource; m_audioSource = nullptr;
        bool txOpened = false;
        constexpr int fallbackRates[] = {48000, 44100, 24000, 16000};
        const int initialRate = fmt.sampleRate();
        const int initialChannels = fmt.channelCount();
        for (int rate : fallbackRates) {
            for (int ch : {2, 1}) {
                // Skip only the exact (rate, ch) combo that just failed —
                // a mono-only 48 kHz USB mic needs a 48 kHz mono retry (#2929).
                if (rate == initialRate && ch == initialChannels) continue;
                fmt.setSampleRate(rate);
                fmt.setChannelCount(ch);
                noteTxAttempt(fmt);
                m_audioSource = new QAudioSource(dev, fmt, this);
                m_micDevice = m_audioSource->start();
                if (m_micDevice) {
                    qCInfo(lcAudio) << "AudioEngine: TX source opened at fallback"
                                    << rate << "Hz" << ch << "ch";
                    noteTxFallback(QStringLiteral("initial TX source open failed -> %1Hz %2ch")
                                       .arg(rate)
                                       .arg(ch));
                    m_txInputRate = rate;
                    m_txInputChannels = ch;
                    m_txInputMono = (m_txInputChannels == 1);
                    m_txNeedsResample = (rate != 24000);
                    if (m_txNeedsResample) {
                        m_txResampler = std::make_unique<Resampler>(rate, 24000, 16384);
                    } else {
                        m_txResampler.reset();
                    }
                    txOpened = true;
                    break;
                }
                delete m_audioSource; m_audioSource = nullptr;
            }
            if (txOpened) break;
        }
        if (!txOpened) {
            qCWarning(lcAudio) << "AudioEngine: all TX source formats failed";
            logAudioOpenFailure(QStringLiteral("TX source"),
                                QStringLiteral("QAudioSource"),
                                dev,
                                txFormatAttempts,
                                QStringLiteral("QAudioSource::start failed for all TX formats (initial %1)")
                                    .arg(firstError),
                                txFallbackReasons);
            return false;
        }
#else
        logAudioOpenFailure(QStringLiteral("TX source"),
                            QStringLiteral("QAudioSource"),
                            dev,
                            txFormatAttempts,
                            QStringLiteral("QAudioSource::start returned null (%1)").arg(firstError),
                            txFallbackReasons);
        delete m_audioSource; m_audioSource = nullptr;
        return false;
#endif
    }
    connect(m_micDevice, &QIODevice::readyRead, this, &AudioEngine::onTxAudioReady);

#ifdef Q_OS_WIN
    // WASAPI silent-open watchdog (#2929): some USB PnP mics report their
    // native mono format but Qt's QAudioSource::start() returns a non-null
    // QIODevice for an unsupported stereo open, then delivers zero bytes.
    // The null-open fallback ladder above never sees this case (start did
    // not return null). One-shot retry as mono if no bytes arrive in 1.5 s.
    if (!m_txSilenceRetryDone) {
        const quint64 watchdogGen = m_txLifecycleGeneration;
        const QHostAddress watchdogAddr = m_txAddress;
        const quint16 watchdogPort = m_txPort;
        QTimer::singleShot(1500, this, [this, watchdogGen, watchdogAddr, watchdogPort]() {
            if (!m_audioSource) return;
            if (watchdogGen != m_txLifecycleGeneration) return;
            if (m_audioSource->state() != QAudio::ActiveState) return;
            if (m_txReceivedAnyBytes) return;
            qCWarning(lcAudio) << "AudioEngine: TX source opened but produced no bytes in 1.5 s — "
                                  "retrying as mono (likely WASAPI mono-only USB mic, #2929)"
                               << "rate:" << m_txInputRate << "ch:" << m_txInputChannels;
            m_txSilenceRetryDone = true;
            m_txForceMonoOnNextOpen = true;
            QMetaObject::invokeMethod(this, [this, watchdogAddr, watchdogPort]() {
                stopTxStream();
                startTxStream(watchdogAddr, watchdogPort);
            }, Qt::QueuedConnection);
        });
    }
#endif
#endif

    m_txSourceStartTime.restart();
    qCWarning(lcAudio) << "AudioEngine: TX stream started ->" << radioAddress.toString()
             << ":" << radioPort << "streamId:" << Qt::hex << m_txStreamId
             << Qt::dec << "device:" << dev.description() << "id:" << dev.id()
             << "rate:" << m_txInputRate << "ch:" << m_txInputChannels
             << "resample:" << m_txNeedsResample;
    AudioSummaryLogger::TxSourceSummary summary;
    summary.deviceDescription = dev.description();
    summary.sampleRate = m_txInputRate;
    summary.channelCount = m_txInputChannels;
    summary.sampleFormat = fmt.sampleFormat();
    summary.resamplingTo24k = m_txNeedsResample;
    summary.fallbackOccurred = txFallbackOccurred;
    summary.fallbackReason = txFallbackReasons.join(QStringLiteral("; "));
    AudioSummaryLogger::logTxSource(summary);
    return true;
}

void AudioEngine::stopTxStream()
{
    ++m_txLifecycleGeneration;
#ifdef Q_OS_MAC
    QTimer* pollTimer = m_txPollTimer;
    m_txPollTimer = nullptr;
    QBuffer* micBuffer = m_micBuffer;
    m_micBuffer = nullptr;
#endif
    QAudioSource* audioSource = m_audioSource;
    m_audioSource = nullptr;
    m_micDevice = nullptr;

#ifdef Q_OS_MAC
    if (pollTimer) {
        pollTimer->stop();
        delete pollTimer;
    }
#endif
    if (audioSource) {
        // Guard: calling stop() on an already-stopped QAudioSource on macOS causes
        // AudioOutputUnitStop to dereference a stale CoreAudio device handle,
        // producing EXC_ARM_DA_ALIGN / EXC_BAD_ACCESS (#1059).
        if (audioSource->state() != QAudio::StoppedState) {
            audioSource->stop();
        }
        delete audioSource;
    }
#ifdef Q_OS_MAC
    if (micBuffer) {
        delete micBuffer;
    }
#endif
    m_txSocket.close();
    m_txAccumulator.clear();
    m_txFloatAccumulator.clear();
    m_txResampler.reset();
    m_txInputChannels = 2;
    m_txInputMono = false;
    m_txInputRate = DEFAULT_SAMPLE_RATE;
    m_txNeedsResample = false;
    m_txMicChannelState.reset();
    m_lastTxMicChannelLog.invalidate();
    m_txSourceStartTime.invalidate();
}

void AudioEngine::onTxAudioReady()
{
    // If a TCI client is actively feeding TX audio (binary frames via
    // TciServer → feedDaxTxAudio), step the local mic capture aside.
    // Both producers emit txPacketReady; the higher-rate mic stream would
    // otherwise drown out the TCI tone — particularly visible on macOS,
    // where the default CoreAudio input is a real webcam mic that
    // produces continuous ambient packets. The 200 ms window comfortably
    // covers the 50 ms TCI frame cadence.
    if (m_tciAudioTimer.isValid()
        && m_tciAudioTimer.elapsed() < kTciAudioActiveWindowMs) {
        return;
    }
#ifdef Q_OS_MAC
    if (!m_micBuffer || !m_audioSource) return;
    if (m_audioSource->state() == QAudio::StoppedState) return;
    if (!m_micBuffer->isOpen()) return;
    if (m_txStreamId == 0 && m_remoteTxStreamId == 0) return;
    qint64 avail = m_micBuffer->pos();
    if (avail <= 0) return;
    QByteArray data = m_micBuffer->data();
    m_micBuffer->buffer().clear();
    m_micBuffer->seek(0);
    if (data.isEmpty()) return;
#else
    if (!m_micDevice || (m_txStreamId == 0 && m_remoteTxStreamId == 0)) return;
    QByteArray data = m_micDevice->readAll();
    if (data.isEmpty()) return;
    m_txReceivedAnyBytes = true;  // disarms the WASAPI silent-open watchdog (#2929)
#endif

    // Canonicalize immediately after capture: TX voice is logically mono
    // carried as stereo int16, so choose/average the real mic channel before
    // any resampling, RADE/DAX branch, test tone, DSP, gain, limiter, or meter.
    TxMicChannelNormalizer::Diagnostics channelDiagnostics;
    data = TxMicChannelNormalizer::canonicalizeInt16ToMonoStereo(
        data,
        m_txInputChannels,
        m_txInputRate,
        m_txMicChannelMode,
        &m_txMicChannelState,
        &channelDiagnostics);
    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) {
        // Convert canonical duplicated int16 stereo → float32 mono for the
        // mono-to-stereo resampler.
        const auto* i16 = reinterpret_cast<const int16_t*>(data.constData());
        const int frames = data.size() / static_cast<int>(2 * sizeof(int16_t));
        QByteArray f32(frames * static_cast<int>(sizeof(float)), Qt::Uninitialized);
        auto* fd = reinterpret_cast<float*>(f32.data());
        for (int i = 0; i < frames; ++i)
            fd[i] = i16[i * 2] / 32768.0f;

        f32 = m_txResampler->processMonoToStereo(
            reinterpret_cast<const float*>(f32.constData()),
            f32.size() / static_cast<int>(sizeof(float)));

        // Convert back to int16 for the rest of the TX path
        const auto* rsrc = reinterpret_cast<const float*>(f32.constData());
        const int rcount = f32.size() / static_cast<int>(sizeof(float));
        if (rcount <= 0) return;
        data.resize(rcount * static_cast<int>(sizeof(int16_t)));
        auto* rdst = reinterpret_cast<int16_t*>(data.data());
        for (int i = 0; i < rcount; ++i)
            rdst[i] = static_cast<int16_t>(std::clamp(rsrc[i] * 32768.0f, -32768.0f, 32767.0f));
    }

    // RADE mode: apply client-side gain + meter, then convert int16 → float32
    if (m_radeMode) {
        // Apply client-side mic gain (same int16 gain path as SSB below)
        const float gain = m_pcMicGain.load();
        if (gain < 0.999f) {
            auto* pcm = reinterpret_cast<int16_t*>(data.data());
            int sampleCount = data.size() / static_cast<int>(sizeof(int16_t));
            for (int i = 0; i < sampleCount; ++i) {
                pcm[i] = static_cast<int16_t>(std::clamp(
                    static_cast<int>(pcm[i] * gain), -32768, 32767));
            }
        }
        accumulatePcMicMeterInt16Stereo(data);

        // Gate TX audio on PTT (prevents pre-MOX audio leakage into encoder)
        if (!m_transmitting) return;

        const auto* i16 = reinterpret_cast<const int16_t*>(data.constData());
        const int ns = data.size() / static_cast<int>(sizeof(int16_t));
        QByteArray f32(ns * static_cast<int>(sizeof(float)), Qt::Uninitialized);
        auto* fd = reinterpret_cast<float*>(f32.data());
        for (int i = 0; i < ns; ++i)
            fd[i] = i16[i] / 32768.0f;
        emit txRawPcmReady(f32);
        return;
    }

    // DAX TX mode: VirtualAudioBridge handles TX audio via feedDaxTxAudio().
    // 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<const int16_t*>(data.constData());
        const int samples = data.size() / static_cast<int>(sizeof(int16_t));
        m_rn2TxF32In.resize(samples * static_cast<int>(sizeof(float)));
        auto* fin = reinterpret_cast<float*>(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<int>(sizeof(float));
        const auto* fout = reinterpret_cast<const float*>(m_rn2TxF32In.constData());
        data.resize(outSamples * static_cast<int>(sizeof(int16_t)));
        auto* i16Out = reinterpret_cast<int16_t*>(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<int16_t>(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<int>(sizeof(int16_t));
        const int frames  = samples / 2;
        m_clientTxTestTone->process(
            reinterpret_cast<int16_t*>(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.
    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<int16_t*>(data.data());
        int sampleCount = data.size() / static_cast<int>(sizeof(int16_t));
        for (int i = 0; i < sampleCount; ++i)
            pcm[i] = static_cast<int16_t>(std::clamp(
                static_cast<int>(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<int>(sizeof(int16_t) * 2);
        m_clientQuindarTone->process(
            reinterpret_cast<int16_t*>(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 ────────────────────────────────
    // Mirrors the post-PUDU monitor but reads at the chain's tail
    // (post-limiter), so a recording captures EXACTLY what the radio
    // is told to transmit.  Lock-free pointer load; the monitor's
    // feedTxPostDsp() handles the not-recording fast path.
    if (auto* mon = m_txFinalMonitor.load(std::memory_order_acquire)) {
        mon->feedTxPostDsp(data);
    }
    // Expose the same post-limiter int16 stream as a signal so the QSO recorder
    // can capture TX for Client-Side recording (#3556). Emitted unconditionally
    // (independent of whether the PUDU monitor is attached); the recorder slot
    // fast-returns when not recording / not transmitting, so this is cheap.
    emit txFinalMonitorPcmReady(data);

    // ── TX post-final-limiter scope tap ─────────────────────────
    // Sampled here, AFTER everything the strip can do to the audio
    // (user chain, PC mic gain, brickwall limiter), so the strip's
    // "Waveform CE-SSB" panel shows the exact int16 stream that gets
    // packetised into VITA-49 and sent to the radio.
    emitTxPostChainScopeFromInt16Stereo(data, DEFAULT_SAMPLE_RATE);

    // ── Client-side PC mic level metering (int16) ───────────────────────
    accumulatePcMicMeterInt16Stereo(data);

    emitScopeFromInt16Stereo(data, DEFAULT_SAMPLE_RATE, true);

    // ── Opus TX path: always active for remote_audio_tx ────────────────
    // Sends Opus during both RX (VOX/met_in_rx metering) and TX (voice).
    // The radio requires Opus on remote_audio_tx (enforces compression=OPUS).
    // Data is int16 stereo — accumulate directly for Opus encoding.
    if (m_opusTxEnabled) {
        m_opusTxAccumulator.append(data);
        // 240 stereo sample frames × 2 channels × 2 bytes = 960 bytes per 10ms frame
        constexpr int OPUS_FRAME_BYTES = 240 * 2 * sizeof(int16_t);

        while (m_opusTxAccumulator.size() >= OPUS_FRAME_BYTES) {
            if (!m_opusTxCodec) {
                m_opusTxCodec = std::make_unique<OpusCodec>();
                if (!m_opusTxCodec->isValid()) {
                    qCWarning(lcAudio) << "AudioEngine: Opus TX codec init failed, falling back to uncompressed";
                    m_opusTxEnabled = false;
                    m_opusTxCodec.reset();
                    break;
                }
            }

            QByteArray frame = m_opusTxAccumulator.left(OPUS_FRAME_BYTES);
            m_opusTxAccumulator.remove(0, OPUS_FRAME_BYTES);

            QByteArray opus = m_opusTxCodec->encode(frame);
            if (opus.isEmpty()) continue;

            // Build VITA-49 Opus packet matching SmartSDR exactly:
            // Header: 28 bytes + opus payload, NO trailer.
            // FlexLib Opus packets are byte-centric — payload is NOT
            // padded to 32-bit word alignment. Size field in header
            // is still in 32-bit words (rounded up) per VITA-49 spec.
            const int pktBytes = 28 + opus.size();  // exact, no padding
            const int sizeWords = (pktBytes + 3) / 4;  // for header field only
            QByteArray pkt(pktBytes, '\0');
            auto* p = reinterpret_cast<quint32*>(pkt.data());

            // Word 0: type=3 (ExtDataWithStream), C=1, T=0, TSI=3, TSF=1
            p[0] = qToBigEndian<quint32>(
                (3u << 28) | (1u << 27) | (3u << 22) | (1u << 20)
                | ((m_txPacketCount & 0x0F) << 16) | sizeWords);
            m_txPacketCount = (m_txPacketCount + 1) & 0x0F;
            p[1] = qToBigEndian(m_remoteTxStreamId);    // remote_audio_tx stream
            p[2] = qToBigEndian<quint32>(0x00001C2D);   // OUI (FlexRadio)
            p[3] = qToBigEndian<quint32>(0x534C0000 | 0x8005);  // ICC=0x534C, PCC=0x8005
            p[4] = 0; p[5] = 0; p[6] = 0;              // timestamps (all zero)

            memcpy(pkt.data() + 28, opus.constData(), opus.size());

            // Queue for paced delivery instead of sending immediately.
            // The 10ms pacing timer drains one packet per tick for even
            // timing over SmartLink/WAN. Cap queue to ~200ms to prevent
            // runaway growth if the mic delivers faster than real-time.
            m_opusTxQueue.append(pkt);
            if (m_opusTxQueue.size() > 20)
                m_opusTxQueue.removeFirst();
        }
        return;
    }

    // ── Uncompressed TX path (not used — radio forces Opus) ────────────
    m_txAccumulator.append(data);

    while (m_txAccumulator.size() >= TX_PCM_BYTES_PER_PACKET) {
        const int16_t* pcm = reinterpret_cast<const int16_t*>(m_txAccumulator.constData());

        // Convert int16 → float32 for VITA-49 packet (radio expects float32)
        float floatBuf[TX_SAMPLES_PER_PACKET * 2];
        for (int i = 0; i < TX_SAMPLES_PER_PACKET * 2; ++i)
            floatBuf[i] = pcm[i] / 32768.0f;

        QByteArray packet = buildVitaTxPacket(floatBuf, TX_SAMPLES_PER_PACKET);
        emit txPacketReady(packet);

        m_txAccumulator.remove(0, TX_PCM_BYTES_PER_PACKET);
    }
}

QByteArray AudioEngine::buildVitaTxPacket(const float* samples, int numStereoSamples)
{
    const int payloadBytes = numStereoSamples * 2 * 4;  // stereo × sizeof(float)
    const int packetWords = (payloadBytes / 4) + VITA_HEADER_WORDS;
    const int packetBytes = packetWords * 4;

    QByteArray packet(packetBytes, '\0');
    quint32* words = reinterpret_cast<quint32*>(packet.data());

    // ── Word 0: Header (DAX TX format, matches FlexLib DAXTXAudioStream) ─
    // Bits 31-28: packet type = 1 (IFDataWithStream)
    // Bit  27:    C = 1 (class ID present)
    // Bit  26:    T = 0 (no trailer)
    // Bits 25-24: reserved = 0
    // Bits 23-22: TSI = 3 (Other)
    // Bits 21-20: TSF = 1 (SampleCount)
    // Bits 19-16: packet count (4-bit)
    // Bits 15-0:  packet size (in 32-bit words)
    quint32 hdr = 0;
    hdr |= (0x1u << 28);          // pkt_type = IFDataWithStream (DAX TX)
    hdr |= (1u << 27);            // C = 1
    // T = 0 (bit 26)
    hdr |= (0x3u << 22);          // TSI = 3 (Other) — matches FlexLib/nDAX
    hdr |= (0x1u << 20);          // TSF = SampleCount
    hdr |= ((m_txPacketCount & 0xF) << 16);
    hdr |= (packetWords & 0xFFFF);
    words[0] = qToBigEndian(hdr);

    // ── Word 1: Stream ID (dax_tx stream for DAX TX audio) ──────────────
    words[1] = qToBigEndian(m_txStreamId);

    // ── Word 2: Class ID OUI (24-bit, right-justified in 32-bit word) ────
    words[2] = qToBigEndian(FLEX_OUI);

    // ── Word 3: InformationClassCode (upper 16) | PacketClassCode (lower 16)
    words[3] = qToBigEndian(
        (static_cast<quint32>(FLEX_INFO_CLASS) << 16) | PCC_IF_NARROW);

    // ── Words 4-6: Timestamps ─────────────────────────────────────────────
    // ── Words 4-6: Timestamps ─────────────────────────────────────────────
    words[4] = 0;  // integer timestamp
    words[5] = 0;  // fractional timestamp high
    words[6] = 0;  // fractional timestamp low

    // ── Payload: float32 stereo, big-endian ───────────────────────────────
    quint32* payload = words + VITA_HEADER_WORDS;
    for (int i = 0; i < numStereoSamples * 2; ++i) {
        quint32 raw;
        std::memcpy(&raw, &samples[i], 4);
        payload[i] = qToBigEndian(raw);
    }

    // Increment packet count (4-bit, mod 16)
    m_txPacketCount = (m_txPacketCount + 1) & 0xF;

    return packet;
}

void AudioEngine::sendVoiceTxPacket(const QByteArray& pcmData, quint32 streamId)
{
    // Accumulate into a separate buffer for VOX/met_in_rx audio
    m_voxAccumulator.append(pcmData);

    while (m_voxAccumulator.size() >= TX_PCM_BYTES_PER_PACKET) {
        const int16_t* pcm = reinterpret_cast<const int16_t*>(m_voxAccumulator.constData());

        float floatBuf[TX_SAMPLES_PER_PACKET * 2];
        for (int i = 0; i < TX_SAMPLES_PER_PACKET * 2; ++i)
            floatBuf[i] = pcm[i] / 32768.0f;

        // Build packet using the remote_audio_tx stream ID
        quint32 savedId = m_txStreamId;
        m_txStreamId = streamId;
        QByteArray packet = buildVitaTxPacket(floatBuf, TX_SAMPLES_PER_PACKET);
        m_txStreamId = savedId;

        emit txPacketReady(packet);
        m_voxAccumulator.remove(0, TX_PCM_BYTES_PER_PACKET);
    }
}

void AudioEngine::setOutputDevice(const QAudioDevice& dev)
{
    m_outputDevice = dev;
    qCDebug(lcAudio) << "AudioEngine: output device set to" << dev.description();

    // Persist selection
    auto& s = AppSettings::instance();
    s.setValue("AudioOutputDeviceId", dev.id());
    s.save();

    // Restart RX stream if running
    if (m_audioSink) {
        stopRxStream();
        startRxStream();
    }

    emit outputDeviceChanged();
}

void AudioEngine::setInputDevice(const QAudioDevice& dev)
{
    m_inputDevice = dev;
    qCDebug(lcAudio) << "AudioEngine: input device set to" << dev.description();

    // Persist selection
    auto& s = AppSettings::instance();
    s.setValue("AudioInputDeviceId", dev.id());
    s.save();

    // Restart TX stream if running
    if (m_audioSource) {
        QHostAddress addr = m_txAddress;
        quint16 port = m_txPort;
        stopTxStream();
        startTxStream(addr, port);
    }

    emit inputDeviceChanged();
}

#ifdef Q_OS_MAC
void AudioEngine::setAllowBluetoothTelephonyOutput(bool on)
{
    const bool changed = (m_allowBluetoothTelephonyOutput.exchange(on) != on);
    if (!changed || !m_audioSink) {
        return;
    }

    stopRxStream();
    startRxStream();
}
#endif

// ─── RADE digital voice support ──────────────────────────────────────────────

void AudioEngine::setRadeMode(bool on)
{
    if (m_radeMode == on) return;
    m_radeMode = on;
    // 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
    // sendModemTxAudio() → buildVitaTxPacket() → dax_tx VITA-49 stream.
    // The radio routes that stream to the TX modulator only when dax=1.
    // activateRADE() sets the slice to DIGU/DIGL, which fires
    // updateDaxTxMode() → setDax(true) → transmit set dax=1 before PTT.
    // Do NOT emit daxRouteRequested(0) here — dax=0 tells the radio to
    // 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)
        m_radeRxBuffer.clear();
    clearTxAccumulators();
}

void AudioEngine::sendModemTxAudio(const QByteArray& float32pcm)
{
    if (m_txStreamId == 0) return;

    // Gate modem audio on PTT (prevents radio pre-buffer build-up)
    if (!m_transmitting) {
        qCWarning(lcAudio) << "AudioEngine: sendModemTxAudio PTT gate closed —"
                           << float32pcm.size() << "bytes dropped (EOO race?)";
        return;
    }

    if (m_radioTransmitting) {
        emitTxPostChainScopeFromFloat32Stereo(float32pcm, DEFAULT_SAMPLE_RATE);
        emitScopeFromFloat32Stereo(float32pcm, DEFAULT_SAMPLE_RATE, true);
    }

    m_txFloatAccumulator.append(float32pcm);

    constexpr int FLOAT_BYTES_PER_PKT = TX_SAMPLES_PER_PACKET * 2 * sizeof(float); // 1024
    while (m_txFloatAccumulator.size() >= FLOAT_BYTES_PER_PKT) {
        auto* samples = reinterpret_cast<const float*>(m_txFloatAccumulator.constData());
        QByteArray pkt = buildVitaTxPacket(samples, TX_SAMPLES_PER_PACKET);
        emit txPacketReady(pkt);
        m_txFloatAccumulator.remove(0, FLOAT_BYTES_PER_PKT);
    }
}

void AudioEngine::setDaxTxMode(bool on)
{
    const bool previous = m_daxTxMode.exchange(on);
    if (previous != on) {
        qCDebug(lcDax) << "AudioEngine: DAX TX mode"
                       << (on ? "enabled" : "disabled")
                       << "route=" << (m_daxTxUseRadioRoute ? "radio-dax" : "float32-dax-tx")
                       << "stream=0x" + QString::number(m_txStreamId, 16);
    }
}

void AudioEngine::setTransmitting(bool tx)
{
    if (m_transmitting == tx) return;
    m_transmitting = tx;

    if (!tx) {
        // On unkey: drop any partial packet residue so next burst starts cleanly.
        m_txAccumulator.clear();
        m_txFloatAccumulator.clear();
        m_daxPreTxBuffer.clear();
        m_opusTxQueue.clear();
    }
}

void AudioEngine::setRadioTransmitting(bool tx)
{
    const bool previous = m_radioTransmitting.exchange(tx);
    if (previous == tx)
        return;

    // TX→RX edge: NR2 is bypassed entirely during TX (see the RX DSP chain
    // ~line 1512: raw PCM goes straight to writeAudio so the filter doesn't
    // adapt its internal state to TX silence, #367/#1505). But that leaves NR2
    // holding pre-TX state when RX resumes: a stale overlap-add ring (read out
    // as a faint whistle, #3340) and a maxed-out startup-ramp counter, so
    // suppression slams to full-wet on a stale noise estimate that then takes
    // ~3-4s to reconverge — the audio "gap" users hear with NR2 engaged
    // (#1863). reset() flushes the OA ring, re-seeds the noise floor high
    // (gentle suppression), and re-arms the ~1s dry→wet ramp so audio returns
    // immediately on the dry signal and NR2 fades back in cleanly.
    //
    // Scoped to NR2 for now: it's the reported filter and this keeps testing
    // localized. RN2/NR4/DFNR/MNR share the same bypass + stale-state path and
    // can get the same flush as a follow-up once this is validated in the field.
    if (previous && !tx) {
        std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
        if (m_nr2Enabled && m_nr2) m_nr2->reset();
    }

    emit radioTransmittingChanged(tx);
}

void AudioEngine::setDaxTxUseRadioRoute(bool on)
{
    if (m_daxTxUseRadioRoute == on) return;
    m_daxTxUseRadioRoute = on;
    // Switching route changes payload format; drop partial buffered samples.
    m_txFloatAccumulator.clear();
    m_daxPreTxBuffer.clear();
    m_daxRadioTxChannelState.reset();
    m_lastDaxRadioChannelLog.invalidate();
    qCDebug(lcDax) << "AudioEngine: DAX TX route"
                   << (on ? "radio-dax pcc=0x0123" : "float32 pcc=0x03e3")
                   << "stream=0x" + QString::number(m_txStreamId, 16);
}

void AudioEngine::feedDaxTxAudio(const QByteArray& inPcm)
{
    if (m_txStreamId == 0 || inPcm.isEmpty()) return;

    // Mark TCI as the active TX-audio source. While this timer is fresh,
    // onTxAudioReady() suppresses the local mic capture path so the two
    // packet producers don't collide on the same UDP path to the radio.
    m_tciAudioTimer.start();

    // Client-side TX DSP (compressor + EQ) is intentionally NOT
    // applied here.  This path is fed exclusively by TCI and DAX
    // (WSJT-X, fldigi, PipeWire bridge, etc.) — digital modes carry
    // pre-shaped tones that would be destroyed by a voice-tuned
    // compressor or EQ.  Mic voice TX goes through onTxAudioReady,
    // which keeps the full DSP chain.
    const QByteArray& float32pcm = inPcm;

    // Measure DAX TX input level and emit via pcMicLevelChanged so the
    // P/CW mic gauge shows DAX audio level regardless of mic profile (#517)
    {
        const auto* src = reinterpret_cast<const float*>(float32pcm.constData());
        const int samples = float32pcm.size() / sizeof(float);
        float peak = 0.0f;
        double sumSq = 0.0;
        for (int i = 0; i < samples; ++i) {
            float s = std::abs(src[i]);
            if (s > peak) peak = s;
            sumSq += static_cast<double>(src[i]) * src[i];
        }
        m_pcMicPeak = std::max(m_pcMicPeak, peak);
        m_pcMicSumSq += sumSq;
        m_pcMicSampleCount += samples;
        if (m_pcMicSampleCount >= kMicMeterWindowSamples) {
            float rms = static_cast<float>(std::sqrt(m_pcMicSumSq / m_pcMicSampleCount));
            float peakDb = (m_pcMicPeak > 1e-10f) ? 20.0f * std::log10(m_pcMicPeak) : -150.0f;
            float rmsDb  = (rms > 1e-10f)         ? 20.0f * std::log10(rms)          : -150.0f;
            emit pcMicLevelChanged(peakDb, rmsDb);
            m_pcMicPeak = 0.0f;
            m_pcMicSumSq = 0.0;
            m_pcMicSampleCount = 0;
        }
    }

    const bool daxAudioWillTransmit = m_radioTransmitting
        && (!m_daxTxUseRadioRoute || !(m_transmitting && !m_daxTxMode));
    if (daxAudioWillTransmit) {
        emitTxPostChainScopeFromFloat32Stereo(float32pcm, DEFAULT_SAMPLE_RATE);
        emitScopeFromFloat32Stereo(float32pcm, DEFAULT_SAMPLE_RATE, true);
    }

    if (!m_daxTxUseRadioRoute) {
        // Low-latency route: keep radio on mic path (dax=0) and packetize
        // exactly like voice TX (PCC 0x03E3 float32 stereo).
        constexpr int FLOAT_BYTES_PER_PKT = TX_SAMPLES_PER_PACKET * 2 * sizeof(float);

        // Gate on raw radio TX state, not ownership. When an external app
        // (WSJT-X) triggers PTT, m_transmitting is false (we don't own TX)
        // but the radio IS transmitting and needs our DAX audio. (#752)
        if (!m_radioTransmitting) {
            m_daxPreTxBuffer.clear();
            m_txFloatAccumulator.clear();
            return;
        }

        m_txFloatAccumulator.append(float32pcm);
        while (m_txFloatAccumulator.size() >= FLOAT_BYTES_PER_PKT) {
            auto* samples = reinterpret_cast<const float*>(m_txFloatAccumulator.constData());
            QByteArray pkt = buildVitaTxPacket(samples, TX_SAMPLES_PER_PACKET);
            emit txPacketReady(pkt);
            m_txFloatAccumulator.remove(0, FLOAT_BYTES_PER_PKT);
        }
        return;
    }

    // Radio-native DAX route (dax=1): block DAX audio only when mic voice TX is active.
    if (m_transmitting && !m_daxTxMode) return;
    m_daxPreTxBuffer.clear();

    // Convert float32 stereo → int16 mono (reduced BW format, PCC 0x0123).
    // This route is still a digital/DAX bypass: no voice DSP, gain, Quindar, or
    // final limiter. The mono collapse only avoids the same one-sided stereo
    // 6.02 dB loss that can affect virtual/aggregate DAX sources.
    TxMicChannelNormalizer::Diagnostics daxDiagnostics;
    QByteArray mono = TxMicChannelNormalizer::collapseFloat32ToInt16MonoBigEndian(
        float32pcm,
        2,
        DEFAULT_SAMPLE_RATE,
        m_daxRadioTxChannelMode,
        &m_daxRadioTxChannelState,
        &daxDiagnostics);
    if (mono.isEmpty()) return;
    logTxInputChannelDiagnostics(daxDiagnostics, "DAX radio");

    m_txFloatAccumulator.append(mono);

    // Build and send VITA-49 packets: 128 mono int16 samples per packet
    constexpr int MONO_BYTES_PER_PKT = TX_SAMPLES_PER_PACKET * sizeof(qint16);  // 256 bytes
    while (m_txFloatAccumulator.size() >= MONO_BYTES_PER_PKT) {
        const int payloadBytes = MONO_BYTES_PER_PKT;
        const int packetWords = (payloadBytes / 4) + VITA_HEADER_WORDS;
        const int packetBytes = packetWords * 4;

        QByteArray pkt(packetBytes, '\0');
        quint32* words = reinterpret_cast<quint32*>(pkt.data());

        // Header: IFDataWithStream, C=1, TSI=3(Other), TSF=1(SampleCount)
        quint32 hdr = 0;
        hdr |= (0x1u << 28);          // pkt_type = IFDataWithStream
        hdr |= (1u << 27);            // C = 1 (class ID present)
        hdr |= (0x3u << 22);          // TSI = 3 (Other) — matches FlexLib/nDAX
        hdr |= (0x1u << 20);          // TSF = 1 (SampleCount)
        hdr |= ((m_txPacketCount & 0xF) << 16);
        hdr |= (packetWords & 0xFFFF);
        words[0] = qToBigEndian(hdr);
        words[1] = qToBigEndian(m_txStreamId);
        words[2] = qToBigEndian(FLEX_OUI);
        words[3] = qToBigEndian(
            (static_cast<quint32>(FLEX_INFO_CLASS) << 16) | PCC_DAX_REDUCED);
        words[4] = 0;  // integer timestamp (zero)
        words[5] = 0;  // fractional timestamp high (zero)
        words[6] = 0;  // fractional timestamp low (zero)

        // Copy pre-converted big-endian int16 mono payload
        std::memcpy(pkt.data() + VITA_HEADER_BYTES,
                    m_txFloatAccumulator.constData(), payloadBytes);

        m_txPacketCount = (m_txPacketCount + 1) & 0xF;
        emit txPacketReady(pkt);
        m_txFloatAccumulator.remove(0, MONO_BYTES_PER_PKT);
    }
}

void AudioEngine::feedDecodedSpeech(const QByteArray& pcm)
{
    if (!m_audioSink || !m_audioDevice || !m_audioDevice->isOpen()) return;

    // Decoded RADE speech goes into its own output-rate buffer. The drain
    // timer mixes it with m_rxOutputBuffer sample-wise so both are heard
    // simultaneously without doubling the fill rate. A dedicated resampler
    // preserves the filter state independently from the main RX output
    // resampler used by processMixedRxAudioData().
    if (m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE) {
        if (!m_radeRxResampler)
            m_radeRxResampler = std::make_unique<Resampler>(24000, m_rxOutputRate.load());
        const auto* src = reinterpret_cast<const float*>(pcm.constData());
        m_radeRxBuffer.append(
            m_radeRxResampler->processStereoToStereo(
                src, pcm.size() / (2 * static_cast<int>(sizeof(float)))));
    } else {
        m_radeRxBuffer.append(pcm);
    }
}

} // namespace AetherSDR
