#include "AudioEngine.h" #include "AppSettings.h" #include "AudioSummaryLogger.h" #include "AudioDeviceNegotiator.h" #include "ClientEq.h" #include "ClientComp.h" #include "ClientGate.h" #include "ClientDeEss.h" #include "ClientTube.h" #include "ClientPudu.h" #include "ClientPuduMonitor.h" #include "ClientReverb.h" #include "ClientFinalLimiter.h" #include "ClientTxTestTone.h" #include "ClientQuindarTone.h" #include "QuindarLocalSink.h" #include "CwSidetoneGenerator.h" #include "CwSidetoneQAudioSink.h" #include "CwSidetoneSinkBackend.h" #include "DeviceDiagnostics.h" #ifdef HAVE_PORTAUDIO #include "CwSidetonePortAudioSink.h" #endif #include "LogManager.h" #include "OpusCodec.h" #include "ReceivePresentationSync.h" #include "SpectralNR.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 #include #endif #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace AetherSDR { static QString wisdomDir(); static void logNr2WisdomSummary(const QString& context); static void logNr2WisdomGenerationSummary(SpectralNR::WisdomResult result); static void applyNr2SettingsFromAppSettings(SpectralNR& nr2); namespace { constexpr qint64 kTxAutoRestartMinRuntimeMs = 60000; constexpr qint64 kScopeEmitMinIntervalMs = 25; // ~40 fps, per RX/TX source // The strip's "Waveform CE-SSB" panel renders at higher refresh than // the floating Waveform applet, so its dedicated post-chain emit // path uses a shorter throttle so the widget actually has fresh data // to draw on every frame. ~120 Hz max — emissions over the strip // widget's repaint rate are simply ignored by the panel. constexpr qint64 kTxPostChainEmitMinIntervalMs = 8; // RX strip-panel mirror — same 8 ms throttle so the strip's "Aetherial // Waveform — RX" panel sees one emission per audio callback (no dropped // blocks). The shared scopeSamplesReady throttle stays at 25 ms for // the floating WaveApplet which doesn't need this fidelity. constexpr qint64 kRxPostChainEmitMinIntervalMs = 8; constexpr int kAutomationAudioCaptureMaxDurationMs = 15000; constexpr qsizetype kAutomationAudioCaptureMaxBytes = 64 * 1024 * 1024; qint64 steadyNowNs() { using Clock = std::chrono::steady_clock; return std::chrono::duration_cast( Clock::now().time_since_epoch()) .count(); } bool devicePresent(const QList& 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& packets) { qsizetype total = 0; for (const QByteArray& packet : packets) { total += packet.size(); } return total; } void trimAudioPacketQueue(std::deque& 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(sizeof(float)); return (std::max(0, bytes) / kFrameBytes) * kFrameBytes; } qsizetype audioBytesForMsAtRate(int sampleRate, int ms) { if (sampleRate <= 0 || ms <= 0) { return 0; } return alignedStereoFloatBytes( static_cast(sampleRate) * 2 * static_cast(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(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(requestedFrame, 0, frames); if (requestedFrame <= 0 || requestedFrame >= frames) { return requestedFrame * kFrameBytes; } const qsizetype searchFrames = std::max(1, sampleRate * kTrimSearchMs / 1000); const qsizetype begin = std::max(1, requestedFrame - searchFrames); const qsizetype end = std::min(frames - 1, requestedFrame + searchFrames); const auto* samples = reinterpret_cast(buffer.constData()); qsizetype bestFrame = requestedFrame; double bestScore = std::numeric_limits::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(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(sizeof(float)); constexpr int kTrimFadeMs = 2; const qsizetype frames = alignedStereoFloatBytes(buffer.size()) / kFrameBytes; if (frames <= 0 || sampleRate <= 0) { return; } const qsizetype fadeFrames = std::min( frames, std::max(1, sampleRate * kTrimFadeMs / 1000)); auto* samples = reinterpret_cast(buffer.data()); for (qsizetype frame = 0; frame < fadeFrames; ++frame) { const float gain = static_cast(frame + 1) / static_cast(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& 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( 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(sampleRate) * 2 * static_cast(sizeof(float)); if (bytesPerSecond <= 0) { return 0; } return static_cast( (static_cast(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 std::optional 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 QList 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 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 macAudioDeviceForUid(const QByteArray& uid) { if (uid.isEmpty()) { return std::nullopt; } CFStringRef uidString = CFStringCreateWithBytes(kCFAllocatorDefault, reinterpret_cast(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 macBluetoothNativeInputRate(const QAudioDevice& qtDevice) { const auto deviceId = macAudioDeviceForUid(qtDevice.id()); if (!deviceId) { return std::nullopt; } const auto transport = readMacAudioScalar(*deviceId, kAudioDevicePropertyTransportType); if (!transport || (*transport != kAudioDeviceTransportTypeBluetooth && *transport != kAudioDeviceTransportTypeBluetoothLE)) { return std::nullopt; } bool hasHighRate = false; int exactLowRate = 0; const QList nominalRanges = readMacAudioArray( *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(std::lround(range.mMinimum)); const int maxRate = static_cast(std::lround(range.mMaximum)); if (minRate == maxRate && isMacBluetoothLowRate(minRate)) { exactLowRate = std::max(exactLowRate, minRate); } } if (hasHighRate) { return std::nullopt; } const auto nominalRate = readMacAudioScalar(*deviceId, kAudioDevicePropertyNominalSampleRate); const int roundedNominal = nominalRate ? static_cast(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(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(sizeof(float))); const auto* src = reinterpret_cast(pcm.constData()); auto* dst = reinterpret_cast(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(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(sizeof(float))); const auto* src = reinterpret_cast(pcm.constData()); auto* dst = reinterpret_cast(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(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(sizeof(float))); const auto* src = reinterpret_cast(pcm.constData()); auto* dst = reinterpret_cast(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(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(sizeof(float))); const auto* src = reinterpret_cast(pcm.constData()); auto* dst = reinterpret_cast(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(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(sizeof(float))); const auto* src = reinterpret_cast(pcm.constData()); auto* dst = reinterpret_cast(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(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(sizeof(float))); const auto* src = reinterpret_cast(pcm.constData()); auto* dst = reinterpret_cast(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 (externalKiwiSourceAudible(*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 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, externalKiwiSourceAudible(*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 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 = externalKiwiSourceAudible(*source); } updateRxBufferStats(); } void AudioEngine::resetReceivePresentationAudioBuffersForKiwiSource( const QString& sourceId) { const QString id = sourceId.trimmed(); if (id.isEmpty()) { return; } std::lock_guard 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 && externalKiwiSourceAudible(*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 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(); source->id = id; source->presentationDelayMs = receivePresentationExternalKiwiDelayMs( id, m_externalKiwiReceivePresentationDelaySourceId, m_externalKiwiReceivePresentationDelayMs); source->prebuffering = true; if (m_nr2Enabled.load(std::memory_order_relaxed) && m_kiwiSdrNr2) { source->nr2 = std::make_unique(256, DEFAULT_SAMPLE_RATE); if (source->nr2->hasPlanFailed()) { qCWarning(lcAudio) << "AudioEngine: external Kiwi NR2 plan failed for" << id; source->nr2.reset(); } else { applyNr2SettingsFromAppSettings(*source->nr2); } } m_externalKiwiSources.push_back(std::move(source)); return m_externalKiwiSources.back().get(); } bool AudioEngine::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::externalKiwiSourceAudible( const ExternalRxAudioSourceState& source) const { return source.enabled && !source.muted && !kiwiSdrAudioTransmitMuted(); } bool AudioEngine::anyExternalKiwiAudioEnabled() const { for (const auto& source : m_externalKiwiSources) { if (source && externalKiwiSourceAudible(*source)) { return true; } } return false; } bool AudioEngine::anyExternalKiwiBufferQueued() const { for (const auto& source : m_externalKiwiSources) { if (source && !source->muted && !kiwiSdrAudioTransmitMuted() && (!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 && externalKiwiSourceAudible(*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(48000)) , m_cwRecordSidetone(std::make_unique(DEFAULT_SAMPLE_RATE)) , m_clientEqRx(std::make_unique()) , m_clientEqTx(std::make_unique()) , m_clientCompTx(std::make_unique()) , m_clientCompRx(std::make_unique()) , m_clientGateTx(std::make_unique()) , m_clientGateRx(std::make_unique()) , m_clientDeEssTx(std::make_unique()) , m_clientDeEssRx(std::make_unique()) , m_clientTubeTx(std::make_unique()) , m_clientTubeRx(std::make_unique()) , m_clientPuduTx(std::make_unique()) , m_clientPuduRx(std::make_unique()) , m_clientReverbTx(std::make_unique()) , m_clientFinalLimiterTx(std::make_unique()) , m_clientTxTestTone(std::make_unique()) , m_clientQuindarTone(std::make_unique()) { // Recorder-sidetone generator: always enabled at a fixed, audible level and // centre pan so a Client-Side QSO recording captures the operator's sent // 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(sizeof(float))); auto* out = reinterpret_cast(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(sampleRateHz) / kTargetHz; const int outFrames = static_cast(static_cast(frames) / step); if (outFrames <= 0) return; buf.resize(outFrames * 2 * static_cast(sizeof(float))); auto* out = reinterpret_cast(buf.data()); for (int i = 0; i < outFrames; ++i) { const int srcIdx = static_cast(i * step); const float s = mono[std::min(srcIdx, frames - 1)]; out[2 * i] = s; out[2 * i + 1] = s; } } emit txDecodeAudioReady(buf); }); // Prepare client DSP at the native 24 kHz rate. Sink resampling is // handled separately after EQ — EQ always runs at radio-native rate. m_clientEqRx->prepare(DEFAULT_SAMPLE_RATE); m_clientEqTx->prepare(DEFAULT_SAMPLE_RATE); m_clientCompTx->prepare(DEFAULT_SAMPLE_RATE); m_clientGateTx->prepare(DEFAULT_SAMPLE_RATE); m_clientGateRx->prepare(DEFAULT_SAMPLE_RATE); m_clientCompRx->prepare(DEFAULT_SAMPLE_RATE); m_clientTubeRx->prepare(DEFAULT_SAMPLE_RATE); m_clientPuduRx->prepare(DEFAULT_SAMPLE_RATE); m_clientDeEssTx->prepare(DEFAULT_SAMPLE_RATE); m_clientDeEssRx->prepare(DEFAULT_SAMPLE_RATE); m_clientTubeTx->prepare(DEFAULT_SAMPLE_RATE); m_clientPuduTx->prepare(DEFAULT_SAMPLE_RATE); m_clientReverbTx->prepare(DEFAULT_SAMPLE_RATE); m_clientFinalLimiterTx->prepare(DEFAULT_SAMPLE_RATE); m_clientTxTestTone->prepare(DEFAULT_SAMPLE_RATE); m_clientQuindarTone->prepare(DEFAULT_SAMPLE_RATE); loadClientEqSettings(); // restore persisted bands before first audio loadClientCompSettings(); // restore persisted comp params + chain order loadClientGateSettings(); // restore persisted gate params loadClientGateRxSettings(); // restore persisted RX gate params loadClientCompRxSettings(); // restore persisted RX comp params loadClientTubeRxSettings(); // restore persisted RX tube params loadClientPuduRxSettings(); // restore persisted RX PUDU params loadClientDeEssSettings(); // restore persisted de-esser params loadClientDeEssRxSettings(); // restore persisted RX de-esser params loadClientTubeSettings(); // restore persisted tube params loadClientPuduSettings(); // restore persisted PUDU params loadClientReverbSettings(); // restore persisted reverb params loadClientFinalLimiterSettings(); // restore persisted final-limiter params loadClientQuindarSettings(); // restore persisted Quindar tone params loadClientRxChainOrder(); // restore persisted RX chain order (Phase 0+) loadAetherialTubePreampTxSettings(); // restore TX mic pre-amp toggles (#2813) // Restore saved audio device selections auto& s = AppSettings::instance(); QByteArray savedOutId = s.value("AudioOutputDeviceId", "").toByteArray(); QByteArray savedInId = s.value("AudioInputDeviceId", "").toByteArray(); if (!savedOutId.isEmpty()) { for (const auto& dev : QMediaDevices::audioOutputs()) { if (dev.id() == savedOutId) { m_outputDevice = dev; break; } } } if (!savedInId.isEmpty()) { for (const auto& dev : QMediaDevices::audioInputs()) { if (dev.id() == savedInId) { m_inputDevice = dev; break; } } } AudioSummaryLogger::logStartupEnvironment( DeviceDiagnostics::buildAudioStartupSnapshot(this, QJsonObject{})); logNr2WisdomSummary(QStringLiteral("startup")); // Opus TX pacing timer — sends one queued packet every 10ms for even // delivery timing. Without this, QAudioSource delivers bursts of samples // that get Opus-encoded and sent back-to-back, causing jitter-induced // crackling on SmartLink/WAN connections. m_opusTxPaceTimer = new QTimer(this); m_opusTxPaceTimer->setTimerType(Qt::PreciseTimer); m_opusTxPaceTimer->setInterval(10); connect(m_opusTxPaceTimer, &QTimer::timeout, this, [this]() { if (m_opusTxQueue.isEmpty()) return; emit txPacketReady(m_opusTxQueue.takeFirst()); }); m_opusTxPaceTimer->start(); // RX pacing timer -- processes source queues through their RX DSP paths // and drains speaker-ready output into QAudioSink at regular intervals. // Includes latency management: caps buffer at ~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 && externalKiwiSourceAudible(*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(sizeof(float)) * effectiveBufMs / 1000; const qsizetype outputMaxBufBytes = sampleRate * 2 * static_cast(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 && externalKiwiSourceAudible(*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(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(sizeof(float)) * flexPresentationDelayMs / 1000; const qsizetype kiwiPresentationDelayBytes = DEFAULT_SAMPLE_RATE * 2 * static_cast(sizeof(float)) * kiwiPresentationDelayMs / 1000; const auto externalKiwiPresentationDelayBytes = [](const ExternalRxAudioSourceState& source) { return DEFAULT_SAMPLE_RATE * 2 * static_cast(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 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 || !externalKiwiSourceAudible(*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(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 || !externalKiwiSourceAudible(*source) || !source->prebuffering) { continue; } const int prebufferMs = std::min( std::max(kKiwiSdrJitterTargetMs, source->presentationDelayMs), effectiveBufMs); const qsizetype prebufferBytes = DEFAULT_SAMPLE_RATE * 2 * static_cast(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 || !externalKiwiSourceAudible(*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( 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(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 || !externalKiwiSourceAudible(*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( 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; auto emitOutputSource = [this, sampleRate]( const QString& source, const QString& sourceId, const QByteArray& pcm) { if (!pcm.isEmpty()) { captureAutomationAudio(QStringLiteral("output"), source, sourceId, pcm, sampleRate, 2); 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()); emitOutputSource(QStringLiteral("flex"), QString(), chunk); } 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); } 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(chunk.data()); int activeOutputSources = 0; constexpr float kOutputSilenceThreshold = 1.0e-6f; const qsizetype rxTake = (std::min(len, m_rxOutputBuffer.size()) / floatBytes) * floatBytes; if (rxTake > 0) { const QByteArray rxChunk = m_rxOutputBuffer.left(rxTake); const auto* rx = reinterpret_cast(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); } const qsizetype kiwiTake = (std::min(len, kiwiOutputBytes) / floatBytes) * floatBytes; if (kiwiTake > 0) { const QByteArray kiwiChunk = m_kiwiSdrOutputBuffer.left(kiwiTake); const auto* kiwi = reinterpret_cast(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); } for (const auto& source : m_externalKiwiSources) { if (!source || !externalKiwiSourceAudible(*source) || source->prebuffering) { continue; } const qsizetype sourceTake = (std::min(len, source->outputBuffer.size()) / floatBytes) * floatBytes; if (sourceTake <= 0) { continue; } QByteArray sourceChunk = source->outputBuffer.left(sourceTake); const auto* kiwi = reinterpret_cast(sourceChunk.constData()); auto* capturedKiwi = reinterpret_cast(sourceChunk.data()); const qsizetype kiwiSamples = sourceTake / floatBytes; bool sourceActive = false; for (qsizetype i = 0; i < kiwiSamples; ++i) { const float sample = kiwi[i] * source->gain; sourceActive = sourceActive || std::fabs(sample) > kOutputSilenceThreshold; out[i] += sample; capturedKiwi[i] = sample; } if (sourceActive) { ++activeOutputSources; } source->outputBuffer.remove(0, sourceTake); emitOutputSource(QStringLiteral("kiwi"), source->id, sourceChunk); } const qsizetype radeTake = (std::min(len, m_radeRxBuffer.size()) / floatBytes) * floatBytes; if (radeTake > 0) { const auto* rade = reinterpret_cast(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(activeOutputSources) : 1.0f; for (qsizetype i = 0; i < totalSamples; ++i) { out[i] = std::clamp(out[i] * mixGain, -1.0f, 1.0f); } } len = m_audioDevice->write(chunk); if (len > 0) { const qsizetype capturedBytes = alignedStereoFloatBytes( std::min(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(0), std::max(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() { stopRxStream(); stopTxStream(); } QAudioFormat AudioEngine::makeFormat() const { QAudioFormat fmt; fmt.setSampleRate(DEFAULT_SAMPLE_RATE); fmt.setChannelCount(2); // stereo fmt.setSampleFormat(QAudioFormat::Float); return fmt; } QJsonArray AudioEngine::audioEndpointDiagnostics() const { const auto outputDescription = [this]() { const QAudioDevice dev = m_outputDevice.isNull() ? QMediaDevices::defaultAudioOutput() : m_outputDevice; return dev.isNull() ? QStringLiteral("Unavailable") : dev.description(); }; const auto inputDescription = [this]() { const QAudioDevice dev = m_inputDevice.isNull() ? QMediaDevices::defaultAudioInput() : m_inputDevice; return dev.isNull() ? QStringLiteral("Unavailable") : dev.description(); }; QJsonArray endpoints; const bool rxRunning = m_audioSink != nullptr; const bool rxDeviceOpen = !m_audioDevice.isNull() && m_audioDevice->isOpen(); QJsonObject rx; rx["name"] = QStringLiteral("RX output"); rx["direction"] = QStringLiteral("rx"); rx["kind"] = QStringLiteral("sink"); rx["backend"] = QStringLiteral("QAudioSink"); rx["device"] = outputDescription(); rx["running"] = rxRunning; rx["operational"] = rxRunning && rxDeviceOpen; rx["device_open"] = rxDeviceOpen; rx["state"] = rxRunning ? audioStateName(m_audioSink->state()) : QStringLiteral("Stopped"); rx["error"] = rxRunning ? audioErrorName(m_audioSink->error()) : QStringLiteral("NoError"); rx["sample_rate_hz"] = rxRunning ? QJsonValue(m_rxBufferSampleRate.load()) : QJsonValue(); rx["channel_count"] = rxRunning ? QJsonValue(2) : QJsonValue(); rx["sample_format"] = rxRunning ? QStringLiteral("Float") : QString(); rx["resampling_active"] = rxRunning ? QJsonValue(m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE) : QJsonValue(); rx["buffer_bytes"] = static_cast(m_rxBufferBytes.load()); rx["buffer_peak_bytes"] = static_cast(m_rxBufferPeakBytes.load()); rx["underrun_count"] = static_cast(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(); 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 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(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(m_automationCaptureMaxBytes)}, }; } QJsonObject AudioEngine::stopAutomationAudioCapture() { m_automationAudioCaptureActive.store(false, std::memory_order_relaxed); return automationAudioCaptureSnapshot(false); } QJsonObject AudioEngine::automationAudioCaptureSnapshot(bool includePcm) const { const qint64 nowNs = steadyNowNs(); QVector 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 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(chunk.startNs)}, {QStringLiteral("bytes"), chunk.pcm.size()}, {QStringLiteral("frames"), chunk.channels > 0 ? chunk.pcm.size() / (chunk.channels * static_cast(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((nowNs - captureStartNs) / 1000000) : 0.0}, {QStringLiteral("capturedBytes"), static_cast(captureBytes)}, {QStringLiteral("maxBytes"), static_cast(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 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(sizeof(float)); const qsizetype alignedBytes = (std::max(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 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(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(m_rxResampler->dstRate()) != m_rxOutputRate.load()) { m_rxResampler.reset(); m_rxResamplerR.reset(); } if (m_radeRxResampler && static_cast(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 makeSidetoneBackend(QObject* qparent) { const QString pref = AppSettings::instance().value("CwSidetoneBackend", "PortAudio").toString(); #ifdef HAVE_PORTAUDIO if (pref != "QAudioSink") { return std::unique_ptr( new CwSidetonePortAudioSink()); } #endif return std::unique_ptr( 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(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& leftResampler = externalSource ? externalSource->rxResampler : (source == RxDspSource::KiwiSdr ? m_kiwiSdrRxResampler : m_rxResampler); std::unique_ptr& rightResampler = externalSource ? externalSource->rxResamplerR : (source == RxDspSource::KiwiSdr ? m_kiwiSdrRxResamplerR : m_rxResamplerR); if (!leftResampler) { leftResampler = std::make_unique(24000, m_rxOutputRate.load()); } if (!rightResampler) { rightResampler = std::make_unique(24000, m_rxOutputRate.load()); } const int frames = pcm.size() / (2 * static_cast(sizeof(float))); if (frames <= 0) return {}; const auto* src = reinterpret_cast(pcm.constData()); std::vector 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(sizeof(float)); const int rFrames = rOut.size() / static_cast(sizeof(float)); const int commonFrames = std::min(outFrames, rFrames); if (commonFrames <= 0) return {}; QByteArray result(commonFrames * 2 * static_cast(sizeof(float)), Qt::Uninitialized); auto* dst = reinterpret_cast(result.data()); const auto* lSrc = reinterpret_cast(lOut.constData()); const auto* rSrc = reinterpret_cast(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(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 dspLock(m_dspMutex); m_kiwiSdrRxPackets.push_back(alignedPcm); updateRxBufferStats(); return; } processRxAudioData(alignedPcm, false, RxAudioBuffer::KiwiSdr); } void AudioEngine::feedKiwiSdrAudioData(const QString& sourceId, const QByteArray& pcm24kStereoFloat) { ExternalRxAudioSourceState* source = externalKiwiSource(sourceId, true); if (!source || !source->enabled) { return; } m_lastAudioFeedTime.start(); if (source->muted || kiwiSdrAudioTransmitMuted()) { return; } constexpr qsizetype kFrameBytes = 2 * static_cast(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); if (m_nr2Enabled.load(std::memory_order_relaxed)) { std::lock_guard dspLock(m_dspMutex); source->rxPackets.push_back(alignedPcm); updateRxBufferStats(); return; } source->rxBuffer.append(alignedPcm); updateRxBufferStats(); } void AudioEngine::setKiwiSdrAudioEnabled(bool on) { if (m_kiwiSdrAudioEnabled.exchange(on, std::memory_order_relaxed) == on) { return; } std::lock_guard dspLock(m_dspMutex); m_kiwiSdrRxBuffer.clear(); m_kiwiSdrRxPackets.clear(); m_kiwiSdrOutputBuffer.clear(); m_kiwiSdrNr2Mono.clear(); m_kiwiSdrNr2Processed.clear(); m_kiwiSdrNr2Output.clear(); m_kiwiSdrRxResampler.reset(); m_kiwiSdrRxResamplerR.reset(); if (m_nr2Enabled && m_kiwiSdrNr2) { m_kiwiSdrNr2->reset(); } m_kiwiSdrPrebuffering.store(on && !kiwiSdrAudioTransmitMuted(), std::memory_order_relaxed); updateRxBufferStats(); } void AudioEngine::setKiwiSdrAudioSourceEnabled(const QString& sourceId, bool on) { std::lock_guard dspLock(m_dspMutex); ExternalRxAudioSourceState* source = externalKiwiSource(sourceId, on); if (!source || source->enabled == on) { return; } source->enabled = on; qCDebug(lcKiwiSdrAudio).noquote() << "Audio source" << (on ? "enabled" : "disabled") << source->id; source->rxBuffer.clear(); source->rxPackets.clear(); source->outputBuffer.clear(); source->nr2Mono.clear(); source->nr2Processed.clear(); source->nr2Output.clear(); source->rxResampler.reset(); source->rxResamplerR.reset(); if (on && m_nr2Enabled.load(std::memory_order_relaxed) && !source->nr2) { source->nr2 = std::make_unique(256, DEFAULT_SAMPLE_RATE); if (source->nr2->hasPlanFailed()) { qCWarning(lcAudio) << "AudioEngine: external Kiwi NR2 plan failed for" << source->id; source->nr2.reset(); } else { applyNr2SettingsFromAppSettings(*source->nr2); } } source->prebuffering = on; updateRxBufferStats(); } void AudioEngine::setKiwiSdrAudioSourceGain(const QString& sourceId, float gainPercent) { std::lock_guard 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 dspLock(m_dspMutex); ExternalRxAudioSourceState* source = externalKiwiSource(sourceId, true); if (!source || source->muted == muted) { return; } source->muted = muted; source->rxBuffer.clear(); source->rxPackets.clear(); source->outputBuffer.clear(); source->nr2Mono.clear(); source->nr2Processed.clear(); source->nr2Output.clear(); source->prebuffering = !muted && source->enabled && !kiwiSdrAudioTransmitMuted(); updateRxBufferStats(); } void AudioEngine::setKiwiSdrAudioTransmitMuted(bool muted) { if (m_kiwiSdrAudioTransmitMuted.exchange( muted, std::memory_order_relaxed) == muted) { return; } std::lock_guard dspLock(m_dspMutex); m_kiwiSdrRxBuffer.clear(); m_kiwiSdrRxPackets.clear(); m_kiwiSdrOutputBuffer.clear(); m_kiwiSdrNr2Mono.clear(); m_kiwiSdrNr2Processed.clear(); m_kiwiSdrNr2Output.clear(); if (m_nr2Enabled && m_kiwiSdrNr2) { m_kiwiSdrNr2->reset(); } m_kiwiSdrPrebuffering.store( !muted && m_kiwiSdrAudioEnabled.load(std::memory_order_relaxed), std::memory_order_relaxed); for (const auto& source : m_externalKiwiSources) { if (!source) { continue; } source->rxBuffer.clear(); source->rxPackets.clear(); source->outputBuffer.clear(); source->nr2Mono.clear(); source->nr2Processed.clear(); source->nr2Output.clear(); if (m_nr2Enabled && source->nr2) { source->nr2->reset(); } source->prebuffering = !muted && source->enabled && !source->muted; } updateRxBufferStats(); } void AudioEngine::setKiwiSdrAudioSourcePan(const QString& sourceId, int pan) { std::lock_guard dspLock(m_dspMutex); ExternalRxAudioSourceState* source = externalKiwiSource(sourceId, true); if (!source) { return; } source->pan = qBound(0, pan, 100); } void AudioEngine::removeKiwiSdrAudioSource(const QString& sourceId) { const QString id = sourceId.trimmed(); if (id.isEmpty()) { return; } std::lock_guard dspLock(m_dspMutex); const auto it = std::remove_if( m_externalKiwiSources.begin(), m_externalKiwiSources.end(), [&id](const std::unique_ptr& 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 dspLock(m_dspMutex); m_rxResampler.reset(); m_rxResamplerR.reset(); m_rxPackets.clear(); m_kiwiSdrRxResampler.reset(); m_kiwiSdrRxResamplerR.reset(); m_clientEqRxScratch.clear(); m_clientGateRxScratch.clear(); m_clientCompRxScratch.clear(); m_clientDeEssRxScratch.clear(); m_clientTubeRxScratch.clear(); m_clientPuduRxScratch.clear(); m_nr2Mono.clear(); m_nr2Processed.clear(); m_nr2Output.clear(); m_kiwiSdrNr2Mono.clear(); m_kiwiSdrNr2Processed.clear(); m_kiwiSdrNr2Output.clear(); for (const auto& source : m_externalKiwiSources) { if (!source) { continue; } source->rxBuffer.clear(); source->rxPackets.clear(); source->outputBuffer.clear(); source->nr2Mono.clear(); source->nr2Processed.clear(); source->nr2Output.clear(); source->rxResampler.reset(); source->rxResamplerR.reset(); if (m_nr2Enabled && source->nr2) { source->nr2->reset(); } source->prebuffering = externalKiwiSourceAudible(*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(); } #ifdef HAVE_SPECBLEACH if (m_nr4Enabled && m_nr4) { m_nr4->reset(); } #endif #ifdef HAVE_DFNR if (m_dfnrEnabled && m_dfnr) { m_dfnr->reset(); } #endif #ifdef __APPLE__ if (m_mnrEnabled && m_mnr) { m_mnr->reset(); } #endif #ifdef HAVE_NVIDIA_AFX if (m_nvAfxEnabled && m_nvAfx) { m_nvAfx->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(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 dspLock(m_dspMutex); m_rxPackets.push_back(alignedPcm); updateRxBufferStats(); return; } QByteArray& target = targetBuffer == RxAudioBuffer::KiwiSdr ? m_kiwiSdrRxBuffer : m_rxBuffer; target.append(alignedPcm); updateRxBufferStats(); } void AudioEngine::processMixedRxAudioData(const QByteArray& pcm, RxDspSource source, ExternalRxAudioSourceState* externalSource) { if (!m_audioSink) return; // PC audio disabled const auto sourcePan = [this, externalSource]() { return externalSource ? externalSource->pan : m_rxPan.load(); }; // feedAudioData() handles all remote_audio_rx paths: SSB/CW/digital on any // pan, and the zero-filled frames the radio sends for muted slices // (audio_mute=1 zeroes the payload; it does NOT suppress packets). // The caller supplies exactly one native 24 kHz stereo source stream: // Flex audio, the legacy Kiwi stream, or one virtual Kiwi antenna stream. // Stateful NR/output resamplers must never see alternating Flex/Kiwi or // different Kiwi endpoints on the same DSP state. auto writeAudio = [this, source, externalSource, sourcePan]( const QByteArray& data, bool applyOutputPan = false) { if (!m_audioDevice || !m_audioDevice->isOpen()) return; // Client-side parametric EQ runs at the native 24 kHz rate, after // any NR chain, before resample-to-48k and soft boost. Copy-then- // process because the caller owns `data`. Skip when disabled or // during TX (matches the NR-chain TX bypass policy). const QByteArray* eqSource = &data; if (m_clientEqRx && m_clientEqRx->isEnabled() && !m_radioTransmitting) { m_clientEqRxScratch = data; const int frames = m_clientEqRxScratch.size() / (2 * static_cast(sizeof(float))); m_clientEqRx->process( reinterpret_cast(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(sizeof(float))); if (tapFrames > 0) { tapClientEqRxStereo( reinterpret_cast(eqSource->constData()), tapFrames); } // RX chain stage: GATE — runs after EQ, in place on a scratch // buffer so the EQ tap above sees the post-EQ / pre-gate signal // (matches the user's signal-flow expectation). Skip during TX // for the same reason as EQ. const QByteArray* gateSource = eqSource; if (m_clientGateRx && m_clientGateRx->isEnabled() && !m_radioTransmitting) { m_clientGateRxScratch = *eqSource; applyClientGateRxFloat32(m_clientGateRxScratch); gateSource = &m_clientGateRxScratch; } // RX chain stage: COMP — runs after GATE. Same scratch-copy // pattern. const QByteArray* compSource = gateSource; if (m_clientCompRx && m_clientCompRx->isEnabled() && !m_radioTransmitting) { m_clientCompRxScratch = *gateSource; applyClientCompRxFloat32(m_clientCompRxScratch); compSource = &m_clientCompRxScratch; } // RX chain stage: DESS — runs after COMP, before TUBE. Same // scratch-copy pattern as the surrounding stages. const QByteArray* deEssSource = compSource; if (m_clientDeEssRx && m_clientDeEssRx->isEnabled() && !m_radioTransmitting) { m_clientDeEssRxScratch = *compSource; applyClientDeEssRxFloat32(m_clientDeEssRxScratch); deEssSource = &m_clientDeEssRxScratch; } // RX chain stage: TUBE — runs after DESS. const QByteArray* tubeSource = deEssSource; if (m_clientTubeRx && m_clientTubeRx->isEnabled() && !m_radioTransmitting) { m_clientTubeRxScratch = *deEssSource; applyClientTubeRxFloat32(m_clientTubeRxScratch); tubeSource = &m_clientTubeRxScratch; } // RX chain stage: PUDU — runs after TUBE. const QByteArray* puduSource = tubeSource; if (m_clientPuduRx && m_clientPuduRx->isEnabled() && !m_radioTransmitting) { m_clientPuduRxScratch = *tubeSource; applyClientPuduRxFloat32(m_clientPuduRxScratch); puduSource = &m_clientPuduRxScratch; } const int scopeSampleRate = m_rxOutputRate.load(); const QByteArray& resampled = (m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE) ? resampleStereo(*puduSource, source, externalSource) : *puduSource; const QByteArray* output = &resampled; QByteArray boosted; if (m_rxBoost.load()) { // Soft-knee boost — increases perceived loudness without hard clipping. // Uses tanh compression: loud signals are gently limited while quiet // signals get ~2x gain. tanh(2*x) ≈ 2*x for small x, ≈ 1.0 for large x. boosted.resize(resampled.size()); const auto* src = reinterpret_cast(resampled.constData()); auto* dst = reinterpret_cast(boosted.data()); const int nSamples = resampled.size() / static_cast(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(output->constData()); auto* dst = reinterpret_cast(trimmed.data()); const int nSamples = output->size() / static_cast(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(panned.data()), panned.size() / (2 * static_cast(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); emit receivePresentationPostDspAudioReady( source == RxDspSource::KiwiSdr ? QStringLiteral("kiwi") : QStringLiteral("flex"), externalSource ? externalSource->id : QString(), *output, scopeSampleRate); outputBuffer.append(*output); emitScopeFromFloat32Stereo(*output, scopeSampleRate, false); emitRxPostChainScopeFromFloat32Stereo(*output, scopeSampleRate); updateRxBufferStats(); }; const auto writeAudioAndLevel = [this, externalSource, &writeAudio]( const QByteArray& data) { writeAudio(data, externalSource != nullptr); emit levelChanged(computeRMS(data)); }; // Bypass client-side DSP during TX (#367, #1505). NR2/RN2/BNR adapt // their internal state to silence during TX, causing distorted audio // after returning to RX. Use m_radioTransmitting (raw interlock state) // so bypass kicks in even when an external app triggers PTT. // DSP mutex: prevents use-after-free if enable/disable runs concurrently (#502) { std::lock_guard dspLock(m_dspMutex); if (m_radioTransmitting) { writeAudioAndLevel(pcm); } else if (m_rn2Enabled && m_rn2) { QByteArray processed = m_rn2->process(pcm); // Re-apply pan lost during NR mono-mix (#1460) applyRxPanInPlace(reinterpret_cast(processed.data()), processed.size() / (2 * static_cast(sizeof(float))), sourcePan()); writeAudio(processed); emit levelChanged(computeRMS(processed)); } else if (m_nr2Enabled && m_nr2) { processNr2(pcm, source, externalSource); // applyRxPanInPlace called inside processNr2 const QByteArray& nr2Output = externalSource ? externalSource->nr2Output : (source == RxDspSource::KiwiSdr ? m_kiwiSdrNr2Output : m_nr2Output); writeAudio(nr2Output); emit levelChanged(computeRMS(nr2Output)); #ifdef HAVE_SPECBLEACH } else if (m_nr4Enabled && m_nr4) { QByteArray processed = m_nr4->process(pcm); // Re-apply pan lost during NR mono-mix (#1460) applyRxPanInPlace(reinterpret_cast(processed.data()), processed.size() / (2 * static_cast(sizeof(float))), sourcePan()); writeAudio(processed); emit levelChanged(computeRMS(processed)); #endif #ifdef HAVE_DFNR } else if (m_dfnrEnabled && m_dfnr) { QByteArray processed = m_dfnr->process(pcm); // Re-apply pan lost during NR mono-mix (#1460) applyRxPanInPlace(reinterpret_cast(processed.data()), processed.size() / (2 * static_cast(sizeof(float))), sourcePan()); writeAudio(processed); emit levelChanged(computeRMS(processed)); #endif #ifdef HAVE_NVIDIA_AFX } else if (m_nvAfxEnabled && m_nvAfx) { QByteArray processed = m_nvAfx->process(pcm); // Re-apply pan lost during NR mono-mix (#1460) applyRxPanInPlace(reinterpret_cast(processed.data()), processed.size() / (2 * static_cast(sizeof(float))), sourcePan()); writeAudio(processed); emit levelChanged(computeRMS(processed)); #endif #ifdef __APPLE__ } else if (m_mnrEnabled && m_mnr) { QByteArray processed = m_mnr->process(pcm); // Re-apply pan lost during NR mono-mix (#1460) applyRxPanInPlace(reinterpret_cast(processed.data()), processed.size() / (2 * static_cast(sizeof(float))), sourcePan()); writeAudio(processed); emit levelChanged(computeRMS(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(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( 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(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(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 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 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 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 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 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(sizeof(int16_t)); if ((samples & 1) != 0) return; // must be stereo const int frames = samples / 2; // EQ processing only when enabled. The tap below runs regardless // so the editor's TX FFT analyzer always reflects live mic input, // even when the EQ stage is bypassed in the CHAIN widget. if (m_clientEqTx && m_clientEqTx->isEnabled()) { m_clientEqTxScratch.resize(samples * static_cast(sizeof(float))); auto* f32 = reinterpret_cast(m_clientEqTxScratch.data()); const auto* i16 = reinterpret_cast(int16stereo.constData()); for (int i = 0; i < samples; ++i) { f32[i] = i16[i] / 32768.0f; } m_clientEqTx->process(f32, frames, 2); auto* out = reinterpret_cast(int16stereo.data()); for (int i = 0; i < samples; ++i) { out[i] = static_cast(std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); } } // Always tap — bypassed-EQ case means tap captures pre-EQ samples // (which equal post-EQ samples since no processing happened). tapClientEqTxInt16(reinterpret_cast(int16stereo.constData()), frames); } void AudioEngine::applyClientEqTxFloat32(QByteArray& float32) { if (float32.isEmpty()) return; const int samples = float32.size() / static_cast(sizeof(float)); // feedDaxTxAudio can deliver mono OR stereo float32 (depends on packet // class). Treat even sample counts as stereo, odd counts as mono. const int channels = (samples % 2 == 0) ? 2 : 1; const int frames = samples / channels; if (m_clientEqTx && m_clientEqTx->isEnabled()) { m_clientEqTx->process(reinterpret_cast(float32.data()), frames, channels); } // Always tap so the editor's TX FFT analyzer reflects live audio // even when the EQ stage is bypassed in the CHAIN widget. tapClientEqTxFloat32(reinterpret_cast(float32.constData()), samples, channels); } void AudioEngine::applyClientCompTxInt16(QByteArray& int16stereo) { if (!m_clientCompTx) return; const bool compOn = m_clientCompTx->isEnabled(); const bool driveOn = m_clientCompTx->driveDb() > 0.0f; const bool phaseOn = m_clientCompTx->phaseRotatorStages() > 0; const bool limOn = m_clientCompTx->limiterEnabled(); if (!compOn && !driveOn && !phaseOn && !limOn) return; if (int16stereo.isEmpty()) return; const int samples = int16stereo.size() / static_cast(sizeof(int16_t)); if ((samples & 1) != 0) return; const int frames = samples / 2; m_clientCompTxScratch.resize(samples * static_cast(sizeof(float))); auto* f32 = reinterpret_cast(m_clientCompTxScratch.data()); const auto* i16 = reinterpret_cast(int16stereo.constData()); for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; m_clientCompTx->process(f32, frames, 2); auto* out = reinterpret_cast(int16stereo.data()); for (int i = 0; i < samples; ++i) { out[i] = static_cast( std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); } } void AudioEngine::applyClientCompTxFloat32(QByteArray& float32) { if (!m_clientCompTx) return; // Drive and Phase (#2887) and the brickwall limiter inside the comp // are useful even when the comp curve itself is bypassed, so the // dispatch only short-circuits when none of the four sub-stages // need to run. const bool compOn = m_clientCompTx->isEnabled(); const bool driveOn = m_clientCompTx->driveDb() > 0.0f; const bool phaseOn = m_clientCompTx->phaseRotatorStages() > 0; const bool limOn = m_clientCompTx->limiterEnabled(); if (!compOn && !driveOn && !phaseOn && !limOn) return; if (float32.isEmpty()) return; const int samples = float32.size() / static_cast(sizeof(float)); const int channels = (samples % 2 == 0) ? 2 : 1; const int frames = samples / channels; m_clientCompTx->process(reinterpret_cast(float32.data()), frames, channels); } void AudioEngine::applyClientCompRxFloat32(QByteArray& float32) { if (!m_clientCompRx || !m_clientCompRx->isEnabled()) return; if (float32.isEmpty()) return; const int samples = float32.size() / static_cast(sizeof(float)); if ((samples & 1) != 0) return; const int frames = samples / 2; m_clientCompRx->process(reinterpret_cast(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(sizeof(int16_t)); if ((samples & 1) != 0) return; const int frames = samples / 2; m_clientGateTxScratch.resize(samples * static_cast(sizeof(float))); auto* f32 = reinterpret_cast(m_clientGateTxScratch.data()); const auto* i16 = reinterpret_cast(int16stereo.constData()); for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; m_clientGateTx->process(f32, frames, 2); auto* out = reinterpret_cast(int16stereo.data()); for (int i = 0; i < samples; ++i) { out[i] = static_cast( std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); } } void AudioEngine::applyClientGateTxFloat32(QByteArray& float32) { if (!m_clientGateTx || !m_clientGateTx->isEnabled()) return; if (float32.isEmpty()) return; const int samples = float32.size() / static_cast(sizeof(float)); const int channels = (samples % 2 == 0) ? 2 : 1; const int frames = samples / channels; m_clientGateTx->process(reinterpret_cast(float32.data()), frames, channels); } void AudioEngine::applyClientGateRxFloat32(QByteArray& float32) { if (!m_clientGateRx || !m_clientGateRx->isEnabled()) return; if (float32.isEmpty()) return; const int samples = float32.size() / static_cast(sizeof(float)); if ((samples & 1) != 0) return; const int frames = samples / 2; // RX path is always stereo m_clientGateRx->process(reinterpret_cast(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(sizeof(int16_t)); if ((samples & 1) != 0) return; const int frames = samples / 2; m_clientDeEssTxScratch.resize(samples * static_cast(sizeof(float))); auto* f32 = reinterpret_cast(m_clientDeEssTxScratch.data()); const auto* i16 = reinterpret_cast(int16stereo.constData()); for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; m_clientDeEssTx->process(f32, frames, 2); auto* out = reinterpret_cast(int16stereo.data()); for (int i = 0; i < samples; ++i) { out[i] = static_cast( std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); } } void AudioEngine::applyClientDeEssRxFloat32(QByteArray& float32) { if (!m_clientDeEssRx || !m_clientDeEssRx->isEnabled()) return; const int frames = float32.size() / static_cast(sizeof(float)) / 2; if (frames <= 0) return; m_clientDeEssRx->process(reinterpret_cast(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(sizeof(float)); const int channels = (samples % 2 == 0) ? 2 : 1; const int frames = samples / channels; m_clientDeEssTx->process(reinterpret_cast(float32.data()), frames, channels); } void AudioEngine::applyClientTubeTxInt16(QByteArray& int16stereo) { if (!m_clientTubeTx || !m_clientTubeTx->isEnabled()) return; if (int16stereo.isEmpty()) return; const int samples = int16stereo.size() / static_cast(sizeof(int16_t)); if ((samples & 1) != 0) return; const int frames = samples / 2; m_clientTubeTxScratch.resize(samples * static_cast(sizeof(float))); auto* f32 = reinterpret_cast(m_clientTubeTxScratch.data()); const auto* i16 = reinterpret_cast(int16stereo.constData()); for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; m_clientTubeTx->process(f32, frames, 2); auto* out = reinterpret_cast(int16stereo.data()); for (int i = 0; i < samples; ++i) { out[i] = static_cast( std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); } } void AudioEngine::applyClientTubeTxFloat32(QByteArray& float32) { if (!m_clientTubeTx || !m_clientTubeTx->isEnabled()) return; if (float32.isEmpty()) return; const int samples = float32.size() / static_cast(sizeof(float)); const int channels = (samples % 2 == 0) ? 2 : 1; const int frames = samples / channels; m_clientTubeTx->process(reinterpret_cast(float32.data()), frames, channels); } void AudioEngine::applyClientTubeRxFloat32(QByteArray& float32) { if (!m_clientTubeRx || !m_clientTubeRx->isEnabled()) return; if (float32.isEmpty()) return; const int samples = float32.size() / static_cast(sizeof(float)); if ((samples & 1) != 0) return; const int frames = samples / 2; m_clientTubeRx->process(reinterpret_cast(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(sizeof(int16_t)); if ((samples & 1) != 0) return; const int frames = samples / 2; m_clientPuduTxScratch.resize(samples * static_cast(sizeof(float))); auto* f32 = reinterpret_cast(m_clientPuduTxScratch.data()); const auto* i16 = reinterpret_cast(int16stereo.constData()); for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; m_clientPuduTx->process(f32, frames, 2); auto* out = reinterpret_cast(int16stereo.data()); for (int i = 0; i < samples; ++i) { out[i] = static_cast( std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); } } void AudioEngine::applyClientPuduTxFloat32(QByteArray& float32) { if (!m_clientPuduTx || !m_clientPuduTx->isEnabled()) return; if (float32.isEmpty()) return; const int samples = float32.size() / static_cast(sizeof(float)); const int channels = (samples % 2 == 0) ? 2 : 1; const int frames = samples / channels; m_clientPuduTx->process(reinterpret_cast(float32.data()), frames, channels); } void AudioEngine::applyClientPuduRxFloat32(QByteArray& float32) { if (!m_clientPuduRx || !m_clientPuduRx->isEnabled()) return; if (float32.isEmpty()) return; const int samples = float32.size() / static_cast(sizeof(float)); if ((samples & 1) != 0) return; const int frames = samples / 2; m_clientPuduRx->process(reinterpret_cast(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(sizeof(int16_t)); if ((samples & 1) != 0) return; const int frames = samples / 2; m_clientReverbTxScratch.resize(samples * static_cast(sizeof(float))); auto* f32 = reinterpret_cast(m_clientReverbTxScratch.data()); const auto* i16 = reinterpret_cast(int16stereo.constData()); for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; m_clientReverbTx->process(f32, frames, 2); auto* out = reinterpret_cast(int16stereo.data()); for (int i = 0; i < samples; ++i) { out[i] = static_cast( std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); } } void AudioEngine::applyClientFinalLimiterTxInt16(QByteArray& int16stereo) { if (!m_clientFinalLimiterTx) return; if (int16stereo.isEmpty()) return; const int samples = int16stereo.size() / static_cast(sizeof(int16_t)); if ((samples & 1) != 0) return; const int frames = samples / 2; m_clientFinalLimiterTxScratch.resize(samples * static_cast(sizeof(float))); auto* f32 = reinterpret_cast(m_clientFinalLimiterTxScratch.data()); const auto* i16 = reinterpret_cast(int16stereo.constData()); for (int i = 0; i < samples; ++i) f32[i] = i16[i] / 32768.0f; m_clientFinalLimiterTx->process(f32, frames, 2); auto* out = reinterpret_cast(int16stereo.data()); for (int i = 0; i < samples; ++i) { out[i] = static_cast( std::clamp(f32[i] * 32768.0f, -32768.0f, 32767.0f)); } } void AudioEngine::applyClientFinalLimiterTxFloat32(QByteArray& float32) { if (!m_clientFinalLimiterTx) return; if (float32.isEmpty()) return; const int samples = float32.size() / static_cast(sizeof(float)); const int channels = (samples % 2 == 0) ? 2 : 1; const int frames = samples / channels; m_clientFinalLimiterTx->process(reinterpret_cast(float32.data()), frames, channels); } void AudioEngine::applyClientReverbTxFloat32(QByteArray& float32) { if (!m_clientReverbTx || !m_clientReverbTx->isEnabled()) return; if (float32.isEmpty()) return; const int samples = float32.size() / static_cast(sizeof(float)); const int channels = (samples % 2 == 0) ? 2 : 1; const int frames = samples / channels; m_clientReverbTx->process(reinterpret_cast(float32.data()), frames, channels); } void AudioEngine::applyClientTxDspInt16(QByteArray& int16stereo) { // Order determines whether the compressor colours the raw mic signal // before the EQ shapes it (default, Pro-XL "tone shaping after // dynamics"), or the EQ shapes first and the compressor tames the // resulting peaks. EQ's tap is always fed post-EQ so the analyzer // shows the final signal leaving the TX DSP chain. // Walk the packed chain-stage list and dispatch each entry to its // matching per-stage apply helper. The audio thread loads the // full chain in one atomic read — each byte is a TxChainStage. const uint64_t packed = m_txChainPacked.load(std::memory_order_acquire); for (int i = 0; i < kMaxTxChainStages; ++i) { const auto stage = static_cast((packed >> (i * 8)) & 0xFF); switch (stage) { case TxChainStage::None: return; // end-of-list marker case TxChainStage::Eq: applyClientEqTxInt16(int16stereo); break; case TxChainStage::Comp: applyClientCompTxInt16(int16stereo); break; case TxChainStage::Gate: applyClientGateTxInt16(int16stereo); break; case TxChainStage::DeEss: applyClientDeEssTxInt16(int16stereo); break; case TxChainStage::Tube: applyClientTubeTxInt16(int16stereo); break; // "Enh" is the legacy enum name; the user-facing label is // PUDU (Phase 5 exciter, Aphex/Behringer-modelled). case TxChainStage::Enh: applyClientPuduTxInt16(int16stereo); break; case TxChainStage::Reverb: applyClientReverbTxInt16(int16stereo); break; } } } void AudioEngine::applyClientTxDspFloat32(QByteArray& float32) { const uint64_t packed = m_txChainPacked.load(std::memory_order_acquire); for (int i = 0; i < kMaxTxChainStages; ++i) { const auto stage = static_cast((packed >> (i * 8)) & 0xFF); switch (stage) { case TxChainStage::None: return; case TxChainStage::Eq: applyClientEqTxFloat32(float32); break; case TxChainStage::Comp: applyClientCompTxFloat32(float32); break; case TxChainStage::Gate: applyClientGateTxFloat32(float32); break; case TxChainStage::DeEss: applyClientDeEssTxFloat32(float32); break; case TxChainStage::Tube: applyClientTubeTxFloat32(float32); break; case TxChainStage::Enh: applyClientPuduTxFloat32(float32); break; case TxChainStage::Reverb: applyClientReverbTxFloat32(float32); break; } } } void AudioEngine::applyClientRxDspFloat32(QByteArray& float32) { // Walk the packed RX chain-stage list and dispatch each entry to // 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((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& stages) { uint64_t v = 0; const int n = std::min(static_cast(stages.size()), AudioEngine::kMaxTxChainStages); for (int i = 0; i < n; ++i) { v |= static_cast(static_cast(stages[i])) << (i * 8); } return v; } QVector unpackChain(uint64_t v) { QVector out; out.reserve(AudioEngine::kMaxTxChainStages); for (int i = 0; i < AudioEngine::kMaxTxChainStages; ++i) { const auto s = static_cast((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 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& stages) { uint64_t v = 0; const int n = std::min(static_cast(stages.size()), AudioEngine::kMaxRxChainStages); for (int i = 0; i < n; ++i) { v |= static_cast(static_cast(stages[i])) << (i * 8); } return v; } QVector unpackRxChain(uint64_t v) { QVector out; out.reserve(AudioEngine::kMaxRxChainStages); for (int i = 0; i < AudioEngine::kMaxRxChainStages; ++i) { const auto s = static_cast((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 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& 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::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 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 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& 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::rxChainStages() const { return unpackRxChain(m_rxChainPacked.load(std::memory_order_acquire)); } void AudioEngine::loadClientRxChainOrder() { auto& s = AppSettings::instance(); QVector 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 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::Gate, TxChainStage::Eq, TxChainStage::DeEss, TxChainStage::Comp, TxChainStage::Tube, TxChainStage::Enh} : QVector{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(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(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(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(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(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(m_clientPuduRx->mode()))); s.setValue("ClientPuduRxPooDriveDb", QString::number(m_clientPuduRx->pooDriveDb())); s.setValue("ClientPuduRxPooTuneHz", QString::number(m_clientPuduRx->pooTuneHz())); s.setValue("ClientPuduRxPooMix", QString::number(m_clientPuduRx->pooMix())); s.setValue("ClientPuduRxDooTuneHz", QString::number(m_clientPuduRx->dooTuneHz())); s.setValue("ClientPuduRxDooHarmonicsDb", QString::number(m_clientPuduRx->dooHarmonicsDb())); s.setValue("ClientPuduRxDooMix", QString::number(m_clientPuduRx->dooMix())); } void AudioEngine::loadClientReverbSettings() { if (!m_clientReverbTx) return; auto& s = AppSettings::instance(); m_clientReverbTx->setEnabled( s.value("ClientReverbTxEnabled", "False").toString() == "True"); m_clientReverbTx->setSize( s.value("ClientReverbTxSize", "0.5").toFloat()); m_clientReverbTx->setDecayS( s.value("ClientReverbTxDecayS", "1.2").toFloat()); m_clientReverbTx->setDamping( s.value("ClientReverbTxDamping", "0.5").toFloat()); m_clientReverbTx->setPreDelayMs( s.value("ClientReverbTxPreDelayMs", "20.0").toFloat()); m_clientReverbTx->setMix( s.value("ClientReverbTxMix", "0.15").toFloat()); } void AudioEngine::saveClientReverbSettings() { if (!m_clientReverbTx) return; auto& s = AppSettings::instance(); auto toBool = [](bool on) { return on ? QString("True") : QString("False"); }; s.setValue("ClientReverbTxEnabled", toBool(m_clientReverbTx->isEnabled())); s.setValue("ClientReverbTxSize", QString::number(m_clientReverbTx->size())); s.setValue("ClientReverbTxDecayS", QString::number(m_clientReverbTx->decayS())); s.setValue("ClientReverbTxDamping", QString::number(m_clientReverbTx->damping())); s.setValue("ClientReverbTxPreDelayMs", QString::number(m_clientReverbTx->preDelayMs())); s.setValue("ClientReverbTxMix", QString::number(m_clientReverbTx->mix())); emit clientReverbStateChanged(); } void AudioEngine::loadClientFinalLimiterSettings() { if (!m_clientFinalLimiterTx) return; auto& s = AppSettings::instance(); // Default OFF: SmartSDR has no client-side brickwall limiter, so a fresh // install with the limiter on at a -1 dBFS ceiling produces noticeably // less forward power than SmartSDR for the same mic level (radio's SW ALC // sees ~1 dB less peak to set its working point off of). The limiter is // still available for users who want headroom protection when running // hot Comp/Tube/PUDU/Reverb settings — they can flip LIM on in the // Aetherial Final Output Stage panel. Existing users whose setting was // already persisted keep their previous behavior. m_clientFinalLimiterTx->setEnabled( s.value("ClientFinalLimiterTxEnabled", "False").toString() == "True"); m_clientFinalLimiterTx->setCeilingDb( s.value("ClientFinalLimiterTxCeilingDb", "-1.0").toFloat()); m_clientFinalLimiterTx->setOutputTrimDb( s.value("ClientFinalLimiterTxOutputTrimDb", "0.0").toFloat()); m_clientFinalLimiterTx->setDcBlockEnabled( s.value("ClientFinalLimiterTxDcBlock", "True").toString() == "True"); } void AudioEngine::saveClientFinalLimiterSettings() const { if (!m_clientFinalLimiterTx) return; auto& s = AppSettings::instance(); s.setValue("ClientFinalLimiterTxEnabled", m_clientFinalLimiterTx->isEnabled() ? QString("True") : QString("False")); s.setValue("ClientFinalLimiterTxCeilingDb", QString::number(m_clientFinalLimiterTx->ceilingDb())); s.setValue("ClientFinalLimiterTxOutputTrimDb", QString::number(m_clientFinalLimiterTx->outputTrimDb())); s.setValue("ClientFinalLimiterTxDcBlock", m_clientFinalLimiterTx->dcBlockEnabled() ? QString("True") : QString("False")); } // Aetherial Tube Pre-Amp TX — nested-JSON persistence (Principle V). // One AppSettings key holds a JSON object so future mic-preamp toggles // (high-pass, phase invert, polarity, etc.) can be added without further // migration. Shape today: {"rn2": bool}. (#2813) void AudioEngine::loadAetherialTubePreampTxSettings() { auto& s = AppSettings::instance(); const QString raw = s.value("AetherialTubePreampTx", "{}").toString(); QJsonParseError err; const auto doc = QJsonDocument::fromJson(raw.toUtf8(), &err); if (err.error != QJsonParseError::NoError || !doc.isObject()) { // Bad JSON — treat as empty, all defaults off. return; } const auto obj = doc.object(); if (obj.value("rn2").toBool(false)) { // Route through the setter so the lazy-allocation + signal // emission both happen exactly as on a user toggle. setRn2TxEnabled(true); } } void AudioEngine::saveAetherialTubePreampTxSettings() const { QJsonObject obj; obj["rn2"] = m_rn2TxEnabled.load(); const QString raw = QString::fromUtf8( QJsonDocument(obj).toJson(QJsonDocument::Compact)); auto& s = AppSettings::instance(); s.setValue("AetherialTubePreampTx", raw); s.save(); } void AudioEngine::loadClientQuindarSettings() { if (!m_clientQuindarTone) return; auto& s = AppSettings::instance(); m_clientQuindarTone->setEnabled( s.value("QuindarEnabled", "False").toString() == "True"); const QString styleStr = s.value("QuindarStyle", "Tone").toString(); m_clientQuindarTone->setStyle(styleStr == "Morse" ? ClientQuindarTone::Style::Morse : ClientQuindarTone::Style::Tone); m_clientQuindarTone->setLevelDb( s.value("QuindarLevelDb", "-6.0").toFloat()); m_clientQuindarTone->setIntroFreqHz( s.value("QuindarIntroFreqHz", "2525.0").toFloat()); m_clientQuindarTone->setOutroFreqHz( s.value("QuindarOutroFreqHz", "2475.0").toFloat()); m_clientQuindarTone->setDurationMs( s.value("QuindarDurationMs", "250").toInt()); m_clientQuindarTone->setMorseWpm( s.value("QuindarMorseWpm", "45").toInt()); m_clientQuindarTone->setMorsePitchHz( s.value("QuindarMorsePitchHz", "750.0").toFloat()); } void AudioEngine::saveClientQuindarSettings() const { if (!m_clientQuindarTone) return; auto& s = AppSettings::instance(); s.setValue("QuindarEnabled", m_clientQuindarTone->isEnabled() ? QString("True") : QString("False")); s.setValue("QuindarStyle", m_clientQuindarTone->style() == ClientQuindarTone::Style::Morse ? QString("Morse") : QString("Tone")); s.setValue("QuindarLevelDb", QString::number(m_clientQuindarTone->levelDb())); s.setValue("QuindarIntroFreqHz", QString::number(m_clientQuindarTone->introFreqHz())); s.setValue("QuindarOutroFreqHz", QString::number(m_clientQuindarTone->outroFreqHz())); s.setValue("QuindarDurationMs", QString::number(m_clientQuindarTone->durationMs())); s.setValue("QuindarMorseWpm", QString::number(m_clientQuindarTone->morseWpm())); s.setValue("QuindarMorsePitchHz", QString::number(m_clientQuindarTone->morsePitchHz())); } static QString wisdomDir() { #ifdef _WIN32 // Windows: use %APPDATA%/AetherSDR/ QString dir = QDir::homePath() + "/AppData/Roaming/AetherSDR/"; #else // Singular ~/.config/AetherSDR/ — matches AppSettings, the log dir, // and the other ConfigLocation users. Pre-fix this was the // double-nested ~/.config/AetherSDR/AetherSDR/ path, which forced an // FFTW wisdom regeneration on first launch after the dir unified. QString dir = QDir::homePath() + "/.config/AetherSDR/"; #endif QDir().mkpath(dir); return dir; } QString AudioEngine::wisdomFilePath() { return wisdomDir() + "aethersdr_fftw_wisdom"; } static QString wisdomFileDetailText(const QFileInfo& info) { if (!info.exists()) { return QStringLiteral("path=\"%1\"") .arg(QDir::toNativeSeparators(info.absoluteFilePath())); } return QStringLiteral("path=\"%1\" size=%2B modified=\"%3\"") .arg(QDir::toNativeSeparators(info.absoluteFilePath())) .arg(info.size()) .arg(info.lastModified().toString(Qt::ISODateWithMs)); } static QString wisdomResultText(SpectralNR::WisdomResult result) { switch (result) { case SpectralNR::WisdomResult::Ready: return QStringLiteral("ready"); case SpectralNR::WisdomResult::Generated: return QStringLiteral("generated"); case SpectralNR::WisdomResult::Cancelled: return QStringLiteral("cancelled"); case SpectralNR::WisdomResult::Failed: return QStringLiteral("failed"); } return QStringLiteral("unknown"); } static void logNr2WisdomSummary(const QString& context) { #ifndef HAVE_FFTW3 QStringList lines; lines << QStringLiteral("Audio NR2 wisdom summary:") << QStringLiteral(" context=%1 status=unavailable action=runtime-plans reason=\"built without FFTW3\"") .arg(context); qCInfo(lcAudioSummary).noquote() << lines.join(QLatin1Char('\n')); #else const QString directory = wisdomDir(); const QString path = directory + "aethersdr_fftw_wisdom"; const QFileInfo info(path); QString status; QString action; bool warn = false; if (!info.exists()) { status = QStringLiteral("missing"); action = QStringLiteral("train-on-first-enable"); } else if (!info.isFile()) { status = QStringLiteral("invalid"); action = QStringLiteral("discard-and-regenerate-on-first-enable"); warn = true; } else if (SpectralNR::loadWisdom(directory.toStdString())) { status = QStringLiteral("valid"); action = QStringLiteral("use-cached-wisdom"); } else { status = QStringLiteral("invalid-or-stale"); action = QStringLiteral("discard-and-regenerate-on-first-enable"); warn = true; } QStringList lines; lines << QStringLiteral("Audio NR2 wisdom summary:") << QStringLiteral(" context=%1 status=%2 action=%3") .arg(context, status, action) << QStringLiteral(" %1").arg(wisdomFileDetailText(info)); const QString summary = lines.join(QLatin1Char('\n')); if (warn) { qCWarning(lcAudioSummary).noquote() << summary; qCWarning(lcAudio).noquote() << QStringLiteral("AudioEngine: NR2 FFTW wisdom %1; %2 %3") .arg(status, action, wisdomFileDetailText(info)); } else { qCInfo(lcAudioSummary).noquote() << summary; } #endif } static void logNr2WisdomGenerationSummary(SpectralNR::WisdomResult result) { const QFileInfo info(AudioEngine::wisdomFilePath()); QStringList lines; lines << QStringLiteral("Audio NR2 wisdom generation summary:") << QStringLiteral(" result=%1").arg(wisdomResultText(result)) << QStringLiteral(" %1").arg(wisdomFileDetailText(info)); const QString summary = lines.join(QLatin1Char('\n')); if (result == SpectralNR::WisdomResult::Failed) { qCWarning(lcAudioSummary).noquote() << summary; } else { qCInfo(lcAudioSummary).noquote() << summary; } } static void applyNr2SettingsFromAppSettings(SpectralNR& nr2) { auto& s = AppSettings::instance(); nr2.setGainMax(s.value("NR2GainMax", "1.00").toFloat()); // default 1.0 = no amplification (#1507) nr2.setGainSmooth(s.value("NR2GainSmooth", "0.85").toFloat()); nr2.setQspp(s.value("NR2Qspp", "0.20").toFloat()); nr2.setGainMethod(s.value("NR2GainMethod", "2").toInt()); nr2.setNpeMethod(s.value("NR2NpeMethod", "0").toInt()); nr2.setAeFilter(s.value("NR2AeFilter", "True").toString() == "True"); } bool AudioEngine::needsWisdomGeneration() { #ifndef HAVE_FFTW3 return false; #else const QString path = wisdomFilePath(); if (!QFile::exists(path)) { logNr2WisdomSummary(QStringLiteral("NR2 enable preflight")); return true; } if (!SpectralNR::loadWisdom(wisdomDir().toStdString())) { logNr2WisdomSummary(QStringLiteral("NR2 enable preflight")); return true; } return false; #endif } SpectralNR::WisdomResult AudioEngine::generateWisdom( SpectralNR::WisdomProgressCb progress, SpectralNR::WisdomCancelCb shouldCancel) { const auto result = SpectralNR::generateWisdom(wisdomDir().toStdString(), std::move(progress), std::move(shouldCancel)); logNr2WisdomGenerationSummary(result); return result; } void AudioEngine::setNr2Enabled(bool on) { if (m_nr2Enabled == on) return; std::lock_guard lock(m_dspMutex); m_rxBuffer.clear(); m_rxPackets.clear(); m_rxOutputBuffer.clear(); m_kiwiSdrRxBuffer.clear(); m_kiwiSdrRxPackets.clear(); m_kiwiSdrOutputBuffer.clear(); m_kiwiSdrRxResampler.reset(); m_kiwiSdrRxResamplerR.reset(); m_nr2Mono.clear(); m_nr2Processed.clear(); m_nr2Output.clear(); m_kiwiSdrNr2Mono.clear(); m_kiwiSdrNr2Processed.clear(); m_kiwiSdrNr2Output.clear(); m_kiwiSdrPrebuffering.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->nr2Mono.clear(); source->nr2Processed.clear(); source->nr2Output.clear(); source->rxResampler.reset(); source->rxResamplerR.reset(); source->prebuffering = externalKiwiSourceAudible(*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 = std::make_unique(256, DEFAULT_SAMPLE_RATE); m_kiwiSdrNr2 = std::make_unique(256, DEFAULT_SAMPLE_RATE); if (m_nr2->hasPlanFailed() || m_kiwiSdrNr2->hasPlanFailed()) { qCWarning(lcAudio) << "AudioEngine: NR2 FFTW plan creation failed — disabling"; m_nr2.reset(); m_kiwiSdrNr2.reset(); emit nr2EnabledChanged(false); return; } // Restore user-adjusted parameters from AppSettings applyNr2SettingsFromAppSettings(*m_nr2); applyNr2SettingsFromAppSettings(*m_kiwiSdrNr2); for (const auto& source : m_externalKiwiSources) { if (!source) { continue; } source->nr2 = std::make_unique(256, DEFAULT_SAMPLE_RATE); if (source->nr2->hasPlanFailed()) { qCWarning(lcAudio) << "AudioEngine: external Kiwi NR2 plan failed for" << source->id; source->nr2.reset(); } else { applyNr2SettingsFromAppSettings(*source->nr2); } } m_nr2Enabled = true; } else { m_nr2Enabled = false; m_nr2.reset(); m_kiwiSdrNr2.reset(); for (const auto& source : m_externalKiwiSources) { if (!source) { continue; } source->nr2.reset(); source->nr2Mono.clear(); source->nr2Processed.clear(); source->nr2Output.clear(); source->outputBuffer.clear(); source->rxResampler.reset(); source->rxResamplerR.reset(); source->prebuffering = externalKiwiSourceAudible(*source); } } qCDebug(lcAudio) << "AudioEngine: NR2" << (on ? "enabled" : "disabled"); emit nr2EnabledChanged(on); } void AudioEngine::setNr2GainMax(float v) { if (m_nr2) m_nr2->setGainMax(v); if (m_kiwiSdrNr2) m_kiwiSdrNr2->setGainMax(v); for (const auto& source : m_externalKiwiSources) { if (source && source->nr2) { source->nr2->setGainMax(v); } } } void AudioEngine::setNr2Qspp(float v) { if (m_nr2) m_nr2->setQspp(v); if (m_kiwiSdrNr2) m_kiwiSdrNr2->setQspp(v); for (const auto& source : m_externalKiwiSources) { if (source && source->nr2) { source->nr2->setQspp(v); } } } void AudioEngine::setNr2GainSmooth(float v) { if (m_nr2) m_nr2->setGainSmooth(v); if (m_kiwiSdrNr2) m_kiwiSdrNr2->setGainSmooth(v); for (const auto& source : m_externalKiwiSources) { if (source && source->nr2) { source->nr2->setGainSmooth(v); } } } void AudioEngine::setNr2GainMethod(int m) { if (m_nr2) m_nr2->setGainMethod(m); if (m_kiwiSdrNr2) m_kiwiSdrNr2->setGainMethod(m); for (const auto& source : m_externalKiwiSources) { if (source && source->nr2) { source->nr2->setGainMethod(m); } } } void AudioEngine::setNr2NpeMethod(int m) { if (m_nr2) m_nr2->setNpeMethod(m); if (m_kiwiSdrNr2) m_kiwiSdrNr2->setNpeMethod(m); for (const auto& source : m_externalKiwiSources) { if (source && source->nr2) { source->nr2->setNpeMethod(m); } } } void AudioEngine::setNr2AeFilter(bool on) { if (m_nr2) m_nr2->setAeFilter(on); if (m_kiwiSdrNr2) m_kiwiSdrNr2->setAeFilter(on); for (const auto& source : m_externalKiwiSources) { if (source && source->nr2) { source->nr2->setAeFilter(on); } } } #ifdef HAVE_SPECBLEACH void AudioEngine::setNr4Enabled(bool on) { if (m_nr4Enabled == on) return; std::lock_guard lock(m_dspMutex); 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 = std::make_unique(); if (!m_nr4->isValid()) { qCWarning(lcAudio) << "AudioEngine: NR4 initialization failed"; m_nr4.reset(); emit nr4EnabledChanged(false); return; } // Restore all saved params auto& s = AppSettings::instance(); m_nr4->setReductionAmount(s.value("NR4ReductionAmount", "10.0").toFloat()); m_nr4->setSmoothingFactor(s.value("NR4SmoothingFactor", "0.0").toFloat()); m_nr4->setWhiteningFactor(s.value("NR4WhiteningFactor", "0.0").toFloat()); m_nr4->setAdaptiveNoise(s.value("NR4AdaptiveNoise", "True").toString() == "True"); m_nr4->setNoiseEstimationMethod(s.value("NR4NoiseEstimationMethod", "0").toInt()); m_nr4->setMaskingDepth(s.value("NR4MaskingDepth", "0.50").toFloat()); m_nr4->setSuppressionStrength(s.value("NR4SuppressionStrength", "0.50").toFloat()); m_nr4Enabled = true; } else { m_nr4Enabled = false; m_nr4.reset(); } qCDebug(lcAudio) << "AudioEngine: NR4" << (on ? "enabled" : "disabled"); emit nr4EnabledChanged(on); } void AudioEngine::setNr4ReductionAmount(float dB) { if (m_nr4) m_nr4->setReductionAmount(dB); } void AudioEngine::setNr4SmoothingFactor(float pct) { if (m_nr4) m_nr4->setSmoothingFactor(pct); } void AudioEngine::setNr4WhiteningFactor(float pct) { if (m_nr4) m_nr4->setWhiteningFactor(pct); } void AudioEngine::setNr4AdaptiveNoise(bool on) { if (m_nr4) m_nr4->setAdaptiveNoise(on); } void AudioEngine::setNr4NoiseEstimationMethod(int m) { if (m_nr4) m_nr4->setNoiseEstimationMethod(m); } void AudioEngine::setNr4MaskingDepth(float v) { if (m_nr4) m_nr4->setMaskingDepth(v); } void AudioEngine::setNr4SuppressionStrength(float v) { if (m_nr4) m_nr4->setSuppressionStrength(v); } #else // !HAVE_SPECBLEACH — stubs void AudioEngine::setNr4Enabled(bool on) { if (on) emit nr4EnabledChanged(false); } void AudioEngine::setNr4ReductionAmount(float) {} void AudioEngine::setNr4SmoothingFactor(float) {} void AudioEngine::setNr4WhiteningFactor(float) {} void AudioEngine::setNr4AdaptiveNoise(bool) {} void AudioEngine::setNr4NoiseEstimationMethod(int) {} void AudioEngine::setNr4MaskingDepth(float) {} void AudioEngine::setNr4SuppressionStrength(float) {} #endif // HAVE_SPECBLEACH // MNR (macOS MMSE-Wiener noise reduction) void AudioEngine::setMnrEnabled(bool on) { if (m_mnrEnabled == on) return; std::lock_guard lock(m_dspMutex); #ifdef __APPLE__ if (on) { // Disable all other noise-reduction modes — they're mutually exclusive if (m_nr2Enabled) setNr2Enabled(false); if (m_rn2Enabled) setRn2Enabled(false); if (m_nr4Enabled) setNr4Enabled(false); if (m_dfnrEnabled) setDfnrEnabled(false); if (m_nvAfxEnabled) setNvAfxEnabled(false); m_mnr = std::make_unique(); if (!m_mnr->isValid()) { qCWarning(lcAudio) << "AudioEngine: MNR vDSP setup failed — disabling"; m_mnr.reset(); return; } // Restore strength from settings (default 1.0 = full suppression) m_mnrStrength.store(std::clamp( AppSettings::instance().value("MnrStrength", "1.00").toFloat(), 0.0f, 1.0f)); m_mnr->setStrength(m_mnrStrength.load()); } else { m_mnr.reset(); } #endif m_mnrEnabled = on; emit mnrEnabledChanged(on); } void AudioEngine::setMnrStrength(float normalized) { m_mnrStrength.store(std::clamp(normalized, 0.0f, 1.0f)); AppSettings::instance().setValue("MnrStrength", QString::number(m_mnrStrength.load(), 'f', 2)); #ifdef __APPLE__ if (m_mnr) m_mnr->setStrength(m_mnrStrength.load()); #endif } float AudioEngine::mnrStrength() const { return m_mnrStrength.load(); } void AudioEngine::setRn2Enabled(bool on) { if (m_rn2Enabled == on) return; std::lock_guard lock(m_dspMutex); 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 = std::make_unique(); if (!m_rn2->isValid()) { qCWarning(lcAudio) << "AudioEngine: RN2 rnnoise_create() failed — disabling"; m_rn2.reset(); emit rn2EnabledChanged(false); return; } // Set flag AFTER object is fully constructed m_rn2Enabled = true; } else { m_rn2Enabled = false; m_rn2.reset(); } qCDebug(lcAudio) << "AudioEngine: RN2 (RNNoise)" << (on ? "enabled" : "disabled"); emit rn2EnabledChanged(on); } // ─── RN2 — TX path (mic pre-amp) ────────────────────────────────────────────── // Mirrors the RX RN2 setter above (lazy-alloc under m_dspMutex, atomic guard // for the audio-thread read). No mutual-exclusion with other TX-side NR // because there is none — RN2 is the only neural denoiser on the mic path // today. Persistence is via the AetherialTubePreampTx nested-JSON key. void AudioEngine::setRn2TxEnabled(bool on) { if (m_rn2TxEnabled.load() == on) return; std::lock_guard lock(m_dspMutex); if (on) { m_rn2Tx = std::make_unique(); 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); } // ─── DFNR (DeepFilterNet3 neural noise reduction) ──────────────────────────── #ifdef HAVE_DFNR void AudioEngine::setDfnrEnabled(bool on) { if (m_dfnrEnabled == on) return; std::lock_guard lock(m_dspMutex); if (on) { // Mutual exclusion with all other NR modes if (m_nr2Enabled) setNr2Enabled(false); if (m_rn2Enabled) setRn2Enabled(false); if (m_nr4Enabled) setNr4Enabled(false); if (m_mnrEnabled) setMnrEnabled(false); if (m_nvAfxEnabled) setNvAfxEnabled(false); m_dfnr = std::make_unique(); if (!m_dfnr->isValid()) { qCWarning(lcAudio) << "AudioEngine: DFNR df_create() failed — disabling"; m_dfnr.reset(); emit dfnrEnabledChanged(false); return; } // Restore saved attenuation limit auto& s = AppSettings::instance(); m_dfnr->setAttenLimit(s.value("DfnrAttenLimit", "100").toFloat()); m_dfnr->setPostFilterBeta(s.value("DfnrPostFilterBeta", "0.0").toFloat()); // Set flag AFTER object is fully constructed m_dfnrEnabled = true; } else { m_dfnrEnabled = false; m_dfnr.reset(); } qCDebug(lcAudio) << "AudioEngine: DFNR (DeepFilterNet3)" << (on ? "enabled" : "disabled"); emit dfnrEnabledChanged(on); } void AudioEngine::setDfnrAttenLimit(float db) { if (m_dfnr) m_dfnr->setAttenLimit(db); } float AudioEngine::dfnrAttenLimit() const { return m_dfnr ? m_dfnr->attenLimit() : 100.0f; } void AudioEngine::setDfnrPostFilterBeta(float beta) { if (m_dfnr) m_dfnr->setPostFilterBeta(beta); } #else // !HAVE_DFNR — stubs void AudioEngine::setDfnrEnabled(bool) {} void AudioEngine::setDfnrAttenLimit(float) {} float AudioEngine::dfnrAttenLimit() const { return 100.0f; } void AudioEngine::setDfnrPostFilterBeta(float) {} #endif // HAVE_DFNR // ─── NVIDIA AFX GPU denoiser (optional, runtime-loaded) ────────────────────── #ifdef HAVE_NVIDIA_AFX void AudioEngine::setNvAfxEnabled(bool on) { if (m_nvAfxEnabled == on) return; std::lock_guard lock(m_dspMutex); if (on) { // Mutual exclusion with all other NR modes if (m_nr2Enabled) setNr2Enabled(false); if (m_rn2Enabled) setRn2Enabled(false); if (m_nr4Enabled) setNr4Enabled(false); if (m_dfnrEnabled) setDfnrEnabled(false); if (m_nvAfxEnabled) setNvAfxEnabled(false); if (m_mnrEnabled) setMnrEnabled(false); m_nvAfx = std::make_unique(); if (!m_nvAfx->isValid()) { qCWarning(lcAudio) << "AudioEngine: NVIDIA AFX denoiser unavailable —" << m_nvAfx->lastError(); m_nvAfx.reset(); emit nvAfxEnabledChanged(false); return; } m_nvAfx->setIntensity(NvidiaBnrSettings::intensity()); m_nvAfxEnabled = true; // set AFTER the object is fully constructed } else { m_nvAfxEnabled = false; m_nvAfx.reset(); } qCDebug(lcAudio) << "AudioEngine: NVIDIA AFX denoiser" << (on ? "enabled" : "disabled"); emit nvAfxEnabledChanged(on); } void AudioEngine::setNvAfxIntensity(float ratio) { if (m_nvAfx) m_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(sizeof(float)); const int stereoFrames = totalFloats / 2; const auto* src = reinterpret_cast(stereoPcm.constData()); SpectralNR* nr2 = externalSource ? externalSource->nr2.get() : (source == RxDspSource::KiwiSdr ? m_kiwiSdrNr2.get() : m_nr2.get()); std::vector& mono = externalSource ? externalSource->nr2Mono : (source == RxDspSource::KiwiSdr ? m_kiwiSdrNr2Mono : m_nr2Mono); std::vector& processed = externalSource ? externalSource->nr2Processed : (source == RxDspSource::KiwiSdr ? m_kiwiSdrNr2Processed : m_nr2Processed); QByteArray& output = externalSource ? externalSource->nr2Output : (source == RxDspSource::KiwiSdr ? m_kiwiSdrNr2Output : m_nr2Output); if (!nr2) { output = stereoPcm; return; } // Resize pre-allocated buffers if needed if (static_cast(mono.size()) < stereoFrames) { mono.resize(stereoFrames); processed.resize(stereoFrames); } // Stereo float32 → mono float32 (average L+R) for (int i = 0; i < stereoFrames; ++i) mono[i] = (src[2 * i] + src[2 * i + 1]) * 0.5f; // Process through SpectralNR (float32 I/O) nr2->process(mono.data(), processed.data(), stereoFrames); // Mono float32 → stereo float32, then re-apply the pan the radio had set // before NR mono-mixed it away (#1460). // Hard-clamp to ±1.0: if gainMax was tuned above 1.0 (not recommended), // unclamped samples would cause digital crackling at the audio sink (#1507). const int outBytes = stereoFrames * 2 * static_cast(sizeof(float)); output.resize(outBytes); auto* dst = reinterpret_cast(output.data()); for (int i = 0; i < stereoFrames; ++i) { const float s = std::clamp(processed[i], -1.0f, 1.0f); dst[2 * i] = s; dst[2 * i + 1] = s; } const int pan = externalSource ? externalSource->pan : m_rxPan.load(); applyRxPanInPlace(dst, stereoFrames, pan); } QByteArray AudioEngine::applyBoost(const QByteArray& pcm, float gain) const { const int nSamples = static_cast(pcm.size() / sizeof(int16_t)); const auto* src = reinterpret_cast(pcm.constData()); QByteArray out(pcm.size(), Qt::Uninitialized); auto* dst = reinterpret_cast(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(s); } return out; } float AudioEngine::computeRMS(const QByteArray& pcm) const { const int samples = pcm.size() / static_cast(sizeof(float)); if (samples == 0) return 0.0f; const float* data = reinterpret_cast(pcm.constData()); double sum = 0.0; for (int i = 0; i < samples; ++i) { sum += static_cast(data[i]) * data[i]; } return static_cast(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(std::sqrt(m_pcMicSumSq / m_pcMicSampleCount)); emit pcMicLevelChanged(TxMicChannelNormalizer::dbfs(m_pcMicPeak), TxMicChannelNormalizer::dbfs(rms)); m_pcMicPeak = 0.0f; m_pcMicSumSq = 0.0; m_pcMicSampleCount = 0; } } void AudioEngine::logTxInputChannelDiagnostics(const TxMicChannelNormalizer::Diagnostics& diagnostics, const char* route) { if (!diagnostics.oneSidedStereo) { return; } QElapsedTimer& throttle = (route && std::strcmp(route, "DAX radio") == 0) ? m_lastDaxRadioChannelLog : m_lastTxMicChannelLog; if (throttle.isValid() && throttle.elapsed() < 1000) { return; } if (throttle.isValid()) throttle.restart(); else throttle.start(); qCDebug(lcAudio) << "AudioEngine:" << (route ? route : "TX mic") << "one-sided stereo input" << "leftRmsDbfs:" << TxMicChannelNormalizer::dbfs(diagnostics.leftRms) << "rightRmsDbfs:" << TxMicChannelNormalizer::dbfs(diagnostics.rightRms) << "leftPeakDbfs:" << TxMicChannelNormalizer::dbfs(diagnostics.leftPeak) << "rightPeakDbfs:" << TxMicChannelNormalizer::dbfs(diagnostics.rightPeak) << "selected:" << TxMicChannelNormalizer::channelModeName(diagnostics.selectedMode); } // ─── TX stream ──────────────────────────────────────────────────────────────── bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioPort) { if (m_audioSource) return true; // already running // WASAPI silent-open recovery (#2929). If the previous open was driven // by the silence watchdog, m_txForceMonoOnNextOpen is true; consume it // here. A fresh (non-watchdog) start re-enables the one-shot retry budget. const bool isWatchdogRetry = m_txForceMonoOnNextOpen; m_txForceMonoOnNextOpen = false; if (!isWatchdogRetry) { m_txSilenceRetryDone = false; } m_txReceivedAnyBytes = false; m_txAddress = radioAddress; m_txPort = radioPort; m_txPacketCount = 0; m_txAccumulator.clear(); m_txMicChannelState.reset(); m_lastTxMicChannelLog.invalidate(); // TX mic capture uses Int16 — we convert to float32 after capture. // (makeFormat() returns Float for the RX sink, but mic hardware is Int16.) QAudioFormat fmt; fmt.setSampleRate(DEFAULT_SAMPLE_RATE); fmt.setChannelCount(2); fmt.setSampleFormat(QAudioFormat::Int16); QAudioDevice dev = QMediaDevices::defaultAudioInput(); bool txFallbackOccurred = false; QStringList txFallbackReasons; QStringList txFormatAttempts; const auto noteTxFallback = [&txFallbackOccurred, &txFallbackReasons](const QString& reason) { txFallbackOccurred = true; if (!reason.isEmpty() && !txFallbackReasons.contains(reason)) { txFallbackReasons << reason; } }; const auto noteTxAttempt = [&txFormatAttempts](const QAudioFormat& format) { const QString attempt = formatAudioAttempt(format.sampleRate(), format.channelCount(), format.sampleFormat()); if (!txFormatAttempts.contains(attempt)) { txFormatAttempts << attempt; } }; if (!m_inputDevice.isNull()) { const auto inputs = QMediaDevices::audioInputs(); if (devicePresent(inputs, m_inputDevice)) { dev = m_inputDevice; } else { qCWarning(lcAudio) << "AudioEngine: saved input device is unavailable, using the system default input instead"; noteTxFallback(QStringLiteral("saved input unavailable -> system default")); m_inputDevice = QAudioDevice{}; } } if (dev.isNull()) { qCWarning(lcAudio) << "AudioEngine: no audio input device available"; return false; } qCDebug(lcAudio) << "AudioEngine: input device caps:" << dev.minimumSampleRate() << "-" << dev.maximumSampleRate() << "Hz" << dev.minimumChannelCount() << "-" << dev.maximumChannelCount() << "ch"; // Negotiate the TX mic input format via the consolidated factory (#3306). // The mic is captured as Int16; the factory supplies the per-OS rate ladder // in ONE place (macOS preferred/HAL-native-rate-first to dodge the silent // 48k-open trap #2930 and the Bluetooth-HFP native rate #2615; Linux native // 24k). We walk it preferring stereo across all rates then mono, preserving // the existing channel fallback. bool formatFound = false; #ifdef Q_OS_WIN // Windows WASAPI shared mode handles rate conversion transparently, but Qt's // isFormatSupported() returns false for many valid devices (Voicemeeter, // FlexRadio DAX). Default to 48kHz and let WASAPI handle the rate. Clamp the // channel count to the device's maximumChannelCount() so mono-only USB PnP // mics open as mono on the first attempt — opening them stereo silently // returns a non-null QIODevice that delivers zero bytes (#2929). This path // already matches the factory's Windows policy (force 48k + probe-at-open); // migrating its mono-clamp onto the wrapper is a separate, soakable step. constexpr int preferredTxRate = 48000; fmt.setSampleRate(48000); const int maxCh = dev.maximumChannelCount(); const int initialCh = (isWatchdogRetry || (maxCh > 0 && maxCh < 2)) ? 1 : 2; fmt.setChannelCount(initialCh); noteTxAttempt(fmt); formatFound = true; #else bool txBluetoothHfp = false; int txPreferredOverride = 0; #ifdef Q_OS_MAC // CoreAudio-HAL detection the factory can't derive from QAudioDevice: if this // is a Bluetooth-HFP capture route, put its native low rate first (#2615). if (const auto nativeRate = macBluetoothNativeInputRate(dev)) { txBluetoothHfp = true; txPreferredOverride = *nativeRate; } #endif const QList 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(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(rate, 24000, 16384); } else { m_txResampler.reset(); } txOpened = true; break; } delete m_audioSource; m_audioSource = nullptr; } if (txOpened) break; } if (!txOpened) { qCWarning(lcAudio) << "AudioEngine: all TX source formats failed"; logAudioOpenFailure(QStringLiteral("TX source"), QStringLiteral("QAudioSource"), dev, txFormatAttempts, QStringLiteral("QAudioSource::start failed for all TX formats (initial %1)") .arg(firstError), txFallbackReasons); return false; } #else logAudioOpenFailure(QStringLiteral("TX source"), QStringLiteral("QAudioSource"), dev, txFormatAttempts, QStringLiteral("QAudioSource::start returned null (%1)").arg(firstError), txFallbackReasons); delete m_audioSource; m_audioSource = nullptr; return false; #endif } connect(m_micDevice, &QIODevice::readyRead, this, &AudioEngine::onTxAudioReady); #ifdef Q_OS_WIN // WASAPI silent-open watchdog (#2929): some USB PnP mics report their // native mono format but Qt's QAudioSource::start() returns a non-null // QIODevice for an unsupported stereo open, then delivers zero bytes. // The null-open fallback ladder above never sees this case (start did // not return null). One-shot retry as mono if no bytes arrive in 1.5 s. if (!m_txSilenceRetryDone) { const quint64 watchdogGen = m_txLifecycleGeneration; const QHostAddress watchdogAddr = m_txAddress; const quint16 watchdogPort = m_txPort; QTimer::singleShot(1500, this, [this, watchdogGen, watchdogAddr, watchdogPort]() { if (!m_audioSource) return; if (watchdogGen != m_txLifecycleGeneration) return; if (m_audioSource->state() != QAudio::ActiveState) return; if (m_txReceivedAnyBytes) return; qCWarning(lcAudio) << "AudioEngine: TX source opened but produced no bytes in 1.5 s — " "retrying as mono (likely WASAPI mono-only USB mic, #2929)" << "rate:" << m_txInputRate << "ch:" << m_txInputChannels; m_txSilenceRetryDone = true; m_txForceMonoOnNextOpen = true; QMetaObject::invokeMethod(this, [this, watchdogAddr, watchdogPort]() { stopTxStream(); startTxStream(watchdogAddr, watchdogPort); }, Qt::QueuedConnection); }); } #endif #endif m_txSourceStartTime.restart(); qCWarning(lcAudio) << "AudioEngine: TX stream started ->" << radioAddress.toString() << ":" << radioPort << "streamId:" << Qt::hex << m_txStreamId << Qt::dec << "device:" << dev.description() << "id:" << dev.id() << "rate:" << m_txInputRate << "ch:" << m_txInputChannels << "resample:" << m_txNeedsResample; AudioSummaryLogger::TxSourceSummary summary; summary.deviceDescription = dev.description(); summary.sampleRate = m_txInputRate; summary.channelCount = m_txInputChannels; summary.sampleFormat = fmt.sampleFormat(); summary.resamplingTo24k = m_txNeedsResample; summary.fallbackOccurred = txFallbackOccurred; summary.fallbackReason = txFallbackReasons.join(QStringLiteral("; ")); AudioSummaryLogger::logTxSource(summary); return true; } void AudioEngine::stopTxStream() { ++m_txLifecycleGeneration; #ifdef Q_OS_MAC QTimer* pollTimer = m_txPollTimer; m_txPollTimer = nullptr; QBuffer* micBuffer = m_micBuffer; m_micBuffer = nullptr; #endif QAudioSource* audioSource = m_audioSource; m_audioSource = nullptr; m_micDevice = nullptr; #ifdef Q_OS_MAC if (pollTimer) { pollTimer->stop(); delete pollTimer; } #endif if (audioSource) { // Guard: calling stop() on an already-stopped QAudioSource on macOS causes // AudioOutputUnitStop to dereference a stale CoreAudio device handle, // producing EXC_ARM_DA_ALIGN / EXC_BAD_ACCESS (#1059). if (audioSource->state() != QAudio::StoppedState) { audioSource->stop(); } delete audioSource; } #ifdef Q_OS_MAC if (micBuffer) { delete micBuffer; } #endif m_txSocket.close(); m_txAccumulator.clear(); m_txFloatAccumulator.clear(); m_txResampler.reset(); m_txInputChannels = 2; m_txInputMono = false; m_txInputRate = DEFAULT_SAMPLE_RATE; m_txNeedsResample = false; m_txMicChannelState.reset(); m_lastTxMicChannelLog.invalidate(); m_txSourceStartTime.invalidate(); } void AudioEngine::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((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(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(sizeof(int16_t))); auto* o16 = reinterpret_cast(pcm.data()); for (int i = 0; i < frames * 2; ++i) o16[i] = static_cast( std::clamp(m_cwRecordSidetoneScratch[static_cast(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 (m_tciAudioTimer.isValid() && m_tciAudioTimer.elapsed() < kTciAudioActiveWindowMs) { return; } #ifdef Q_OS_MAC if (!m_micBuffer || !m_audioSource) return; if (m_audioSource->state() == QAudio::StoppedState) return; if (!m_micBuffer->isOpen()) return; if (m_txStreamId == 0 && m_remoteTxStreamId == 0) return; qint64 avail = m_micBuffer->pos(); if (avail <= 0) return; QByteArray data = m_micBuffer->data(); m_micBuffer->buffer().clear(); m_micBuffer->seek(0); if (data.isEmpty()) return; #else if (!m_micDevice || (m_txStreamId == 0 && m_remoteTxStreamId == 0)) return; QByteArray data = m_micDevice->readAll(); if (data.isEmpty()) return; m_txReceivedAnyBytes = true; // disarms the WASAPI silent-open watchdog (#2929) #endif // Canonicalize immediately after capture: TX voice is logically mono // carried as stereo int16, so choose/average the real mic channel before // any resampling, RADE/DAX branch, test tone, DSP, gain, limiter, or meter. TxMicChannelNormalizer::Diagnostics channelDiagnostics; data = TxMicChannelNormalizer::canonicalizeInt16ToMonoStereo( data, m_txInputChannels, m_txInputRate, m_txMicChannelMode, &m_txMicChannelState, &channelDiagnostics); if (data.isEmpty()) return; logTxInputChannelDiagnostics(channelDiagnostics, "TX mic"); // Resample canonical mono int16 to 24kHz duplicated stereo if needed, then // convert to float32 for RADE. Normal TX path stays int16 (Opus requires // int16). Do not call processStereoToStereo() here: that helper would // average raw mic L/R and reintroduce the one-sided-channel 6.02 dB loss. if (m_txNeedsResample && m_txResampler) { // Convert canonical duplicated int16 stereo → float32 mono for the // mono-to-stereo resampler. const auto* i16 = reinterpret_cast(data.constData()); const int frames = data.size() / static_cast(2 * sizeof(int16_t)); QByteArray f32(frames * static_cast(sizeof(float)), Qt::Uninitialized); auto* fd = reinterpret_cast(f32.data()); for (int i = 0; i < frames; ++i) fd[i] = i16[i * 2] / 32768.0f; f32 = m_txResampler->processMonoToStereo( reinterpret_cast(f32.constData()), f32.size() / static_cast(sizeof(float))); // Convert back to int16 for the rest of the TX path const auto* rsrc = reinterpret_cast(f32.constData()); const int rcount = f32.size() / static_cast(sizeof(float)); if (rcount <= 0) return; data.resize(rcount * static_cast(sizeof(int16_t))); auto* rdst = reinterpret_cast(data.data()); for (int i = 0; i < rcount; ++i) rdst[i] = static_cast(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(data.data()); int sampleCount = data.size() / static_cast(sizeof(int16_t)); for (int i = 0; i < sampleCount; ++i) { pcm[i] = static_cast(std::clamp( static_cast(pcm[i] * gain), -32768, 32767)); } } accumulatePcMicMeterInt16Stereo(data); // Gate TX audio on PTT (prevents pre-MOX audio leakage into encoder) if (!m_transmitting) return; const auto* i16 = reinterpret_cast(data.constData()); const int ns = data.size() / static_cast(sizeof(int16_t)); QByteArray f32(ns * static_cast(sizeof(float)), Qt::Uninitialized); auto* fd = reinterpret_cast(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(data.constData()); const int samples = data.size() / static_cast(sizeof(int16_t)); m_rn2TxF32In.resize(samples * static_cast(sizeof(float))); auto* fin = reinterpret_cast(m_rn2TxF32In.data()); for (int i = 0; i < samples; ++i) fin[i] = i16[i] / 32768.0f; m_rn2TxF32In = m_rn2Tx->process(m_rn2TxF32In); const int outSamples = m_rn2TxF32In.size() / static_cast(sizeof(float)); const auto* fout = reinterpret_cast(m_rn2TxF32In.constData()); data.resize(outSamples * static_cast(sizeof(int16_t))); auto* i16Out = reinterpret_cast(data.data()); for (int i = 0; i < outSamples; ++i) { const float clamped = std::clamp(fout[i] * 32768.0f, -32768.0f, 32767.0f); i16Out[i] = static_cast(clamped); } } // ── Client-side TX DSP: compressor + parametric EQ ────────────────── // Runs after mic capture and resample, before PC mic gain / metering / // Opus / VITA-49, so the user hears the shaped signal exactly as the // radio will receive it. Chain order (CMP→EQ vs EQ→CMP) is user- // selectable via setTxChainOrder(). // ── Test tone (head of chain) ─────────────────────────────── // When enabled, replaces mic input with a sine so the user can // run the chain on a known signal. Runs BEFORE the user's DSP // chain so the tone exits the strip with all stages applied. if (m_clientTxTestTone && m_clientTxTestTone->isEnabled()) { const int samples = data.size() / static_cast(sizeof(int16_t)); const int frames = samples / 2; m_clientTxTestTone->process( reinterpret_cast(data.data()), frames, 2); } applyClientTxDspInt16(data); // ── PUDU monitor tap ───────────────────────────────────────── // Feeds the post-DSP int16 bytes into the TX monitor if one is // registered. Lock-free atomic pointer load; the monitor's // feedTxPostDsp() itself handles the not-recording fast-path. if (auto* mon = m_txPostDspMonitor.load(std::memory_order_acquire)) { mon->feedTxPostDsp(data); } // ── Apply client-side PC mic gain (int16) ─────────────────────────── const float gain = m_pcMicGain.load(); if (gain < 0.999f) { auto* pcm = reinterpret_cast(data.data()); int sampleCount = data.size() / static_cast(sizeof(int16_t)); for (int i = 0; i < sampleCount; ++i) pcm[i] = static_cast(std::clamp( static_cast(pcm[i] * gain), -32768, 32767)); } // ── Quindar tones (#2262) ─────────────────────────────────────────── // Sits AFTER the user DSP chain and PC mic gain but BEFORE the final // brickwall limiter, so the generated tone is unprocessed by Comp/EQ // (no comp pumping, no EQ tilt) but is still bounded by the configured // ceiling. Driven by TransmitModel's PTT coordinator on phone modes; // the stage replaces samples wholesale during Engaging/Disengaging // phases and is a no-op the rest of the time. if (m_clientQuindarTone) { const int frames = data.size() / static_cast(sizeof(int16_t) * 2); m_clientQuindarTone->process( reinterpret_cast(data.data()), frames, 2); } // ── Final brickwall limiter (TX tail) ─────────────────────────────── // Sits at the very end of the chain — after every user-configurable // stage AND after PC mic gain — so no sample escapes louder than the // configured ceiling regardless of upstream behaviour. Its meters // (input / output peak, GR, active) are what the strip's "Final // Output Stage" panel reads. applyClientFinalLimiterTxInt16(data); // ── Final-output monitor tap (+ local CW/CWX sidetone for recording) ── // Mirror the post-PUDU monitor at the chain's tail (post-limiter) for the // PUDU TX monitor and the Client-Side QSO recorder's VOICE tap (#3556). // 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(); 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(pkt.data()); // Word 0: type=3 (ExtDataWithStream), C=1, T=0, TSI=3, TSF=1 p[0] = qToBigEndian( (3u << 28) | (1u << 27) | (3u << 22) | (1u << 20) | ((m_txPacketCount & 0x0F) << 16) | sizeWords); m_txPacketCount = (m_txPacketCount + 1) & 0x0F; p[1] = qToBigEndian(m_remoteTxStreamId); // remote_audio_tx stream p[2] = qToBigEndian(0x00001C2D); // OUI (FlexRadio) p[3] = qToBigEndian(0x534C0000 | 0x8005); // ICC=0x534C, PCC=0x8005 p[4] = 0; p[5] = 0; p[6] = 0; // timestamps (all zero) memcpy(pkt.data() + 28, opus.constData(), opus.size()); // Queue for paced delivery instead of sending immediately. // The 10ms pacing timer drains one packet per tick for even // timing over SmartLink/WAN. Cap queue to ~200ms to prevent // runaway growth if the mic delivers faster than real-time. m_opusTxQueue.append(pkt); if (m_opusTxQueue.size() > 20) m_opusTxQueue.removeFirst(); } return; } // ── Uncompressed TX path (not used — radio forces Opus) ──────────── m_txAccumulator.append(data); while (m_txAccumulator.size() >= TX_PCM_BYTES_PER_PACKET) { const int16_t* pcm = reinterpret_cast(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(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(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(m_voxAccumulator.constData()); float floatBuf[TX_SAMPLES_PER_PACKET * 2]; for (int i = 0; i < TX_SAMPLES_PER_PACKET * 2; ++i) floatBuf[i] = pcm[i] / 32768.0f; // Build packet using the remote_audio_tx stream ID quint32 savedId = m_txStreamId; m_txStreamId = streamId; QByteArray packet = buildVitaTxPacket(floatBuf, TX_SAMPLES_PER_PACKET); m_txStreamId = savedId; emit txPacketReady(packet); m_voxAccumulator.remove(0, TX_PCM_BYTES_PER_PACKET); } } void AudioEngine::setOutputDevice(const QAudioDevice& dev) { m_outputDevice = dev; qCDebug(lcAudio) << "AudioEngine: output device set to" << dev.description(); // Persist selection auto& s = AppSettings::instance(); s.setValue("AudioOutputDeviceId", dev.id()); s.save(); // Restart RX stream if running if (m_audioSink) { stopRxStream(); startRxStream(); } emit outputDeviceChanged(); } void AudioEngine::setInputDevice(const QAudioDevice& dev) { m_inputDevice = dev; qCDebug(lcAudio) << "AudioEngine: input device set to" << dev.description(); // Persist selection auto& s = AppSettings::instance(); s.setValue("AudioInputDeviceId", dev.id()); s.save(); // Restart TX stream if running if (m_audioSource) { QHostAddress addr = m_txAddress; quint16 port = m_txPort; stopTxStream(); startTxStream(addr, port); } emit inputDeviceChanged(); } #ifdef Q_OS_MAC void AudioEngine::setAllowBluetoothTelephonyOutput(bool on) { const bool changed = (m_allowBluetoothTelephonyOutput.exchange(on) != on); if (!changed || !m_audioSink) { return; } stopRxStream(); startRxStream(); } #endif // ─── RADE digital voice support ────────────────────────────────────────────── void AudioEngine::setRadeMode(bool on) { if (m_radeMode == on) return; m_radeMode = on; // RADE TX: onTxAudioReady() emits txRawPcmReady (float32) then returns // early — the Opus voice TX path never runs. RADEEngine receives the // raw PCM, encodes it to a modem waveform, and emits it via // sendModemTxAudio() → buildVitaTxPacket() → dax_tx VITA-49 stream. // The radio routes that stream to the TX modulator only when dax=1. // activateRADE() sets the slice to DIGU/DIGL, which fires // updateDaxTxMode() → setDax(true) → transmit set dax=1 before PTT. // Do NOT emit daxRouteRequested(0) here — dax=0 tells the radio to // use the physical mic and discard every dax_tx packet, producing no // TX waveform. feedDaxTxAudio/m_daxTxUseRadioRoute are irrelevant: // RADE bypasses feedDaxTxAudio entirely. if (!on) m_radeRxBuffer.clear(); clearTxAccumulators(); } void AudioEngine::sendModemTxAudio(const QByteArray& float32pcm) { if (m_txStreamId == 0) return; // Gate modem audio on PTT (prevents radio pre-buffer build-up) if (!m_transmitting) { qCWarning(lcAudio) << "AudioEngine: sendModemTxAudio PTT gate closed —" << float32pcm.size() << "bytes dropped (EOO race?)"; return; } if (m_radioTransmitting) { emitTxPostChainScopeFromFloat32Stereo(float32pcm, DEFAULT_SAMPLE_RATE); emitScopeFromFloat32Stereo(float32pcm, DEFAULT_SAMPLE_RATE, true); } m_txFloatAccumulator.append(float32pcm); constexpr int FLOAT_BYTES_PER_PKT = TX_SAMPLES_PER_PACKET * 2 * sizeof(float); // 1024 while (m_txFloatAccumulator.size() >= FLOAT_BYTES_PER_PKT) { auto* samples = reinterpret_cast(m_txFloatAccumulator.constData()); QByteArray pkt = buildVitaTxPacket(samples, TX_SAMPLES_PER_PACKET); emit txPacketReady(pkt); m_txFloatAccumulator.remove(0, FLOAT_BYTES_PER_PKT); } } void AudioEngine::setDaxTxMode(bool on) { const bool previous = m_daxTxMode.exchange(on); if (previous != on) { qCDebug(lcDax) << "AudioEngine: DAX TX mode" << (on ? "enabled" : "disabled") << "route=" << (m_daxTxUseRadioRoute ? "radio-dax" : "float32-dax-tx") << "stream=0x" + QString::number(m_txStreamId, 16); } } void AudioEngine::setTransmitting(bool tx) { if (m_transmitting == tx) return; m_transmitting = tx; if (!tx) { // On unkey: drop any partial packet residue so next burst starts cleanly. m_txAccumulator.clear(); m_txFloatAccumulator.clear(); m_daxPreTxBuffer.clear(); m_opusTxQueue.clear(); } } void AudioEngine::setRadioTransmitting(bool tx) { const bool previous = m_radioTransmitting.exchange(tx); if (previous == tx) return; // 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 dspLock(m_dspMutex); if (m_nr2Enabled && m_nr2) m_nr2->reset(); } emit radioTransmittingChanged(tx); } void AudioEngine::setDaxTxUseRadioRoute(bool on) { if (m_daxTxUseRadioRoute == on) return; m_daxTxUseRadioRoute = on; // Switching route changes payload format; drop partial buffered samples. m_txFloatAccumulator.clear(); m_daxPreTxBuffer.clear(); m_daxRadioTxChannelState.reset(); m_lastDaxRadioChannelLog.invalidate(); qCDebug(lcDax) << "AudioEngine: DAX TX route" << (on ? "radio-dax pcc=0x0123" : "float32 pcc=0x03e3") << "stream=0x" + QString::number(m_txStreamId, 16); } void AudioEngine::feedDaxTxAudio(const QByteArray& inPcm) { if (m_txStreamId == 0 || inPcm.isEmpty()) return; // Mark TCI as the active TX-audio source. While this timer is fresh, // onTxAudioReady() suppresses the local mic capture path so the two // packet producers don't collide on the same UDP path to the radio. m_tciAudioTimer.start(); // Client-side TX DSP (compressor + EQ) is intentionally NOT // applied here. This path is fed exclusively by TCI and DAX // (WSJT-X, fldigi, PipeWire bridge, etc.) — digital modes carry // pre-shaped tones that would be destroyed by a voice-tuned // compressor or EQ. Mic voice TX goes through onTxAudioReady, // which keeps the full DSP chain. const QByteArray& float32pcm = inPcm; // Measure DAX TX input level and emit via pcMicLevelChanged so the // P/CW mic gauge shows DAX audio level regardless of mic profile (#517) { const auto* src = reinterpret_cast(float32pcm.constData()); const int samples = static_cast(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(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(std::sqrt(m_pcMicSumSq / m_pcMicSampleCount)); float peakDb = (m_pcMicPeak > 1e-10f) ? 20.0f * std::log10(m_pcMicPeak) : -150.0f; float rmsDb = (rms > 1e-10f) ? 20.0f * std::log10(rms) : -150.0f; emit pcMicLevelChanged(peakDb, rmsDb); m_pcMicPeak = 0.0f; m_pcMicSumSq = 0.0; m_pcMicSampleCount = 0; } } const bool daxAudioWillTransmit = m_radioTransmitting && (!m_daxTxUseRadioRoute || !(m_transmitting && !m_daxTxMode)); if (daxAudioWillTransmit) { emitTxPostChainScopeFromFloat32Stereo(float32pcm, DEFAULT_SAMPLE_RATE); emitScopeFromFloat32Stereo(float32pcm, DEFAULT_SAMPLE_RATE, true); } if (!m_daxTxUseRadioRoute) { // Low-latency route: keep radio on mic path (dax=0) and packetize // exactly like voice TX (PCC 0x03E3 float32 stereo). constexpr int FLOAT_BYTES_PER_PKT = TX_SAMPLES_PER_PACKET * 2 * sizeof(float); // Gate on raw radio TX state, not ownership. When an external app // (WSJT-X) triggers PTT, m_transmitting is false (we don't own TX) // but the radio IS transmitting and needs our DAX audio. (#752) if (!m_radioTransmitting) { m_daxPreTxBuffer.clear(); m_txFloatAccumulator.clear(); return; } m_txFloatAccumulator.append(float32pcm); while (m_txFloatAccumulator.size() >= FLOAT_BYTES_PER_PKT) { auto* samples = reinterpret_cast(m_txFloatAccumulator.constData()); QByteArray pkt = buildVitaTxPacket(samples, TX_SAMPLES_PER_PACKET); emit txPacketReady(pkt); m_txFloatAccumulator.remove(0, FLOAT_BYTES_PER_PKT); } return; } // Radio-native DAX route (dax=1): block DAX audio only when mic voice TX is active. if (m_transmitting && !m_daxTxMode) return; m_daxPreTxBuffer.clear(); // Convert float32 stereo → int16 mono (reduced BW format, PCC 0x0123). // This route is still a digital/DAX bypass: no voice DSP, gain, Quindar, or // final limiter. The mono collapse only avoids the same one-sided stereo // 6.02 dB loss that can affect virtual/aggregate DAX sources. TxMicChannelNormalizer::Diagnostics daxDiagnostics; QByteArray mono = TxMicChannelNormalizer::collapseFloat32ToInt16MonoBigEndian( float32pcm, 2, DEFAULT_SAMPLE_RATE, m_daxRadioTxChannelMode, &m_daxRadioTxChannelState, &daxDiagnostics); if (mono.isEmpty()) return; logTxInputChannelDiagnostics(daxDiagnostics, "DAX radio"); m_txFloatAccumulator.append(mono); // Build and send VITA-49 packets: 128 mono int16 samples per packet constexpr int MONO_BYTES_PER_PKT = TX_SAMPLES_PER_PACKET * sizeof(qint16); // 256 bytes while (m_txFloatAccumulator.size() >= MONO_BYTES_PER_PKT) { const int payloadBytes = MONO_BYTES_PER_PKT; const int packetWords = (payloadBytes / 4) + VITA_HEADER_WORDS; const int packetBytes = packetWords * 4; QByteArray pkt(packetBytes, '\0'); quint32* words = reinterpret_cast(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(FLEX_INFO_CLASS) << 16) | PCC_DAX_REDUCED); words[4] = 0; // integer timestamp (zero) words[5] = 0; // fractional timestamp high (zero) words[6] = 0; // fractional timestamp low (zero) // Copy pre-converted big-endian int16 mono payload std::memcpy(pkt.data() + VITA_HEADER_BYTES, m_txFloatAccumulator.constData(), payloadBytes); m_txPacketCount = (m_txPacketCount + 1) & 0xF; emit txPacketReady(pkt); m_txFloatAccumulator.remove(0, MONO_BYTES_PER_PKT); } } void AudioEngine::feedDecodedSpeech(const QByteArray& pcm) { if (!m_audioSink || !m_audioDevice || !m_audioDevice->isOpen()) return; // Decoded RADE speech goes into its own output-rate buffer. The drain // timer mixes it with m_rxOutputBuffer sample-wise so both are heard // simultaneously without doubling the fill rate. A dedicated resampler // preserves the filter state independently from the main RX output // resampler used by processMixedRxAudioData(). if (m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE) { if (!m_radeRxResampler) m_radeRxResampler = std::make_unique(24000, m_rxOutputRate.load()); const auto* src = reinterpret_cast(pcm.constData()); m_radeRxBuffer.append( m_radeRxResampler->processStereoToStereo( src, pcm.size() / (2 * static_cast(sizeof(float))))); } else { m_radeRxBuffer.append(pcm); } } } // namespace AetherSDR