#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 "WsprBeacon.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 "ReceivePresentationSync.h"
#include "SpectralNR.h"
#include "models/Nr2SettingsModel.h"
#include "models/Rn2SettingsModel.h"
#ifdef HAVE_SPECBLEACH
#include "SpecbleachFilter.h"
#endif
#include "RNNoiseFilter.h"
#ifdef HAVE_DFNR
#include "DeepFilterFilter.h"
#endif
#ifdef HAVE_NVIDIA_AFX
#include "NvidiaAfxFilter.h"
#include "NvidiaBnrSettings.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 <chrono>
#include <cmath>
#include <limits>
#include <numbers>
#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 <QtGlobal>
#include <QtConcurrent/QtConcurrentRun>
#include <algorithm>
#include <cstring>
#include <functional>
#include <optional>
#include <utility>

namespace AetherSDR {

static QString wisdomDir();
static void logNr2WisdomSummary(const QString& context);
static void logNr2WisdomGenerationSummary(SpectralNR::WisdomResult result);
static void applyNr2Settings(SpectralNR& nr2);
static void copyNr2Settings(const SpectralNR& source, SpectralNR& target);
static void applyRn2Settings(RNNoiseFilter& rn2);
#ifdef HAVE_SPECBLEACH
static void applyNr4SettingsFromAppSettings(SpecbleachFilter& nr4);
static void copyNr4Settings(const SpecbleachFilter& source,
                            SpecbleachFilter& target);
#endif
#ifdef HAVE_DFNR
static void applyDfnrSettingsFromAppSettings(DeepFilterFilter& dfnr);
static void copyDfnrSettings(const DeepFilterFilter& source,
                             DeepFilterFilter& target);
#endif

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;
constexpr int kAutomationAudioCaptureMaxDurationMs = 15000;
constexpr qsizetype kAutomationAudioCaptureMaxBytes = 64 * 1024 * 1024;
// WDSP/Thetis runs EMNR at 4096/4 with 48 kHz DSP audio. Geometry sweeps at
// AetherSDR's 24 kHz RX DSP rate show that 1024/4 is the better tradeoff for
// this implementation: 42.7 ms window, 10.7 ms hop, and 23.4 Hz bin spacing.
constexpr int kNr2FftSize = 1024;
constexpr int kNr2Overlap = 4;
constexpr int kNr2OriginalFftSize = 256;
constexpr int kNr2OriginalOverlap = 2;

qint64 steadyNowNs()
{
    using Clock = std::chrono::steady_clock;
    return std::chrono::duration_cast<std::chrono::nanoseconds>(
               Clock::now().time_since_epoch())
        .count();
}

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();
    }
}

qsizetype alignedStereoFloatBytes(qsizetype bytes)
{
    constexpr qsizetype kFrameBytes = 2 * static_cast<qsizetype>(sizeof(float));
    return (std::max<qsizetype>(0, bytes) / kFrameBytes) * kFrameBytes;
}

qsizetype audioBytesForMsAtRate(int sampleRate, int ms)
{
    if (sampleRate <= 0 || ms <= 0) {
        return 0;
    }

    return alignedStereoFloatBytes(
        static_cast<qsizetype>(sampleRate) * 2
        * static_cast<qsizetype>(sizeof(float)) * ms / 1000);
}

qsizetype rawEquivalentAudioBytes(qsizetype bytes, int sampleRate)
{
    if (bytes <= 0 || sampleRate <= 0) {
        return 0;
    }

    return alignedStereoFloatBytes(
        bytes * AudioEngine::DEFAULT_SAMPLE_RATE / sampleRate);
}

qsizetype quietStereoFloatTrimPoint(const QByteArray& buffer,
                                    qsizetype requestedBytes,
                                    int sampleRate)
{
    constexpr qsizetype kFrameBytes = 2 * static_cast<qsizetype>(sizeof(float));
    constexpr int kTrimSearchMs = 6;
    const qsizetype frames = alignedStereoFloatBytes(buffer.size()) / kFrameBytes;
    if (frames <= 0) {
        return 0;
    }

    qsizetype requestedFrame =
        alignedStereoFloatBytes(requestedBytes) / kFrameBytes;
    requestedFrame = std::clamp<qsizetype>(requestedFrame, 0, frames);
    if (requestedFrame <= 0 || requestedFrame >= frames) {
        return requestedFrame * kFrameBytes;
    }

    const qsizetype searchFrames =
        std::max<qsizetype>(1, sampleRate * kTrimSearchMs / 1000);
    const qsizetype begin =
        std::max<qsizetype>(1, requestedFrame - searchFrames);
    const qsizetype end =
        std::min<qsizetype>(frames - 1, requestedFrame + searchFrames);
    const auto* samples = reinterpret_cast<const float*>(buffer.constData());

    qsizetype bestFrame = requestedFrame;
    double bestScore = std::numeric_limits<double>::infinity();
    for (qsizetype frame = begin; frame <= end; ++frame) {
        const float left = samples[frame * 2];
        const float right = samples[frame * 2 + 1];
        const double amplitude =
            std::fabs(std::isfinite(left) ? left : 0.0f)
            + std::fabs(std::isfinite(right) ? right : 0.0f);
        const double distancePenalty =
            static_cast<double>(std::abs(frame - requestedFrame)) * 1.0e-6;
        const double score = amplitude + distancePenalty;
        if (score < bestScore) {
            bestScore = score;
            bestFrame = frame;
        }
    }
    return bestFrame * kFrameBytes;
}

void fadeInStereoFloatFront(QByteArray& buffer, int sampleRate)
{
    constexpr qsizetype kFrameBytes = 2 * static_cast<qsizetype>(sizeof(float));
    constexpr int kTrimFadeMs = 2;
    const qsizetype frames = alignedStereoFloatBytes(buffer.size()) / kFrameBytes;
    if (frames <= 0 || sampleRate <= 0) {
        return;
    }

    const qsizetype fadeFrames =
        std::min<qsizetype>(
            frames,
            std::max<qsizetype>(1, sampleRate * kTrimFadeMs / 1000));
    auto* samples = reinterpret_cast<float*>(buffer.data());
    for (qsizetype frame = 0; frame < fadeFrames; ++frame) {
        const float gain =
            static_cast<float>(frame + 1) / static_cast<float>(fadeFrames);
        samples[frame * 2] *= gain;
        samples[frame * 2 + 1] *= gain;
    }
}

void dropAudioBufferFront(QByteArray& buffer, qsizetype bytes, int sampleRate)
{
    const qsizetype dropBytes =
        std::min(quietStereoFloatTrimPoint(buffer, bytes, sampleRate),
                 alignedStereoFloatBytes(buffer.size()));
    if (dropBytes > 0) {
        buffer.remove(0, dropBytes);
        fadeInStereoFloatFront(buffer, sampleRate);
    }
}

void trimReceivePresentationBuffers(QByteArray& rawBuffer,
                                    std::deque<QByteArray>& rawPackets,
                                    QByteArray& outputBuffer,
                                    int outputRate,
                                    qsizetype targetRawBytes)
{
    targetRawBytes = alignedStereoFloatBytes(targetRawBytes);
    const auto totalRawBytes = [&]() {
        return alignedStereoFloatBytes(rawBuffer.size())
               + queuedAudioBytes(rawPackets)
               + rawEquivalentAudioBytes(outputBuffer.size(), outputRate);
    };

    qsizetype excessRawBytes = totalRawBytes() - targetRawBytes;
    if (excessRawBytes <= 0) {
        return;
    }

    if (!outputBuffer.isEmpty()) {
        const qsizetype outputDropBytes =
            outputRate > 0
                ? alignedStereoFloatBytes(
                      excessRawBytes * outputRate
                      / AudioEngine::DEFAULT_SAMPLE_RATE)
                : excessRawBytes;
        dropAudioBufferFront(outputBuffer, outputDropBytes, outputRate);
        excessRawBytes = totalRawBytes() - targetRawBytes;
    }

    if (excessRawBytes > 0 && !rawBuffer.isEmpty()) {
        dropAudioBufferFront(rawBuffer, excessRawBytes,
                             AudioEngine::DEFAULT_SAMPLE_RATE);
        excessRawBytes = totalRawBytes() - targetRawBytes;
    }

    if (excessRawBytes > 0 && !rawPackets.empty()) {
        const qsizetype packetBudget =
            std::max<qsizetype>(
                0,
                targetRawBytes
                    - alignedStereoFloatBytes(rawBuffer.size())
                    - rawEquivalentAudioBytes(outputBuffer.size(), outputRate));
        trimAudioPacketQueue(rawPackets, packetBudget);
    }
}

int audioBytesToMs(qsizetype bytes, int sampleRate)
{
    if (bytes <= 0 || sampleRate <= 0) {
        return 0;
    }

    const qint64 bytesPerSecond =
        static_cast<qint64>(sampleRate) * 2
        * static_cast<qint64>(sizeof(float));
    if (bytesPerSecond <= 0) {
        return 0;
    }

    return static_cast<int>(
        (static_cast<qint64>(bytes) * 1000) / bytesPerSecond);
}

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()
{
    const qsizetype flexRawBytes =
        m_rxBuffer.size() + queuedAudioBytes(m_rxPackets);
    const qsizetype kiwiSdrRawBytes =
        m_kiwiSdrRxBuffer.size() + queuedAudioBytes(m_kiwiSdrRxPackets);
    const qsizetype flexOutputBytes = m_rxOutputBuffer.size();
    const qsizetype kiwiSdrOutputBytes = m_kiwiSdrOutputBuffer.size();
    qsizetype externalTotal = 0;
    qsizetype externalRawBytes = 0;
    qsizetype externalOutputBytes = 0;
    for (const auto& source : m_externalKiwiSources) {
        if (!source) {
            continue;
        }
        const qsizetype sourceRawBytes =
            source->rxBuffer.size() + queuedAudioBytes(source->rxPackets);
        const qsizetype sourceOutputBytes = source->outputBuffer.size();
        externalTotal += sourceRawBytes + sourceOutputBytes;
        if (externalKiwiSourceProcessing(*source)) {
            externalRawBytes = std::max(externalRawBytes, sourceRawBytes);
            externalOutputBytes =
                std::max(externalOutputBytes, sourceOutputBytes);
        }
    }

    const qsizetype total =
        flexRawBytes + kiwiSdrRawBytes + flexOutputBytes + kiwiSdrOutputBytes
        + m_radeRxBuffer.size() + externalTotal;
    m_rxBufferBytes.store(total);
    m_rxBufferPeakBytes.store(std::max(m_rxBufferPeakBytes.load(), total));

    const int outputRate = std::max(1, m_rxOutputRate.load());
    m_receivePresentationPlaybackQueuedMs.store(
        m_rxPlaybackQueuedMs.load(std::memory_order_relaxed),
        std::memory_order_relaxed);
    m_receivePresentationFlexRawBufferMs.store(
        audioBytesToMs(flexRawBytes, DEFAULT_SAMPLE_RATE),
        std::memory_order_relaxed);
    m_receivePresentationFlexOutputBufferMs.store(
        audioBytesToMs(flexOutputBytes, outputRate),
        std::memory_order_relaxed);
    m_receivePresentationKiwiSdrRawBufferMs.store(
        audioBytesToMs(kiwiSdrRawBytes, DEFAULT_SAMPLE_RATE),
        std::memory_order_relaxed);
    m_receivePresentationKiwiSdrOutputBufferMs.store(
        audioBytesToMs(kiwiSdrOutputBytes, outputRate),
        std::memory_order_relaxed);
    m_receivePresentationExternalKiwiRawBufferMs.store(
        audioBytesToMs(externalRawBytes, DEFAULT_SAMPLE_RATE),
        std::memory_order_relaxed);
    m_receivePresentationExternalKiwiOutputBufferMs.store(
        audioBytesToMs(externalOutputBytes, outputRate),
        std::memory_order_relaxed);
}

AudioEngine::ReceivePresentationAudioQueues
AudioEngine::receivePresentationAudioQueues() const
{
    ReceivePresentationAudioQueues queues;
    queues.playbackQueuedMs = m_receivePresentationPlaybackQueuedMs.load(
        std::memory_order_relaxed);
    queues.flexRawBufferMs = m_receivePresentationFlexRawBufferMs.load(
        std::memory_order_relaxed);
    queues.flexOutputBufferMs = m_receivePresentationFlexOutputBufferMs.load(
        std::memory_order_relaxed);
    queues.kiwiSdrRawBufferMs = m_receivePresentationKiwiSdrRawBufferMs.load(
        std::memory_order_relaxed);
    queues.kiwiSdrOutputBufferMs =
        m_receivePresentationKiwiSdrOutputBufferMs.load(
            std::memory_order_relaxed);
    queues.externalKiwiRawBufferMs =
        m_receivePresentationExternalKiwiRawBufferMs.load(
            std::memory_order_relaxed);
    queues.externalKiwiOutputBufferMs =
        m_receivePresentationExternalKiwiOutputBufferMs.load(
            std::memory_order_relaxed);
    return queues;
}

void AudioEngine::setReceivePresentationDelays(
    int flexDelayMs,
    int kiwiDelayMs,
    const QString& externalKiwiDelaySourceId)
{
    const int flexDelay = qBound(0, flexDelayMs, 5000);
    const int kiwiDelay = qBound(0, kiwiDelayMs, 5000);
    const QString externalKiwiDelayId = externalKiwiDelaySourceId.trimmed();
    const int legacyKiwiDelay = externalKiwiDelayId.isEmpty() ? kiwiDelay : 0;

    const int previousFlex =
        m_flexReceivePresentationDelayMs.exchange(flexDelay,
                                                  std::memory_order_relaxed);
    const int previousKiwi =
        m_kiwiReceivePresentationDelayMs.exchange(legacyKiwiDelay,
                                                  std::memory_order_relaxed);

    std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
    m_externalKiwiReceivePresentationDelaySourceId = externalKiwiDelayId;
    m_externalKiwiReceivePresentationDelayMs = kiwiDelay;

    const int outputRate = std::max(1, m_rxOutputRate.load());
    const auto hasFlexQueuedAudio = [this]() {
        return !m_rxBuffer.isEmpty() || !m_rxPackets.empty()
               || !m_rxOutputBuffer.isEmpty();
    };
    const auto hasLegacyKiwiQueuedAudio = [this]() {
        return !m_kiwiSdrRxBuffer.isEmpty() || !m_kiwiSdrRxPackets.empty()
               || !m_kiwiSdrOutputBuffer.isEmpty();
    };
    const auto hasExternalSourceQueuedAudio =
        [](const ExternalRxAudioSourceState& source) {
            return !source.rxBuffer.isEmpty() || !source.rxPackets.empty()
                   || !source.outputBuffer.isEmpty();
        };
    if (flexDelay < previousFlex) {
        trimReceivePresentationBuffers(
            m_rxBuffer, m_rxPackets, m_rxOutputBuffer, outputRate,
            audioBytesForMsAtRate(DEFAULT_SAMPLE_RATE, flexDelay));
    }
    if (legacyKiwiDelay < previousKiwi) {
        const qsizetype targetBytes =
            audioBytesForMsAtRate(DEFAULT_SAMPLE_RATE, legacyKiwiDelay);
        trimReceivePresentationBuffers(
            m_kiwiSdrRxBuffer, m_kiwiSdrRxPackets, m_kiwiSdrOutputBuffer,
            outputRate, targetBytes);
    }
    for (const auto& source : m_externalKiwiSources) {
        if (!source) {
            continue;
        }
        const int previousSourceDelay = source->presentationDelayMs;
        const int sourceDelay = receivePresentationExternalKiwiDelayMs(
            source->id, externalKiwiDelayId, kiwiDelay);
        source->presentationDelayMs = sourceDelay;
        if (sourceDelay < previousSourceDelay) {
            const qsizetype targetBytes =
                audioBytesForMsAtRate(DEFAULT_SAMPLE_RATE, sourceDelay);
            trimReceivePresentationBuffers(
                source->rxBuffer, source->rxPackets, source->outputBuffer,
                outputRate, targetBytes);
        }
        if (receivePresentationShouldPrebufferAfterDelayChange(
                previousSourceDelay, sourceDelay,
                externalKiwiSourceProcessing(*source),
                hasExternalSourceQueuedAudio(*source))) {
            source->prebuffering = true;
        } else if (sourceDelay <= 0) {
            source->prebuffering = false;
        }
    }

    if (receivePresentationShouldPrebufferAfterDelayChange(
            previousFlex, flexDelay, true, hasFlexQueuedAudio())) {
        m_rxPresentationPrebuffering.store(true, std::memory_order_relaxed);
    } else if (flexDelay <= 0) {
        m_rxPresentationPrebuffering.store(false, std::memory_order_relaxed);
    }

    if (receivePresentationShouldPrebufferAfterDelayChange(
            previousKiwi, legacyKiwiDelay, kiwiSdrAudioActive(),
            hasLegacyKiwiQueuedAudio())) {
        m_kiwiSdrPrebuffering.store(true, std::memory_order_relaxed);
    } else if (legacyKiwiDelay <= 0) {
        m_kiwiSdrPrebuffering.store(false, std::memory_order_relaxed);
    }
    updateRxBufferStats();
}

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

    m_rxBuffer.clear();
    m_rxPackets.clear();
    m_rxOutputBuffer.clear();
    m_kiwiSdrRxBuffer.clear();
    m_kiwiSdrRxPackets.clear();
    m_kiwiSdrOutputBuffer.clear();
    m_radeRxBuffer.clear();

    const bool flexPrebuffer =
        m_flexReceivePresentationDelayMs.load(std::memory_order_relaxed) > 0;
    m_rxPresentationPrebuffering.store(flexPrebuffer,
                                       std::memory_order_relaxed);
    m_kiwiSdrPrebuffering.store(kiwiSdrAudioActive(),
                                std::memory_order_relaxed);
    for (const auto& source : m_externalKiwiSources) {
        if (!source) {
            continue;
        }
        source->rxBuffer.clear();
        source->rxPackets.clear();
        source->outputBuffer.clear();
        source->prebuffering = externalKiwiSourceProcessing(*source);
    }

    updateRxBufferStats();
}

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

    std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
    for (const auto& source : m_externalKiwiSources) {
        if (!source || source->id != id) {
            continue;
        }
        source->rxBuffer.clear();
        source->rxPackets.clear();
        source->outputBuffer.clear();
        source->prebuffering =
            source->presentationDelayMs > 0
            && externalKiwiSourceProcessing(*source);
        updateRxBufferStats();
        return;
    }
}

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->presentationDelayMs = receivePresentationExternalKiwiDelayMs(
        id, m_externalKiwiReceivePresentationDelaySourceId,
        m_externalKiwiReceivePresentationDelayMs);
    source->prebuffering = true;
    m_externalKiwiSources.push_back(std::move(source));
    return m_externalKiwiSources.back().get();
}

