diff --git a/CMakeLists.txt b/CMakeLists.txt index f4841bab..3978e28d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -496,6 +496,8 @@ set(CORE_SOURCES src/core/TgxlConnection.cpp src/core/CommandParser.cpp src/core/AudioSummaryLogger.cpp + src/core/AudioFormatNegotiator.cpp + src/core/AudioDeviceNegotiator.cpp src/core/AudioEngine.cpp src/core/TxMicChannelNormalizer.cpp src/core/ChannelStripPresets.cpp @@ -1712,6 +1714,18 @@ target_include_directories(audio_format_negotiation_test PRIVATE src) target_link_libraries(audio_format_negotiation_test PRIVATE Qt6::Core) add_test(NAME audio_format_negotiation_test COMMAND audio_format_negotiation_test) +# Smoke test for the live Qt-Multimedia wrapper (AudioDeviceNegotiator): probes +# the real default devices and round-trips to an openable QAudioFormat. Tolerant +# of headless runners with no audio hardware. +add_executable(audio_device_negotiator_test + tests/audio_device_negotiator_test.cpp + src/core/AudioDeviceNegotiator.cpp + src/core/AudioFormatNegotiator.cpp +) +target_include_directories(audio_device_negotiator_test PRIVATE src) +target_link_libraries(audio_device_negotiator_test PRIVATE Qt6::Core Qt6::Multimedia) +add_test(NAME audio_device_negotiator_test COMMAND audio_device_negotiator_test) + add_executable(profile_transfer_test tests/profile_transfer_test.cpp ) diff --git a/docs/audio-sink-factory.md b/docs/audio-sink-factory.md index 8422862a..732705fc 100644 --- a/docs/audio-sink-factory.md +++ b/docs/audio-sink-factory.md @@ -1,6 +1,6 @@ # Consolidated audio sink factory -> Status: **in progress** — foundation landed (pure negotiator + golden tests). +> Status: **in progress** — foundation + live wrapper + RX-speaker migration landed. > Tracking issue: [#3306](https://github.com/aethersdr/AetherSDR/issues/3306). > Companion doc: [`audio-pipeline.md`](audio-pipeline.md). @@ -141,13 +141,27 @@ rate ones; the others are enforced in the router/wrapper. 1. **Foundation** ✅ — `AudioFormatNegotiator` + golden matrix (`audio_format_negotiation_test`). Pure addition, zero behaviour change. -2. **Live wrapper** — `AudioDeviceNegotiator`, with a unit smoke test against the - default device. Still no sink uses it yet. -3. **RX speaker** onto the wrapper — behaviour identical for normal devices; the - *only* visible change is that a 44.1k-only output now works instead of - failing (the #3306 regression the foundation guards). Soak on Win/Mac/Linux. -4. **PC mic (TX)** onto the wrapper — preserves macOS preferred-first / BT-HFP / - Windows probe-at-open. +2. **Live wrapper** ✅ — `AudioDeviceNegotiator` + smoke test against the real + default devices (`audio_device_negotiator_test`). +3. **RX speaker** ✅ — `AudioEngine::startRxStream()` now walks the factory's + Float ladder with real `start()` attempts instead of two forked per-OS + `#ifdef` blocks. The `m_resampleTo48k` bool was generalized to an + `m_rxOutputRate` `std::atomic` so a 44.1k device resamples 24k→44.1k correctly instead + of failing. Behaviour is identical for normal devices (Win/Mac→48k, + Linux→24k native); the visible changes are (a) a 44.1k-Float-only output now + works, and (b) the Quindar local monitor now opens on Windows too (the old + Windows branch `return`ed before `startQuindarLocalSink()`). Soak on + Win/Mac/Linux. +4. **PC mic (TX)** ✅ (macOS + Linux) — `AudioEngine::startTxStream()` now drives + the mic rate/format selection from the factory's Int16 Input ladder, walking + stereo-then-mono. macOS BT-HFP native rate (#2615) is fed in via the existing + `macBluetoothNativeInputRate()` HAL detection (new `preferredRateOverride` + on the wrapper), and preferred-rate-first (#2930) is the ladder's macOS rule. + `macTxInputRateCandidates()` is removed (its logic now lives in the factory). + The **Windows** mic path is deliberately left as-is for now: it already + matches the factory's Windows policy (force 48k + probe-at-open) and carries + the mono-only USB-mic channel clamp (#2929) that needs its own soak — that's + the remaining mic increment. 5. **`AudioOutputRouter`** + migrate the three uncoupled sinks (CW sidetone, Pudu monitor, QSO playback) and Quindar onto it — closes the uncoupling class so a future sink can't re-open it. diff --git a/src/core/AudioDeviceNegotiator.cpp b/src/core/AudioDeviceNegotiator.cpp new file mode 100644 index 00000000..9ba1d0ce --- /dev/null +++ b/src/core/AudioDeviceNegotiator.cpp @@ -0,0 +1,126 @@ +#include "core/AudioDeviceNegotiator.h" + +namespace AetherSDR { +namespace AudioDeviceNegotiator { + +namespace AFN = AudioFormatNegotiator; + +QAudioFormat::SampleFormat toQt(AFN::SampleFmt f) +{ + switch (f) { + case AFN::SampleFmt::Int16: return QAudioFormat::Int16; + case AFN::SampleFmt::Float32: return QAudioFormat::Float; + } + return QAudioFormat::Float; +} + +AFN::SampleFmt fromQt(QAudioFormat::SampleFormat f) +{ + // Only Int16 and Float32 are modelled; everything else negotiates as Float. + return (f == QAudioFormat::Int16) ? AFN::SampleFmt::Int16 : AFN::SampleFmt::Float32; +} + +QAudioFormat makeFormat(int rate, AFN::SampleFmt fmt, int channels) +{ + QAudioFormat f; + f.setSampleRate(rate); + f.setChannelCount(channels); + f.setSampleFormat(toQt(fmt)); + return f; +} + +AFN::DeviceCaps probe(const QAudioDevice& dev, AFN::Direction dir, AFN::TargetOs os, + bool bluetoothHfp, int preferredRateOverride) +{ + AFN::DeviceCaps caps; + + if (dev.isNull()) { + // Nothing to probe — treat as unknown so reliable backends fail cleanly + // and probe-at-open backends still take their preferred rung. + caps.isFormatSupportedReliable = (os != AFN::TargetOs::Windows); + if (preferredRateOverride > 0) caps.preferredRate = preferredRateOverride; + caps.isBluetoothHfp = bluetoothHfp; + return caps; + } + + const QAudioFormat pref = dev.preferredFormat(); + const int channels = pref.channelCount() >= 1 ? pref.channelCount() : 2; + caps.channels = channels; + caps.preferredRate = preferredRateOverride > 0 ? preferredRateOverride : pref.sampleRate(); + caps.preferredFormat = fromQt(pref.sampleFormat()); + caps.isBluetoothHfp = bluetoothHfp; + + // WASAPI's isFormatSupported() returns false-negatives for many valid + // devices (Voicemeeter, FlexRadio DAX, shared-mix mismatches), so on Windows + // the policy must probe-at-open instead of trusting the query (#2120/#2929). + caps.isFormatSupportedReliable = (os != AFN::TargetOs::Windows); + + // Probe the candidate rates × the two modelled formats against the device. + static const int kCandidateRates[] = {8000, 16000, 24000, 44100, 48000}; + const AFN::SampleFmt fmts[] = {AFN::SampleFmt::Float32, AFN::SampleFmt::Int16}; + + QList rates; + QList supportedFmts; + for (int rate : kCandidateRates) { + bool rateOk = false; + for (AFN::SampleFmt sf : fmts) { + QAudioFormat test = makeFormat(rate, sf, channels); + if (dev.isFormatSupported(test)) { + rateOk = true; + if (!supportedFmts.contains(sf)) supportedFmts.append(sf); + } + } + if (rateOk) rates.append(rate); + } + + // Always include the device's own preferred rate/format as supported — it is + // by definition openable even when isFormatSupported() is conservative. + if (caps.preferredRate > 0 && !rates.contains(caps.preferredRate)) { + rates.append(caps.preferredRate); + } + if (!supportedFmts.contains(caps.preferredFormat)) { + supportedFmts.append(caps.preferredFormat); + } + + caps.supportedRates = rates; + if (!supportedFmts.isEmpty()) { + caps.supportedFormats = supportedFmts; + } + return caps; +} + +Result negotiate(const QAudioDevice& dev, AFN::Direction dir, AFN::ResamplerPolicy policy, + AFN::TargetOs os, int internalRate, bool bluetoothHfp) +{ + const AFN::DeviceCaps caps = probe(dev, dir, os, bluetoothHfp); + const AFN::NegotiatedFormat n = AFN::negotiate(os, dir, caps, policy, internalRate); + + Result r; + r.ok = n.ok; + r.resampler = n.resampler; + r.fellBack = n.fellBack; + r.reason = n.reason; + if (n.ok) { + r.format = makeFormat(n.rate, n.fmt, n.channels); + } + return r; +} + +QList formatLadder(const QAudioDevice& dev, AFN::Direction dir, + AFN::ResamplerPolicy policy, AFN::TargetOs os, + int internalRate, bool bluetoothHfp, int preferredRateOverride) +{ + const AFN::DeviceCaps caps = probe(dev, dir, os, bluetoothHfp, preferredRateOverride); + const QList ladder = + AFN::buildLadder(os, dir, caps, policy, internalRate); + + QList out; + out.reserve(ladder.size()); + for (const AFN::FormatCandidate& c : ladder) { + out.append(makeFormat(c.rate, c.fmt, c.channels)); + } + return out; +} + +} // namespace AudioDeviceNegotiator +} // namespace AetherSDR diff --git a/src/core/AudioDeviceNegotiator.h b/src/core/AudioDeviceNegotiator.h new file mode 100644 index 00000000..c79f885e --- /dev/null +++ b/src/core/AudioDeviceNegotiator.h @@ -0,0 +1,73 @@ +#pragma once + +// ─── Live Qt-Multimedia wrapper around the pure AudioFormatNegotiator ───────── +// +// This is the ONLY platform-specific part of the audio format/rate negotiation +// stack. It builds a DeviceCaps snapshot from a real QAudioDevice and runs the +// pure policy (AudioFormatNegotiator) against it, returning a ready-to-open +// QAudioFormat plus the resampler strategy. Sinks/sources call this instead of +// hand-rolling their own per-OS ladder (issue #3306). +// +// Keep all live device I/O here; the policy itself stays pure and headless- +// testable in AudioFormatNegotiator. See docs/audio-sink-factory.md. + +#include "core/AudioFormatNegotiator.h" + +#include +#include +#include + +namespace AetherSDR { +namespace AudioDeviceNegotiator { + +// Probe a real device into the pure policy's injected capability snapshot. +// `bluetoothHfp` is supplied by the caller (the CoreAudio-HAL detection that +// already lives in AudioEngine), since it can't be derived from QAudioDevice. +// `preferredRateOverride` (>0) replaces the device's reported preferred rate — +// used on macOS to put a Bluetooth-HFP mic's HAL-native rate first (#2615), +// which QAudioDevice::preferredFormat() does not expose. +AudioFormatNegotiator::DeviceCaps probe( + const QAudioDevice& dev, + AudioFormatNegotiator::Direction dir, + AudioFormatNegotiator::TargetOs os = AudioFormatNegotiator::hostTargetOs(), + bool bluetoothHfp = false, + int preferredRateOverride = 0); + +struct Result { + bool ok = false; + QAudioFormat format; // hand straight to QAudioSink/Source + AudioFormatNegotiator::ResamplerKind resampler = AudioFormatNegotiator::ResamplerKind::None; + bool fellBack = false; + QString reason; +}; + +// Negotiate against a real device (reliable backends resolve fully here; for +// probe-at-open backends the returned format is the preferred first rung — use +// formatLadder() to walk the fallbacks at open). +Result negotiate( + const QAudioDevice& dev, + AudioFormatNegotiator::Direction dir, + AudioFormatNegotiator::ResamplerPolicy policy, + AudioFormatNegotiator::TargetOs os = AudioFormatNegotiator::hostTargetOs(), + int internalRate = AudioFormatNegotiator::kInternalRate, + bool bluetoothHfp = false); + +// The full ordered Qt-format ladder, for backends where isFormatSupported() +// is unreliable and the caller must try-at-open (Windows WASAPI). +QList formatLadder( + const QAudioDevice& dev, + AudioFormatNegotiator::Direction dir, + AudioFormatNegotiator::ResamplerPolicy policy, + AudioFormatNegotiator::TargetOs os = AudioFormatNegotiator::hostTargetOs(), + int internalRate = AudioFormatNegotiator::kInternalRate, + bool bluetoothHfp = false, + int preferredRateOverride = 0); + +QAudioFormat::SampleFormat toQt(AudioFormatNegotiator::SampleFmt f); +AudioFormatNegotiator::SampleFmt fromQt(QAudioFormat::SampleFormat f); + +// Build a concrete QAudioFormat from a negotiator candidate/result. +QAudioFormat makeFormat(int rate, AudioFormatNegotiator::SampleFmt fmt, int channels); + +} // namespace AudioDeviceNegotiator +} // namespace AetherSDR diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 7af6cc46..c50a2df6 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -1,6 +1,7 @@ #include "AudioEngine.h" #include "AppSettings.h" #include "AudioSummaryLogger.h" +#include "AudioDeviceNegotiator.h" #include "ClientEq.h" #include "ClientComp.h" #include "ClientGate.h" @@ -289,27 +290,10 @@ std::optional macBluetoothNativeInputRate(const QAudioDevice& qtDevice) return std::nullopt; } - -QList macTxInputRateCandidates(const QAudioDevice& qtDevice) -{ - QList rates; - if (const auto nativeRate = macBluetoothNativeInputRate(qtDevice)) { - rates << *nativeRate; - } - // Try the device's preferred (native) rate FIRST. CoreAudio reports - // isFormatSupported(48000)==true for many capture devices that actually - // run at a lower native rate (e.g. USB webcam mics at 16 kHz). Opening - // QAudioSource at 48 kHz then "succeeds" (state=Active, no error) but the - // device delivers zero samples — processedUSecs stays 0 and TX is silent. - // Honouring the device's preferred rate avoids that dead-stream trap and - // lets the existing resampler convert to 24 kHz radio-native. - const int preferredRate = qtDevice.preferredFormat().sampleRate(); - if (preferredRate > 0 && !rates.contains(preferredRate)) { - rates << preferredRate; - } - rates << 48000 << 44100 << AudioEngine::DEFAULT_SAMPLE_RATE; - return rates; -} +// (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 } @@ -689,7 +673,7 @@ AudioEngine::AudioEngine(QObject* parent) // Cap buffer to bound latency. Default 200ms, user-adjustable for // high-jitter connections (VPN, SmartLink) where drops cause choppy audio. - const int sampleRate = m_resampleTo48k ? 48000 : DEFAULT_SAMPLE_RATE; + const int sampleRate = m_rxOutputRate.load(); const int bufMs = m_rxBufferCapMs.load(); const qsizetype maxBufBytes = sampleRate * 2 * static_cast(sizeof(float)) * bufMs / 1000; if (m_rxBuffer.size() > maxBufBytes) { @@ -864,7 +848,7 @@ QJsonArray AudioEngine::audioEndpointDiagnostics() const 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_resampleTo48k) : QJsonValue(); + 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()); @@ -958,7 +942,6 @@ bool AudioEngine::startRxStream() m_lastProcessedUSecs = 0; m_lastAudioFeedTime.start(); // initialize liveness watchdog (#1411) - QAudioFormat fmt = makeFormat(); QAudioDevice dev = QMediaDevices::defaultAudioOutput(); bool rxFallbackOccurred = false; QStringList rxFallbackReasons; @@ -1032,172 +1015,70 @@ bool AudioEngine::startRxStream() } #endif - // Prefer 48kHz on Windows — WASAPI shared mode accepts 24kHz but its - // internal resampler introduces artifacts at non-standard rates that - // become audible when radio-side NR (RNN/NRL/NRS) removes the noise - // floor. Use r8brain for clean 24k→48k conversion instead, matching - // the macOS TX-side fix for the same class of issue. (#2120) -#ifdef Q_OS_WIN - fmt.setSampleRate(48000); - noteRxAttempt(fmt); - m_resampleTo48k = true; - m_audioSink = new QAudioSink(dev, fmt, this); - m_audioSink->setVolume(m_muted.load() ? 0.0f : m_rxVolume.load()); - m_audioDevice = m_audioSink->start(); - if (!m_audioDevice) { - const QString firstError = audioErrorName(m_audioSink->error()); - qCWarning(lcAudio) << "AudioEngine: 48kHz sink failed to open, trying 24kHz"; - noteRxFallback(QStringLiteral("48kHz sink failed -> 24kHz")); - delete m_audioSink; - fmt.setSampleRate(DEFAULT_SAMPLE_RATE); - noteRxAttempt(fmt); - m_resampleTo48k = false; - m_audioSink = new QAudioSink(dev, fmt, this); - m_audioSink->setVolume(m_muted.load() ? 0.0f : m_rxVolume.load()); - m_audioDevice = m_audioSink->start(); - if (!m_audioDevice) { - const QString secondError = audioErrorName(m_audioSink->error()); - qCWarning(lcAudio) << "AudioEngine: 24kHz sink also failed"; - logAudioOpenFailure(QStringLiteral("RX sink"), - QStringLiteral("QAudioSink"), - dev, - rxFormatAttempts, - QStringLiteral("QAudioSink::start failed at 48000Hz (%1) and 24000Hz (%2)") - .arg(firstError, secondError), - rxFallbackReasons); - delete m_audioSink; - m_audioSink = nullptr; - return false; - } - } - // Guard against WASAPI silently stopping the sink after idle/sleep. - // Detect the silent stop and restart cleanly, mirroring the TX-side - // fix for CoreAudio (#1149). (#1303) - // Note: IdleState restart logic removed — it caused a restart loop on - // Windows that prevented audio playback (#1405). The zombie sink - // watchdog already 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" << fmt.sampleRate() << "Hz" - << "device:" << dev.description(); - AudioSummaryLogger::RxSinkSummary windowsRxSummary; - windowsRxSummary.deviceDescription = dev.description(); - windowsRxSummary.sampleRate = fmt.sampleRate(); - windowsRxSummary.channelCount = fmt.channelCount(); - windowsRxSummary.sampleFormat = fmt.sampleFormat(); - windowsRxSummary.resamplingActive = m_resampleTo48k; - windowsRxSummary.fallbackOccurred = rxFallbackOccurred; - windowsRxSummary.fallbackReason = rxFallbackReasons.join(QStringLiteral("; ")); - AudioSummaryLogger::logRxSink(windowsRxSummary); - m_rxBufferSampleRate.store(fmt.sampleRate()); - startSidetoneStream(); - emit rxStarted(); - return true; -#else - auto configureOutputFormat = [this, &dev, ¬eRxFallback, ¬eRxAttempt](QAudioFormat& candidateFmt) { - candidateFmt = makeFormat(); -#ifdef Q_OS_MAC - // CoreAudio can route Bluetooth headsets onto the HFP/telephony - // transport when opened directly at 24 kHz. Prefer 48 kHz on macOS - // so A2DP-capable devices stay on the normal output profile. - candidateFmt.setSampleRate(48000); - noteRxAttempt(candidateFmt); - if (dev.isFormatSupported(candidateFmt)) { - m_resampleTo48k = true; - return true; - } - - qCWarning(lcAudio) << "AudioEngine: output device does not support 48kHz stereo float, trying 24kHz"; - candidateFmt.setSampleRate(DEFAULT_SAMPLE_RATE); - noteRxAttempt(candidateFmt); - if (dev.isFormatSupported(candidateFmt)) { - noteRxFallback(QStringLiteral("48kHz stereo float unsupported -> 24kHz")); - m_resampleTo48k = false; - return true; - } - - qCWarning(lcAudio) << "AudioEngine: output device does not support 24kHz stereo float either"; - return false; -#else - noteRxAttempt(candidateFmt); - if (dev.isFormatSupported(candidateFmt)) { - m_resampleTo48k = false; - return true; - } - - qCWarning(lcAudio) << "AudioEngine: output device does not support 24kHz stereo Int16, trying 48kHz"; - candidateFmt.setSampleRate(48000); - noteRxAttempt(candidateFmt); - if (dev.isFormatSupported(candidateFmt)) { - noteRxFallback(QStringLiteral("24kHz stereo float unsupported -> 48kHz")); - m_resampleTo48k = true; - return true; + // 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()); + 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; } - - qCWarning(lcAudio) << "AudioEngine: output device does not support 48kHz stereo Int16 either"; - return false; -#endif - }; - - if (!configureOutputFormat(fmt)) { - qCWarning(lcAudio) << "No audio device detected"; - logAudioOpenFailure(QStringLiteral("RX sink"), - QStringLiteral("QAudioSink"), - dev, - rxFormatAttempts, - QStringLiteral("output device supports no usable RX format"), - rxFallbackReasons); - return false; + lastRxError = audioErrorName(sink->error()); + delete sink; + triedFloatRung = true; } -#endif - - m_audioSink = new QAudioSink(dev, fmt, this); - m_audioSink->setVolume(m_muted.load() ? 0.0f : m_rxVolume.load()); - m_audioDevice = m_audioSink->start(); // push-mode if (!m_audioDevice) { - const QString error = audioErrorName(m_audioSink->error()); - qCWarning(lcAudio) << "AudioEngine: failed to open audio sink"; - if (rxFormatAttempts.isEmpty()) { - noteRxAttempt(fmt); - } + qCWarning(lcAudio) << "AudioEngine: failed to open RX audio sink on any negotiated format"; logAudioOpenFailure(QStringLiteral("RX sink"), QStringLiteral("QAudioSink"), dev, rxFormatAttempts, - QStringLiteral("QAudioSink::start returned null (%1)").arg(error), + QStringLiteral("QAudioSink::start failed on all negotiated formats (%1)") + .arg(lastRxError), rxFallbackReasons); - delete m_audioSink; m_audioSink = nullptr; return false; } - // Guard against the audio backend silently stopping the sink after idle/sleep. - // Detect the silent stop and restart cleanly, mirroring the TX-side - // fix for CoreAudio (#1149). (#1303) - // Note: IdleState restart logic removed — it caused a restart loop on - // Windows that prevented audio playback (#1405). The zombie sink - // watchdog already handles stale WASAPI sessions after idle/sleep. + // 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) { @@ -1220,20 +1101,22 @@ bool AudioEngine::startRxStream() startRxStream(); }, Qt::QueuedConnection); }); - qCDebug(lcAudio) << "AudioEngine: RX stream started"; - m_rxBufferSampleRate.store(fmt.sampleRate()); + 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 = fmt.sampleRate(); - summary.channelCount = fmt.channelCount(); - summary.sampleFormat = fmt.sampleFormat(); - summary.resamplingActive = m_resampleTo48k; + 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 sink alongside the RX sink. Cheap when - // sidetone is disabled — the timer fires but writes silence to a tiny - // primed buffer; no audible output, no extra CPU on the operator side. + // 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(); @@ -1458,10 +1341,13 @@ static void applyRxPanInPlace(float* stereo, int nFrames, int pan) // processStereoToStereo() collapses L+R to mono — do NOT use it here. QByteArray AudioEngine::resampleStereo(const QByteArray& pcm) { + // 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). if (!m_rxResampler) - m_rxResampler = std::make_unique(24000, 48000); + m_rxResampler = std::make_unique(24000, m_rxOutputRate.load()); if (!m_rxResamplerR) - m_rxResamplerR = std::make_unique(24000, 48000); + m_rxResamplerR = std::make_unique(24000, m_rxOutputRate.load()); const int frames = pcm.size() / (2 * static_cast(sizeof(float))); if (frames <= 0) return {}; @@ -1582,8 +1468,8 @@ void AudioEngine::feedAudioData(const QByteArray& pcm) puduSource = &m_clientPuduRxScratch; } - const int scopeSampleRate = m_resampleTo48k ? 48000 : DEFAULT_SAMPLE_RATE; - const QByteArray& resampled = m_resampleTo48k ? resampleStereo(*puduSource) : *puduSource; + const int scopeSampleRate = m_rxOutputRate.load(); + const QByteArray& resampled = (m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE) ? resampleStereo(*puduSource) : *puduSource; const QByteArray* output = &resampled; QByteArray boosted; if (m_rxBoost.load()) { @@ -3960,8 +3846,8 @@ void AudioEngine::processBnr(const QByteArray& stereoPcm) m_bnrOutBuf.remove(0, wantBytes); if (m_audioDevice && m_audioDevice->isOpen()) { - const int scopeSampleRate = m_resampleTo48k ? 48000 : DEFAULT_SAMPLE_RATE; - const QByteArray& resampled = m_resampleTo48k ? resampleStereo(chunk) : chunk; + const int scopeSampleRate = m_rxOutputRate.load(); + const QByteArray& resampled = (m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE) ? resampleStereo(chunk) : chunk; const QByteArray* output = &resampled; QByteArray trimmed; const float trimDb = m_rxOutputTrimDb.load(); @@ -4221,33 +4107,23 @@ bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioP << dev.minimumSampleRate() << "-" << dev.maximumSampleRate() << "Hz" << dev.minimumChannelCount() << "-" << dev.maximumChannelCount() << "ch"; - // Negotiate the best sample rate for TX mic input. - // macOS: prefer 48kHz for general devices, but open Bluetooth headset - // inputs at their HAL-native low rate when CoreAudio reports no high-rate - // capture mode. That avoids a hidden native->48k conversion before the - // app's normal radio-native conversion. - // Linux: prefer 24kHz (radio native, no resampling). Windows uses 48kHz - // WASAPI shared mode and relies on the app's normal 48k->24k resampler. + // 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_MAC - const QList rates = macTxInputRateCandidates(dev); - const int preferredTxRate = rates.isEmpty() ? 48000 : rates.first(); -#elif defined(Q_OS_WIN) - constexpr int preferredTxRate = 48000; -#else - constexpr int rates[] = {24000, 48000, 44100}; - constexpr int preferredTxRate = 24000; -#endif #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, etc.). Default to 48kHz and let WASAPI - // handle the rate. For channel count, clamp to the device's reported - // maximumChannelCount() so mono-only USB PnP mics open as mono on the - // first attempt — opening them as stereo silently returns a non-null - // QIODevice that delivers zero bytes (#2929). Stereo-capable virtual - // devices (Voicemeeter / DAX) still open at stereo because they report - // maximumChannelCount() >= 2. + // 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; @@ -4255,10 +4131,28 @@ bool AudioEngine::startTxStream(const QHostAddress& radioAddress, quint16 radioP 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 (int rate : rates) { + for (const QAudioFormat& cand : txLadder) { + if (cand.sampleFormat() != QAudioFormat::Int16) + continue; // mic is captured as Int16 fmt.setChannelCount(channels); - fmt.setSampleRate(rate); + fmt.setSampleRate(cand.sampleRate()); noteTxAttempt(fmt); if (dev.isFormatSupported(fmt)) { formatFound = true; @@ -5211,9 +5105,9 @@ void AudioEngine::feedDecodedSpeech(const QByteArray& pcm) // m_radeRxBuffer with m_rxBuffer sample-wise so both are heard simultaneously // without doubling the fill rate. A dedicated resampler preserves the filter // state independently from the m_rxResampler used by feedAudioData(). - if (m_resampleTo48k) { + if (m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE) { if (!m_radeRxResampler) - m_radeRxResampler = std::make_unique(24000, 48000); + m_radeRxResampler = std::make_unique(24000, m_rxOutputRate.load()); const auto* src = reinterpret_cast(pcm.constData()); m_radeRxBuffer.append( m_radeRxResampler->processStereoToStereo( diff --git a/src/core/AudioEngine.h b/src/core/AudioEngine.h index de58148b..5003e33d 100644 --- a/src/core/AudioEngine.h +++ b/src/core/AudioEngine.h @@ -128,7 +128,7 @@ class AudioEngine : public QObject { int txInputSampleRate() const { return m_txInputRate; } int txInputChannelCount() const { return m_txInputChannels; } bool txInputResamplingTo24k() const { return m_txNeedsResample; } - bool rxOutputResamplingActive() const { return m_resampleTo48k; } + bool rxOutputResamplingActive() const { return m_rxOutputRate.load() != DEFAULT_SAMPLE_RATE; } QJsonArray audioEndpointDiagnostics() const; // Client-side PC mic gain (0-100 → 0.0-1.0, applied before Opus encoding) @@ -670,10 +670,15 @@ private slots: std::atomic m_rxPan{50}; // 0=left, 50=centre, 100=right (#1460) std::atomic m_rxBufferCapMs{200}; // RX buffer cap in ms (#1505) std::atomic m_muted{false}; - bool m_resampleTo48k{false}; // RX: upsample 24kHz → 48kHz output - std::unique_ptr m_rxResampler; // 24k→48k for L channel (lazy init) - std::unique_ptr m_rxResamplerR; // 24k→48k for R channel — kept in sync with m_rxResampler - std::unique_ptr m_radeRxResampler; // separate 24k→48k for RADE decoded speech + // RX sink device rate (negotiated via AudioFormatNegotiator). Audio is + // resampled from the 24k canonical rate up to this when they differ (#3306). + // Atomic: written from startRxStream() (GUI thread) and read from the RX + // drain timer lambda plus several status/scope read paths — matches the + // surrounding std::atomic neighbours (m_rxPan, m_rxBufferCapMs). + std::atomic m_rxOutputRate{DEFAULT_SAMPLE_RATE}; + std::unique_ptr m_rxResampler; // 24k→device rate, L channel (lazy init) + std::unique_ptr m_rxResamplerR; // 24k→device rate, R channel — kept in sync with m_rxResampler + std::unique_ptr m_radeRxResampler; // separate 24k→device rate for RADE decoded speech std::unique_ptr m_cwSidetone; // local CW sidetone, mixed into RX drain // Atomic gate for the TX-side CW decode tap (#2417). Flipped from // MainWindow on MOX / CwDecodeTxEnabled changes; checked on the diff --git a/tests/audio_device_negotiator_test.cpp b/tests/audio_device_negotiator_test.cpp new file mode 100644 index 00000000..c2403090 --- /dev/null +++ b/tests/audio_device_negotiator_test.cpp @@ -0,0 +1,82 @@ +// Smoke test for the live Qt-Multimedia negotiation wrapper +// (AudioDeviceNegotiator). The pure policy is exhaustively covered by +// audio_format_negotiation_test; this one verifies the wrapper builds a sane +// DeviceCaps from a real QAudioDevice and round-trips to an openable +// QAudioFormat. It is tolerant of headless CI runners with no audio hardware +// (it reports a skip rather than failing). +// +// Run: ./build/audio_device_negotiator_test + +#include "core/AudioDeviceNegotiator.h" + +#include +#include +#include + +#include +#include + +using namespace AetherSDR; +namespace AFN = AetherSDR::AudioFormatNegotiator; + +namespace { + +int g_failed = 0; +int g_total = 0; + +void report(const std::string& name, bool ok, const std::string& detail = {}) +{ + ++g_total; + std::printf("%s %-58s %s\n", ok ? "[ OK ]" : "[FAIL]", name.c_str(), detail.c_str()); + if (!ok) ++g_failed; +} + +void checkDirection(const char* label, const QAudioDevice& dev, AFN::Direction dir) +{ + if (dev.isNull()) { + std::printf("[SKIP] %-58s (no device on this runner)\n", label); + return; + } + + // Probe must produce a non-empty, self-consistent capability snapshot. + const AFN::DeviceCaps caps = AudioDeviceNegotiator::probe(dev, dir); + report(std::string(label) + ": probe found rates", + !caps.supportedRates.isEmpty(), + caps.supportedRates.isEmpty() ? "no rates probed" : ""); + + // Negotiation must succeed and yield an openable (positive-rate) format. + const auto r = AudioDeviceNegotiator::negotiate( + dev, dir, AFN::ResamplerPolicy::PreservePan); + report(std::string(label) + ": negotiate ok", + r.ok && r.format.sampleRate() > 0 && r.format.channelCount() >= 1, + r.ok ? (std::string("rate=") + std::to_string(r.format.sampleRate()) + + " ch=" + std::to_string(r.format.channelCount()) + + " resampler=" + AFN::toString(r.resampler)) + : "negotiate failed"); + + // The format ladder must be non-empty and start at the preferred rung. + const auto ladder = AudioDeviceNegotiator::formatLadder( + dev, dir, AFN::ResamplerPolicy::PreservePan); + report(std::string(label) + ": ladder non-empty", !ladder.isEmpty()); +} + +} // namespace + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + checkDirection("default output / RX", QMediaDevices::defaultAudioOutput(), AFN::Direction::Output); + checkDirection("default input / TX", QMediaDevices::defaultAudioInput(), AFN::Direction::Input); + + // Enum round-trips are pure and always run. + report("toQt/fromQt Int16 round-trips", + AudioDeviceNegotiator::fromQt(AudioDeviceNegotiator::toQt(AFN::SampleFmt::Int16)) + == AFN::SampleFmt::Int16); + report("toQt/fromQt Float32 round-trips", + AudioDeviceNegotiator::fromQt(AudioDeviceNegotiator::toQt(AFN::SampleFmt::Float32)) + == AFN::SampleFmt::Float32); + + std::printf("\n%d/%d checks passed\n", g_total - g_failed, g_total); + return g_failed == 0 ? 0 : 1; +}