Skip to content

[core] Fix TX mic stereo channel canonicalization#2572

Merged
ten9876 merged 5 commits into
aethersdr:mainfrom
jensenpat:aether/tx-mic-channel-canonicalization
May 12, 2026
Merged

[core] Fix TX mic stereo channel canonicalization#2572
ten9876 merged 5 commits into
aethersdr:mainfrom
jensenpat:aether/tx-mic-channel-canonicalization

Conversation

@jensenpat

@jensenpat jensenpat commented May 11, 2026

Copy link
Copy Markdown
Collaborator

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.

  • Adds TxMicChannelNormalizer, a small testable helper that canonicalizes mono/stereo TX input into duplicated stereo Int16.
  • Tracks the negotiated TX input channel count with m_txInputChannels.
  • Canonicalizes TX mic audio immediately after QAudioSource capture and before resampling, RADE, DAX bypass, test tone, voice DSP, PC mic gain, Quindar, final limiter, metering, and Opus accumulation.
  • Uses Auto channel selection for stereo capture: choose the stronger side when the other side is effectively silent or at least 12 dB lower, otherwise average balanced stereo. A short hold keeps Auto from flipping during quiet pauses.
  • Keeps RX speaker resampling untouched and avoids changing Resampler itself.
  • Fixes PC mic and RADE metering to read the canonical stereo frame level instead of only the left channel.
  • Applies the same safe mono-collapse policy to the radio-native DAX reduced-bandwidth route only; the low-latency float32 stereo DAX route remains unchanged.
  • Updates docs/audio-pipeline.md to 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)/2 before 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_test
  • ctest --test-dir build
  • User validation:
    • DAX is working fine.
    • Apple built-in stereo microphone on MacBook Air 2026 works.
    • Apple EarPods over USB works.
    • Soundcore Life Q20 works, with some recording crackle observed.

👨🏼‍💻 Generated with OpenAI Codex (GPT-5.5 Pro 4/23) and tested by @jensenpat

@jensenpat jensenpat changed the title Fix TX mic stereo channel canonicalization [core] Fix TX mic stereo channel canonicalization May 11, 2026
@jensenpat jensenpat marked this pull request as ready for review May 11, 2026 11:43

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

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 dBFS and kWeakToStrongRatio = -12 dB are 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/4 frames) when both channels go silent prevents the algorithm from flipping decisions during natural pauses. Good UX call.
  • Auto mode hardcodedm_txMicChannelMode{Auto} and m_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 in Resampler.cpp and 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_txInputChannels member added in header (:614), initialized to 2, updated from fmt.channelCount() in two places (:3449, :3559) and reset to 2 on shutdown (:3632). No paths read it before initialization.
  • m_txInputMono retained 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
ten9876 previously approved these changes May 12, 2026

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

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

Jabra headset microphone not working

2 participants