std::unique_ptr<SpectralNR>
AudioEngine::createNr2Filter(const QString& label, bool forceLegacyGeometry) const
{
    // The demo (SimBackend) delivers native 128-sample frames — exactly one hop of
    // the ORIGINAL 256/2 geometry, but only half a hop of the improved 1024/4
    // geometry (#4400). Under 1024/4 the tiny frames misalign the overlap-add
    // cadence: audible wobble, over-attenuation, and the downstream DSP/RADE (which
    // key off NR2's output) go dead. So the MAIN-source filter uses the original
    // geometry when the connected source is the demo, while real radios and Kiwi
    // (larger, hop-aligned blocks) keep the improved 1024 geometry.
    const bool useOriginal = forceLegacyGeometry
        || m_nr2UseOriginalGeometry.load(std::memory_order_relaxed);
    const int fftSize = useOriginal ? kNr2OriginalFftSize : kNr2FftSize;
    const int overlap = useOriginal ? kNr2OriginalOverlap : kNr2Overlap;
    auto filter = std::make_unique<SpectralNR>(
        fftSize, DEFAULT_SAMPLE_RATE, overlap, useOriginal);
    if (filter->hasPlanFailed()) {
        qCWarning(lcAudio).noquote()
            << "AudioEngine: NR2 plan creation failed for" << label;
        return {};
    }
    return filter;
}

std::unique_ptr<RNNoiseFilter>
AudioEngine::createRn2Filter(const QString& label) const
{
    auto filter = std::make_unique<RNNoiseFilter>();
    if (!filter->isValid()) {
        qCWarning(lcAudio).noquote()
            << "AudioEngine: RN2 rnnoise_create() failed for" << label;
        return {};
    }
    // Restore the feature-owned RN2 configuration.
    applyRn2Settings(*filter);
    return filter;
}

#ifdef HAVE_SPECBLEACH
std::unique_ptr<SpecbleachFilter>
AudioEngine::createNr4Filter(const QString& label) const
{
    auto filter = std::make_unique<SpecbleachFilter>();
    if (!filter->isValid()) {
        qCWarning(lcAudio).noquote()
            << "AudioEngine: NR4 initialization failed for" << label;
        return {};
    }
    return filter;
}
#endif

#ifdef __APPLE__
std::unique_ptr<MacNRFilter>
AudioEngine::createMnrFilter(const QString& label) const
{
    auto filter = std::make_unique<MacNRFilter>();
    if (!filter->isValid()) {
        qCWarning(lcAudio).noquote()
            << "AudioEngine: MNR vDSP setup failed for" << label;
        return {};
    }
    filter->setStrength(m_mnrStrength.load());
    return filter;
}
#endif

#ifdef HAVE_DFNR
std::unique_ptr<DeepFilterFilter>
AudioEngine::createDfnrFilter(const QString& label) const
{
    auto filter = std::make_unique<DeepFilterFilter>();
    if (!filter->isValid()) {
        qCWarning(lcAudio).noquote()
            << "AudioEngine: DFNR df_create() failed for" << label;
        return {};
    }
    return filter;
}
#endif

#ifdef HAVE_NVIDIA_AFX
std::unique_ptr<NvidiaAfxFilter>
AudioEngine::createNvAfxFilter(const QString& label) const
{
    auto filter = std::make_unique<NvidiaAfxFilter>();
    if (!filter->isValid()) {
        qCWarning(lcAudio).noquote()
            << "AudioEngine: NVIDIA AFX denoiser unavailable for" << label
            << "-" << filter->lastError();
        return {};
    }
    return filter;
}
#endif

bool AudioEngine::ensureLegacyKiwiDspState()
{
    quint64 configurationGeneration = 0;
    bool needNr2 = false;
    bool needRn2 = false;
#ifdef HAVE_SPECBLEACH
    bool needNr4 = false;
#endif
#ifdef __APPLE__
    bool needMnr = false;
#endif
#ifdef HAVE_DFNR
    bool needDfnr = false;
#endif
#ifdef HAVE_NVIDIA_AFX
    bool needNvAfx = false;
#endif
    {
        std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
        if (!kiwiSdrAudioActive() || m_legacyKiwiDspInitializationPending) {
            return true;
        }
        needNr2 = m_nr2Enabled.load(std::memory_order_relaxed) && m_nr2
            && !m_kiwiSdrNr2;
        needRn2 = m_rn2Enabled.load(std::memory_order_relaxed) && m_rn2
            && !m_kiwiSdrRn2;
#ifdef HAVE_SPECBLEACH
        needNr4 = m_nr4Enabled.load(std::memory_order_relaxed) && m_nr4
            && !m_kiwiSdrNr4;
#endif
#ifdef __APPLE__
        needMnr = m_mnrEnabled.load(std::memory_order_relaxed) && m_mnr
            && !m_kiwiSdrMnr;
#endif
#ifdef HAVE_DFNR
        needDfnr = m_dfnrEnabled.load(std::memory_order_relaxed) && m_dfnr
            && !m_kiwiSdrDfnr;
#endif
#ifdef HAVE_NVIDIA_AFX
        needNvAfx = m_nvAfxEnabled.load(std::memory_order_relaxed) && m_nvAfx
            && !m_kiwiSdrNvAfx;
#endif
        m_legacyKiwiDspInitializationPending = needNr2 || needRn2
#ifdef HAVE_SPECBLEACH
            || needNr4
#endif
#ifdef __APPLE__
            || needMnr
#endif
#ifdef HAVE_DFNR
            || needDfnr
#endif
#ifdef HAVE_NVIDIA_AFX
            || needNvAfx
#endif
            ;
        if (!m_legacyKiwiDspInitializationPending) {
            return true;
        }
        configurationGeneration = m_dspConfigurationGeneration;
    }

    bool ok = true;
    std::unique_ptr<SpectralNR> nr2;
    std::unique_ptr<RNNoiseFilter> rn2;
#ifdef HAVE_SPECBLEACH
    std::unique_ptr<SpecbleachFilter> nr4;
#endif
#ifdef __APPLE__
    std::unique_ptr<MacNRFilter> mnr;
#endif
#ifdef HAVE_DFNR
    std::unique_ptr<DeepFilterFilter> dfnr;
#endif
#ifdef HAVE_NVIDIA_AFX
    std::unique_ptr<NvidiaAfxFilter> nvAfx;
#endif

    if (needNr2) {
        nr2 = createNr2Filter(QStringLiteral("legacy Kiwi"));
        if (!nr2) {
            ok = false;
        }
    }
    if (needRn2) {
        rn2 = createRn2Filter(QStringLiteral("legacy Kiwi"));
        ok = ok && static_cast<bool>(rn2);
    }
#ifdef HAVE_SPECBLEACH
    if (needNr4) {
        nr4 = createNr4Filter(QStringLiteral("legacy Kiwi"));
        ok = ok && static_cast<bool>(nr4);
    }
#endif
#ifdef __APPLE__
    if (needMnr) {
        mnr = createMnrFilter(QStringLiteral("legacy Kiwi"));
        ok = ok && static_cast<bool>(mnr);
    }
#endif
#ifdef HAVE_DFNR
    if (needDfnr) {
        dfnr = createDfnrFilter(QStringLiteral("legacy Kiwi"));
        ok = ok && static_cast<bool>(dfnr);
    }
#endif
#ifdef HAVE_NVIDIA_AFX
    if (needNvAfx) {
        nvAfx = createNvAfxFilter(QStringLiteral("legacy Kiwi"));
        ok = ok && static_cast<bool>(nvAfx);
    }
#endif

    bool retryForNewConfiguration = false;
    {
        std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
        m_legacyKiwiDspInitializationPending = false;
        if (!kiwiSdrAudioActive()) {
            return ok;
        }
        if (needNr2 && m_nr2Enabled && m_nr2 && !m_kiwiSdrNr2) {
            if (nr2) {
                copyNr2Settings(*m_nr2, *nr2);
            }
            m_kiwiSdrNr2 = std::move(nr2);
        }
        if (needRn2 && m_rn2Enabled && m_rn2 && !m_kiwiSdrRn2) {
            m_kiwiSdrRn2 = std::move(rn2);
        }
#ifdef HAVE_SPECBLEACH
        if (needNr4 && m_nr4Enabled && m_nr4 && !m_kiwiSdrNr4) {
            if (nr4) {
                copyNr4Settings(*m_nr4, *nr4);
            }
            m_kiwiSdrNr4 = std::move(nr4);
        }
#endif
#ifdef __APPLE__
        if (needMnr && m_mnrEnabled && m_mnr && !m_kiwiSdrMnr) {
            if (mnr) {
                mnr->setStrength(m_mnrStrength.load());
            }
            m_kiwiSdrMnr = std::move(mnr);
        }
#endif
#ifdef HAVE_DFNR
        if (needDfnr && m_dfnrEnabled && m_dfnr && !m_kiwiSdrDfnr) {
            if (dfnr) {
                copyDfnrSettings(*m_dfnr, *dfnr);
            }
            m_kiwiSdrDfnr = std::move(dfnr);
        }
#endif
#ifdef HAVE_NVIDIA_AFX
        if (needNvAfx && m_nvAfxEnabled && m_nvAfx && !m_kiwiSdrNvAfx) {
            if (nvAfx) {
                nvAfx->setIntensity(m_nvAfx->intensity());
            }
            m_kiwiSdrNvAfx = std::move(nvAfx);
        }
#endif
        retryForNewConfiguration =
            configurationGeneration != m_dspConfigurationGeneration;
    }
    if (retryForNewConfiguration) {
        ok = ensureLegacyKiwiDspState() && ok;
    }
    return ok;
}

bool AudioEngine::ensureExternalKiwiSourceDspState(
    const QString& sourceId)
{
    quint64 configurationGeneration = 0;
    const QString id = sourceId.trimmed();
    if (id.isEmpty()) {
        return false;
    }

    const auto findSource = [this, &id]() -> ExternalRxAudioSourceState* {
        for (const auto& candidate : m_externalKiwiSources) {
            if (candidate && candidate->id == id) {
                return candidate.get();
            }
        }
        return nullptr;
    };

    bool needNr2 = false;
    bool needRn2 = false;
#ifdef HAVE_SPECBLEACH
    bool needNr4 = false;
#endif
#ifdef __APPLE__
    bool needMnr = false;
#endif
#ifdef HAVE_DFNR
    bool needDfnr = false;
#endif
#ifdef HAVE_NVIDIA_AFX
    bool needNvAfx = false;
#endif
    {
        std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
        ExternalRxAudioSourceState* source = findSource();
        if (!source || !externalKiwiSourceProcessing(*source)
            || source->dspInitializationPending) {
            return true;
        }
        needNr2 = m_nr2Enabled.load(std::memory_order_relaxed) && m_nr2
            && !source->nr2;
        needRn2 = m_rn2Enabled.load(std::memory_order_relaxed) && m_rn2
            && !source->rn2;
#ifdef HAVE_SPECBLEACH
        needNr4 = m_nr4Enabled.load(std::memory_order_relaxed) && m_nr4
            && !source->nr4;
#endif
#ifdef __APPLE__
        needMnr = m_mnrEnabled.load(std::memory_order_relaxed) && m_mnr
            && !source->mnr;
#endif
#ifdef HAVE_DFNR
        needDfnr = m_dfnrEnabled.load(std::memory_order_relaxed) && m_dfnr
            && !source->dfnr;
#endif
#ifdef HAVE_NVIDIA_AFX
        needNvAfx = m_nvAfxEnabled.load(std::memory_order_relaxed) && m_nvAfx
            && !source->nvAfx;
#endif
        source->dspInitializationPending = needNr2 || needRn2
#ifdef HAVE_SPECBLEACH
            || needNr4
#endif
#ifdef __APPLE__
            || needMnr
#endif
#ifdef HAVE_DFNR
            || needDfnr
#endif
#ifdef HAVE_NVIDIA_AFX
            || needNvAfx
#endif
            ;
        if (!source->dspInitializationPending) {
            return true;
        }
        configurationGeneration = m_dspConfigurationGeneration;
    }

    bool ok = true;
    std::unique_ptr<SpectralNR> nr2;
    std::unique_ptr<RNNoiseFilter> rn2;
#ifdef HAVE_SPECBLEACH
    std::unique_ptr<SpecbleachFilter> nr4;
#endif
#ifdef __APPLE__
    std::unique_ptr<MacNRFilter> mnr;
#endif
#ifdef HAVE_DFNR
    std::unique_ptr<DeepFilterFilter> dfnr;
#endif
#ifdef HAVE_NVIDIA_AFX
    std::unique_ptr<NvidiaAfxFilter> nvAfx;
#endif

    if (needNr2) {
        nr2 = createNr2Filter(QStringLiteral("external Kiwi %1").arg(id));
        if (!nr2) {
            ok = false;
        }
    }
    if (needRn2) {
        rn2 = createRn2Filter(QStringLiteral("external Kiwi %1").arg(id));
        ok = ok && static_cast<bool>(rn2);
    }
#ifdef HAVE_SPECBLEACH
    if (needNr4) {
        nr4 = createNr4Filter(QStringLiteral("external Kiwi %1").arg(id));
        ok = ok && static_cast<bool>(nr4);
    }
#endif
#ifdef __APPLE__
    if (needMnr) {
        mnr = createMnrFilter(QStringLiteral("external Kiwi %1").arg(id));
        ok = ok && static_cast<bool>(mnr);
    }
#endif
#ifdef HAVE_DFNR
    if (needDfnr) {
        dfnr = createDfnrFilter(QStringLiteral("external Kiwi %1").arg(id));
        ok = ok && static_cast<bool>(dfnr);
    }
#endif
#ifdef HAVE_NVIDIA_AFX
    if (needNvAfx) {
        nvAfx = createNvAfxFilter(QStringLiteral("external Kiwi %1").arg(id));
        ok = ok && static_cast<bool>(nvAfx);
    }
#endif

    bool retryForNewConfiguration = false;
    {
        std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
        ExternalRxAudioSourceState* source = findSource();
        if (!source) {
            return ok;
        }
        source->dspInitializationPending = false;
        if (!externalKiwiSourceProcessing(*source)) {
            return ok;
        }
        if (needNr2 && m_nr2Enabled && m_nr2 && !source->nr2) {
            if (nr2) {
                copyNr2Settings(*m_nr2, *nr2);
            }
            source->nr2 = std::move(nr2);
        }
        if (needRn2 && m_rn2Enabled && m_rn2 && !source->rn2) {
            source->rn2 = std::move(rn2);
        }
#ifdef HAVE_SPECBLEACH
        if (needNr4 && m_nr4Enabled && m_nr4 && !source->nr4) {
            if (nr4) {
                copyNr4Settings(*m_nr4, *nr4);
            }
            source->nr4 = std::move(nr4);
        }
#endif
#ifdef __APPLE__
        if (needMnr && m_mnrEnabled && m_mnr && !source->mnr) {
            if (mnr) {
                mnr->setStrength(m_mnrStrength.load());
            }
            source->mnr = std::move(mnr);
        }
#endif
#ifdef HAVE_DFNR
        if (needDfnr && m_dfnrEnabled && m_dfnr && !source->dfnr) {
            if (dfnr) {
                copyDfnrSettings(*m_dfnr, *dfnr);
            }
            source->dfnr = std::move(dfnr);
        }
#endif
#ifdef HAVE_NVIDIA_AFX
        if (needNvAfx && m_nvAfxEnabled && m_nvAfx && !source->nvAfx) {
            if (nvAfx) {
                nvAfx->setIntensity(m_nvAfx->intensity());
            }
            source->nvAfx = std::move(nvAfx);
        }
#endif
        retryForNewConfiguration =
            configurationGeneration != m_dspConfigurationGeneration;
    }
    if (retryForNewConfiguration) {
        ok = ensureExternalKiwiSourceDspState(id) && ok;
    }
    return ok;
}

bool AudioEngine::ensureAllKiwiDspState()
{
    bool ok = ensureLegacyKiwiDspState();
    QStringList sourceIds;
    {
        std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
        for (const auto& source : m_externalKiwiSources) {
            if (source && externalKiwiSourceProcessing(*source)) {
                sourceIds.append(source->id);
            }
        }
    }
    for (const QString& sourceId : sourceIds) {
        ok = ensureExternalKiwiSourceDspState(sourceId) && ok;
    }
    return ok;
}

void AudioEngine::scheduleAllKiwiDspStateInitialization()
{
    std::lock_guard<std::mutex> lock(m_dspInitializationTasksMutex);
    if (m_dspInitializationStopping) {
        return;
    }
    QFuture<void> task = QtConcurrent::run([this]() {
        ensureAllKiwiDspState();
    });
    m_dspInitializationTasks.addFuture(task);
}

void AudioEngine::resetLegacyKiwiDspState()
{
    if (m_nr2Enabled && m_kiwiSdrNr2) {
        m_kiwiSdrNr2->reset();
    }
    if (m_rn2Enabled && m_kiwiSdrRn2) {
        m_kiwiSdrRn2->reset();
    }
#ifdef HAVE_SPECBLEACH
    if (m_nr4Enabled && m_kiwiSdrNr4) {
        m_kiwiSdrNr4->reset();
    }
#endif
#ifdef __APPLE__
    if (m_mnrEnabled && m_kiwiSdrMnr) {
        m_kiwiSdrMnr->reset();
    }
#endif
#ifdef HAVE_DFNR
    if (m_dfnrEnabled && m_kiwiSdrDfnr) {
        m_kiwiSdrDfnr->reset();
    }
#endif
#ifdef HAVE_NVIDIA_AFX
    if (m_nvAfxEnabled && m_kiwiSdrNvAfx) {
        m_kiwiSdrNvAfx->reset();
    }
#endif
}

void AudioEngine::clearLegacyKiwiDspState()
{
    m_kiwiSdrNr2.reset();
    m_kiwiSdrNr2Output.clear();
    m_kiwiSdrRn2.reset();
#ifdef HAVE_SPECBLEACH
    m_kiwiSdrNr4.reset();
#endif
#ifdef __APPLE__
    m_kiwiSdrMnr.reset();
#endif
#ifdef HAVE_DFNR
    m_kiwiSdrDfnr.reset();
#endif
#ifdef HAVE_NVIDIA_AFX
    m_kiwiSdrNvAfx.reset();
#endif
}

