Skip to content

feat(audio): capture local CW/CWX sidetone in Client-Side recordings (#2539)#3895

Merged
ten9876 merged 3 commits into
aethersdr:mainfrom
jensenpat:aether/cw-sidetone-recording
Jun 30, 2026
Merged

feat(audio): capture local CW/CWX sidetone in Client-Side recordings (#2539)#3895
ten9876 merged 3 commits into
aethersdr:mainfrom
jensenpat:aether/cw-sidetone-recording

Conversation

@jensenpat

@jensenpat jensenpat commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #2539 (the AetherSDR-keyed half — contest CW recording).

What

Client-Side QSO recordings captured phone TX (#3556) but were silent during CW — the operator's sent CW was missing, so the recorder was useless for contest review. This captures the AetherSDR-generated CW/CWX sidetone so a recording carries both voice and CW across SSB↔CW switches, in one continuous file.

Three fixes (all confirmed on a live FLEX-8400M and by the operator from the UI)

  1. RecordingMode defaulted to "Radio" — the real reason the UI failed. The slice/VFO ⏺ button (and the QSO Record/Playback controllers) only drive the Client-Side QsoRecorder when RecordingMode=="Client"; otherwise they record on the radio, which can never contain the client's local sidetone. Out of the box the slice button recorded server-side and the entire Client-Side recording feature was off — recording in CW gave back a radio file with "noise floor, no CW". Flipped the default to "Client" in all eight value("RecordingMode", …) fallbacks; Radio Side stays selectable in Radio Setup.
  2. CW transmit state never reached the recorder. CW break-in keys the radio without a local MOX press: applyTransmitStatus() updates m_mox but emits only stateChanged(), never moxChanged — which the recorder gate and AudioEngine both depend on. Fix: AudioEngine emits cwRecordingActiveChanged() when our keyer is active during interlock TX (ownership-correct — another MultiFlex client's TX can't gate our recorder), wired to QsoRecorder::onMoxChanged alongside the voice path.
  3. No audio was produced for CW. The TX monitor tap is in onTxAudioReady(), which is mic-capture-driven and never fires in CW. Fix: a free-running CW-sidetone record pump on the audio thread renders a dedicated 24 kHz recorder-sidetone generator to the recorder while the radio is keyed for CW. Frame counts come from elapsed wall-time so morse timing tracks real time; it emits through inter-element gaps so spacing is preserved.

Safe by construction

  • The pump gates on m_cwKeyedThisOver (latched in setCwKeyDown when our manual/CWX/iambic keyer fires, cleared on the radio TX→RX edge), so it captures CW but not voice/DAX/tune overs — no double-feed (phone and CW are mutually exclusive per over).
  • The recorder sidetone is rendered into a recorder-only buffer and never touches the radio TX path — recorded, never transmitted. Keyed in lockstep with the audible sidetone via one setCwKeyDown(), always enabled at a fixed level so CW records even when the audible sidetone is off (operator monitoring via the radio).
  • Scope: AetherSDR-keyed CW only (keyboard / paddle / CWX) — the contest case. Radio rear-panel / external keyers produce no client sidetone and are out.

Note on voice in Client mode

Client-Side voice capture rides on onTxAudioReady → the client DSP chain, which requires MIC = PC (DAX off). CW is captured regardless of mic source.

Automation bridge — new verbs (also in tools/automation_probe.py)

  • record start|stop|status|path|dir <path> — drive the Client-Side recorder, read/redirect the WAV.
  • testtone on [freqHz] [levelDb] | off — drive the client-side TX test tone through onTxAudioReady for a deterministic phone segment. Actual TX still requires the gated key.

Live verification ✅

tools/cw_recording_test.py (hard ANT2 dummy-load safety gate) records SSB(1200 Hz) → CWX → SSB(1800 Hz); tools/cw_recording_analyze.py (pure-stdlib Goertzel) verifies all three in order.

FLEX-8400M into the ANT2 dummy load @ 10 W:

phone1 ~1200Hz : PASS
CW ~600Hz      : PASS  (keyed morse, correct spacing)
phone2 ~1800Hz : PASS
RESULT: PASS — both phone + CW captured across SSB<->CW<->SSB switches

Operator also confirmed CW + phone capture from the UI slice record button on real hardware.

Build

Full macOS app builds & links clean (RelWithDebInfo).

Refs #2539, #3556

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

@jensenpat jensenpat force-pushed the aether/cw-sidetone-recording branch from 69f94bc to 481e856 Compare June 28, 2026 23:35
@jensenpat

Copy link
Copy Markdown
Collaborator Author

Live capture-file verification (FLEX-8400M, ANT2 dummy load @ 10 W)

Drove the automation bridge against a running build: record start → SSB test tone (1200 Hz, PTT) → switch to CW + cwx send "TEST TEST DE PARIS" → switch back to SSB test tone (1800 Hz) → record stop. Pure-stdlib Goertzel analysis of the resulting WAV:

non-silent tone segments (time / dominant Hz / dur):
   0.44s   1200.0 Hz  2.56s     <- phone 1 (SSB test tone)
   3.80s    600.0 Hz  ...        <- CW sidetone, keyed morse (16 bursts, gaps preserved)
   ...      600.0 Hz  ...
  13.92s   1800.0 Hz  2.76s     <- phone 2 (SSB test tone)

phone1 ~1200Hz : PASS
CW ~600Hz      : PASS  (segments: 16)
phone2 ~1800Hz : PASS
RESULT: PASS — both phone + CW captured across switches

Confirms the previously-missing CW sidetone is now captured, with correct morse element timing, and that phone↔CW↔phone switching mid-recording lands all sources in one file. Radio left as found (TX antenna ANT1, RF power 100 W).

@jensenpat

Copy link
Copy Markdown
Collaborator Author

Follow-up: the UI slice record button needed a second fix (RecordingMode default)

The first commit wired CW capture into QsoRecorder, and the bridge test passed — but a user couldn't reproduce it from the UI. Root cause: the bridge record start verb calls QsoRecorder::startRecording() directly, bypassing the UI gate. The slice/VFO ⏺ button only drives QsoRecorder when RecordingMode=="Client"; the default was "Radio", where the slice button records on the radio (server-side) and never touches the client recorder — so CW sidetone (and all Client-Side recording) was off out of the box.

Fix: default RecordingMode to "Client" in all eight value("RecordingMode", …) fallbacks (Radio Side remains selectable in Radio Setup). Manually confirmed by the operator: CWX sidetone now plays back in the slice recording.

Lesson for the test harness: driving startRecording() directly masked the gate. The harness should click the real "Record slice audio" button (via invoke) so it exercises the same path the user does.

…ngs (aethersdr#2539)

Client-Side recordings captured phone TX (aethersdr#3556) but were SILENT during CW — the
operator's sent CW was missing, making the recorder useless for contest review.
Capture the AetherSDR-generated CW/CWX sidetone so a recording carries BOTH voice
and CW across SSB<->CW mode switches, in one continuous file.

Three things had to be fixed, all confirmed on a live FLEX-8400M and by the
operator from the UI:

1. RecordingMode defaulted to "Radio". The slice/VFO record button (and the QSO
   Record/Playback controllers) only drive the Client-Side QsoRecorder when
   RecordingMode=="Client"; otherwise they record on the radio, which can never
   contain the client's local sidetone. So out of the box the slice record button
   recorded server-side and the whole Client-Side recording feature was off —
   pressing record in CW gave back a radio recording with "noise floor, no CW".
   Flip the default to "Client" in all eight value("RecordingMode", ...)
   fallbacks; "Radio Side" stays selectable in Radio Setup.

2. CW transmit state never reached the recorder. CW break-in keys the radio
   without a local MOX press: applyTransmitStatus() updates m_mox from the radio
   status but emits only stateChanged(), never moxChanged() — and both the
   recorder gate and AudioEngine hang off moxChanged. Fix: AudioEngine emits
   cwRecordingActiveChanged() when OUR keyer is active during interlock TX
   (ownership-correct, so another MultiFlex client's TX can't gate our recorder),
   wired to QsoRecorder::onMoxChanged alongside the voice moxChanged path.

3. No audio was produced for CW. The TX monitor tap is in onTxAudioReady(), which
   is mic-capture-driven and never fires in CW. Fix: a free-running CW-sidetone
   record pump (onCwRecordPump) on the audio thread renders a dedicated 24 kHz
   recorder-sidetone generator to the recorder while the radio is keyed for CW.
   Frame counts come from elapsed wall-time so morse timing tracks real time; it
   emits through inter-element gaps so element spacing is preserved.

Safe by construction: the pump gates on m_cwKeyedThisOver (latched in
setCwKeyDown when our manual/CWX/iambic keyer fires, cleared on the radio TX->RX
edge), so it captures CW but not voice/DAX/tune overs — no double-feed with the
voice path (the two are mutually exclusive per over). The recorder sidetone is
rendered into a recorder-only buffer and NEVER touches the radio TX path, so the
tone is recorded but never transmitted. It is keyed in lockstep with the audible
sidetone via one setCwKeyDown() and always enabled at a fixed level so CW records
even when the audible sidetone is off (operator monitors via the radio). Scope is
AetherSDR-keyed CW only (keyboard / paddle / CWX) — the contest case.

Automation bridge gains verbs for end-to-end live testing (also exposed in
tools/automation_probe.py):
- record start|stop|status|path|dir <path> — drive the Client-Side recorder and
  read/redirect the WAV (dir lets a test write to a non-TCC path).
- testtone on [freqHz] [levelDb] | off — drive the client-side TX test tone
  through onTxAudioReady for a deterministic phone segment; actual TX still needs
  the separately-gated key.
tools/cw_recording_test.py drives the bridge against a running build on a real
radio (hard ANT2 dummy-load safety gate) recording SSB -> CWX -> SSB;
tools/cw_recording_analyze.py (pure-stdlib Goertzel) verifies all three segments.

Verification: live FLEX-8400M into the ANT2 dummy load @ 10 W — capture contains
1200 Hz, then 600 Hz keyed morse (correct spacing), then 1800 Hz. Operator also
confirmed CW + phone capture from the UI slice record button on real hardware.

Refs aethersdr#2539, aethersdr#3556

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jensenpat jensenpat force-pushed the aether/cw-sidetone-recording branch from 46704ce to bbca0ec Compare June 29, 2026 00:15
@jensenpat jensenpat marked this pull request as ready for review June 29, 2026 00:21
@jensenpat jensenpat requested review from a team as code owners June 29, 2026 00:21

@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.

Thanks for this, @jensenpat — this is a careful, well-documented piece of work, and the three-part diagnosis (mode default → MOX gate → no CW audio source) is exactly right. I traced the threading and gating end-to-end and it holds up. CI is green on all three platforms + CodeQL.

What I verified

  • Thread safety of the record pumponCwRecordPump, m_cwPumpActive, m_cwPumpElapsed, and m_cwRecordSidetoneScratch are touched only on the audio thread; setCwKeyDown reaches the generator through CwSidetoneGenerator's atomic setters; m_cwKeyedThisOver is a proper std::atomic. The QTimer is correctly created on the audio thread via the queued startCwRecordPump(). ✅
  • process() is additive (out[2*i] += …), so zeroing the scratch with assign(n, 0.0f) before each render is correct, and stereo sizing (frames*2 floats / frames*2*sizeof(int16)) matches. ✅
  • Emit ordering — on the activation tick you emit cwRecordingActiveChanged(true) then fall through to a ~0-frame render, so onMoxChanged(true) is queued ahead of the first PCM; on deactivation you emit false and return, so no PCM trails the gate close. Single queue preserves order into the recorder thread. ✅
  • Gate ownershipm_radioTransmitting && m_cwKeyedThisOver && !isTxStreaming() correctly excludes voice/DAX/tune overs and another MultiFlex client's TX. ✅
  • All eight RecordingMode fallbacks flipped consistently to "Client". ✅
  • Null guards at the automation boundary (m_qsoRecorder, m_audioEngine->clientTxTestTone()) are present. ✅

Worth surfacing (non-blocking)

  1. The RecordingMode default flip is a silent behavior change for existing users. The value is only persisted when someone toggles it in Radio Setup (RadioSetupDialog.cpp:2483/2491), so every upgrading user who never touched it goes from radio-side → client-side recording on first launch after this lands. That's defensible given the feature was effectively dead, but it's a real product decision the maintainers should make consciously — and it deserves a CHANGELOG entry (this PR doesn't touch CHANGELOG.md, which sibling PRs in this batch do). Client-side capture also silently requires MIC = PC for voice, per your own note — an upgrading SSB user could end up with quieter/empty phone recordings without knowing why.

  2. Trivial: the record error string advertises (start|stop|status|path) but dir is also a valid action (AutomationServer.cpp:2742) — worth adding for discoverability.

  3. Optional: the pump free-runs at 100 Hz for the app's entire lifetime. The body early-returns cheaply, so this is fine, but if you wanted you could only start it when recording is armed.

Nice touch wiring the recorder-only sidetone generator at a fixed level so CW records even when the audible monitor is off, and the wall-clock frame accounting for morse timing. Scope (automation record/testtone verbs + the two Python harnesses) reads as the verification rig for this feature rather than creep. Just the default-flip call-out for a maintainer before merge.


🤖 aethersdr-agent · cost: $4.3843 · model: claude-opus-4-8

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Automated high-effort review (8 finder angles + verify). The correctness cluster mostly traces to one design choice: a free-running, audio-thread, wall-clock CW pump routed through the voice onMoxChanged/m_transmitting path via a queued cross-thread signal, plus a second always-on sidetone generator. CwSidetoneGenerator already exposes setSampleTap() (the TX CW decoder, #2417, uses it) which delivers the envelope-shaped sidetone on every process() block — driving the recorder from that tap, with the CW path owning its own gate (not overloading voice MOX), would dissolve findings #3/#4/#7/#9 at the root.

Independently shippable blockers: the dual-MOX-source clobber (#1), the all-users RecordingMode default flip (#2), and the recordingFilePath() data race (#5).

One more (can't inline — feedTxAudio isn't in this diff): QsoRecorder::feedTxAudio takes m_writeMutex before the !m_recording/!m_transmitting fast-path. The CW pump now feeds it ~100×/s from the audio thread; if the GUI thread holds the mutex during stopRecording/finalize disk I/O, the audio thread blocks inside the real-time pump → xrun. Check the atomics before locking.

Also noted (minor): the pump is started (queued) ~300 lines before its signal connections are made (first over after a fast startup is dropped); doTestTone assumes a TX audio pump it doesn't start (silent empty segment); the inline float→int16 uses 32768.0f vs the codebase's 32767.0f (QsoRecorder::float32ToInt16); per-tick QByteArray alloc + redundant zero-fill on the audio thread; brace-on-control-flow style; AppSettings read on each MOX edge; the analyzer hardcodes 16-bit WAV with no empty-file guard.

Comment thread src/gui/MainWindow.cpp
Comment thread src/core/QsoRecorder.cpp
Comment thread src/core/AudioEngine.cpp
Comment thread src/core/AudioEngine.cpp
Comment thread src/core/AudioEngine.cpp
Comment thread src/core/AudioEngine.cpp
Comment thread src/core/AudioEngine.cpp
Comment thread src/core/QsoRecorder.h
Comment thread tools/cw_recording_test.py
Comment thread tools/cw_recording_analyze.py

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requesting changes per the inline comments. Blockers before merge: (#1) the CW path overloads the voice onMoxChanged/m_transmitting gate — two producers clobber one shared flag/idle-timer; (#2) the all-users RecordingMode default flip from "Radio" to "Client" is out of scope and changes behavior on upgrade for everyone; (#5) recordingFilePath() reads m_file without the write mutex (UAF vs finalize). The correctness cluster (#3/#4/#7/#9) largely dissolves by driving the recorder from the existing CwSidetoneGenerator::setSampleTap() and giving CW its own gate rather than a free-running wall-clock pump on the audio thread. Happy to re-review once those are addressed.

ten9876 and others added 2 commits June 30, 2026 08:04
…n error

Two self-contained fixes from review (aethersdr#3895); the larger blockers are left
for the author + a maintainer call (see PR thread).

- recordingFilePath() read m_file (and m_lastRecordingPath) without
  m_writeMutex while the feed path and finalizeFile() mutate/deleteLater
  m_file under that lock — a data race / use-after-free against the new
  ~100 Hz CW pump and the automation `record path`/`dir` verb. Take the
  lock (mutex made mutable; all callers are external, no self-deadlock).

- The `record` unknown-action error listed (start|stop|status|path) but
  `dir` is also valid (the comment already documents it) — add it for
  discoverability.

NOT addressed here (need the author/hardware or a product decision):
the dual-MOX-source gate clobber and the free-running wall-clock CW pump
re-route to CwSidetoneGenerator::setSampleTap() (a real-time audio
re-architecture, hardware-verified path); and the RecordingMode default
flip to "Client" for all users (a deliberate product call + CHANGELOG).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…review #3)

feedTxAudio/feedRxAudio took m_writeMutex *before* their fast-path checks.
The new CW record pump calls feedTxAudio ~100x/s from the real-time audio
thread; if the GUI thread holds m_writeMutex during stopRecording/finalize
disk I/O, the audio thread blocks inside the real-time path -> xrun. (RX
feed has the same shape and is fed continuously from the RX audio thread.)

Make m_recording std::atomic<bool> and check the atomics (m_recording +
m_transmitting) lock-free before taking the mutex, returning early in the
common not-recording / wrong-direction case. The post-lock re-check stays
authoritative, so behavior is unchanged — only the lock is skipped when
there's nothing to write.

Per maintainer call on the other items: #1 (CW pump architecture) is not a
blocker; #2 (RecordingMode "Client" default) is approved as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ten9876 ten9876 dismissed their stale review June 30, 2026 15:37

Dismissing — the three blockers are resolved or decided: #1 (CW pump → setSampleTap re-architecture) is not a blocker per maintainer call; #2 (RecordingMode "Client" default) is approved as-is; #3 (real-time feed lock hazard) is fixed in 9433921 + db3387f (recordingFilePath() mutex; m_recording made atomic with a lock-free fast path on feedTxAudio/feedRxAudio). Re-review/approval to follow once CI is green on db3387f.

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review after the blockers were resolved/decided (prior changes-requested dismissed).

Disposition of the three blockers:

  • #1 CW pump architecture — not a blocker per maintainer call; jensenpat's free-running pump + recorder-sidetone generator stays. The earlier high-effort pass verified its threading/gating end-to-end (audio-thread-only state, atomic latch, queued timer start, additive render, ownership-correct gate).
  • #2 RecordingMode "Client" default — approved as-is. (Worth a CHANGELOG line at release-prep noting client-side capture is now the default and needs MIC=PC for voice.)
  • #3 real-time feed lock hazard — fixed.

Transparency: two commits here are my own maintainer edits, not jensenpat's:

  • 94339219recordingFilePath() now reads m_file under m_writeMutex (was a data race / UAF vs finalize); record verb error string lists dir.
  • db3387fdm_recording made std::atomic<bool> with a lock-free fast path on feedTxAudio/feedRxAudio, so the ~100 Hz CW pump can't block the real-time audio thread on the write mutex during finalize; post-lock re-check stays authoritative (behavior unchanged).

The core feature — capturing local CW/CWX sidetone into Client-Side recordings across SSB↔CW — is well-built and hardware-verified (FLEX-8400M), the 3-part diagnosis was correct, and the two audio taps verify against the tree.

Approving. Merge once build + check-windows finish green on db3387fd (macOS already passed). Thanks @jensenpat.

@ten9876 ten9876 merged commit 599374f into aethersdr:main Jun 30, 2026
6 checks passed
ten9876 added a commit that referenced this pull request Jul 2, 2026
#3961)

## Release documentation prep for **v26.7.1** (2026-07-02)

29 commits since v26.6.5. Docs-only + version bump — no code behavior
change.

### Version bump 26.6.5 → 26.7.1
The three canonical locations (per `AGENTS.md`):
- `CMakeLists.txt:2` — `project(AetherSDR VERSION 26.7.1)` →
`AETHERSDR_VERSION` (the app's version string)
- `README.md` — Current version line
- `AGENTS.md` — Current version line

### `CHANGELOG.md`
Promoted `[Unreleased]` → **`## [v26.7.1] — 2026-07-02`** in house style
(v-prefix, em-dash, "N commits since…" opener + headline), authored from
all merged PRs and categorized:
- **Added:** 3D stacked-trace spectrum (#3899), 3D dBm scale (#3937),
in-process NVIDIA BNR + AFX download-on-demand (#3902), TX meter
mouse-over readouts (#3936), SWR manual sweep range (#3885), CW sidetone
capture (#3895), KiwiSDR metadata scaffolding (#3898), bridge verbs
(#3920)
- **Changed:** BNR container/NIM backend removed, 60 fps + per-pixel GPU
FFT trace (#3958), FlexLib-sourced model capabilities (#3954/#2177), RX
speaker-latency cut (#3897), Display pane sections (#3935)
- **Fixed:** #3922, #3926, #3941, #3940, #3942, #3921, #3903, #3892,
#3924, #3891, #3890, #3900, #3947
- Fresh empty `[Unreleased]` restored above the release block.
Internal/CI/docs/self-fix PRs (#3931/#3916/#3934/#3929) omitted per
house style.

### `ROADMAP.md`
- Cycle header → "post-v26.7.1"
- NVIDIA BNR removed from **In flight** (shipped this cycle)
- Five v26.7.1 highlights prepended to **Recently shipped**

### `README.md`
- Highlights: GPU spectrum bullet now notes the **per-pixel FFT trace at
up to 60 fps** and the **3D stacked-trace** spectrum mode

### Reviewer notes
- Touches protected paths (`*.md` + `AGENTS.md` Tier-1 +
`CMakeLists.txt`) — needs `@aethersdr/infrastructure` sign-off.
- Tagging `v26.7.1` and cutting the release build are **not** part of
this PR (docs prep only).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.

Contest Support - Ability to record CW sidetone along with RX Audio.

2 participants