[core] Fix TX mic stereo channel canonicalization#2572
Conversation
There was a problem hiding this comment.
Thanks for this @jensenpat — the root-cause writeup is exemplary and the fix is well-scoped. Pulling channel canonicalization out into its own pure helper with a standalone test binary is a nice shape: the test that resamples a left-only stereo block and asserts the result is within ±1% of the mono baseline directly nails the 6.02 dB regression. The Auto-mode hold logic (frame-decremented quarter-second hold against silence floor / -12 dB ratio) reads cleanly, and limiting the DAX impact to the radio-native reduced-bandwidth route while leaving the float32 low-latency path untouched is the right call.
A few small observations, nothing blocking:
1. m_txInputMono is now dead. After this PR it's only assigned (3 sites) and never read — the header comment even calls it "legacy convenience mirror of m_txInputChannels == 1". Since m_txInputChannels is now the source of truth and there are no remaining read sites, I'd just drop the member and its writes to keep the two from drifting.
2. logTxInputChannelDiagnostics route dispatch via strcmp. The throttle picks between m_lastTxMicChannelLog and m_lastDaxRadioChannelLog by comparing the C-string against "DAX radio". Functionally fine, but a small enum (or just two named helper methods) would be harder to break later — a typo in the call site silently sends a DAX event into the mic throttle.
3. Hold reset on manual mode. In chooseStereoMode, switching out of Auto calls autoState->reset() and into Auto starts fresh. Worth confirming that's intended if a user toggles channel mode mid-transmission — currently a quick Auto → Left → Auto round-trip wipes any held side, which feels correct but is worth a one-liner comment given the operator-controlled mic settings elsewhere.
4. Channel mode is currently hardcoded to Auto (m_txMicChannelMode/m_daxRadioTxChannelMode). The infrastructure for Left/Right/Average is all there and tested — no need to wire UI in this PR, but a follow-up to expose it as an AppSettings override would let users force a side when Auto picks wrong (e.g. the Soundcore Q20 crackle you mentioned).
5. Throttle restart pattern. if (throttle.isValid()) throttle.restart(); else throttle.start(); works but QElapsedTimer::start() already handles both cases — start() on its own would be enough.
The user-validation matrix (built-in mic / EarPods USB / Q20 / DAX) covers the real-world cases this is targeting. LGTM overall, just the dead m_txInputMono is worth a quick cleanup.
ten9876
left a comment
There was a problem hiding this comment.
Claude here, reviewing on Jeremy's behalf.
Verdict
Strong merge. Closes four issues (#2347, #1417, #2565, #2567) with a clean, testable helper module that surgically fixes the three bugs surfaced in the audio-pipeline doc (#2571). Built and ran the test suite locally — all 17 tests pass; the "left-only 48 kHz resample is not 6 dB below mono" test reports ratio=1.000000 exactly, confirming the resample path now preserves full level for one-sided stereo input.
Independent verification against current code
1. The bug fix at the canonicalize point (AudioEngine.cpp:3663-3672):
data = TxMicChannelNormalizer::canonicalizeInt16ToMonoStereo(
data, m_txInputChannels, m_txInputRate,
m_txMicChannelMode, &m_txMicChannelState, &channelDiagnostics);Placed immediately after QAudioSource read on both macOS push-buffer and Linux/Windows pull-mode paths, before resampling, RADE branch, DAX bypass, test tone, voice DSP, gain, Quindar, limiter, or metering. Correct ordering — every downstream consumer now sees a canonical signal with both channels carrying the chosen signal.
2. Resampler path now safe (AudioEngine.cpp:3678-3690):
The resample branch explicitly avoids processStereoToStereo(). It reads only the left channel (which after canonicalization equals the right channel), runs processMonoToStereo(), and the documented mono-collapse trap in Resampler::processStereoToStereo() never gets exercised on raw asymmetric stereo. Inline comment captures the rationale.
3. PC mic meter fix (AudioEngine.cpp:3325-3343):
accumulatePcMicMeterInt16Stereo now uses measureInt16StereoLevelBlock, which does max(L, R) per frame and accumulates RMS over the max. Right-only devices now meter correctly. The left-channel-only i += 2 stride from AudioEngine.cpp:3656 (the bug surfaced in #2571) is gone.
4. DAX radio-native fix (AudioEngine.cpp:4175-4184):
collapseFloat32ToInt16MonoBigEndian replaces the raw (L+R)*0.5 averaging. Auto mode keeps the full level for one-sided stereo DAX sources (virtual / aggregate devices that expose stereo but carry digital-mode audio on one side).
5. Auto state lifecycle:
m_txMicChannelState.reset()on TX stream start (:3384) and shutdown (:3636)m_daxRadioTxChannelState.reset()on DAX route toggle (:4093)
Auto mode starts fresh each capture session, so a previous session's stuck-in-Left decision can't haunt the next.
Design notes worth flagging
kSilenceFloor = -65 dBFSandkWeakToStrongRatio = -12 dBare reasonable thresholds. Below -65 dBFS is essentially digital noise floor (typical mic noise floor is -50 to -60 dBFS), and 12 dB asymmetry is a clean separator between "intentionally one-sided" and "balanced stereo with image."- 250 ms hold (
sampleRate/4frames) when both channels go silent prevents the algorithm from flipping decisions during natural pauses. Good UX call. Automode hardcoded —m_txMicChannelMode{Auto}andm_daxRadioTxChannelMode{Auto}. No UI to override yet. Worth a follow-up if any user hits an edge case where Auto picks wrong, but Auto is the right default for the bug class this is fixing.
Concerns
Soundcore Life Q20 recording crackle. The PR body mentions "Soundcore Life Q20 works, with some recording crackle observed." Worth clarifying:
- Is the crackle present on
main(pre-PR) too, or new with this PR? - Does the crackle happen on transmit (audible to far end) or only in local monitoring?
- Could it be related to sample-rate negotiation between this Bluetooth headset and the new resample path?
Recommend filing a follow-up issue regardless of outcome — Bluetooth crackle is a recurring class of issue worth tracking separately so it doesn't get conflated with the channel canonicalization work.
Stale-code audit
- ✓
processStereoToStereo()still exists inResampler.cppand is used by RX RADE speech mixing (AudioEngine::feedDecodedSpeech) and TCI RX. Both are documented in #2571 as logically-mono-duplicated paths where the mono collapse is harmless. Left in place by design. - ✓
m_txInputChannelsmember added in header (:614), initialized to 2, updated fromfmt.channelCount()in two places (:3449,:3559) and reset to 2 on shutdown (:3632). No paths read it before initialization. - ✓
m_txInputMonoretained as legacy convenience mirror — no orphaned readers. - ✓ Doc updates accurately reflect new behavior; the "current known behavior" bullets from #2571 are appropriately rewritten to reflect the fix.
- ✓ No removed call sites left orphaned.
CI
All five checks green: build, analyze (cpp), check-paths, check-windows, CodeQL.
Local validation
cmake --build build -j$(nproc)— clean build, only pre-existing unrelated warning./build/tx_mic_channel_normalizer_test— 17/17 tests pass
The Opus framing test (mono 10 ms normalizes to 960-byte Opus frame input) confirms the canonical output stays compatible with the Opus encoder's 240-frame-per-packet expectation. The DAX radio-native collapse test confirms the second bug fix end-to-end.
Recommendation
Merge. The Soundcore crackle should be filed as a separate issue so it gets tracked independently rather than being conflated with this work.
Thanks @jensenpat — the discipline of pairing the audio pipeline doc with a tightly-scoped fix that exercises every documented edge case is exactly the right way to land work on a thorny area. The new helper module is small, testable, and replaces ad-hoc averaging at four different sites with one well-tested entry point.
73, Jeremy KK7GWY & Claude (AI dev partner)
ten9876
left a comment
There was a problem hiding this comment.
Approved — closes 4 issues, comprehensive fix, all tests pass locally, ratio=1.000000 on the headline scenario.
Resolves add/add conflict in docs/audio-pipeline.md introduced when aethersdr#2571 squash-merged to main while this PR was open. Both branches added the file independently; this PR's version contains the post-fix description ("canonical mono voice signal using Auto Left/Right/Average") which supersedes aethersdr#2571's pre-fix description that was correct at the time it was written. Conflict resolved by taking the PR branch's content. Verified: - 0 conflict markers remain - post-fix "canonical mono voice signal" passage preserved (2 occurrences) - pre-fix "averages L/R and then duplicates the result" language removed
Summary
This PR builds on the audio pipeline documentation from #2571 and fixes PC mic voice TX for capture devices that expose stereo while carrying voice on only one side.
TxMicChannelNormalizer, a small testable helper that canonicalizes mono/stereo TX input into duplicated stereo Int16.m_txInputChannels.QAudioSourcecapture and before resampling, RADE, DAX bypass, test tone, voice DSP, PC mic gain, Quindar, final limiter, metering, and Opus accumulation.Resampleritself.docs/audio-pipeline.mdto document the new channel policy and route ordering.Fixes #2347 #1417 #2565 #2567
Root Cause
Some USB, Bluetooth, aggregate, and virtual capture devices report stereo even though the microphone signal is present on only one channel. The previous TX mic resample path called
Resampler::processStereoToStereo()on raw stereo input, which averaged(L+R)/2before duplicating to stereo. With one silent channel, that produced an exact 6.02 dB loss. Separately, the PC mic meter sampled only the left channel, so right-only devices could appear silent or incorrect.Validation
cmake --build build -j8./build/tx_mic_channel_normalizer_testctest --test-dir build👨🏼💻 Generated with OpenAI Codex (GPT-5.5 Pro 4/23) and tested by @jensenpat