void AudioEngine::resetExternalKiwiDspState(ExternalRxAudioSourceState& source)
{
    if (m_nr2Enabled && source.nr2) {
        source.nr2->reset();
    }
    if (m_rn2Enabled && source.rn2) {
        source.rn2->reset();
    }
#ifdef HAVE_SPECBLEACH
    if (m_nr4Enabled && source.nr4) {
        source.nr4->reset();
    }
#endif
#ifdef __APPLE__
    if (m_mnrEnabled && source.mnr) {
        source.mnr->reset();
    }
#endif
#ifdef HAVE_DFNR
    if (m_dfnrEnabled && source.dfnr) {
        source.dfnr->reset();
    }
#endif
#ifdef HAVE_NVIDIA_AFX
    if (m_nvAfxEnabled && source.nvAfx) {
        source.nvAfx->reset();
    }
#endif
}

void AudioEngine::clearExternalKiwiDspState(ExternalRxAudioSourceState& source)
{
    source.nr2.reset();
    source.nr2Output.clear();
    source.rn2.reset();
#ifdef HAVE_SPECBLEACH
    source.nr4.reset();
#endif
#ifdef __APPLE__
    source.mnr.reset();
#endif
#ifdef HAVE_DFNR
    source.dfnr.reset();
#endif
#ifdef HAVE_NVIDIA_AFX
    source.nvAfx.reset();
#endif
}

RNNoiseFilter* AudioEngine::rn2ForSource(
    RxDspSource source,
    ExternalRxAudioSourceState* externalSource) const
{
    if (externalSource) {
        return externalSource->rn2.get();
    }
    return source == RxDspSource::KiwiSdr ? m_kiwiSdrRn2.get() : m_rn2.get();
}

#ifdef HAVE_SPECBLEACH
SpecbleachFilter* AudioEngine::nr4ForSource(
    RxDspSource source,
    ExternalRxAudioSourceState* externalSource) const
{
    if (externalSource) {
        return externalSource->nr4.get();
    }
    return source == RxDspSource::KiwiSdr ? m_kiwiSdrNr4.get() : m_nr4.get();
}
#endif

#ifdef __APPLE__
MacNRFilter* AudioEngine::mnrForSource(
    RxDspSource source,
    ExternalRxAudioSourceState* externalSource) const
{
    if (externalSource) {
        return externalSource->mnr.get();
    }
    return source == RxDspSource::KiwiSdr ? m_kiwiSdrMnr.get() : m_mnr.get();
}
#endif

#ifdef HAVE_DFNR
DeepFilterFilter* AudioEngine::dfnrForSource(
    RxDspSource source,
    ExternalRxAudioSourceState* externalSource) const
{
    if (externalSource) {
        return externalSource->dfnr.get();
    }
    return source == RxDspSource::KiwiSdr ? m_kiwiSdrDfnr.get() : m_dfnr.get();
}
#endif

#ifdef HAVE_NVIDIA_AFX
NvidiaAfxFilter* AudioEngine::nvAfxForSource(
    RxDspSource source,
    ExternalRxAudioSourceState* externalSource) const
{
    if (externalSource) {
        return externalSource->nvAfx.get();
    }
    return source == RxDspSource::KiwiSdr ? m_kiwiSdrNvAfx.get() : m_nvAfx.get();
}
#endif

bool AudioEngine::kiwiSdrAudioTransmitMuted() const
{
    return m_kiwiSdrAudioTransmitMuted.load(std::memory_order_relaxed);
}

bool AudioEngine::kiwiSdrAudioActive() const
{
    return m_kiwiSdrAudioEnabled.load(std::memory_order_relaxed)
        && !kiwiSdrAudioTransmitMuted();
}

bool AudioEngine::externalKiwiSourceProcessing(
    const ExternalRxAudioSourceState& source) const
{
    // Deliberately not gated on the transmit mute: managed Kiwi sources keep
    // buffering, draining, and running DSP through TX so audio can resume at
    // unkey without a jitter-buffer re-prime or NR re-convergence. Transmit
    // audibility is enforced by the per-source gate at the final mix.
    return source.enabled && !source.muted;
}

bool AudioEngine::anyExternalKiwiAudioEnabled() const
{
    for (const auto& source : m_externalKiwiSources) {
        if (source && externalKiwiSourceProcessing(*source)) {
            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 && externalKiwiSourceProcessing(*source)
            && !source->prebuffering) {
            maxBytes = std::max(maxBytes, source->outputBuffer.size());
        }
    }
    return maxBytes;
}

AudioEngine::AudioEngine(QObject* parent)
    : QObject(parent)
    // NOTE: initializer order below MUST match member declaration order in
    // AudioEngine.h (m_cwSidetone/m_cwRecordSidetone are declared before the
    // m_clientEq* block) to keep -Wreorder clean (#4031).
    , m_cwSidetone(std::make_unique<CwSidetoneGenerator>(48000))
    , m_cwRecordSidetone(std::make_unique<CwSidetoneGenerator>(DEFAULT_SAMPLE_RATE))
    , 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_wsprBeacon(std::make_unique<WsprBeacon>())
    , m_clientQuindarTone(std::make_unique<ClientQuindarTone>())
{
    // Recorder-sidetone generator: always enabled at a fixed, audible level and
    // centre pan so a Client-Side QSO recording captures the operator's sent
    // CW/CWX regardless of the audible monitor's volume/enable state (#2539).
    // Its pitch is mirrored from the audible generator each TX block.
    m_cwRecordSidetone->setEnabled(true);
    m_cwRecordSidetone->setVolume(0.5f);
    m_cwRecordSidetone->setPan(0.5f);
    // 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_wsprBeacon->prepare(DEFAULT_SAMPLE_RATE);
    m_clientQuindarTone->prepare(DEFAULT_SAMPLE_RATE);
    m_wsprPumpTimer = new QTimer(this);
    m_wsprPumpTimer->setTimerType(Qt::PreciseTimer);
    m_wsprPumpTimer->setInterval(5);
    connect(m_wsprPumpTimer, &QTimer::timeout,
            this, &AudioEngine::pumpWsprBeacon);
    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();
    const Nr2SettingsModel::Config nr2Config =
        Nr2SettingsModel::instance().config();
    m_nr2UseOriginalGeometry.store(
        nr2Config.legacyGeometryAndGainMapping,
        std::memory_order_relaxed);
    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 — follows a 10 ms wall-clock schedule. The mic
    // callback and RN2 share this event loop, so late timer events drain a
    // small bounded catch-up batch rather than permanently losing ground.
    m_opusTxPaceTimer = new QTimer(this);
    m_opusTxPaceTimer->setTimerType(Qt::PreciseTimer);
    m_opusTxPaceTimer->setInterval(10);
    m_opusTxPaceClock.start();
    connect(m_opusTxPaceTimer, &QTimer::timeout, this, [this]() {
        OpusTxPacer::DrainResult drain =
            m_opusTxPacer.takeDue(m_opusTxPaceClock.elapsed(),
                                  m_txPacketCount);
        for (const QByteArray& packet : drain.packets) {
            emit txPacketReady(packet);
        }
    });
    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 ~100ms 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 100ms, user-adjustable for
        // high-jitter connections (VPN, SmartLink) where drops cause choppy audio.
        const int sampleRate = m_rxOutputRate.load();
        const bool kiwiAudio = kiwiSdrAudioActive();
        const bool externalKiwiAudio = anyExternalKiwiAudioEnabled();
        const bool anyKiwiAudio = kiwiAudio || externalKiwiAudio;
        const int configuredBufMs = m_rxBufferCapMs.load();
        const int flexPresentationDelayMs =
            m_flexReceivePresentationDelayMs.load(std::memory_order_relaxed);
        const int kiwiPresentationDelayMs =
            m_kiwiReceivePresentationDelayMs.load(std::memory_order_relaxed);
        int externalKiwiPresentationDelayMs = 0;
        for (const auto& source : m_externalKiwiSources) {
            if (source && externalKiwiSourceProcessing(*source)) {
                externalKiwiPresentationDelayMs =
                    std::max(externalKiwiPresentationDelayMs,
                             source->presentationDelayMs);
            }
        }
        const int kiwiPresentationBufferMs =
            anyKiwiAudio
                ? std::max(kiwiPresentationDelayMs,
                           externalKiwiPresentationDelayMs)
                : 0;
        const int presentationBufMs =
            std::max(flexPresentationDelayMs, kiwiPresentationBufferMs);
        const int effectiveBufMs =
            std::max({configuredBufMs,
                      anyKiwiAudio ? kKiwiSdrBufferCapMs : configuredBufMs,
                      presentationBufMs > 0 ? presentationBufMs + 100 : 0});
        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;
        trimReceivePresentationBuffers(
            m_rxBuffer, m_rxPackets, m_rxOutputBuffer, sampleRate,
            sourceMaxBufBytes);
        trimReceivePresentationBuffers(
            m_kiwiSdrRxBuffer, m_kiwiSdrRxPackets, m_kiwiSdrOutputBuffer,
            sampleRate, sourceMaxBufBytes);
        for (const auto& source : m_externalKiwiSources) {
            if (!source) {
                continue;
            }
            trimReceivePresentationBuffers(
                source->rxBuffer, source->rxPackets, source->outputBuffer,
                sampleRate, sourceMaxBufBytes);
        }
        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.store(true, std::memory_order_relaxed);
                for (const auto& source : m_externalKiwiSources) {
                    if (source && externalKiwiSourceProcessing(*source)) {
                        source->prebuffering = true;
                    }
                }
            } else {
                m_rxBufferUnderrunCount.fetch_add(1);
            }
            if (flexPresentationDelayMs > 0) {
                m_rxPresentationPrebuffering.store(true,
                                                   std::memory_order_relaxed);
            }
        }

        // Align to stereo float32 frame boundaries before any arithmetic.
        const qsizetype floatBytes = static_cast<qsizetype>(sizeof(float));
        const qsizetype frameBytes = 2 * floatBytes;
        const qsizetype freeFrames = freeBytes / frameBytes;
        const bool nr2PacketMode = m_nr2Enabled.load(std::memory_order_relaxed);
        const qsizetype flexPrebufferBytes =
            DEFAULT_SAMPLE_RATE * 2 * static_cast<qsizetype>(sizeof(float))
            * flexPresentationDelayMs / 1000;
        const qsizetype kiwiPresentationDelayBytes =
            DEFAULT_SAMPLE_RATE * 2 * static_cast<qsizetype>(sizeof(float))
            * kiwiPresentationDelayMs / 1000;
        const auto externalKiwiPresentationDelayBytes =
            [](const ExternalRxAudioSourceState& source) {
                return DEFAULT_SAMPLE_RATE * 2
                       * static_cast<qsizetype>(sizeof(float))
                       * source.presentationDelayMs / 1000;
            };
        if (flexPresentationDelayMs <= 0) {
            m_rxPresentationPrebuffering.store(false,
                                               std::memory_order_relaxed);
        } else if (m_rxPresentationPrebuffering.load(std::memory_order_relaxed)) {
            const qsizetype flexQueuedBytes =
                nr2PacketMode ? queuedAudioBytes(m_rxPackets) : m_rxBuffer.size();
            if (flexQueuedBytes >= flexPrebufferBytes) {
                m_rxPresentationPrebuffering.store(false,
                                                   std::memory_order_relaxed);
            }
        } else if (m_rxBuffer.isEmpty() && m_rxPackets.empty()
                   && m_rxOutputBuffer.isEmpty()) {
            m_rxPresentationPrebuffering.store(true,
                                               std::memory_order_relaxed);
        }
        const bool flexPresentationPrebuffering =
            m_rxPresentationPrebuffering.load(std::memory_order_relaxed);

        // 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 (nr2PacketMode) {
            std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
            auto queuedRawEquivalent = [sampleRate](qsizetype rawBytes,
                                                    qsizetype outputBytes) {
                return rawBytes
                       + rawEquivalentAudioBytes(outputBytes, sampleRate);
            };
            while (!flexPresentationPrebuffering
                   && !m_rxPackets.empty()
                   && (m_rxOutputBuffer.size() / frameBytes) < freeFrames
                   && (flexPrebufferBytes <= 0
                       || queuedRawEquivalent(queuedAudioBytes(m_rxPackets),
                                             m_rxOutputBuffer.size())
                              > flexPrebufferBytes)) {
                QByteArray packet = std::move(m_rxPackets.front());
                m_rxPackets.pop_front();
                processMixedRxAudioData(packet, RxDspSource::Main);
            }
            while (kiwiAudio
                   && !m_kiwiSdrPrebuffering.load(std::memory_order_relaxed)
                   && !m_kiwiSdrRxPackets.empty()
                   && (m_kiwiSdrOutputBuffer.size() / frameBytes) < freeFrames
                   && queuedAudioBytes(m_kiwiSdrRxPackets)
                          + rawEquivalentAudioBytes(m_kiwiSdrOutputBuffer.size(),
                                                    sampleRate)
                          > kiwiPresentationDelayBytes) {
                QByteArray packet = std::move(m_kiwiSdrRxPackets.front());
                m_kiwiSdrRxPackets.pop_front();
                processMixedRxAudioData(packet, RxDspSource::KiwiSdr);
            }
            for (const auto& source : m_externalKiwiSources) {
                if (!source || !externalKiwiSourceProcessing(*source)) {
                    continue;
                }
                const qsizetype sourcePresentationDelayBytes =
                    externalKiwiPresentationDelayBytes(*source);
                while (!source->prebuffering
                       && !source->rxPackets.empty()
                       && (source->outputBuffer.size() / frameBytes) < freeFrames
                       && queuedAudioBytes(source->rxPackets)
                              + rawEquivalentAudioBytes(source->outputBuffer.size(),
                                                        sampleRate)
                              > sourcePresentationDelayBytes) {
                    QByteArray packet = std::move(source->rxPackets.front());
                    source->rxPackets.pop_front();
                    processMixedRxAudioData(packet, RxDspSource::KiwiSdr, source.get());
                }
            }
        }

        if (kiwiAudio
            && m_kiwiSdrPrebuffering.load(std::memory_order_relaxed)) {
            // 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(
                std::max(kKiwiSdrJitterTargetMs, kiwiPresentationDelayMs),
                effectiveBufMs);
            const qsizetype prebufferBytes =
                DEFAULT_SAMPLE_RATE * 2 * static_cast<qsizetype>(sizeof(float))
                * prebufferMs / 1000;
            const qsizetype bufferedBytes =
                nr2PacketMode
                    ? queuedAudioBytes(m_kiwiSdrRxPackets)
                          + rawEquivalentAudioBytes(m_kiwiSdrOutputBuffer.size(),
                                                    sampleRate)
                    : m_kiwiSdrRxBuffer.size();
            if (bufferedBytes >= prebufferBytes) {
                m_kiwiSdrPrebuffering.store(false, std::memory_order_relaxed);
            }
        }
        for (const auto& source : m_externalKiwiSources) {
            if (!source || !externalKiwiSourceProcessing(*source)
                || !source->prebuffering) {
                continue;
            }
            const int prebufferMs = std::min(
                std::max(kKiwiSdrJitterTargetMs,
                         source->presentationDelayMs),
                effectiveBufMs);
            const qsizetype prebufferBytes =
                DEFAULT_SAMPLE_RATE * 2 * static_cast<qsizetype>(sizeof(float))
                * prebufferMs / 1000;
            const qsizetype bufferedBytes =
                nr2PacketMode
                    ? queuedAudioBytes(source->rxPackets)
                          + rawEquivalentAudioBytes(source->outputBuffer.size(),
                                                    sampleRate)
                    : source->rxBuffer.size();
            if (bufferedBytes >= prebufferBytes) {
                source->prebuffering = false;
            }
        }

        const bool kiwiNr2PacketMode = kiwiAudio && nr2PacketMode;
        // Queued packets below the delay target are intentional delay growth,
        // not an underrun; keep playback state live while the queue catches up.
        if (kiwiNr2PacketMode
            && !m_kiwiSdrPrebuffering.load(std::memory_order_relaxed)
            && m_kiwiSdrOutputBuffer.isEmpty()
            && m_kiwiSdrRxPackets.empty()) {
            m_kiwiSdrPrebuffering.store(true, std::memory_order_relaxed);
        }
        const bool kiwiMixActive =
            kiwiAudio && !kiwiNr2PacketMode
            && !m_kiwiSdrPrebuffering.load(std::memory_order_relaxed);
        for (const auto& source : m_externalKiwiSources) {
            if (!source || !externalKiwiSourceProcessing(*source)
                || source->prebuffering) {
                continue;
            }
            // Same as the legacy Kiwi path: packets held for presentation delay
            // should not flip an already-live source back into prebuffering.
            const bool sourceEmpty =
                nr2PacketMode
                    ? source->outputBuffer.isEmpty()
                          && source->rxPackets.empty()
                    : source->rxBuffer.isEmpty();
            if (sourceEmpty) {
                source->prebuffering = true;
            }
        }
        const qsizetype kiwiMixBytes =
            kiwiMixActive
                ? std::max<qsizetype>(
                      0, m_kiwiSdrRxBuffer.size() - kiwiPresentationDelayBytes)
                : 0;
        // 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 availableMainBytes =
            (!nr2PacketMode && !flexPresentationPrebuffering)
                ? std::max<qsizetype>(0, m_rxBuffer.size() - flexPrebufferBytes)
                : 0;
        const qsizetype mainBytes =
            (std::min(wantedMainNativeBytes, availableMainBytes) / 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 || !externalKiwiSourceProcessing(*source)
                    || 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 availableSourceBytes =
                    std::max<qsizetype>(
                        0,
                        source->rxBuffer.size()
                            - externalKiwiPresentationDelayBytes(*source));
                const qsizetype sourceBytes =
                    (std::min(wantedSourceNativeBytes, availableSourceBytes)
                     / 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.load(std::memory_order_relaxed))
                ? 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;
            // While the transmit gate silences every Kiwi source, pause the
            // receive-presentation feed on BOTH sides (Flex and Kiwi), like
            // the pre-warm-pipeline mute froze both correlator buffers. A
            // TX-gated source's ramp-zeroed chunks (or a one-sided Flex feed
            // against them) would pollute the GCC-PHAT delay estimate and
            // drop the auto-assist confidence on every over. A source's gate
            // stays held through its pending post-unkey resume hold ("Resume
            // audio after TX delay"), so the pause must cover that window
            // too; it lifts as soon as any source is audible through the
            // gate (keepAudioDuringTx during TX, or a source with no pending
            // hold after unkey).
            const bool kiwiTxGateEngaged = kiwiSdrAudioTransmitMuted();
            bool anyKiwiGateHeld = false;
            bool anyKiwiAudibleThroughGate = false;
            for (const auto& s : m_externalKiwiSources) {
                if (!s || !externalKiwiSourceProcessing(*s)) {
                    continue;
                }
                const bool held = !s->keepAudioDuringTx
                    && (kiwiTxGateEngaged
                        || !s->txResumeDeadline.hasExpired());
                anyKiwiGateHeld = anyKiwiGateHeld || held;
                anyKiwiAudibleThroughGate =
                    anyKiwiAudibleThroughGate || !held;
            }
            const bool presentationPausedForTx =
                (kiwiTxGateEngaged || anyKiwiGateHeld)
                && !anyKiwiAudibleThroughGate;
            // Per-frame gate ramp step; depends only on the device rate, so
            // compute it once for the Flex gate and every Kiwi source alike.
            const float gateStep =
                sampleRate > 0
                    ? 1000.0f
                          / (static_cast<float>(kKiwiSdrTxGateRampMs)
                             * static_cast<float>(sampleRate))
                    : 1.0f;
            // Delayed-Flex transmit gate: with a Receive Sync delay applied,
            // the Flex presentation buffer holds flexDelayMs of pre-key-down
            // RX audio that would keep playing into the transmission — the
            // radio's own TX-time zero-fill only reaches the speaker after
            // the delay. Mirror the Kiwi design: buffers stay warm and
            // aligned, only the mix contribution ramps. Inactive with no
            // delay so undelayed TX monitor audio is untouched, and FDX
            // never engages the TX mute in the first place.
            const float flexGateTarget =
                (kiwiTxGateEngaged && flexPresentationDelayMs > 0)
                    ? 0.0f : 1.0f;
            bool flexGateApplied = false;
            auto applyFlexTxGate = [this, flexGateTarget, gateStep,
                                    &flexGateApplied](QByteArray& pcm) {
                flexGateApplied = true;
                if (m_flexTxGateGain == 1.0f && flexGateTarget == 1.0f) {
                    return;
                }
                auto* samples = reinterpret_cast<float*>(pcm.data());
                const qsizetype count =
                    pcm.size() / static_cast<qsizetype>(sizeof(float));
                float gate = m_flexTxGateGain;
                for (qsizetype i = 0; i + 1 < count; i += 2) {
                    if (gate < flexGateTarget) {
                        gate = std::min(flexGateTarget, gate + gateStep);
                    } else if (gate > flexGateTarget) {
                        gate = std::max(flexGateTarget, gate - gateStep);
                    }
                    samples[i] *= gate;
                    samples[i + 1] *= gate;
                }
                m_flexTxGateGain = gate;
            };
            auto emitOutputSource = [this, sampleRate, anyKiwiAudio](
                                        const QString& source,
                                        const QString& sourceId,
                                        const QByteArray& pcm,
                                        bool txGated = false) {
                if (!pcm.isEmpty()) {
                    captureAutomationAudio(QStringLiteral("output"), source,
                                           sourceId, pcm, sampleRate, 2);
                    if (!anyKiwiAudio || txGated) {
                        m_receivePresentationOutputSignalSuppressedCount
                            .fetch_add(1, std::memory_order_relaxed);
                        return;
                    }

                    m_receivePresentationOutputSignalEmitCount.fetch_add(
                        1, std::memory_order_relaxed);
                    emit receivePresentationOutputAudioReady(
                        source, sourceId, pcm, sampleRate);
                }
            };
            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());
                applyFlexTxGate(chunk);
                emitOutputSource(QStringLiteral("flex"), QString(), chunk,
                                 presentationPausedForTx);
            } 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());
                emitOutputSource(QStringLiteral("kiwi"), QString(), chunk,
                                 presentationPausedForTx);
            } 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) {
                    QByteArray rxChunk = m_rxOutputBuffer.left(rxTake);
                    applyFlexTxGate(rxChunk);
                    const auto* rx =
                        reinterpret_cast<const float*>(rxChunk.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);
                    emitOutputSource(QStringLiteral("flex"), QString(), rxChunk,
                                     presentationPausedForTx);
                }

                const qsizetype kiwiTake =
                    (std::min(len, kiwiOutputBytes) / floatBytes)
                    * floatBytes;
                if (kiwiTake > 0) {
                    const QByteArray kiwiChunk =
                        m_kiwiSdrOutputBuffer.left(kiwiTake);
                    const auto* kiwi =
                        reinterpret_cast<const float*>(kiwiChunk.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);
                    emitOutputSource(QStringLiteral("kiwi"), QString(), kiwiChunk,
                                     presentationPausedForTx);
                }

                for (const auto& source : m_externalKiwiSources) {
                    if (!source) {
                        continue;
                    }
                    // Transmit gate: the source keeps draining at real-time
                    // rate through TX so playback rejoins the live stream at
                    // unkey; only its mix contribution ramps to zero. The
                    // short ramp avoids a hard-mute click on both edges. A
                    // pending resume deadline extends the hold past unkey
                    // ("Resume audio after TX delay").
                    const bool gateOpen = source->keepAudioDuringTx
                        || (!kiwiSdrAudioTransmitMuted()
                            && source->txResumeDeadline.hasExpired());
                    const float gateTarget = gateOpen ? 1.0f : 0.0f;
                    if (!externalKiwiSourceProcessing(*source)
                        || source->prebuffering) {
                        // Silent while skipped: snap the gate DOWNWARD only —
                        // a dry tick or prebuffer stretch spanning the unkey
                        // edge holds the gate and finishes the up-ramp on
                        // the next tick with data instead of hard-stepping
                        // to full amplitude. Mid-TX entry with a stale
                        // full-gain gate is closed at the source setters
                        // (enable/unmute snap the gate to zero while the
                        // transmit gate is engaged), since this loop never
                        // runs for a lone prebuffering source.
                        source->txGateGain =
                            std::min(source->txGateGain, gateTarget);
                        continue;
                    }
                    const qsizetype sourceTake =
                        (std::min(len, source->outputBuffer.size()) / floatBytes)
                        * floatBytes;
                    if (sourceTake <= 0) {
                        source->txGateGain =
                            std::min(source->txGateGain, gateTarget);
                        continue;
                    }
                    QByteArray sourceChunk = source->outputBuffer.left(sourceTake);
                    const auto* kiwi =
                        reinterpret_cast<const float*>(sourceChunk.constData());
                    auto* capturedKiwi =
                        reinterpret_cast<float*>(sourceChunk.data());
                    const qsizetype kiwiSamples = sourceTake / floatBytes;
                    // Whole stereo frames only: len and every outputBuffer
                    // append are frame-aligned, so kiwiSamples is even and
                    // the unrolled per-frame writes below stay in bounds.
                    Q_ASSERT((kiwiSamples & 1) == 0);
                    float gate = source->txGateGain;
                    bool sourceActive = false;
                    for (qsizetype i = 0; i + 1 < kiwiSamples; i += 2) {
                        if (gate < gateTarget) {
                            gate = std::min(gateTarget, gate + gateStep);
                        } else if (gate > gateTarget) {
                            gate = std::max(gateTarget, gate - gateStep);
                        }
                        const float scale = source->gain * gate;
                        const float s0 = kiwi[i] * scale;
                        const float s1 = kiwi[i + 1] * scale;
                        sourceActive = sourceActive
                            || std::fabs(s0) > kOutputSilenceThreshold
                            || std::fabs(s1) > kOutputSilenceThreshold;
                        out[i] += s0;
                        out[i + 1] += s1;
                        capturedKiwi[i] = s0;
                        capturedKiwi[i + 1] = s1;
                    }
                    source->txGateGain = gate;
                    if (sourceActive) {
                        ++activeOutputSources;
                    }
                    source->outputBuffer.remove(0, sourceTake);
                    // Suppress the correlator feed exactly while the mix
                    // gate holds this source closed — including the
                    // post-unkey resume hold, when the chunks above were
                    // just ramped to zero.
                    emitOutputSource(QStringLiteral("kiwi"), source->id,
                                     sourceChunk, !gateOpen);
                }

                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);
                }
            }

            if (!flexGateApplied) {
                // Flex produced nothing this tick: snap its gate downward
                // only, mirroring the per-source skip snap above.
                m_flexTxGateGain = std::min(m_flexTxGateGain, flexGateTarget);
            }

            len = m_audioDevice->write(chunk);
            if (len > 0) {
                const qsizetype capturedBytes =
                    alignedStereoFloatBytes(
                        std::min<qsizetype>(len, chunk.size()));
                if (capturedBytes > 0) {
                    captureAutomationAudio(
                        QStringLiteral("final"), QStringLiteral("mix"),
                        QString(), chunk.left(capturedBytes),
                        sampleRate, 2);
                }
            }

            // 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;
            }
        }

        if (m_audioSink && sampleRate > 0) {
            const qsizetype sinkBufferBytes = m_audioSink->bufferSize();
            const qsizetype sinkFreeBytes = m_audioSink->bytesFree();
            const qsizetype sinkQueuedBytes =
                std::clamp(sinkBufferBytes - sinkFreeBytes,
                           static_cast<qsizetype>(0),
                           std::max<qsizetype>(0, sinkBufferBytes));
            const int playbackQueuedMs =
                qBound(0, audioBytesToMs(sinkQueuedBytes, sampleRate), 1000);
            m_rxPlaybackQueuedMs.store(playbackQueuedMs,
                                       std::memory_order_relaxed);
        } else {
            m_rxPlaybackQueuedMs.store(0, std::memory_order_relaxed);
        }

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

