fix(audio): capture TX in Client-Side recordings — they were silent during transmit (#3556)#3632
Conversation
…ethersdr#3556) Client-Side QSO recordings produced a full-length but silent WAV during any SSB/phone transmit. Root cause: nothing was ever connected to QsoRecorder::feedTxAudio, and during TX the radio mutes the RX stream, so the only wired source (PanadapterStream::audioDataReady -> feedRxAudio) wrote full-length silence while the operator's TX audio was never captured. The RADE-only txRawPcmReady tap doesn't fire for SSB, so it isn't the fix. Route the post-final-limiter TX monitor — the exact int16 stream packetised to the radio, already used by the Aetherial strip's record button — into the recorder: - AudioEngine emits a new txFinalMonitorPcmReady(int16 stereo) signal at the post-limiter tap (independent of whether the PUDU monitor is attached). - MainWindow connects it to QsoRecorder::feedTxAudio. - QsoRecorder now MOX-gates its two feeds (atomic set in onMoxChanged): RX is written only while receiving, the TX monitor only while transmitting, so the two never double-write and the WAV is a single time-interleaved RX/TX stream matching Radio-Side recording. feedTxAudio writes the int16 monitor directly (it is already the WAV's 24 kHz stereo int16 format — no float conversion). Also corrects the stale MainWindow comment that claimed txRawPcmReady is int16 and the QsoRecorder usage docs. Needs a real-radio soak: Client-Side record across an SSB TX, confirm the WAV carries TX audio (not -91 dB silence). Refs aethersdr#3556 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed the diff against the source. This is a well-reasoned fix and the root-cause analysis holds up. Thanks for the careful writeup, @jensenpat. ✅ All six CI checks (build/macOS/Windows/CodeQL) are green.
What I verified
datais genuinely int16, so the direct write is correct. InonTxAudioReady,datais treated asint16_t*throughout (test tone, mic gain, Quindar, limiter), and the newemit txFinalMonitorPcmReady(data)sits immediately afterapplyClientFinalLimiterTxInt16(data)at the same post-limiter point as them_txFinalMonitortap. SofeedTxAudiowriting it directly — bypassingfloat32ToInt16— is right, and it matches the WAV's native format.- Sample rate matches. The monitor stream is
DEFAULT_SAMPLE_RATE = 24000(AudioEngine.h:72), which equalsQsoRecorder::SAMPLE_RATE = 24000. RX/TX land in the same 24 kHz stereo int16 container, so the time-interleaving is dimensionally sound. - MOX gating prevents double-write.
m_transmittingis set at the top ofonMoxChangedbefore any early return, both feeds check it underm_writeMutex, and the atomic gives correct cross-thread visibility. The two streams are mutually exclusive per MOX edge, and writes are mutex-serialized — no corruption, only a few-ms of boundary slop at transitions, which is acceptable for QSO capture. - Nothing was previously wired to
feedTxAudio, so no double-connect, and the stale "TX is int16, would need separate handling" comment is now corrected.
Minor (non-blocking)
txFinalMonitorPcmReadyis emitted on every TX-pipeline buffer, including during RX (the tap runs whenever mic capture runs, e.g. VOX/metering). That's a queued cross-thread signal (~every 10 ms) even when not recording. TheQByteArrayis COW so the copy is cheap and the slot fast-returns, as your comment notes — so this is fine, just flagging that the cost isn't strictly zero when idle. No change needed unless profiling later shows it.
One thing to confirm
The PR already calls this out: the defect is integration-level and the fix is unproven without a real-radio soak (RX → SSB TX → RX, confirm the WAV carries TX audio and is time-correct, not −91 dB silence). The static reasoning is sound; the behavioral confirmation is what closes it out. Worth landing the soak result in the PR before merge per Principle XI.
Nice work — clean, minimal, and the format/threading details are correct.
🤖 aethersdr-agent · cost: $5.0872 · model: claude-opus-4-8
There was a problem hiding this comment.
Reviewed the diff against the surrounding code — this is a clean, well-reasoned fix. Thanks @jensenpat. ✅ All 6 checks green (CodeQL, macOS, Windows, build, accessibility).
What I verified:
- Format claim holds. At the emit point in
onTxAudioReady(),datahas been resampled to 24 kHz duplicated-stereo int16 and run through the final limiter (DEFAULT_SAMPLE_RATE = 24000). That matches the recorder's WAV header exactly (SAMPLE_RATE/NUM_CHANNELS/BITS_PER_SAMPLE= 24000/2/16), so writing the monitor bytes directly — skipping the float32 round-trip — is correct, and removing the conversion infeedTxAudiois safe because the only connection now delivers int16. - Root cause confirmed. Nothing was wired to
QsoRecorder::feedTxAudioanywhere in the tree before this PR, so the "silent during TX" diagnosis is accurate and the float→int16 signature change has no stale float caller to break. - Threading is sound.
AudioEnginelives onm_audioThread;m_qsoRecorderis parented toMainWindow(GUI thread), sotxFinalMonitorPcmReady → feedTxAudiois a queued connection — consistent with the header comment.feedRxAudio,feedTxAudio, andonMoxChangedall run serialized on the GUI event loop, and thestd::atomic<bool> m_transmittinggate (set first inonMoxChanged, before any branch) is correct and appropriately conservative.
Two non-blocking notes for the soak you already flagged:
- MOX-edge interleave. Because the feeds arrive via queued connections, a buffer or two right at each RX↔TX transition can get dropped (RX still queued when
m_transmittingflips true, or vice-versa). That's benign and matches the time-interleave intent, but worth confirming during the real-radio soak that the transitions don't produce an audible click/gap at the seam. - Wider-than-auto behavior change. The MOX gate now applies to any active recording, including a manual
startRecording()in Radio-Side mode — during TX such a recording will now capture the TX monitor instead of RX silence. That's an improvement, just confirming it's intended beyond the Client-Side auto path.
Code looks good to merge once the soak confirms TX audio lands in the WAV (not −91 dB) with RX/TX time-correct.
🤖 aethersdr-agent · cost: $4.1204 · model: claude-opus-4-8
Fixes #3556. Based directly on
main(independent of the audio-sink-factory work).Bug
With record mode set to Client Side, the WAV is full-length but silent during any SSB/phone transmit (reporter measured −91 dB — pure int16 noise floor — across the whole file). Radio-Side recording of the same QSO works.
Root cause (per the reporter's investigation, confirmed)
QsoRecorder::feedTxAudio— the slot is never called.PanadapterStream::audioDataReady → feedRxAudio. During TX the radio mutes the RX stream, sofeedRxAudiowrites full-length silence while the operator's TX audio is never captured.txRawPcmReadytap doesn't fire for SSB, so wiring that alone wouldn't fix phone TX.Fix
Route the post-final-limiter TX monitor — the exact int16 stream packetised to the radio, already used by the Aetherial strip's record button — into the recorder:
AudioEngine::txFinalMonitorPcmReady(int16 stereo), emitted at the post-limiter monitor tap (independent of whether the PUDU monitor is attached, so recording works regardless).MainWindowconnects it toQsoRecorder::feedTxAudio.QsoRecorderMOX-gates its two feeds (atomic, set inonMoxChanged): RX is written only while receiving, the TX monitor only while transmitting, so they never double-write and the WAV is a single time-interleaved RX/TX stream that matches Radio-Side behaviour (what the reporter expected).feedTxAudiowrites the int16 monitor directly — it's already the WAV's native 24 kHz stereo int16 format, no float round-trip.MainWindowcomment that claimedtxRawPcmReadyis int16, and theQsoRecorderusage docs.Test / soak
main.Closes #3556
💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat