Skip to content

fix(audio): capture TX in Client-Side recordings — they were silent during transmit (#3556)#3632

Open
jensenpat wants to merge 1 commit into
aethersdr:mainfrom
jensenpat:aether/fix-3556-clientside-tx-recording
Open

fix(audio): capture TX in Client-Side recordings — they were silent during transmit (#3556)#3632
jensenpat wants to merge 1 commit into
aethersdr:mainfrom
jensenpat:aether/fix-3556-clientside-tx-recording

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

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)

  • Nothing is ever connected to QsoRecorder::feedTxAudio — the slot is never called.
  • The only wired source is RX: PanadapterStream::audioDataReady → feedRxAudio. During TX the radio mutes the RX stream, so feedRxAudio writes full-length silence while the operator's TX audio is never captured.
  • The RADE-only txRawPcmReady tap 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:

  • New signal AudioEngine::txFinalMonitorPcmReady(int16 stereo), emitted at the post-limiter monitor tap (independent of whether the PUDU monitor is attached, so recording works regardless).
  • MainWindow connects it to QsoRecorder::feedTxAudio.
  • QsoRecorder MOX-gates its two feeds (atomic, set in onMoxChanged): 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). feedTxAudio writes the int16 monitor directly — it's already the WAV's native 24 kHz stereo int16 format, no float round-trip.
  • Corrects the stale MainWindow comment that claimed txRawPcmReady is int16, and the QsoRecorder usage docs.

Test / soak

  • Full macOS app builds & links clean (RelWithDebInfo) directly on main.
  • Needs a real-radio soak (the defect is integration-level): set Client-Side, record across RX → SSB TX → RX, and confirm the WAV carries TX audio (not −91 dB silence) with RX/TX time-correct. Deployed to my macOS test box.

Closes #3556

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

…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>
@jensenpat jensenpat marked this pull request as ready for review June 17, 2026 04:23
@jensenpat jensenpat requested review from a team as code owners June 17, 2026 04:23

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • data is genuinely int16, so the direct write is correct. In onTxAudioReady, data is treated as int16_t* throughout (test tone, mic gain, Quindar, limiter), and the new emit txFinalMonitorPcmReady(data) sits immediately after applyClientFinalLimiterTxInt16(data) at the same post-limiter point as the m_txFinalMonitor tap. So feedTxAudio writing it directly — bypassing float32ToInt16 — 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 equals QsoRecorder::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_transmitting is set at the top of onMoxChanged before any early return, both feeds check it under m_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)

  • txFinalMonitorPcmReady is 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. The QByteArray is 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

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(), data has 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 in feedTxAudio is safe because the only connection now delivers int16.
  • Root cause confirmed. Nothing was wired to QsoRecorder::feedTxAudio anywhere 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. AudioEngine lives on m_audioThread; m_qsoRecorder is parented to MainWindow (GUI thread), so txFinalMonitorPcmReady → feedTxAudio is a queued connection — consistent with the header comment. feedRxAudio, feedTxAudio, and onMoxChanged all run serialized on the GUI event loop, and the std::atomic<bool> m_transmitting gate (set first in onMoxChanged, before any branch) is correct and appropriately conservative.

Two non-blocking notes for the soak you already flagged:

  1. 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_transmitting flips 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.
  2. 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Client Side recording produces a silent file — TX audio is never captured (QsoRecorder::feedTxAudio has no signal connected)

1 participant