AudioEngine::~AudioEngine()
{
    {
        std::lock_guard<std::mutex> lock(m_dspInitializationTasksMutex);
        m_dspInitializationStopping = true;
        m_dspInitializationTasks.waitForFinished();
    }
    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
{
    QThread* const ownerThread = thread();
    if (ownerThread && ownerThread != QThread::currentThread()) {
        if (!ownerThread->isRunning()) {
            return {};
        }

        QJsonArray endpoints;
        const bool invoked = QMetaObject::invokeMethod(
            const_cast<AudioEngine*>(this),
            [this, &endpoints]() {
                endpoints = audioEndpointDiagnostics();
            },
            Qt::BlockingQueuedConnection);
        return invoked ? endpoints : QJsonArray{};
    }

    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_capacity_bytes"] = rxRunning
        ? static_cast<double>(m_audioSink->bufferSize()) : 0.0;
    rx["buffer_peak_bytes"] = static_cast<double>(m_rxBufferPeakBytes.load());
    rx["underrun_count"] = static_cast<double>(m_rxBufferUnderrunCount.load());
    QJsonObject presentation;
    presentation["flex_delay_ms"] =
        m_flexReceivePresentationDelayMs.load(std::memory_order_relaxed);
    presentation["kiwi_sdr_delay_ms"] =
        m_kiwiReceivePresentationDelayMs.load(std::memory_order_relaxed);
    presentation["flex_prebuffering"] =
        m_rxPresentationPrebuffering.load(std::memory_order_relaxed);
    presentation["kiwi_sdr_prebuffering"] =
        m_kiwiSdrPrebuffering.load(std::memory_order_relaxed);
    const ReceivePresentationAudioQueues queues =
        receivePresentationAudioQueues();
    presentation["playback_queued_ms"] = queues.playbackQueuedMs;
    presentation["flex_raw_buffer_ms"] =
        queues.flexRawBufferMs;
    presentation["flex_output_buffer_ms"] =
        queues.flexOutputBufferMs;
    presentation["kiwi_sdr_raw_buffer_ms"] =
        queues.kiwiSdrRawBufferMs;
    presentation["kiwi_sdr_output_buffer_ms"] =
        queues.kiwiSdrOutputBufferMs;
    presentation["external_kiwi_raw_buffer_ms"] =
        queues.externalKiwiRawBufferMs;
    presentation["external_kiwi_output_buffer_ms"] =
        queues.externalKiwiOutputBufferMs;
    rx["receive_presentation"] = presentation;
    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();
    const TxCaptureHealthTracker::Snapshot txHealth =
        m_txCaptureHealth.snapshot(txCaptureNowMs());
    tx["buffer_bytes_available"] = static_cast<double>(txCaptureBufferedBytes());
    tx["buffer_capacity_bytes"] = static_cast<double>(txCaptureBufferCapacityBytes());
    tx["source_was_active"] = txHealth.sourceWasActive;
    tx["saturation_observed"] = txHealth.saturationObserved;
    tx["tci_suppressed_callbacks"] = static_cast<double>(txHealth.tciSuppressedCallbacks);
    tx["full_buffer_during_tci_observations"] =
        static_cast<double>(txHealth.fullBufferDuringTciObservations);
    tx["idle_during_tci_transitions"] = static_cast<double>(txHealth.idleDuringTciTransitions);
    tx["post_tci_local_tx_while_saturated"] =
        static_cast<double>(txHealth.postTciLocalTxWhileSaturated);
    tx["last_mic_read_age_ms"] = txHealth.lastMicReadAgeMs >= 0
        ? QJsonValue(static_cast<double>(txHealth.lastMicReadAgeMs))
        : QJsonValue();
    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;
}

QJsonObject AudioEngine::startAutomationAudioCapture(
    int durationMs,
    const QStringList& points)
{
    if (!qEnvironmentVariableIsSet("AETHER_AUTOMATION")) {
        return QJsonObject{
            {QStringLiteral("ok"), false},
            {QStringLiteral("error"),
             QStringLiteral("audioCapture requires AETHER_AUTOMATION=1")},
        };
    }

    QStringList normalizedPoints;
    normalizedPoints.reserve(points.size());
    for (const QString& point : points) {
        const QString normalized = point.trimmed().toLower();
        if (!normalized.isEmpty()) {
            normalizedPoints.append(normalized);
        }
    }

    const bool allPoints =
        normalizedPoints.isEmpty()
        || normalizedPoints.contains(QStringLiteral("all"));
    const bool captureRaw =
        allPoints || normalizedPoints.contains(QStringLiteral("raw"));
    const bool capturePost =
        allPoints || normalizedPoints.contains(QStringLiteral("post"));
    const bool captureOutput =
        allPoints || normalizedPoints.contains(QStringLiteral("output"));
    const bool captureFinal =
        allPoints || normalizedPoints.contains(QStringLiteral("final"));
    if (!captureRaw && !capturePost && !captureOutput && !captureFinal) {
        return QJsonObject{
            {QStringLiteral("ok"), false},
            {QStringLiteral("error"),
             QStringLiteral("audioCapture start points must include raw, post, output, final, or all")},
        };
    }

    const int boundedDurationMs =
        qBound(100, durationMs, kAutomationAudioCaptureMaxDurationMs);
    const qint64 nowNs = steadyNowNs();

    std::lock_guard<std::mutex> lock(m_automationAudioCaptureMutex);
    m_automationCaptureChunks.clear();
    m_automationCaptureBytes = 0;
    m_automationCaptureMaxBytes = kAutomationAudioCaptureMaxBytes;
    m_automationCaptureRaw = captureRaw;
    m_automationCapturePost = capturePost;
    m_automationCaptureOutput = captureOutput;
    m_automationCaptureFinal = captureFinal;
    m_automationCaptureStartNs = nowNs;
    m_automationCaptureEndNs =
        nowNs + static_cast<qint64>(boundedDurationMs) * 1000000;
    m_automationAudioCaptureActive.store(true, std::memory_order_relaxed);

    return QJsonObject{
        {QStringLiteral("ok"), true},
        {QStringLiteral("active"), true},
        {QStringLiteral("durationMs"), boundedDurationMs},
        {QStringLiteral("raw"), captureRaw},
        {QStringLiteral("post"), capturePost},
        {QStringLiteral("output"), captureOutput},
        {QStringLiteral("final"), captureFinal},
        {QStringLiteral("maxBytes"),
         static_cast<double>(m_automationCaptureMaxBytes)},
    };
}

QJsonObject AudioEngine::stopAutomationAudioCapture()
{
    m_automationAudioCaptureActive.store(false, std::memory_order_relaxed);
    return automationAudioCaptureSnapshot(false);
}

namespace {

constexpr int kAutomationDspProbeFrames = AudioEngine::DEFAULT_SAMPLE_RATE * 3;
constexpr int kAutomationDspProbeDiscardFrames =
    AudioEngine::DEFAULT_SAMPLE_RATE + AudioEngine::DEFAULT_SAMPLE_RATE / 2;
constexpr int kAutomationDspProbeBlockFrames = 960;

QByteArray makeAutomationDspStereoProbeInput()
{
    QByteArray input(kAutomationDspProbeFrames * 2 * static_cast<int>(sizeof(float)),
                     Qt::Uninitialized);
    auto* src = reinterpret_cast<float*>(input.data());
    for (int i = 0; i < kAutomationDspProbeFrames; ++i) {
        const double t = static_cast<double>(i) / AudioEngine::DEFAULT_SAMPLE_RATE;
        const float envelope = static_cast<float>(
            0.62 + 0.38 * std::sin(2.0 * std::numbers::pi * 4.2 * t));
        const float signal = static_cast<float>(
            envelope * (0.30 * std::sin(2.0 * std::numbers::pi * 720.0 * t)
                      + 0.16 * std::sin(2.0 * std::numbers::pi * 1180.0 * t)
                      + 0.08 * std::sin(2.0 * std::numbers::pi * 1740.0 * t))
          + 0.03 * std::sin(2.0 * std::numbers::pi * 43.0 * t));
        src[2 * i] = 0.80f * signal;
        src[2 * i + 1] = 0.20f * signal;
    }
    return input;
}

QJsonObject stereoRmsRatio(const QByteArray& pcm, int startFrame)
{
    const auto* samples = reinterpret_cast<const float*>(pcm.constData());
    const int totalFrames = pcm.size() / (2 * static_cast<int>(sizeof(float)));
    const int firstFrame = std::clamp(startFrame, 0, totalFrames);
    double leftSum = 0.0;
    double rightSum = 0.0;
    int count = 0;
    for (int frame = firstFrame; frame < totalFrames; ++frame) {
        const double left = samples[2 * frame];
        const double right = samples[2 * frame + 1];
        leftSum += left * left;
        rightSum += right * right;
        ++count;
    }
    const double leftRms = std::sqrt(leftSum / std::max(count, 1));
    const double rightRms = std::sqrt(rightSum / std::max(count, 1));
    return QJsonObject{
        {QStringLiteral("leftRms"), leftRms},
        {QStringLiteral("rightRms"), rightRms},
        {QStringLiteral("ratio"), leftRms / std::max(rightRms, 1e-12)},
        {QStringLiteral("frames"), count},
    };
}

QByteArray processAutomationDspProbeBlocks(
    const QByteArray& input,
    const std::function<QByteArray(const QByteArray&)>& processBlock)
{
    const int blockBytes =
        kAutomationDspProbeBlockFrames * 2 * static_cast<int>(sizeof(float));
    QByteArray output;
    output.reserve(input.size());
    for (int offset = 0; offset < input.size(); offset += blockBytes) {
        output.append(processBlock(input.mid(offset, blockBytes)));
    }
    return output;
}

QJsonObject unavailableAutomationDspProbe(const QString& mode, const QString& reason)
{
    return QJsonObject{
        {QStringLiteral("ok"), true},
        {QStringLiteral("mode"), mode},
        {QStringLiteral("available"), false},
        {QStringLiteral("skipped"), true},
        {QStringLiteral("tested"), false},
        {QStringLiteral("reason"), reason},
    };
}

QJsonObject completedAutomationDspProbe(const QString& mode,
                                        const QByteArray& input,
                                        const QByteArray& output)
{
    constexpr double kMinOutputInputRmsRatio = 0.02;
    const QJsonObject inputRms =
        stereoRmsRatio(input, kAutomationDspProbeDiscardFrames);
    const QJsonObject outputRms =
        stereoRmsRatio(output, kAutomationDspProbeDiscardFrames);
    const double inputRatio = inputRms.value(QStringLiteral("ratio")).toDouble();
    const double outputRatio = outputRms.value(QStringLiteral("ratio")).toDouble();
    const double ratioError = std::fabs(outputRatio - inputRatio);
    const double leftLevelRatio =
        outputRms.value(QStringLiteral("leftRms")).toDouble()
        / std::max(inputRms.value(QStringLiteral("leftRms")).toDouble(), 1.0e-12);
    const double rightLevelRatio =
        outputRms.value(QStringLiteral("rightRms")).toDouble()
        / std::max(inputRms.value(QStringLiteral("rightRms")).toDouble(), 1.0e-12);
    const bool audible =
        leftLevelRatio >= kMinOutputInputRmsRatio
        && rightLevelRatio >= kMinOutputInputRmsRatio;
    const bool preserved = ratioError < 0.08 && audible;

    return QJsonObject{
        {QStringLiteral("ok"), preserved},
        {QStringLiteral("mode"), mode},
        {QStringLiteral("available"), true},
        {QStringLiteral("skipped"), false},
        {QStringLiteral("tested"), true},
        {QStringLiteral("frames"),
         output.size() / (2 * static_cast<int>(sizeof(float)))},
        {QStringLiteral("discardFrames"), kAutomationDspProbeDiscardFrames},
        {QStringLiteral("input"), inputRms},
        {QStringLiteral("output"), outputRms},
        {QStringLiteral("ratioError"), ratioError},
        {QStringLiteral("leftLevelRatio"), leftLevelRatio},
        {QStringLiteral("rightLevelRatio"), rightLevelRatio},
        {QStringLiteral("minimumLevelRatio"), kMinOutputInputRmsRatio},
        {QStringLiteral("audible"), audible},
        {QStringLiteral("preserved"), preserved},
    };
}

} // namespace

QJsonObject AudioEngine::automationNr2StereoProbe() const
{
    QJsonObject result = automationDspStereoProbe(QStringLiteral("NR2"));
    result[QStringLiteral("probe")] = QStringLiteral("nr2StereoBalance");
    return result;
}

QJsonObject AudioEngine::automationDspStereoProbe(const QString& mode) const
{
    if (!qEnvironmentVariableIsSet("AETHER_AUTOMATION")) {
        return QJsonObject{
            {QStringLiteral("ok"), false},
            {QStringLiteral("error"),
             QStringLiteral("audioCapture probeDspStereo requires AETHER_AUTOMATION=1")},
        };
    }

    const QByteArray input = makeAutomationDspStereoProbeInput();
    QString tokenText = mode.trimmed();
    tokenText.replace(QLatin1Char(','), QLatin1Char(' '));
    const QStringList requestTokens =
        tokenText.split(QLatin1Char(' '), Qt::SkipEmptyParts);
    QStringList modeTokens;
    bool strictCoverage = false;
    for (const QString& token : requestTokens) {
        const QString normalizedToken = token.toUpper();
        if (normalizedToken == QLatin1String("STRICT")) {
            strictCoverage = true;
        } else {
            modeTokens.append(normalizedToken);
        }
    }
    if (modeTokens.size() > 1) {
        return QJsonObject{
            {QStringLiteral("ok"), false},
            {QStringLiteral("error"),
             QStringLiteral("probeDspStereo accepts one mode or all, plus optional strict")},
        };
    }
    const QString normalizedMode = modeTokens.isEmpty()
        ? QStringLiteral("ALL")
        : modeTokens.constFirst();

    const auto probeOne = [this, &input](const QString& requestedMode) -> QJsonObject {
        if (requestedMode == QLatin1String("NR2")) {
            std::unique_ptr<SpectralNR> nr2 = createNr2Filter(
                QStringLiteral("automation probe"));
            if (!nr2) {
                return QJsonObject{
                    {QStringLiteral("ok"), false},
                    {QStringLiteral("mode"), requestedMode},
                    {QStringLiteral("available"), false},
                    {QStringLiteral("skipped"), false},
                    {QStringLiteral("error"), QStringLiteral("NR2 plan creation failed")},
                };
            }
            applyNr2Settings(*nr2);
            QByteArray output;
            processNr2StereoSharedMask(
                *nr2,
                reinterpret_cast<const float*>(input.constData()),
                input.size() / (2 * static_cast<int>(sizeof(float))),
                output);
            return completedAutomationDspProbe(requestedMode, input, output);
        }

        if (requestedMode == QLatin1String("RN2")) {
            RNNoiseFilter rn2;
            applyRn2Settings(rn2);
            if (!rn2.isValid()) {
                return QJsonObject{
                    {QStringLiteral("ok"), false},
                    {QStringLiteral("mode"), requestedMode},
                    {QStringLiteral("available"), false},
                    {QStringLiteral("skipped"), false},
                    {QStringLiteral("error"), QStringLiteral("RN2 initialization failed")},
                };
            }
            const QByteArray output = processAutomationDspProbeBlocks(
                input, [&rn2](const QByteArray& block) { return rn2.process(block); });
            return completedAutomationDspProbe(requestedMode, input, output);
        }

        if (requestedMode == QLatin1String("NR4")) {
#ifdef HAVE_SPECBLEACH
            SpecbleachFilter nr4;
            if (!nr4.isValid()) {
                return unavailableAutomationDspProbe(
                    requestedMode, QStringLiteral("NR4/specbleach unavailable"));
            }
            applyNr4SettingsFromAppSettings(nr4);
            const QByteArray output = processAutomationDspProbeBlocks(
                input, [&nr4](const QByteArray& block) { return nr4.process(block); });
            return completedAutomationDspProbe(requestedMode, input, output);
#else
            return unavailableAutomationDspProbe(
                requestedMode, QStringLiteral("NR4 not built in this configuration"));
#endif
        }

        if (requestedMode == QLatin1String("MNR")) {
#ifdef __APPLE__
            MacNRFilter mnr;
            if (!mnr.isValid()) {
                return unavailableAutomationDspProbe(
                    requestedMode, QStringLiteral("MNR/Accelerate initialization failed"));
            }
            mnr.setStrength(m_mnrStrength.load());
            const QByteArray output = processAutomationDspProbeBlocks(
                input, [&mnr](const QByteArray& block) { return mnr.process(block); });
            return completedAutomationDspProbe(requestedMode, input, output);
#else
            return unavailableAutomationDspProbe(
                requestedMode, QStringLiteral("MNR is macOS-only"));
#endif
        }

        if (requestedMode == QLatin1String("DFNR")) {
#ifdef HAVE_DFNR
            DeepFilterFilter dfnr;
            if (!dfnr.isValid()) {
                return unavailableAutomationDspProbe(
                    requestedMode, QStringLiteral("DFNR model/runtime unavailable"));
            }
            applyDfnrSettingsFromAppSettings(dfnr);
            const QByteArray output = processAutomationDspProbeBlocks(
                input, [&dfnr](const QByteArray& block) { return dfnr.process(block); });
            return completedAutomationDspProbe(requestedMode, input, output);
#else
            return unavailableAutomationDspProbe(
                requestedMode, QStringLiteral("DFNR not built in this configuration"));
#endif
        }

        if (requestedMode == QLatin1String("BNR")) {
#ifdef HAVE_NVIDIA_AFX
            NvidiaAfxFilter bnr;
            if (!bnr.isValid()) {
                return unavailableAutomationDspProbe(
                    requestedMode,
                    bnr.lastError().isEmpty()
                        ? QStringLiteral("BNR/NVIDIA AFX runtime unavailable")
                        : bnr.lastError());
            }
            bnr.setIntensity(NvidiaBnrSettings::intensity());
            const QByteArray output = processAutomationDspProbeBlocks(
                input, [&bnr](const QByteArray& block) { return bnr.process(block); });
            return completedAutomationDspProbe(requestedMode, input, output);
#else
            return unavailableAutomationDspProbe(
                requestedMode, QStringLiteral("BNR not built in this configuration"));
#endif
        }

        return QJsonObject{
            {QStringLiteral("ok"), false},
            {QStringLiteral("mode"), requestedMode},
            {QStringLiteral("error"),
             QStringLiteral("unknown DSP mode; use NR2, RN2, NR4, MNR, DFNR, BNR, or all")},
        };
    };

    if (normalizedMode != QLatin1String("ALL")) {
        QJsonObject result = probeOne(normalizedMode);
        result[QStringLiteral("probe")] = QStringLiteral("dspStereoBalance");
        const bool skipped = result.value(QStringLiteral("skipped")).toBool();
        result[QStringLiteral("strict")] = strictCoverage;
        result[QStringLiteral("fullCoverage")] = !skipped;
        result[QStringLiteral("coverageStatus")] =
            skipped ? QStringLiteral("skipped") : QStringLiteral("complete");
        if (strictCoverage && skipped) {
            result[QStringLiteral("ok")] = false;
            result[QStringLiteral("coverageError")] =
                QStringLiteral("strict DSP probe skipped requested mode");
        }
        return result;
    }

    const QStringList modes{
        QStringLiteral("NR2"),
        QStringLiteral("RN2"),
        QStringLiteral("NR4"),
        QStringLiteral("MNR"),
        QStringLiteral("DFNR"),
        QStringLiteral("BNR"),
    };
    QJsonArray results;
    bool testedOk = true;
    int skipped = 0;
    int tested = 0;
    for (const QString& oneMode : modes) {
        const QJsonObject result = probeOne(oneMode);
        results.append(result);
        if (result.value(QStringLiteral("skipped")).toBool()) {
            ++skipped;
        } else {
            ++tested;
            testedOk = testedOk && result.value(QStringLiteral("ok")).toBool();
        }
    }
    const bool fullCoverage = skipped == 0;
    const bool ok = testedOk && (!strictCoverage || fullCoverage);

    return QJsonObject{
        {QStringLiteral("ok"), ok},
        {QStringLiteral("probe"), QStringLiteral("dspStereoBalance")},
        {QStringLiteral("modes"), results},
        {QStringLiteral("strict"), strictCoverage},
        {QStringLiteral("tested"), tested},
        {QStringLiteral("skipped"), skipped},
        {QStringLiteral("testedOk"), testedOk},
        {QStringLiteral("fullCoverage"), fullCoverage},
        {QStringLiteral("coverageStatus"),
         fullCoverage ? QStringLiteral("complete") : QStringLiteral("partial")},
        {QStringLiteral("frames"), kAutomationDspProbeFrames},
        {QStringLiteral("discardFrames"), kAutomationDspProbeDiscardFrames},
    };
}

QJsonObject AudioEngine::automationAudioCaptureSnapshot(bool includePcm) const
{
    const qint64 nowNs = steadyNowNs();
    QVector<AutomationAudioCaptureChunk> chunksCopy;
    bool captureRaw = false;
    bool capturePost = false;
    bool captureOutput = false;
    bool captureFinal = false;
    qint64 captureStartNs = 0;
    qint64 captureEndNs = 0;
    qsizetype captureBytes = 0;
    qsizetype captureMaxBytes = 0;
    {
        std::lock_guard<std::mutex> lock(m_automationAudioCaptureMutex);
        chunksCopy = m_automationCaptureChunks;
        captureRaw = m_automationCaptureRaw;
        capturePost = m_automationCapturePost;
        captureOutput = m_automationCaptureOutput;
        captureFinal = m_automationCaptureFinal;
        captureStartNs = m_automationCaptureStartNs;
        captureEndNs = m_automationCaptureEndNs;
        captureBytes = m_automationCaptureBytes;
        captureMaxBytes = m_automationCaptureMaxBytes;
    }

    QJsonArray chunks;
    for (const AutomationAudioCaptureChunk& chunk : chunksCopy) {
        QJsonObject item{
            {QStringLiteral("point"), chunk.point},
            {QStringLiteral("source"), chunk.source},
            {QStringLiteral("sourceId"), chunk.sourceId},
            {QStringLiteral("sampleRate"), chunk.sampleRate},
            {QStringLiteral("channels"), chunk.channels},
            {QStringLiteral("format"), QStringLiteral("float32le")},
            {QStringLiteral("startNs"), static_cast<double>(chunk.startNs)},
            {QStringLiteral("bytes"), chunk.pcm.size()},
            {QStringLiteral("frames"),
             chunk.channels > 0
                 ? chunk.pcm.size()
                       / (chunk.channels
                          * static_cast<int>(sizeof(float)))
                 : 0},
        };
        if (includePcm) {
            item[QStringLiteral("pcmBase64")] =
                QString::fromLatin1(chunk.pcm.toBase64());
        }
        chunks.append(item);
    }

    const bool active =
        m_automationAudioCaptureActive.load(std::memory_order_relaxed)
        && nowNs < captureEndNs;
    return QJsonObject{
        {QStringLiteral("ok"), true},
        {QStringLiteral("active"), active},
        {QStringLiteral("raw"), captureRaw},
        {QStringLiteral("post"), capturePost},
        {QStringLiteral("output"), captureOutput},
        {QStringLiteral("final"), captureFinal},
        {QStringLiteral("elapsedMs"),
         captureStartNs > 0
             ? static_cast<double>((nowNs - captureStartNs)
                                   / 1000000)
             : 0.0},
        {QStringLiteral("capturedBytes"),
         static_cast<double>(captureBytes)},
        {QStringLiteral("maxBytes"),
         static_cast<double>(captureMaxBytes)},
        {QStringLiteral("chunkCount"), chunks.size()},
        {QStringLiteral("chunks"), chunks},
    };
}

void AudioEngine::captureAutomationAudio(const QString& point,
                                         const QString& source,
                                         const QString& sourceId,
                                         const QByteArray& pcm,
                                         int sampleRate,
                                         int channels)
{
    if (!m_automationAudioCaptureActive.load(std::memory_order_relaxed)
        || pcm.isEmpty() || channels <= 0 || sampleRate <= 0) {
        return;
    }

    std::lock_guard<std::mutex> lock(m_automationAudioCaptureMutex);
    if (!m_automationAudioCaptureActive.load(std::memory_order_relaxed)) {
        return;
    }
    if ((point == QLatin1String("raw") && !m_automationCaptureRaw)
        || (point == QLatin1String("post") && !m_automationCapturePost)
        || (point == QLatin1String("output") && !m_automationCaptureOutput)
        || (point == QLatin1String("final") && !m_automationCaptureFinal)) {
        return;
    }

    const qint64 nowNs = steadyNowNs();
    if (nowNs >= m_automationCaptureEndNs) {
        m_automationAudioCaptureActive.store(false, std::memory_order_relaxed);
        return;
    }

    const qsizetype frameBytes =
        channels * static_cast<qsizetype>(sizeof(float));
    const qsizetype alignedBytes =
        (std::max<qsizetype>(0, pcm.size()) / frameBytes) * frameBytes;
    const qsizetype remainingBytes =
        m_automationCaptureMaxBytes - m_automationCaptureBytes;
    const qsizetype captureBytes =
        (std::min(alignedBytes, remainingBytes) / frameBytes) * frameBytes;
    if (captureBytes <= 0) {
        m_automationAudioCaptureActive.store(false, std::memory_order_relaxed);
        return;
    }

    m_automationCaptureChunks.append(
        AutomationAudioCaptureChunk{
            .point = point,
            .source = source,
            .sourceId = sourceId,
            .sampleRate = sampleRate,
            .channels = channels,
            .startNs = nowNs - m_automationCaptureStartNs,
            .pcm = pcm.left(captureBytes),
        });
    m_automationCaptureBytes += captureBytes;
    if (m_automationCaptureBytes >= m_automationCaptureMaxBytes) {
        m_automationAudioCaptureActive.store(false, std::memory_order_relaxed);
    }
}

// ─── 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_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->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());
#ifdef Q_OS_WIN
        // Constrain the WASAPI shared-mode ring buffer (#3193). Without an explicit
        // size, class-compliant USB interfaces (Scarlett, Focusrite, etc.) inherit
        // their driver's default ring of 100-300 ms, which stacks on top of the
        // app-side m_rxBufferCapMs cap and produces 300-500 ms+ speaker latency.
        // A 50 ms device buffer is comfortably fed by the 10 ms RX drain timer and
        // mirrors the explicit buffers already used for the sidetone/Quindar sinks.
        constexpr int kWinRxDeviceBufferMs = 50;
        const qint64 winRxBufBytes = candidate.bytesForDuration(kWinRxDeviceBufferMs * 1000LL);
        if (winRxBufBytes > 0)
            sink->setBufferSize(static_cast<qsizetype>(winRxBufBytes));
#endif
        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_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->prebuffering = source->enabled;
    }
    m_rxBufferBytes.store(0);
    m_rxBufferPeakBytes.store(0);
    m_rxBufferSampleRate.store(DEFAULT_SAMPLE_RATE);
    m_rxPlaybackQueuedMs.store(0, std::memory_order_relaxed);

    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)
{
    captureAutomationAudio(QStringLiteral("raw"), QStringLiteral("flex"),
                           QString(), pcm, DEFAULT_SAMPLE_RATE, 2);
    processRxAudioData(pcm, true);
}

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

    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);
    captureAutomationAudio(QStringLiteral("raw"), QStringLiteral("kiwi"),
                           QString(), alignedPcm, DEFAULT_SAMPLE_RATE, 2);

    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)
{
    std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
    ExternalRxAudioSourceState* source = externalKiwiSource(sourceId, true);
    if (!source || !source->enabled) {
        return;
    }
    m_lastAudioFeedTime.start();
    if (source->muted) {
        return;
    }

    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);
    captureAutomationAudio(QStringLiteral("raw"), QStringLiteral("kiwi"),
                           sourceId, alignedPcm, DEFAULT_SAMPLE_RATE, 2);

    // Feed-side cap: with the transmit gate no longer dropping packets, the
    // jitter buffer must be bounded here too — the drain-side trim only runs
    // while the RX timer is ticking, so a stopped audio sink would otherwise
    // grow these buffers without limit. Discard oldest-first. The cap must
    // stay above the source's receive-presentation delay (+100 ms headroom,
    // mirroring the drain-side effectiveBufMs budget): the prebuffer release
    // and the live drain both wait for presentationDelayMs worth of queued
    // audio, so a cap below it would starve the source permanently.
    const int capMs = std::max(kKiwiSdrBufferCapMs,
                               source->presentationDelayMs > 0
                                   ? source->presentationDelayMs + 100
                                   : 0);
    const qsizetype capBytes =
        DEFAULT_SAMPLE_RATE * kFrameBytes * capMs / 1000;
    if (m_nr2Enabled.load(std::memory_order_relaxed)) {
        source->rxPackets.push_back(alignedPcm);
        // Keep at least the packet just pushed even if it alone exceeds the
        // cap; m_dspMutex is already held for the whole function.
        trimAudioPacketQueue(
            source->rxPackets,
            std::max(capBytes, source->rxPackets.back().size()));
        updateRxBufferStats();
        return;
    }

    source->rxBuffer.append(alignedPcm);
    if (source->rxBuffer.size() > capBytes) {
        const qsizetype excess =
            ((source->rxBuffer.size() - capBytes) / kFrameBytes) * kFrameBytes;
        source->rxBuffer.remove(0, excess);
    }
    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_kiwiSdrNr2Output.clear();
        m_kiwiSdrRxResampler.reset();
        m_kiwiSdrRxResamplerR.reset();
        if (!on) {
            clearLegacyKiwiDspState();
        }
        m_kiwiSdrPrebuffering.store(on && !kiwiSdrAudioTransmitMuted(),
                                    std::memory_order_relaxed);
        updateRxBufferStats();
    }
    if (on) {
        scheduleAllKiwiDspStateInitialization();
    }
}

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->nr2Output.clear();
        source->rxResampler.reset();
        source->rxResamplerR.reset();
        if (!on) {
            clearExternalKiwiDspState(*source);
        }
        source->prebuffering = on;
        // Entry snap: the mix-loop's downward snap only runs on ticks that
        // reach the mix path, which a lone prebuffering source never does —
        // without this, a source enabled mid-TX would enter the mix at its
        // stale full-gain gate and blip ~8 ms of audio during transmit. The
        // gate stays engaged through a pending post-unkey resume hold
        // ("Resume audio after TX delay"), so cover that window too.
        if (on && !source->keepAudioDuringTx
            && (kiwiSdrAudioTransmitMuted()
                || !source->txResumeDeadline.hasExpired())) {
            source->txGateGain = 0.0f;
        }
        updateRxBufferStats();
    }
    if (on) {
        scheduleAllKiwiDspStateInitialization();
    }
}

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->nr2Output.clear();
        if (muted) {
            clearExternalKiwiDspState(*source);
        }
        source->prebuffering = !muted && source->enabled;
        // Entry snap — same reason as setKiwiSdrAudioSourceEnabled: a source
        // muted before key-down still has txGateGain at 1.0, and unmuting it
        // mid-TX (or during a pending resume hold) must not let it enter the
        // mix at full gain.
        if (!muted && !source->keepAudioDuringTx
            && (kiwiSdrAudioTransmitMuted()
                || !source->txResumeDeadline.hasExpired())) {
            source->txGateGain = 0.0f;
        }
        updateRxBufferStats();
    }
    if (!muted) {
        scheduleAllKiwiDspStateInitialization();
    }
}

