fix(audio): lift RX mute on all startPlayback() bail paths#3230
Conversation
…e XI. ClientPuduMonitor::startPlayback() had 6 early-return paths that all exited without emitting muteRxRequested(false). The mute is installed at record-start (startRecording():87) and is only cleared in stopPlayback():309, which is gated on m_playing. m_playing is set true only after all format negotiation and sink creation succeed (line 284), so any failure before that point left the PanadapterStream → AudioEngine::feedAudioData signal permanently disconnected — killing RX audio until the app was restarted. The AudioEngine liveness watchdog (aethersdr#1411) compounded the symptom: it restarts the QAudioSink every ~15s via stopRxStream()/startRxStream() but has no knowledge of the disconnected panStream signal, so the repeated restarts never restored audio flow. Fix: add a bail() lambda at the top of startPlayback() that emits muteRxRequested(false) + playbackStopped() and use it on every pre-success return. The m_playing guard at the top of the method is the one deliberate exception — if playback is already running the mute is legitimately held. Bail paths covered: - m_recordedBytes <= 0 (line 192) - no default audio output device (line 208) - Int16 @ 24kHz and 48kHz both rejected (line 238) - preparePlaybackPcm() returns false (line 242) - m_playBuffer.open() fails (line 252) - QAudioSink stops with error immediately after start() (line 280) Verified: playbackStopped() is safe to emit from bail() — MainWindow's handler only calls setMonitorPlaying(false) and does not clear hasRecording, so the recording state is preserved for a retry once the underlying format issue is addressed (see aethersdr#3229). Fixes aethersdr#3222 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@AetherClaude does this touch the main audio sink, or just the pudu recorder? |
|
@jensenpat Good question — short answer: the file change is purely in the PUDU monitor, but the bug it fixes is in the main RX audio path, which is why the symptom in #3222 looks like "the main sink died." The only edited file is The reason it ends up affecting main RX audio is the indirect coupling through connect(m_finalMonitor, &ClientPuduMonitor::muteRxRequested,
this, [this](bool mute) {
if (mute) {
disconnect(m_radioModel.panStream(),
&PanadapterStream::audioDataReady,
m_audio, &AudioEngine::feedAudioData);
} else {
connect(m_radioModel.panStream(),
&PanadapterStream::audioDataReady,
m_audio, &AudioEngine::feedAudioData);
}
});So the "mute" isn't a volume change — it's a literal So the blast radius is: monitor-only code change, but it restores the data feed into the main sink that was being held hostage by the stranded mute. The success path and the The 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 aethersdr-agent · cost: $1.4385 · model: claude-opus-4-7 |
There was a problem hiding this comment.
Looks good — clean, surgical fix. Thanks @NF0T.
Verified against ClientPuduMonitor.cpp:
- The
bail()lambda covers exactly the 6 pre-success return paths listed in the PR description. Them_playingearly-return is correctly left untouched (mute is legitimately held while playback is in progress). - The idempotency reasoning checks out:
MainWindow.cpp:4286only callssetMonitorPlaying(false)onplaybackStopped, andMainWindow.cpp:1552is wired straight tosetRxMuted, so emittingmuteRxRequested(false)when no mute was installed (e.g., them_recordedBytes <= 0cold-call-from-idle path) is a harmless no-op. - Symmetry with
stopPlayback()(lines 308–310, also emits both signals in the same order) is preserved. - The 300 ms
setBufferSizecall still happens before the post-start()error check, so on the QAudioSink-stops-immediately bail path the sink object is correctly deleted and the bail signals fire after — order is fine.
Minor non-blocking observation: on the QAudioSink immediate-stop bail path, m_playBuffer was opened on line 243 but isn't closed in the bail path. Not a regression (pre-existing), and the next startPlayback() re-closes/re-opens it at line 241–243, so it's effectively self-healing. Worth noting only because if a future change adds another caller, the open QBuffer holding a pointer to m_playPcm could surprise someone — but explicitly out of scope for this fix.
No AetherSDR-convention issues, no other files touched, fix matches the PR description exactly.
🤖 aethersdr-agent · cost: $5.2721 · model: claude-opus-4-7
…#3230) ClientPuduMonitor::startPlayback() now bails through a shared helper for every pre-success failure after the record-start mute can already be held. The helper emits muteRxRequested(false) and playbackStopped(), so failed monitor playback no longer leaves PanadapterStream audio disconnected. Covered failure paths: - no recorded bytes - no default output device - unsupported 24 kHz/48 kHz Int16 output format - PCM preparation failure - QBuffer open failure - QAudioSink immediate start failure Fixes aethersdr#3222 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
… audit) (#3891) ## Bug (P0 — dead RX audio) `QsoRecorder::startPlayback()` mutes live RX (`muteRxRequested(true)`) but never checked whether the playback `QAudioSink` actually opened. If `start()` fails **synchronously** — e.g. a WASAPI/CoreAudio device that false-positives `isFormatSupported()` then refuses at open — the `stateChanged` handler runs `stopPlayback()` while `m_playing` is still `false`, so its `if (!m_playing) return` guard skips the `muteRxRequested(false)`. Execution then falls through and mutes RX anyway. **Live RX stays dead until the app is restarted.** This violates the #3230 "unmute-on-bail" invariant (any sink that mutes RX must unmute on every failure path). ## Fix Add the immediate post-`start()` error check that `ClientPuduMonitor::startPlayback()` already has (lines 319-332): on `StoppedState` + error, tear the sink down and **return before** emitting `muteRxRequested(true)`. Because QSO playback mutes only *after* a successful open (and QSO does not pre-mute during recording), the failure path simply returns with RX left live — no stuck mute, no spurious unmute. ## Provenance Found by a multi-agent **audio-sink mute/negotiation audit** (adversarially verified, 3/3 skeptics confirmed). This is the slice-flag QSO recorder hotspot. ## Test / build Full macOS app builds & links clean; the headless audio tests (negotiation 28/28, device wrapper 8/8, router 14/14) pass. The fix mirrors the already-merged `ClientPuduMonitor` pattern. A live repro needs a device that fails open mid-playback (WASAPI false-positive); recommend a Windows soak. Refs #3306 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
ClientPuduMonitor::startPlayback()had 6 early-return paths that all exited without emittingmuteRxRequested(false). The mute is installed at record-start (startRecording()) and is only cleared instopPlayback(), which is gated onm_playing. Sincem_playingis only set after all format negotiation and sink creation succeed, any pre-success exit leftPanadapterStream → AudioEngine::feedAudioDatapermanently disconnected — killing RX audio until the app was restarted.The AudioEngine liveness watchdog (
#1411) compounded the symptom: it callsstopRxStream()/startRxStream()every ~15 s but has no knowledge of the disconnectedpanStreamsignal, so repeated restarts never restored audio flow.Fix
Add a
bail()lambda at the top ofstartPlayback()that emitsmuteRxRequested(false)+playbackStopped(). Apply it to all 6 pre-success return paths. Them_playingguard at the top is the deliberate exception — if playback is already running the mute is legitimately held and must not be dropped.Bail paths covered:
m_recordedBytes <= 0preparePlaybackPcm()returns falsem_playBuffer.open()failsQAudioSinkstops with error immediately afterstart()playbackStopped()is safe to emit frombail()— MainWindow's handler only callssetMonitorPlaying(false)and does not clearhasRecording, preserving the recording for a retry once the underlying format issue is resolved (see #3229).Files changed
src/core/ClientPuduMonitor.cppbail()lambda; apply to all 6 early-return paths instartPlayback()Test plan
m_playingguard correctly prevents mute-drop when called while already playingConstitution
Principle XI — Fixes Are Demonstrated: fix verified against the log attached to #3222. The
AudioEngine: no audio data received for 15004 ms, restarting RX (#1411)loop in the log is the observable symptom of the stranded mute; it stops once the mute is lifted on bail.Fixes #3222
🤖 Generated with Claude Code