fix(audio): WASAPI mono-only USB PnP mic silent-open recovery (#2929). Principle VIII.#3036
Conversation
…. Principle VIII. Generic USB PnP microphones (CMedia-class etc.) often expose a single native capture channel. Qt's WASAPI backend accepts our unsupported 48 kHz stereo open without error -- QAudioSource::start() returns a non-null QIODevice -- but then delivers zero bytes. The existing fallback ladder is gated on start() returning null, so it never fires; the mic meter stays flat and no PC audio reaches the radio. Fix (contained to AudioEngine::startTxStream / onTxAudioReady): 1. Device-driven initial channel count. On Windows, clamp the initial open's channel count to dev.maximumChannelCount() so mono-only mics open as mono on the first attempt. Voicemeeter / FlexRadio DAX virtual devices still report >= 2 channels and continue to open at stereo -- the existing isFormatSupported()-bypass behaviour is preserved. 2. Fallback-ladder skip-check now compares (rate, ch) tuples instead of just rate. The old `if (rate == fmt.sampleRate()) continue;` skipped 48 kHz entirely on retry, preventing a 48 kHz mono attempt for mono-only USB mics. 3. Silence watchdog. After a successful open on the Windows pull-mode path, arm a one-shot 1500 ms timer. If the source reports ActiveState but no bytes have arrived (m_txReceivedAnyBytes set in onTxAudioReady), log a clear warning, tear down, and reopen as mono. Bounded by m_txSilenceRetryDone so a still-silent mono open fails loudly instead of looping. Lifecycle-guarded by m_txLifecycleGeneration so a manual stop in between is a no-op. Principle VIII: verification is by behaviour. The warning string "TX source opened but produced no bytes in 1.5 s" is grep-able in the support-bundle log so a maintainer can confirm the fix path was taken. Principle I (FlexLib authority) is unaffected -- no SmartSDR command or status surface is touched; the fix is entirely local to the Qt audio open path. Blast radius: risk_score=0.454, 18 high-risk affected (top: MainWindow::MainWindow, MainWindow::wirePanadapter, MainWindow::buildMenuBar). All affected symbols include AudioEngine.h transitively and recompile; no public API or ABI surface changed -- three new private bool members and modifications confined to two private member-function bodies. Callers of startTxStream(addr, port) see the same return contract. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for tackling this — the WASAPI silent-open behavior on mono-only USB mics is exactly the right diagnosis for #2929, and the changes hang together well. A couple of items worth a second look:
1. Watchdog QAudio::ActiveState check may miss the silent-open case
if (m_audioSource->state() != QAudio::ActiveState) return;For the WASAPI silent-open bug, QAudioSource returns a non-null QIODevice but never delivers bytes. In that condition Qt's WASAPI plugin often transitions to QAudio::IdleState (capture buffer never fills), so this guard could cause the watchdog to bail out exactly when it's most needed — meaning the recovery only kicks in if the source happens to still be reported as Active at the 1.5 s mark.
The real signal is already !m_txReceivedAnyBytes. Consider either dropping the state check entirely or weakening it to state() == QAudio::StoppedState ? return : continue; so an Idle source still triggers the retry. If you've verified on real hardware that the broken state is Active, a short comment to that effect would lock the behavior in.
2. Fallback-loop skip fix is good and worth calling out
The change from if (rate == fmt.sampleRate()) continue; to skipping only the exact (rate, ch) combo is a real bug fix — the old loop refused to try 48 kHz mono when 48 kHz stereo had just failed, which is the exact combination a mono-only 48 kHz USB mic needs. Nice catch alongside the primary fix.
3. Lifecycle generation handshake — looks correct
I traced the m_txLifecycleGeneration capture/check across stopTxStream() → startTxStream() on the Windows path. The Windows start path doesn't bump the generation (only macOS does at line 4044), but stopTxStream() at line 4209 does, so the watchdog's watchdogGen != m_txLifecycleGeneration guard correctly invalidates the lambda when a stop has intervened. The one-shot m_txSilenceRetryDone flag also correctly prevents the post-retry start from re-arming the watchdog. Good.
Minor
- The three new bool members are only touched on the AudioEngine thread (start/stop,
readyReadslot, queued lambda), so no atomicity needed — worth a brief comment in the header if you want to make that ownership explicit, but not required. QTimer::singleShot(1500, this, ...)correctly auto-cancels on destruction via the context object — good.
Otherwise the change is well-scoped and the diagnostics-style qCWarning line will make this case obvious in support bundles. Just the watchdog state check is the one item I'd ask you to verify before merge.
🤖 aethersdr-agent · cost: $4.8658 · model: claude-opus-4-7
…sdr#2929). Principle VIII. (aethersdr#3036) ## Summary Fixes aethersdr#2929 ### What was changed fix(audio): WASAPI mono-only USB PnP mic silent-open recovery (aethersdr#2929). Principle VIII. ### Files modified - `src/core/AudioEngine.cpp` - `src/core/AudioEngine.h` ``` src/core/AudioEngine.cpp | 58 ++++++++++++++++++++++++++++++++++++++++++++---- src/core/AudioEngine.h | 6 +++++ 2 files changed, 60 insertions(+), 4 deletions(-) ``` --- Generated by AetherClaude (automated agent for AetherSDR) --- <sub>🤖 aethersdr-agent · cost: $32.2212 · model: claude-opus-4-7</sub> Co-authored-by: aethersdr-agent[bot] <273844287+aethersdr-agent[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Fixes #2929
What was changed
fix(audio): WASAPI mono-only USB PnP mic silent-open recovery (#2929). Principle VIII.
Files modified
src/core/AudioEngine.cppsrc/core/AudioEngine.hGenerated by AetherClaude (automated agent for AetherSDR)
🤖 aethersdr-agent · cost: $32.2212 · model: claude-opus-4-7