void AudioEngine::setKiwiSdrAudioTransmitMuted(bool muted)
{
    if (m_kiwiSdrAudioTransmitMuted.exchange(
            muted, std::memory_order_relaxed) == muted) {
        return;
    }

    // Presentation-only for managed Kiwi sources: their feed, jitter buffer,
    // and DSP state stay warm through TX, and the final mix ramps each
    // source's contribution to zero instead (unless keepAudioDuringTx). Only
    // the legacy single-stream path keeps the historical wipe-and-reprime,
    // since nothing gates its feed during TX.
    std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
    if (!muted) {
        // Unkey: sources configured for delayed resume hold their gate
        // closed for the Kiwi chain latency so playback rejoins on audio
        // received after the transmission ended.
        for (const auto& source : m_externalKiwiSources) {
            if (source && source->txResumeHoldMs > 0
                && !source->keepAudioDuringTx) {
                source->txResumeDeadline =
                    QDeadlineTimer(source->txResumeHoldMs);
            }
        }
    }
    m_kiwiSdrRxBuffer.clear();
    m_kiwiSdrRxPackets.clear();
    m_kiwiSdrOutputBuffer.clear();
    m_kiwiSdrNr2Output.clear();
    resetLegacyKiwiDspState();
    m_kiwiSdrPrebuffering.store(
        !muted && m_kiwiSdrAudioEnabled.load(std::memory_order_relaxed),
        std::memory_order_relaxed);
    updateRxBufferStats();
}

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

    source->keepAudioDuringTx = keep;
}

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

    source->txResumeHoldMs = std::max(holdMs, 0);
    if (source->txResumeHoldMs == 0) {
        // Hold disabled: disarm any deadline from the last unkey so the
        // gate reopens promptly instead of waiting out a stale hold
        // (default-constructed QDeadlineTimer is already expired).
        source->txResumeDeadline = QDeadlineTimer();
    }
}

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);
}

bool AudioEngine::hasKiwiSdrAudioSource(const QString& sourceId) const
{
    const QString id = sourceId.trimmed();
    if (id.isEmpty()) {
        return false;
    }

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

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_nr2Output.clear();
    m_kiwiSdrNr2Output.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();
        resetExternalKiwiDspState(*source);
        source->prebuffering = externalKiwiSourceProcessing(*source);
    }

    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();
    }
    if (m_rn2Enabled && m_kiwiSdrRn2) {
        m_kiwiSdrRn2->reset();
    }
#ifdef HAVE_SPECBLEACH
    if (m_nr4Enabled && m_nr4) {
        m_nr4->reset();
    }
    if (m_nr4Enabled && m_kiwiSdrNr4) {
        m_kiwiSdrNr4->reset();
    }
#endif
#ifdef HAVE_DFNR
    if (m_dfnrEnabled && m_dfnr) {
        m_dfnr->reset();
    }
    if (m_dfnrEnabled && m_kiwiSdrDfnr) {
        m_kiwiSdrDfnr->reset();
    }
#endif
#ifdef __APPLE__
    if (m_mnrEnabled && m_mnr) {
        m_mnr->reset();
    }
    if (m_mnrEnabled && m_kiwiSdrMnr) {
        m_kiwiSdrMnr->reset();
    }
#endif
#ifdef HAVE_NVIDIA_AFX
    if (m_nvAfxEnabled && m_nvAfx) {
        m_nvAfx->reset();
    }
    if (m_nvAfxEnabled && m_kiwiSdrNvAfx) {
        m_kiwiSdrNvAfx->reset();
    }
#endif
}

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();
    };
    // Virtual Kiwi profiles have their own client-side pan. Flex and the
    // legacy Kiwi stream keep the stereo orientation already present in their
    // input; m_rxPan mirrors a Flex radio control and must not retarget Kiwi.
    const bool applyKiwiOutputPan = externalSource != nullptr;

    // While the transmit gate is silencing this source at the final mix,
    // processing still runs (to keep DSP state warm through TX) but the
    // presentation-side telemetry — RX level meter, scopes, EQ FFT tap,
    // receive-presentation post-DSP feed — must stay quiet, or the meters
    // bounce with audio the operator cannot hear. Mirrors the mix gate's
    // closed condition exactly, including the post-unkey resume hold.
    const bool txPresentationGated = externalSource
        && !externalSource->keepAudioDuringTx
        && (kiwiSdrAudioTransmitMuted()
            || !externalSource->txResumeDeadline.hasExpired());

    // TX-time RX-chain bypass (#367/#1505), decided once per packet: non-Kiwi
    // sources bypass the client RX DSP during TX so stateful stages don't
    // adapt to TX silence; managed Kiwi sources are exempt because their
    // input stays live off-air signal through TX. One read of the atomic
    // also keeps every stage in this call on the same side of a mid-packet
    // interlock flip.
    const bool bypassRxChainForTx = m_radioTransmitting && !externalSource;

    // 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,
                       txPresentationGated, bypassRxChainForTx](
                          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) — except for
        // managed Kiwi sources, whose input stays live signal during TX.
        const QByteArray* eqSource = &data;
        if (m_clientEqRx && m_clientEqRx->isEnabled()
            && !bypassRxChainForTx) {
            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 && !txPresentationGated) {
            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()
            && !bypassRxChainForTx) {
            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()
            && !bypassRxChainForTx) {
            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()
            && !bypassRxChainForTx) {
            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()
            && !bypassRxChainForTx) {
            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()
            && !bypassRxChainForTx) {
            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);
        captureAutomationAudio(
            QStringLiteral("post"),
            source == RxDspSource::KiwiSdr ? QStringLiteral("kiwi")
                                           : QStringLiteral("flex"),
            externalSource ? externalSource->id : QString(),
            *output, scopeSampleRate, 2);
        if (!txPresentationGated) {
            emit receivePresentationPostDspAudioReady(
                source == RxDspSource::KiwiSdr ? QStringLiteral("kiwi")
                                               : QStringLiteral("flex"),
                externalSource ? externalSource->id : QString(),
                *output, scopeSampleRate);
        }
        outputBuffer.append(*output);
        if (!txPresentationGated) {
            emitScopeFromFloat32Stereo(*output, scopeSampleRate, false);
            emitRxPostChainScopeFromFloat32Stereo(*output, scopeSampleRate);
        }
        updateRxBufferStats();
    };
    const auto writeAudioAndLevel = [this, applyKiwiOutputPan,
                                     txPresentationGated, &writeAudio](
                                        const QByteArray& data) {
        writeAudio(data, applyKiwiOutputPan);
        if (!txPresentationGated) {
            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.
    // Managed Kiwi sources are exempt: their input keeps being live off-air
    // signal during TX (the network feed is never TX-gated), so bypassing
    // would cause the very stale-state artifact the bypass exists to prevent.
    // DSP mutex: prevents use-after-free if enable/disable runs concurrently (#502)
    {
        std::lock_guard<std::recursive_mutex> dspLock(m_dspMutex);
        if (bypassRxChainForTx) {
            writeAudioAndLevel(pcm);
        } else if (m_rn2Enabled) {
            RNNoiseFilter* rn2 = rn2ForSource(source, externalSource);
            if (!rn2) {
                writeAudioAndLevel(pcm);
                return;
            }
            QByteArray processed = rn2->process(pcm);
            writeAudioAndLevel(processed);
        } else if (m_nr2Enabled && m_nr2) {
            processNr2(pcm, source, externalSource);
            const QByteArray& nr2Output = externalSource
                ? externalSource->nr2Output
                : (source == RxDspSource::KiwiSdr ? m_kiwiSdrNr2Output
                                                   : m_nr2Output);
            writeAudioAndLevel(nr2Output);

#ifdef HAVE_SPECBLEACH
        } else if (m_nr4Enabled) {
            SpecbleachFilter* nr4 = nr4ForSource(source, externalSource);
            if (!nr4) {
                writeAudioAndLevel(pcm);
                return;
            }
            QByteArray processed = nr4->process(pcm);
            writeAudioAndLevel(processed);
#endif
#ifdef HAVE_DFNR
        } else if (m_dfnrEnabled) {
            DeepFilterFilter* dfnr = dfnrForSource(source, externalSource);
            if (!dfnr) {
                writeAudioAndLevel(pcm);
                return;
            }
            QByteArray processed = dfnr->process(pcm);
            writeAudioAndLevel(processed);
#endif
#ifdef HAVE_NVIDIA_AFX
        } else if (m_nvAfxEnabled) {
            NvidiaAfxFilter* nvAfx = nvAfxForSource(source, externalSource);
            if (!nvAfx) {
                writeAudioAndLevel(pcm);
                return;
            }
            QByteArray processed = nvAfx->process(pcm);
            writeAudioAndLevel(processed);
#endif
#ifdef __APPLE__
        } else if (m_mnrEnabled) {
            MacNRFilter* mnr = mnrForSource(source, externalSource);
            if (!mnr) {
                writeAudioAndLevel(pcm);
                return;
            }
            QByteArray processed = mnr->process(pcm);
            writeAudioAndLevel(processed);
#endif
        } 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 applyNr2Settings(SpectralNR& nr2)
{
    const Nr2SettingsModel::Config config =
        Nr2SettingsModel::instance().config();
    nr2.setGainMax(config.gainMax);
    nr2.setGainFloor(config.gainFloor);
    nr2.setGainSmooth(config.gainSmooth);
    nr2.setQspp(config.qspp);
    nr2.setGainMethod(config.gainMethod);
    nr2.setNpeMethod(config.npeMethod);
    nr2.setAeFilter(config.aeFilter);
}

// RN2's only user-adjustable parameter. The TX (ProcessedMono) instance is
// deliberately NOT fed this: the dry mix exists so RX gaps between phrases do
// not go dead, which is meaningless for a mic pre-amp.
static void applyRn2Settings(RNNoiseFilter& rn2)
{
    rn2.setDryMix(Rn2SettingsModel::instance().config().rxDryMix);
}

static void copyNr2Settings(const SpectralNR& source, SpectralNR& target)
{
    target.setGainMax(source.gainMax());
    target.setGainFloor(source.gainFloor());
    target.setGainSmooth(source.gainSmooth());
    target.setQspp(source.qspp());
    target.setGainMethod(source.gainMethod());
    target.setNpeMethod(source.npeMethod());
    target.setAeFilter(source.aeFilter());
}

#ifdef HAVE_SPECBLEACH
static void applyNr4SettingsFromAppSettings(SpecbleachFilter& nr4)
{
    auto& s = AppSettings::instance();
    nr4.setReductionAmount(s.value("NR4ReductionAmount", "10.0").toFloat());
    nr4.setSmoothingFactor(s.value("NR4SmoothingFactor", "0.0").toFloat());
    nr4.setWhiteningFactor(s.value("NR4WhiteningFactor", "0.0").toFloat());
    nr4.setAdaptiveNoise(s.value("NR4AdaptiveNoise", "True").toString() == "True");
    nr4.setNoiseEstimationMethod(s.value("NR4NoiseEstimationMethod", "0").toInt());
    nr4.setMaskingDepth(s.value("NR4MaskingDepth", "0.50").toFloat());
    nr4.setSuppressionStrength(
        s.value("NR4SuppressionStrength", "0.50").toFloat());
}

static void copyNr4Settings(const SpecbleachFilter& source,
                            SpecbleachFilter& target)
{
    target.setReductionAmount(source.reductionAmount());
    target.setSmoothingFactor(source.smoothingFactor());
    target.setWhiteningFactor(source.whiteningFactor());
    target.setAdaptiveNoise(source.adaptiveNoise());
    target.setNoiseEstimationMethod(source.noiseEstimationMethod());
    target.setMaskingDepth(source.maskingDepth());
    target.setSuppressionStrength(source.suppressionStrength());
}
#endif

#ifdef HAVE_DFNR
static void applyDfnrSettingsFromAppSettings(DeepFilterFilter& dfnr)
{
    auto& s = AppSettings::instance();
    dfnr.setAttenLimit(s.value("DfnrAttenLimit", "100").toFloat());
    dfnr.setPostFilterBeta(s.value("DfnrPostFilterBeta", "0.0").toFloat());
}

static void copyDfnrSettings(const DeepFilterFilter& source,
                             DeepFilterFilter& target)
{
    target.setAttenLimit(source.attenLimit());
    target.setPostFilterBeta(source.postFilterBeta());
}
#endif

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::unique_lock<std::recursive_mutex> lock(m_dspMutex);
    ++m_dspConfigurationGeneration;
    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_nr2Output.clear();
    m_kiwiSdrNr2Output.clear();
    m_kiwiSdrPrebuffering.store(kiwiSdrAudioActive(),
                                std::memory_order_relaxed);
    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->prebuffering = externalKiwiSourceProcessing(*source);
    }
    if (on) {
        // Disable all other NR modes — they're mutually exclusive
        if (m_rn2Enabled)  setRn2Enabled(false);
        if (m_nr4Enabled)  setNr4Enabled(false);
        if (m_dfnrEnabled) setDfnrEnabled(false);
        if (m_nvAfxEnabled) setNvAfxEnabled(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 = createNr2Filter(
            QStringLiteral("main RX"),
            m_mainSourceLegacyNr2.load(std::memory_order_relaxed));
        if (!m_nr2) {
            emit nr2EnabledChanged(false);
            return;
        }
        // Restore the feature-owned NR2 configuration.
        applyNr2Settings(*m_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->nr2Output.clear();
            source->outputBuffer.clear();
            source->rxResampler.reset();
            source->rxResamplerR.reset();
            source->prebuffering = externalKiwiSourceProcessing(*source);
        }
    }
    lock.unlock();
    if (on) {
        scheduleAllKiwiDspStateInitialization();
    }
    qCDebug(lcAudio) << "AudioEngine: NR2" << (on ? "enabled" : "disabled");
    emit nr2EnabledChanged(on);
}

void AudioEngine::setRn2DryMix(float value)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_rn2) m_rn2->setDryMix(value);
    if (m_kiwiSdrRn2) m_kiwiSdrRn2->setDryMix(value);
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->rn2) {
            source->rn2->setDryMix(value);
        }
    }
}

void AudioEngine::setNr2GainMax(float v)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    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::setNr2GainFloor(float v)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_nr2) m_nr2->setGainFloor(v);
    if (m_kiwiSdrNr2) m_kiwiSdrNr2->setGainFloor(v);
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr2) {
            source->nr2->setGainFloor(v);
        }
    }
}

void AudioEngine::setNr2Qspp(float v)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    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)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    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)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    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)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    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);
        }
    }
}

QJsonObject AudioEngine::nr2RuntimeDiagnostics() const
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    const auto filterSnapshot = [](const SpectralNR* filter) {
        if (!filter) {
            return QJsonObject{{QStringLiteral("present"), false}};
        }
        return QJsonObject{
            {QStringLiteral("present"), true},
            {QStringLiteral("gainMethod"), filter->gainMethod()},
            {QStringLiteral("npeMethod"), filter->npeMethod()},
            {QStringLiteral("aeFilter"), filter->aeFilter()},
            {QStringLiteral("gainMax"), filter->gainMax()},
            {QStringLiteral("gainFloor"), filter->gainFloor()},
            {QStringLiteral("gainSmooth"), filter->gainSmooth()},
            {QStringLiteral("qspp"), filter->qspp()},
            {QStringLiteral("fftSize"), filter->fftSize()},
            {QStringLiteral("legacyGainMethods"),
                filter->usesLegacyGainMethods()},
        };
    };

    QJsonArray externalKiwi;
    for (const auto& source : m_externalKiwiSources) {
        if (!source) {
            continue;
        }
        QJsonObject snapshot = filterSnapshot(source->nr2.get());
        snapshot[QStringLiteral("sourceId")] = source->id;
        externalKiwi.append(snapshot);
    }

    return QJsonObject{
        {QStringLiteral("main"), filterSnapshot(m_nr2.get())},
        {QStringLiteral("legacyKiwi"), filterSnapshot(m_kiwiSdrNr2.get())},
        {QStringLiteral("externalKiwi"), externalKiwi},
    };
}

void AudioEngine::setNr2AeFilter(bool on)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    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);
        }
    }
}

void AudioEngine::setNr2UseOriginalGeometry(bool useOriginal)
{
    const bool previous = m_nr2UseOriginalGeometry.exchange(
        useOriginal, std::memory_order_relaxed);
    if (previous == useOriginal) {
        return;
    }

    qCInfo(lcAudio).noquote()
        << "AudioEngine: NR2 comparison mode switched to"
        << (useOriginal ? "original geometry/gain"
                        : "1024/4 with WDSP gain mapping");
    if (!m_nr2Enabled.load(std::memory_order_relaxed)) {
        return;
    }

    // Re-enter through the normal lifecycle so every Flex/Kiwi NR2 instance,
    // presentation buffer, and startup estimator is rebuilt together.
    setNr2Enabled(false);
    setNr2Enabled(true);
}

void AudioEngine::setMainSourceLegacyNr2(bool legacy)
{
    const bool previous =
        m_mainSourceLegacyNr2.exchange(legacy, std::memory_order_relaxed);
    if (previous == legacy) {
        return;
    }
    qCInfo(lcAudio).noquote()
        << "AudioEngine: main-source NR2 geometry ->"
        << (legacy ? "original 256/2 (demo)" : "1024/4 (real radio)");
    // If NR2 is live, rebuild so the main filter picks up the new geometry now.
    if (m_nr2Enabled.load(std::memory_order_relaxed)) {
        setNr2Enabled(false);
        setNr2Enabled(true);
    }
}


#ifdef HAVE_SPECBLEACH

void AudioEngine::setNr4Enabled(bool on)
{
    if (m_nr4Enabled == on) return;
    std::unique_lock<std::recursive_mutex> lock(m_dspMutex);
    ++m_dspConfigurationGeneration;
    if (on) {
        if (m_nr2Enabled)  setNr2Enabled(false);
        if (m_rn2Enabled)  setRn2Enabled(false);
        if (m_dfnrEnabled) setDfnrEnabled(false);
        if (m_nvAfxEnabled) setNvAfxEnabled(false);
        if (m_mnrEnabled)  setMnrEnabled(false);
        m_nr4 = createNr4Filter(QStringLiteral("Flex"));
        if (!m_nr4) {
            m_nr4.reset();
            emit nr4EnabledChanged(false);
            return;
        }
        applyNr4SettingsFromAppSettings(*m_nr4);
        m_nr4Enabled = true;
    } else {
        m_nr4Enabled = false;
        m_nr4.reset();
        m_kiwiSdrNr4.reset();
        for (const auto& source : m_externalKiwiSources) {
            if (source) {
                source->nr4.reset();
            }
        }
    }
    lock.unlock();
    if (on) {
        scheduleAllKiwiDspStateInitialization();
    }
    qCDebug(lcAudio) << "AudioEngine: NR4" << (on ? "enabled" : "disabled");
    emit nr4EnabledChanged(on);
}

void AudioEngine::setNr4ReductionAmount(float dB)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_nr4) {
        m_nr4->setReductionAmount(dB);
    }
    if (m_kiwiSdrNr4) {
        m_kiwiSdrNr4->setReductionAmount(dB);
    }
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr4) {
            source->nr4->setReductionAmount(dB);
        }
    }
}
void AudioEngine::setNr4SmoothingFactor(float pct)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_nr4) {
        m_nr4->setSmoothingFactor(pct);
    }
    if (m_kiwiSdrNr4) {
        m_kiwiSdrNr4->setSmoothingFactor(pct);
    }
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr4) {
            source->nr4->setSmoothingFactor(pct);
        }
    }
}
void AudioEngine::setNr4WhiteningFactor(float pct)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_nr4) {
        m_nr4->setWhiteningFactor(pct);
    }
    if (m_kiwiSdrNr4) {
        m_kiwiSdrNr4->setWhiteningFactor(pct);
    }
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr4) {
            source->nr4->setWhiteningFactor(pct);
        }
    }
}
void AudioEngine::setNr4AdaptiveNoise(bool on)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_nr4) {
        m_nr4->setAdaptiveNoise(on);
    }
    if (m_kiwiSdrNr4) {
        m_kiwiSdrNr4->setAdaptiveNoise(on);
    }
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr4) {
            source->nr4->setAdaptiveNoise(on);
        }
    }
}
void AudioEngine::setNr4NoiseEstimationMethod(int m)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_nr4) {
        m_nr4->setNoiseEstimationMethod(m);
    }
    if (m_kiwiSdrNr4) {
        m_kiwiSdrNr4->setNoiseEstimationMethod(m);
    }
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr4) {
            source->nr4->setNoiseEstimationMethod(m);
        }
    }
}
void AudioEngine::setNr4MaskingDepth(float v)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_nr4) {
        m_nr4->setMaskingDepth(v);
    }
    if (m_kiwiSdrNr4) {
        m_kiwiSdrNr4->setMaskingDepth(v);
    }
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr4) {
            source->nr4->setMaskingDepth(v);
        }
    }
}
void AudioEngine::setNr4SuppressionStrength(float v)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_nr4) {
        m_nr4->setSuppressionStrength(v);
    }
    if (m_kiwiSdrNr4) {
        m_kiwiSdrNr4->setSuppressionStrength(v);
    }
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nr4) {
            source->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::unique_lock<std::recursive_mutex> lock(m_dspMutex);
    ++m_dspConfigurationGeneration;
#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_nr4Enabled)  setNr4Enabled(false);
        if (m_dfnrEnabled) setDfnrEnabled(false);
        if (m_nvAfxEnabled) setNvAfxEnabled(false);
        // 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 = createMnrFilter(QStringLiteral("Flex"));
        if (!m_mnr) {
            m_mnr.reset();
            emit mnrEnabledChanged(false);
            return;
        }
        m_mnrEnabled = true;
    } else {
        m_mnr.reset();
        m_kiwiSdrMnr.reset();
        for (const auto& source : m_externalKiwiSources) {
            if (source) {
                source->mnr.reset();
            }
        }
    }
#endif
    m_mnrEnabled = on;
    lock.unlock();
    if (on) {
        scheduleAllKiwiDspStateInitialization();
    }
    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__
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_mnr) {
        m_mnr->setStrength(m_mnrStrength.load());
    }
    if (m_kiwiSdrMnr) {
        m_kiwiSdrMnr->setStrength(m_mnrStrength.load());
    }
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->mnr) {
            source->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::unique_lock<std::recursive_mutex> lock(m_dspMutex);
    ++m_dspConfigurationGeneration;
    if (on) {
        // Disable all other NR modes — they're mutually exclusive
        if (m_nr2Enabled)  setNr2Enabled(false);
        if (m_nr4Enabled)  setNr4Enabled(false);
        if (m_dfnrEnabled) setDfnrEnabled(false);
        if (m_nvAfxEnabled) setNvAfxEnabled(false);
        if (m_mnrEnabled)  setMnrEnabled(false);
        m_rn2 = createRn2Filter(QStringLiteral("Flex"));
        if (!m_rn2) {
            m_rn2.reset();
            emit rn2EnabledChanged(false);
            return;
        }
        m_rn2Enabled = true;
    } else {
        m_rn2Enabled = false;
        m_rn2.reset();
        m_kiwiSdrRn2.reset();
        for (const auto& source : m_externalKiwiSources) {
            if (source) {
                source->rn2.reset();
            }
        }
    }
    lock.unlock();
    if (on) {
        scheduleAllKiwiDspStateInitialization();
    }
    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>(
            RNNoiseFilter::OutputMode::ProcessedMono);
        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);
}

QJsonObject AudioEngine::opusTxPacingDiagnostics() const
{
    return QJsonObject{
        {QStringLiteral("queueDepth"), m_opusTxPacer.queueDepth()},
        {QStringLiteral("maxQueueDepth"), m_opusTxPacer.maxQueueDepth()},
        {QStringLiteral("packetsSent"),
         static_cast<double>(m_opusTxPacer.packetsSent())},
        {QStringLiteral("catchUpPackets"),
         static_cast<double>(m_opusTxPacer.catchUpPackets())},
        {QStringLiteral("droppedPackets"),
         static_cast<double>(m_opusTxPacer.droppedPackets())},
    };
}

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

#ifdef HAVE_DFNR

void AudioEngine::setDfnrEnabled(bool on)
{
    if (m_dfnrEnabled == on) return;
    std::unique_lock<std::recursive_mutex> lock(m_dspMutex);
    ++m_dspConfigurationGeneration;
    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_mnrEnabled)  setMnrEnabled(false);
        if (m_nvAfxEnabled) setNvAfxEnabled(false);
        m_dfnr = createDfnrFilter(QStringLiteral("Flex"));
        if (!m_dfnr) {
            m_dfnr.reset();
            emit dfnrEnabledChanged(false);
            return;
        }
        applyDfnrSettingsFromAppSettings(*m_dfnr);
        m_dfnrEnabled = true;
    } else {
        m_dfnrEnabled = false;
        m_dfnr.reset();
        m_kiwiSdrDfnr.reset();
        for (const auto& source : m_externalKiwiSources) {
            if (source) {
                source->dfnr.reset();
            }
        }
    }
    lock.unlock();
    if (on) {
        scheduleAllKiwiDspStateInitialization();
    }
    qCDebug(lcAudio) << "AudioEngine: DFNR (DeepFilterNet3)" << (on ? "enabled" : "disabled");
    emit dfnrEnabledChanged(on);
}

void AudioEngine::setDfnrAttenLimit(float db)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_dfnr) {
        m_dfnr->setAttenLimit(db);
    }
    if (m_kiwiSdrDfnr) {
        m_kiwiSdrDfnr->setAttenLimit(db);
    }
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->dfnr) {
            source->dfnr->setAttenLimit(db);
        }
    }
}

float AudioEngine::dfnrAttenLimit() const
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    return m_dfnr ? m_dfnr->attenLimit() : 100.0f;
}

void AudioEngine::setDfnrPostFilterBeta(float beta)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_dfnr) {
        m_dfnr->setPostFilterBeta(beta);
    }
    if (m_kiwiSdrDfnr) {
        m_kiwiSdrDfnr->setPostFilterBeta(beta);
    }
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->dfnr) {
            source->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

// ─── NVIDIA AFX GPU denoiser (optional, runtime-loaded) ──────────────────────

#ifdef HAVE_NVIDIA_AFX

void AudioEngine::setNvAfxEnabled(bool on)
{
    if (m_nvAfxEnabled == on) return;
    std::unique_lock<std::recursive_mutex> lock(m_dspMutex);
    ++m_dspConfigurationGeneration;
    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_nvAfxEnabled) setNvAfxEnabled(false);
        if (m_mnrEnabled)  setMnrEnabled(false);
        m_nvAfx = createNvAfxFilter(QStringLiteral("Flex"));
        if (!m_nvAfx) {
            m_nvAfx.reset();
            emit nvAfxEnabledChanged(false);
            return;
        }
        m_nvAfx->setIntensity(NvidiaBnrSettings::intensity());
        m_nvAfxEnabled = true;
    } else {
        m_nvAfxEnabled = false;
        m_nvAfx.reset();
        m_kiwiSdrNvAfx.reset();
        for (const auto& source : m_externalKiwiSources) {
            if (source) {
                source->nvAfx.reset();
            }
        }
    }
    lock.unlock();
    if (on) {
        scheduleAllKiwiDspStateInitialization();
    }
    qCDebug(lcAudio) << "AudioEngine: NVIDIA AFX denoiser" << (on ? "enabled" : "disabled");
    emit nvAfxEnabledChanged(on);
}

void AudioEngine::setNvAfxIntensity(float ratio)
{
    std::lock_guard<std::recursive_mutex> lock(m_dspMutex);
    if (m_nvAfx) {
        m_nvAfx->setIntensity(ratio);
    }
    if (m_kiwiSdrNvAfx) {
        m_kiwiSdrNvAfx->setIntensity(ratio);
    }
    for (const auto& source : m_externalKiwiSources) {
        if (source && source->nvAfx) {
            source->nvAfx->setIntensity(ratio);
        }
    }
}

#else // !HAVE_NVIDIA_AFX — stubs
void AudioEngine::setNvAfxEnabled(bool) {}
void AudioEngine::setNvAfxIntensity(float) {}
#endif // HAVE_NVIDIA_AFX

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());
    QByteArray& output = externalSource
        ? externalSource->nr2Output
        : (source == RxDspSource::KiwiSdr ? m_kiwiSdrNr2Output : m_nr2Output);
    if (!nr2) {
        output = stereoPcm;
        return;
    }

    // Compute one NR2 mask from mono analysis, then apply it to both original
    // channels.  Flex remote_audio_rx is already a radio-mixed stereo stream;
    // duplicating a mono NR output and panning from the active slice loses the
    // per-slice balance inside that stream (#4035).
    processNr2StereoSharedMask(*nr2, src, stereoFrames, output);
}

void AudioEngine::processNr2StereoSharedMask(SpectralNR& nr2,
                                             const float* src,
                                             int stereoFrames,
                                             QByteArray& output)
{
    // 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());
    nr2.processStereoSharedMask(src, dst, stereoFrames);
    for (int i = 0; i < stereoFrames * 2; ++i) {
        dst[i] = std::clamp(dst[i], -1.0f, 1.0f);
    }
}

QByteArray AudioEngine::applyBoost(const QByteArray& pcm, float gain) const
{
    const int nSamples = static_cast<int>(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);
}

TxCaptureHealthTracker::CaptureState AudioEngine::txCaptureState(QAudio::State state)
{
    switch (state) {
    case QAudio::ActiveState:    return TxCaptureHealthTracker::CaptureState::Active;
    case QAudio::IdleState:      return TxCaptureHealthTracker::CaptureState::Idle;
    case QAudio::SuspendedState: return TxCaptureHealthTracker::CaptureState::Suspended;
    case QAudio::StoppedState:   return TxCaptureHealthTracker::CaptureState::Stopped;
    }
    return TxCaptureHealthTracker::CaptureState::Stopped;
}

qint64 AudioEngine::txCaptureBufferedBytes() const
{
#ifdef Q_OS_MAC
    return m_micBuffer ? m_micBuffer->size() : 0;
#else
    return m_micDevice ? m_micDevice->bytesAvailable() : 0;
#endif
}

qint64 AudioEngine::txCaptureBufferCapacityBytes() const
{
    return m_audioSource ? m_audioSource->bufferSize() : 0;
}

qint64 AudioEngine::txCaptureNowMs() const
{
    return m_txCaptureHealthClock.isValid() ? m_txCaptureHealthClock.elapsed() : 0;
}

bool AudioEngine::tciAudioFresh() const
{
    return m_tciAudioTimer.isValid()
        && m_tciAudioTimer.elapsed() < kTciAudioActiveWindowMs;
}

void AudioEngine::observeTxCaptureState(QAudio::State state)
{
    const TxCaptureHealthTracker::Event event = m_txCaptureHealth.observeState(
        txCaptureState(state), tciAudioFresh(), txCaptureBufferedBytes());
    if (event != TxCaptureHealthTracker::Event::None) {
        logTxCaptureHealthEvent(event);
    }
}

void AudioEngine::recordTxCaptureLocalTxAttempt()
{
    if (!m_audioSource) {
        return;
    }

    const TxCaptureHealthTracker::Event event = m_txCaptureHealth.recordLocalTxAttempt(
        txCaptureState(m_audioSource->state()),
        m_transmitting.load(std::memory_order_acquire),
        m_daxTxMode.load(std::memory_order_acquire),
        tciAudioFresh(),
        txCaptureBufferedBytes(),
        txCaptureBufferCapacityBytes());
    if (event != TxCaptureHealthTracker::Event::None) {
        logTxCaptureHealthEvent(event);
    }
}

void AudioEngine::logTxCaptureHealthEvent(TxCaptureHealthTracker::Event event)
{
    switch (event) {
    case TxCaptureHealthTracker::Event::BufferSaturatedDuringTci:
        logTxCaptureHealthSummary(QStringLiteral("buffer saturated during TCI suppression"), true);
        break;
    case TxCaptureHealthTracker::Event::LocalTxWhileSaturated:
        logTxCaptureHealthSummary(QStringLiteral("local TX with saturated post-TCI capture"), true);
        break;
    case TxCaptureHealthTracker::Event::None:
        break;
    }
}

void AudioEngine::logTxCaptureHealthSummary(const QString& reason, bool anomaly)
{
    // TCI server diagnostics use lcCat today. Keep these support summaries
    // opt-in with the same Help -> Support debug toggle; warnings must not make
    // the capture-health instrumentation default-on by bypassing that choice.
    if (!lcCat().isDebugEnabled()) {
        return;
    }

    const TxCaptureHealthTracker::Snapshot health =
        m_txCaptureHealth.snapshot(txCaptureNowMs());
    if (!anomaly && health.tciSuppressedCallbacks == 0
        && health.fullBufferDuringTciObservations == 0
        && health.idleDuringTciTransitions == 0
        && health.postTciLocalTxWhileSaturated == 0) {
        return;
    }

    const QAudioDevice device = m_inputDevice.isNull()
        ? QMediaDevices::defaultAudioInput()
        : m_inputDevice;

    AudioSummaryLogger::TxCaptureHealthSummary summary;
    summary.reason = reason;
    summary.deviceDescription = device.description();
    summary.state = m_audioSource
        ? audioStateName(m_audioSource->state())
        : QStringLiteral("Stopped");
    summary.error = m_audioSource
        ? audioErrorName(m_audioSource->error())
        : QStringLiteral("NoError");
    summary.lifecycleMs = health.lifecycleMs;
    summary.bufferedBytes = txCaptureBufferedBytes();
    summary.bufferCapacityBytes = txCaptureBufferCapacityBytes();
    summary.lastMicReadAgeMs = health.lastMicReadAgeMs;
    summary.tciSuppressedCallbacks = health.tciSuppressedCallbacks;
    summary.suppressedBufferPeakBytes = health.suppressedBufferPeakBytes;
    summary.fullBufferDuringTciObservations = health.fullBufferDuringTciObservations;
    summary.idleDuringTciTransitions = health.idleDuringTciTransitions;
    summary.postTciLocalTxWhileSaturated = health.postTciLocalTxWhileSaturated;
    summary.sourceWasActive = health.sourceWasActive;
    summary.saturationObserved = health.saturationObserved;
    AudioSummaryLogger::logTxCaptureHealth(summary, anomaly);
}

// ─── 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_txCaptureHealthClock.restart();
    m_txCaptureHealth.reset(txCaptureState(m_audioSource->state()));
    QAudioSource* const observedSource = m_audioSource;
    connect(observedSource, &QAudioSource::stateChanged, this,
            [this, observedSource](QAudio::State state) {
        if (observedSource != m_audioSource) {
            return;
        }
        observeTxCaptureState(state);
    }, Qt::QueuedConnection);

    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()
{
    if (m_audioSource) {
        logTxCaptureHealthSummary(QStringLiteral("source lifecycle ended"), false);
    }
    ++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::setCwKeyDown(bool down)
{
    // Drive the audible sidetone and the recorder-sidetone generator together so
    // the recording's CW envelope matches what the operator hears/sends. Both
    // setKeyDown()s are lock-free atomics, safe to call from the keyer threads.
    if (m_cwSidetone)       m_cwSidetone->setKeyDown(down);
    if (m_cwRecordSidetone) m_cwRecordSidetone->setKeyDown(down);
    // Latch that this TX over is a CW over (our keyer fired). The record pump
    // gates on this so it captures CW but not voice/DAX/tune overs that never
    // key the sidetone. Reset on the radio TX→RX edge (setRadioTransmitting).
    if (down) m_cwKeyedThisOver.store(true, std::memory_order_release);
}

// ── CW-sidetone record pump (#2539) ──────────────────────────────────────────
// CW has no mic-driven onTxAudioReady, so the recorder's TX side would be silent
// during CW. This free-running audio-thread timer renders our local sidetone to
// the recorder while the radio is keyed for CW. It feeds a COPY destined only for
// the recorder — it never touches the radio TX path.

void AudioEngine::startCwRecordPump()
{
    if (m_cwRecordPump) return;                  // idempotent
    m_cwRecordPump = new QTimer(this);
    m_cwRecordPump->setInterval(10);             // 10 ms → ~240 frames @ 24 kHz
    connect(m_cwRecordPump, &QTimer::timeout, this, &AudioEngine::onCwRecordPump);
    m_cwRecordPump->start();
}

void AudioEngine::onCwRecordPump()
{
    // Active only when WE are sending CW: the radio is keyed AND our keyer has
    // fired this over. isTxStreaming() (mic capture) is never true in CW, so a
    // voice over can't reach here even if mis-flagged.
    const bool active = m_radioTransmitting.load(std::memory_order_acquire)
                        && m_cwKeyedThisOver.load(std::memory_order_acquire)
                        && !isTxStreaming();

    if (active != m_cwPumpActive) {
        m_cwPumpActive = active;
        if (active) m_cwPumpElapsed.restart();
        // Open/close the recorder's TX gate for CW the same way moxChanged does
        // for voice (the recorder MOX-gates feedTxAudio).
        emit cwRecordingActiveChanged(active);
        if (!active) return;
    }
    if (!active) return;

    // Frame count from elapsed wall-time so morse timing in the WAV tracks real
    // time despite timer jitter on a busy audio thread.
    const qint64 ns = m_cwPumpElapsed.nsecsElapsed();
    m_cwPumpElapsed.restart();
    int frames = static_cast<int>((ns * DEFAULT_SAMPLE_RATE) / 1000000000LL);
    if (frames <= 0) return;
    frames = std::min(frames, DEFAULT_SAMPLE_RATE / 5);   // clamp 200 ms (stall guard)

    if (m_cwSidetone) m_cwRecordSidetone->setPitchHz(m_cwSidetone->pitchHz());
    m_cwRecordSidetoneScratch.assign(static_cast<size_t>(frames) * 2, 0.0f);
    // process() adds tone when keyed, leaves silence in the inter-element gaps —
    // emit either way so the gaps (and thus the morse spacing) are preserved.
    m_cwRecordSidetone->process(m_cwRecordSidetoneScratch.data(), frames);

    QByteArray pcm;
    pcm.resize(frames * 2 * static_cast<int>(sizeof(int16_t)));
    auto* o16 = reinterpret_cast<int16_t*>(pcm.data());
    for (int i = 0; i < frames * 2; ++i)
        o16[i] = static_cast<int16_t>(
            std::clamp(m_cwRecordSidetoneScratch[static_cast<size_t>(i)] * 32768.0f,
                       -32768.0f, 32767.0f));
    emit cwSidetoneRecordPcmReady(pcm);
}

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 (tciAudioFresh()) {
        const TxCaptureHealthTracker::Event event = m_txCaptureHealth.recordSuppressedCallback(
            txCaptureBufferedBytes(), txCaptureBufferCapacityBytes());
        if (event != TxCaptureHealthTracker::Event::None) {
            logTxCaptureHealthEvent(event);
        }
#ifdef Q_OS_LINUX
        // Qt/PipeWire capture is edge-driven: leaving the pull device unread
        // fills its ring, after which no new readyRead edge arrives to resume
        // PC Audio when TCI stops (#4230). Keep consuming the Linux source but
        // discard these samples so only TCI audio reaches the radio. Windows
        // and macOS retain their existing behavior until equivalent evidence
        // exists for those backends.
        if (m_micDevice) {
            const QByteArray discarded = m_micDevice->readAll();
            if (!discarded.isEmpty()) {
                m_txCaptureHealth.recordMicRead(txCaptureNowMs());
            }
        }
#endif
        if (m_audioSource) {
            observeTxCaptureState(m_audioSource->state());
        }
        return;
    }
#ifdef Q_OS_MAC
    if (!m_micBuffer || !m_audioSource) return;
    if (m_audioSource->state() == QAudio::StoppedState) return;
    if (!m_micBuffer->isOpen()) return;
    // A host-modulating backend has no Flex stream id and never will; the
    // audio's destination is the local modulator. See setHostModulation().
    if (!m_hostModulation && 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_hostModulation && 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

    m_txCaptureHealth.recordMicRead(txCaptureNowMs());

    // 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 (+ local CW/CWX sidetone for recording) ──
    // Mirror the post-PUDU monitor at the chain's tail (post-limiter) for the
    // PUDU TX monitor and the Client-Side QSO recorder's VOICE tap (#3556).
    // This path is mic-driven, so it only carries phone/SSB. Local CW/CWX
    // sidetone has no mic stream and is fed to the recorder separately by the
    // CW record pump (onCwRecordPump, #2539).
    if (auto* mon = m_txFinalMonitor.load(std::memory_order_acquire)) {
        mon->feedTxPostDsp(data);
    }
    // Expose the post-limiter int16 stream so the QSO recorder captures voice TX
    // for Client-Side recording (#3556). Emitted unconditionally; 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)
                | sizeWords);
            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 10 ms pacer follows elapsed deadlines and drains a bounded
            // catch-up batch after a late timer event. Cap the queue to
            // ~200 ms if the producer still outruns that recovery.
            if (m_opusTxPacer.enqueue(std::move(pkt))) {
                ++m_opusTxDropsSinceLog;
                if (!m_opusTxDropLogTimer.isValid()
                    || m_opusTxDropLogTimer.hasExpired(1000)) {
                    qCWarning(lcAudio)
                        << "AudioEngine: Opus TX pacing queue overflow — dropped"
                        << m_opusTxDropsSinceLog
                        << "oldest 10 ms packet(s); queue depth"
                        << m_opusTxPacer.queueDepth();
                    m_opusTxDropsSinceLog = 0;
                    m_opusTxDropLogTimer.restart();
                }
            }
        }
        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)
{
    // A host-modulating backend (HL2) runs the modulator on THIS host and has
    // no Flex TX stream id — the AFSK belongs in the final-monitor tap, not in
    // VITA-49 packets aimed at a Flex. Gating on m_txStreamId here silently
    // discarded every AX.25 frame on such a radio, the same class of bug that
    // made WSJT-X key the rig and transmit silence (see setHostModulation()
    // and feedDaxTxAudioInternal()).
    //
    // forceRadioDaxRoute is irrelevant on this arm — the host-modulation branch
    // returns before the route choice — but it is passed for symmetry with the
    // WSPR pump, which feeds the same entry point with pre-shaped tones.
    //
    // No PTT gate here: Hl2Backend::submitTxAudio drops audio unless keyed, so
    // the gate lives with the consumer. m_transmitting is decoded from Flex
    // interlock status a host-modulating radio never sends, so testing it would
    // discard everything.
    if (m_hostModulation) {
        feedDaxTxAudioInternal(float32pcm, /*markExternalSource=*/false,
                               /*forceRadioDaxRoute=*/true);
        return;
    }

    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_opusTxPacer.clear();
    }
}

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

    if (tx) {
        // radioTransmittingChanged originates on the UI thread while AudioEngine
        // owns its QAudioSource and health tracker on the audio thread. Preserve
        // the existing immediate atomic TX edge, but sample capture state only
        // on the owning thread so diagnostics cannot race readyRead/stateChanged.
        if (thread() == QThread::currentThread()) {
            recordTxCaptureLocalTxAttempt();
        } else {
            QMetaObject::invokeMethod(this, [this]() {
                recordTxCaptureLocalTxAttempt();
            }, Qt::QueuedConnection);
        }
    }

    // Close the CW-record over on unkey so the next over re-arms cleanly (the
    // pump latches on our keyer, clears here). #2539.
    if (!tx) m_cwKeyedThisOver.store(false, std::memory_order_release);

    // 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)
{
    // The built-in WSPR source owns the DAX TX stream for its one-shot frame.
    // Ignore concurrent external DAX/TCI samples instead of interleaving two
    // unrelated packet producers.
    if (m_wsprBeacon && m_wsprBeacon->isActive()) {
        return;
    }
    feedDaxTxAudioInternal(inPcm, true, false);
}

void AudioEngine::feedDaxTxAudioInternal(const QByteArray& inPcm,
                                         bool markExternalSource,
                                         bool forceRadioDaxRoute)
{
    if (inPcm.isEmpty()) return;
    // A host-modulating backend (HL2) has no Flex TX stream id and never will —
    // its modulator runs here, fed from the final-monitor tap below. Gating this
    // path on the stream id dropped every TCI/DAX frame on such a radio, so
    // WSJT-X keyed the rig and transmitted silence. See setHostModulation().
    if (!m_hostModulation && m_txStreamId == 0) 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.
    if (markExternalSource) {
        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 = static_cast<int>(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);
    }

    // ── Host-modulated backend (HL2): no VITA-49 plane ──────────────────
    // Both routes below packetize for a Flex radio that modulates on its own
    // side. A host-modulating backend has no TX stream and no radio-side
    // modulator; its transmit audio arrives through the SAME final-monitor tap
    // the microphone uses, which MainWindow routes to
    // RadioModel::submitTxAudio() (and to the QSO recorder).
    //
    // Still a DSP bypass, for the reason stated above: this path carries
    // pre-shaped digital tones from TCI/DAX, so no compressor, EQ, Quindar or
    // brickwall limiter runs on it — only the TCI gain/overflow stage the
    // caller already applied.
    //
    // No TX-state gate here. m_radioTransmitting is decoded from Flex interlock
    // status, which a host-modulating backend never sends, so testing it would
    // discard every frame. Both consumers of the signal gate themselves:
    // Hl2Backend::submitTxAudio drops audio unless keyed, and QsoRecorder gates
    // on MOX.
    if (m_hostModulation) {
        const auto* src = reinterpret_cast<const float*>(float32pcm.constData());
        const int samples = static_cast<int>(float32pcm.size() / sizeof(float));
        QByteArray out(samples * static_cast<int>(sizeof(qint16)), Qt::Uninitialized);
        auto* dst = reinterpret_cast<qint16*>(out.data());
        for (int i = 0; i < samples; ++i) {
            const float v = std::isfinite(src[i]) ? src[i] : 0.0f;
            dst[i] = static_cast<qint16>(
                std::clamp(v * 32768.0f, -32768.0f, 32767.0f));
        }
        emit txFinalMonitorPcmReady(out);
        return;
    }

    const bool useRadioDaxRoute = forceRadioDaxRoute || m_daxTxUseRadioRoute;
    if (!useRadioDaxRoute) {
        // 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 (!forceRadioDaxRoute && 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::startWsprPump()
{
    // Suppress the local mic capture path for the whole frame. onTxAudioReady()
    // only bails out on m_daxTxMode; the WSPR feed passes
    // markExternalSource=false (it is not TCI, and claiming so would corrupt
    // the TCI-active diagnostics), so without this the mic path keeps building
    // TX packets that share and advance m_txPacketCount with the WSPR dax_tx
    // packets — two producers interleaving on one UDP path to the radio, with a
    // scrambled packet-count sequence. The PipeWire DAX route happens to hold
    // the mic silent, which is why this only bites on Windows and on Linux
    // without PipeWire. Save/restore mirrors the AX.25 TX path.
    if (!m_wsprSavedDaxTxMode) {
        m_wsprPreviousDaxTxMode = isDaxTxMode();
        m_wsprSavedDaxTxMode = true;
    }
    setDaxTxMode(true);
    m_wsprPumpedFrames = 0;
    m_wsprPumpClock.start();
    m_wsprPumpTimer->start();
}

void AudioEngine::stopWsprPump()
{
    m_wsprPumpTimer->stop();
    m_wsprPumpClock.invalidate();
    m_wsprPumpedFrames = 0;
    m_txFloatAccumulator.clear();
    // The forced WSPR feed buffers in the radio-native int16 route, so drop that
    // residue too — a stop mid-symbol otherwise leaves a partial packet to be
    // prepended to whatever fills the DAX TX stream next.
    m_daxPreTxBuffer.clear();
    // Guarded so the early-return callers in pumpWsprBeacon() (and a queued
    // stop that lands after another one already ran) cannot clobber a genuine
    // DAX TX mode with a stale saved value.
    if (m_wsprSavedDaxTxMode) {
        setDaxTxMode(m_wsprPreviousDaxTxMode);
        m_wsprSavedDaxTxMode = false;
    }
}

void AudioEngine::pumpWsprBeacon()
{
    if (!m_wsprBeacon || !m_wsprBeacon->isActive()
        || !m_wsprPumpClock.isValid()) {
        stopWsprPump();
        return;
    }

    const qint64 targetFrames = WsprBeacon::framesForElapsedNanoseconds(
        m_wsprPumpClock.nsecsElapsed());
    const qint64 dueFrames = targetFrames - m_wsprPumpedFrames;
    if (dueFrames <= 0) {
        return;
    }

    // A worker-thread stall is recoverable. The generator is sample-accurate,
    // so emitting the backlog only runs the radio's DAX buffer ahead of the
    // wall clock — it does not shift symbol timing within the frame. This
    // thread also carries the RX DSP chain, where a >100 ms hiccup (model
    // load under m_dspMutex, device change, load spike) is ordinary, and
    // aborting would cost the operator the whole 111.6 s frame plus a
    // two-minute wait for the next slot. Only give up once the lag exceeds
    // what a WSPR decoder tolerates against the slot boundary (~1 s).
    constexpr qint64 kMaximumRecoverableFrames = WsprBeacon::kSampleRate;
    if (dueFrames > kMaximumRecoverableFrames) {
        qCWarning(lcAudio)
            << "AudioEngine: WSPR pacing deadline missed by"
            << dueFrames << "frames; aborting beacon";
        m_wsprBeacon->stop();
        stopWsprPump();
        return;
    }

    // Drain a backlog over several ticks so one catch-up never bursts more
    // than ~340 ms (half a symbol) of packets at the radio in a single go.
    constexpr qint64 kMaximumCatchUpFrames = WsprBeacon::kFramesPerSymbol / 2;
    const int frames = static_cast<int>(
        std::min(dueFrames, kMaximumCatchUpFrames));
    // Straight to float. This used to generate int16 and divide by 32768 right
    // back into float, which bought nothing and cost an undithered
    // quantization of a pure tone — the one signal for which quantization
    // error is harmonically correlated rather than noise-like.
    m_wsprFloatScratch.resize(
        frames * 2 * static_cast<int>(sizeof(float)));
    // process() leaves the buffer untouched if the beacon was stopped from the
    // GUI thread since the isActive() check above, and QByteArray::resize does
    // not initialize the bytes it adds. Clear first so a stop landing inside
    // that window can never put uninitialized memory on the air.
    m_wsprFloatScratch.fill('\0');
    m_wsprBeacon->process(
        reinterpret_cast<float*>(m_wsprFloatScratch.data()), frames, 2);
    feedDaxTxAudioInternal(m_wsprFloatScratch, false, true);
    m_wsprPumpedFrames += frames;
}

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
