Skip to content

fix(audio): restore L-R pan on Windows 48kHz resampler path (#2403)#2459

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
wa2n-code:fix/audio-pan-stereo-resample
May 8, 2026
Merged

fix(audio): restore L-R pan on Windows 48kHz resampler path (#2403)#2459
ten9876 merged 1 commit into
aethersdr:mainfrom
wa2n-code:fix/audio-pan-stereo-resample

Conversation

@wa2n-code

Copy link
Copy Markdown
Contributor

Summary

                                                                                                                      - `processStereoToStereo()` collapses L+R to mono then duplicates both channels — it was designed for mono-source

audio, not true stereo. - Commit 029613c made 48kHz the Windows default (to fix WASAPI artefacts), routing all non-NR RX audio through
resampleStereo()processStereoToStereo(), which destroyed the radio's VITA-49 per-channel pan encoding. - Result: both channels always equal → slider had no effect; centre was louder than extremes because panned audio was being averaged to mono.

  • Fix: added m_rxResamplerR (a second independent r8brain instance) and rewrote resampleStereo() to deinterleave, resample L and R separately, then reinterleave — preserving the radio-applied audio_pan through the upsample.
  • processStereoToStereo() is unchanged; TciServer DAX TX and RADE callers correctly want mono-collapse.

Test plan

  • Move L-R pan slider to left limit — audio heard in left speaker only - [ ] Move L-R pan slider to right limit — audio heard in right speaker only - [ ] Centre position (50) — equal volume in both channels, no loudness change vs. before
  • Verify with NR2/RN2/NR4/DFNR enabled — pan still applies correctly (existing path, no regression) - [ ] Windows only — Linux/macOS unaffected (24kHz path, m_resampleTo48k = false) Fixes Windows L-R pan has not worked since build 9.2.1, not a direct dupe of #1603 #2403 🤖 Generated with Claude Code

commit 029613c flipped the Windows RX sink preference to 48kHz to avoid WASAPI artefacts. All non-NR audio now passes through resampleStereo(), which called processStereoToStereo() — a helper that collapses L+R to mono then duplicates both channels. This destroyed the radio-applied audio_pan encoding in the VITA-49 stereo stream, so the slider had no effect (both channels always equal, centre sounded louder than extremes).

Fix: process L and R through separate Resampler instances (m_rxResampler and new m_rxResamplerR) so per-channel amplitude differences are preserved through the upsample. processStereoToStereo() is unchanged — other callers (TciServer DAX TX, RADE) intentionally want mono-collapse.

Fixes #2403

…r#2403)

commit 029613c flipped the Windows RX sink preference to 48kHz to avoid
WASAPI artefacts. All non-NR audio now passes through resampleStereo(),
which called processStereoToStereo() — a helper that collapses L+R to
mono then duplicates both channels. This destroyed the radio-applied
audio_pan encoding in the VITA-49 stereo stream, so the slider had no
effect (both channels always equal, centre sounded louder than extremes).

Fix: process L and R through separate Resampler instances (m_rxResampler
and new m_rxResamplerR) so per-channel amplitude differences are
preserved through the upsample. processStereoToStereo() is unchanged —
other callers (TciServer DAX TX, RADE) intentionally want mono-collapse.

Fixes aethersdr#2403

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@wa2n-code wa2n-code requested a review from ten9876 as a code owner May 7, 2026 20:13
@ten9876 ten9876 merged commit f7d9119 into aethersdr:main May 8, 2026
4 checks passed
@ten9876

ten9876 commented May 8, 2026

Copy link
Copy Markdown
Collaborator

Claude here — clean diagnosis and surgical fix, Wayne. Catching that processStereoToStereo() is a mono-source helper masquerading as a stereo passthrough is the kind of thing that's only obvious when you actually trace the per-channel data flow. Two separate Resampler instances is the right answer (r8brain's filter delay state is per-instance), and the commonFrames defensive min() handles the warmup-frame edge case nicely. Leaving processStereoToStereo() alone for the DAX TX / RADE callers that genuinely want mono-collapse is exactly the right scope decision. Merged.

73, Jeremy KK7GWY & Claude (AI dev partner)

ten9876 pushed a commit that referenced this pull request Jun 6, 2026
…3397)

## What

First, **pure-addition** step of the consolidated audio sink factory
(#3306): a single shared, OS-parameterized format/rate **negotiation
policy** that every audio sink and source will migrate onto, replacing
the ~9 divergent per-sink fallback ladders and per-OS `#ifdef` branches
that are the root of a recurring class of platform audio bugs.

Zero behaviour change — this is the maintainer-endorsed *"land the
helper first, migrate sinks incrementally"* approach (#3306, and the
#3194 thread's "sensitive audio-stack changes must be separate PRs with
soak time").

## Why this shape

The historical bugs escaped CI because the per-OS rate ladders were
compiled behind `Q_OS_*`, so a Linux runner could only ever exercise the
Linux path. The new policy is a **pure function over an injected
`DeviceCaps` snapshot with `TargetOs` as a parameter, not an `#ifdef`**
— so one headless, hardware-free test binary exercises the Windows,
macOS *and* Linux ladders on any runner.

## Contents

- **`src/core/AudioFormatNegotiator.{h,cpp}`** — dependency-free policy
(links only `Qt6::Core`). One ladder owns:
- Windows force-48k + r8brain (#2120/#2123), macOS 48k/A2DP (#1705),
Linux native-24k — divergences preserved as **data**, not `#ifdef`.
- The **universal 44.1 kHz rung** that RX/Quindar/QSO are missing today
(#3385).
- `Int16`↔`Float32` + `preferredFormat()` catch-all (#2669 / #1090 /
#3231).
- macOS mic preferred-rate-first (#2930) and Bluetooth-HFP native rate
(#2615); Windows probe-at-open (#2929).
- The two stereo resampler strategies — **PreservePan** (RX/QSO) vs
**MonoCollapse** (TCI-TX/RADE) — kept deliberately distinct
(#2403/#2459).
- **`tests/audio_format_negotiation_test.cpp`** — table-driven golden
matrix, registered under CTest. **25/25 pass**, including the
44.1k-only-device regression guard.
- **`docs/audio-sink-factory.md`** — the full three-layer design (pure
policy → live Qt wrapper → device-ownership router), the
device-following **"uncoupling"** fix (CW / RADE / Aetherial-Audio
"Pudu" recorder following the selected output), the regression-guard
invariants, and the one-sink-per-PR migration plan.

## Test

```
$ ./build/audio_format_negotiation_test
...
25/25 checks passed
```

## Follow-ups (per the migration plan in the doc)

Live Qt wrapper → migrate RX speaker → migrate PC mic →
`AudioOutputRouter` (closes the uncoupling class) → TCI-TX client-rate →
PipeWire DAX shared resampler. Each a separate, soakable PR.

Refs #3306

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

Co-authored-by: Codex <noreply@openai.com>
ten9876 pushed a commit to jensenpat/AetherSDR that referenced this pull request Jun 6, 2026
…dr#3306)

Replace the two forked per-OS #ifdef negotiation blocks in
AudioEngine::startRxStream() with a single walk of the factory's Float format
ladder (AudioDeviceNegotiator), trying each rung with a real start() so
reliable backends and WASAPI probe-at-open behave identically. Net -87 lines.

Behaviour is identical for normal devices: Windows/macOS still open 48k (dodging
the WASAPI 24k resampler artifacts aethersdr#2120 and keeping macOS A2DP devices off the
HFP/telephony route), Linux still opens native 24k. New, strictly-additive wins:
- a 44.1 kHz-only output now works (24k->44.1k resample) instead of failing —
  the aethersdr#3306 / aethersdr#3385 regression the golden matrix guards;
- the Quindar local monitor now opens on Windows too (the old Windows branch
  returned before startQuindarLocalSink()).

To support a non-48k device rate, the m_resampleTo48k bool is generalized to an
m_rxOutputRate int. resampleStereo() now targets that rate with its two
independent L/R r8brain instances (PreservePan — VITA-49 pan preserved, never
mono-collapsed here, aethersdr#2403/aethersdr#2459); the RADE decoded-speech path (MonoCollapse)
and both TX-scope rate sites follow suit. Cached resamplers rebuild when the
device rate changes (e.g. a 48k->44.1k device swap).

The macOS telephony/HFP output substitution (aethersdr#1705) is unchanged — it is
device selection, applied before the format ladder.

Builds clean into the full app; audio_format_negotiation_test (25/25) and
audio_device_negotiator_test (8/8, real macOS devices -> 48000/PreservePan) pass.

Refs aethersdr#3306

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Codex <noreply@openai.com>
ten9876 pushed a commit to jensenpat/AetherSDR that referenced this pull request Jun 6, 2026
…dr#3306)

Replace the two forked per-OS #ifdef negotiation blocks in
AudioEngine::startRxStream() with a single walk of the factory's Float format
ladder (AudioDeviceNegotiator), trying each rung with a real start() so
reliable backends and WASAPI probe-at-open behave identically. Net -87 lines.

Behaviour is identical for normal devices: Windows/macOS still open 48k (dodging
the WASAPI 24k resampler artifacts aethersdr#2120 and keeping macOS A2DP devices off the
HFP/telephony route), Linux still opens native 24k. New, strictly-additive wins:
- a 44.1 kHz-only output now works (24k->44.1k resample) instead of failing —
  the aethersdr#3306 / aethersdr#3385 regression the golden matrix guards;
- the Quindar local monitor now opens on Windows too (the old Windows branch
  returned before startQuindarLocalSink()).

To support a non-48k device rate, the m_resampleTo48k bool is generalized to an
m_rxOutputRate int. resampleStereo() now targets that rate with its two
independent L/R r8brain instances (PreservePan — VITA-49 pan preserved, never
mono-collapsed here, aethersdr#2403/aethersdr#2459); the RADE decoded-speech path (MonoCollapse)
and both TX-scope rate sites follow suit. Cached resamplers rebuild when the
device rate changes (e.g. a 48k->44.1k device swap).

The macOS telephony/HFP output substitution (aethersdr#1705) is unchanged — it is
device selection, applied before the format ladder.

Builds clean into the full app; audio_format_negotiation_test (25/25) and
audio_device_negotiator_test (8/8, real macOS devices -> 48000/PreservePan) pass.

Refs aethersdr#3306

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Codex <noreply@openai.com>
ten9876 added a commit that referenced this pull request Jun 6, 2026
…ry (#3306) (#3398)

> **Stacks on #3397** (the pure foundation). The first two commits here
are #3397's (foundation + live wrapper); the last two are the RX and
TX-mic migrations. Merge #3397 first and this collapses to just those
deltas. Kept as separate commits per the maintainer's
one-change-at-a-time / soak rule (#3194 thread).

## What

Layer 2 + the first two sink migrations of the consolidated audio sink
factory (#3306):

1. **Live Qt-Multimedia wrapper** `AudioDeviceNegotiator` — the thin,
and only, platform-specific bridge. `probe(QAudioDevice)` → pure
`DeviceCaps`; `negotiate()`/`formatLadder()` → openable `QAudioFormat`s.
Smoke-tested against the real default devices
(`audio_device_negotiator_test`, 8/8).
2. **RX speaker** — `startRxStream()` walks the factory's Float ladder
with real `start()` attempts instead of two forked per-OS `#ifdef`
blocks (**net −87 lines**). `m_resampleTo48k` (bool) → `m_rxOutputRate`
(int).
3. **PC mic (TX), macOS + Linux** — `startTxStream()` drives mic
rate/format from the factory's Int16 Input ladder (stereo-then-mono),
removing the per-OS rate lists and the bespoke
`macTxInputRateCandidates()` helper.

## Behaviour (preserved)

- **RX:** Windows/macOS → 48k (WASAPI 24k-resampler artifacts #2120;
macOS A2DP off HFP), Linux → 24k native. New strictly-additive wins: a
44.1k-only output now works (#3306/#3385); the Quindar local monitor now
opens on Windows (old branch `return`ed before
`startQuindarLocalSink()`).
- **TX mic:** standard macOS mic → 48k; Linux → 24k native;
**Bluetooth-HFP mic → native low rate first (#2615)**, fed in via
`macBluetoothNativeInputRate()` + the wrapper's new
`preferredRateOverride`; preferred-rate-first avoids the silent-48k-open
trap (#2930).

## Scope notes / invariants

- Two stereo resampler strategies kept distinct: **PreservePan** (RX,
dual L/R, VITA-49 pan #2403/#2459) vs **MonoCollapse** (RADE/TCI-TX).
- macOS telephony/HFP **output** substitution (#1705) unchanged (device
selection, before the ladder).
- **Windows mic left as-is** — already matches the factory's force-48k +
probe-at-open policy and carries the mono-only USB-mic channel clamp
(#2929) that needs its own soak. That + the device-following
`AudioOutputRouter`, TCI-TX client-rate, and PipeWire DAX are the
remaining increments (see `docs/audio-sink-factory.md`).

## Test / build

- `audio_format_negotiation_test` 25/25, `audio_device_negotiator_test`
8/8.
- Full macOS app builds & links clean (RelWithDebInfo); deployed to the
macOS test box. Soak on Windows/Linux requested before un-drafting.

Refs #3306

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…#3306) (aethersdr#3397)

## What

First, **pure-addition** step of the consolidated audio sink factory
(aethersdr#3306): a single shared, OS-parameterized format/rate **negotiation
policy** that every audio sink and source will migrate onto, replacing
the ~9 divergent per-sink fallback ladders and per-OS `#ifdef` branches
that are the root of a recurring class of platform audio bugs.

Zero behaviour change — this is the maintainer-endorsed *"land the
helper first, migrate sinks incrementally"* approach (aethersdr#3306, and the
aethersdr#3194 thread's "sensitive audio-stack changes must be separate PRs with
soak time").

## Why this shape

The historical bugs escaped CI because the per-OS rate ladders were
compiled behind `Q_OS_*`, so a Linux runner could only ever exercise the
Linux path. The new policy is a **pure function over an injected
`DeviceCaps` snapshot with `TargetOs` as a parameter, not an `#ifdef`**
— so one headless, hardware-free test binary exercises the Windows,
macOS *and* Linux ladders on any runner.

## Contents

- **`src/core/AudioFormatNegotiator.{h,cpp}`** — dependency-free policy
(links only `Qt6::Core`). One ladder owns:
- Windows force-48k + r8brain (aethersdr#2120/aethersdr#2123), macOS 48k/A2DP (aethersdr#1705),
Linux native-24k — divergences preserved as **data**, not `#ifdef`.
- The **universal 44.1 kHz rung** that RX/Quindar/QSO are missing today
(aethersdr#3385).
- `Int16`↔`Float32` + `preferredFormat()` catch-all (aethersdr#2669 / aethersdr#1090 /
aethersdr#3231).
- macOS mic preferred-rate-first (aethersdr#2930) and Bluetooth-HFP native rate
(aethersdr#2615); Windows probe-at-open (aethersdr#2929).
- The two stereo resampler strategies — **PreservePan** (RX/QSO) vs
**MonoCollapse** (TCI-TX/RADE) — kept deliberately distinct
(aethersdr#2403/aethersdr#2459).
- **`tests/audio_format_negotiation_test.cpp`** — table-driven golden
matrix, registered under CTest. **25/25 pass**, including the
44.1k-only-device regression guard.
- **`docs/audio-sink-factory.md`** — the full three-layer design (pure
policy → live Qt wrapper → device-ownership router), the
device-following **"uncoupling"** fix (CW / RADE / Aetherial-Audio
"Pudu" recorder following the selected output), the regression-guard
invariants, and the one-sink-per-PR migration plan.

## Test

```
$ ./build/audio_format_negotiation_test
...
25/25 checks passed
```

## Follow-ups (per the migration plan in the doc)

Live Qt wrapper → migrate RX speaker → migrate PC mic →
`AudioOutputRouter` (closes the uncoupling class) → TCI-TX client-rate →
PipeWire DAX shared resampler. Each a separate, soakable PR.

Refs aethersdr#3306

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

Co-authored-by: Codex <noreply@openai.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…ry (aethersdr#3306) (aethersdr#3398)

> **Stacks on aethersdr#3397** (the pure foundation). The first two commits here
are aethersdr#3397's (foundation + live wrapper); the last two are the RX and
TX-mic migrations. Merge aethersdr#3397 first and this collapses to just those
deltas. Kept as separate commits per the maintainer's
one-change-at-a-time / soak rule (aethersdr#3194 thread).

## What

Layer 2 + the first two sink migrations of the consolidated audio sink
factory (aethersdr#3306):

1. **Live Qt-Multimedia wrapper** `AudioDeviceNegotiator` — the thin,
and only, platform-specific bridge. `probe(QAudioDevice)` → pure
`DeviceCaps`; `negotiate()`/`formatLadder()` → openable `QAudioFormat`s.
Smoke-tested against the real default devices
(`audio_device_negotiator_test`, 8/8).
2. **RX speaker** — `startRxStream()` walks the factory's Float ladder
with real `start()` attempts instead of two forked per-OS `#ifdef`
blocks (**net −87 lines**). `m_resampleTo48k` (bool) → `m_rxOutputRate`
(int).
3. **PC mic (TX), macOS + Linux** — `startTxStream()` drives mic
rate/format from the factory's Int16 Input ladder (stereo-then-mono),
removing the per-OS rate lists and the bespoke
`macTxInputRateCandidates()` helper.

## Behaviour (preserved)

- **RX:** Windows/macOS → 48k (WASAPI 24k-resampler artifacts aethersdr#2120;
macOS A2DP off HFP), Linux → 24k native. New strictly-additive wins: a
44.1k-only output now works (aethersdr#3306/aethersdr#3385); the Quindar local monitor now
opens on Windows (old branch `return`ed before
`startQuindarLocalSink()`).
- **TX mic:** standard macOS mic → 48k; Linux → 24k native;
**Bluetooth-HFP mic → native low rate first (aethersdr#2615)**, fed in via
`macBluetoothNativeInputRate()` + the wrapper's new
`preferredRateOverride`; preferred-rate-first avoids the silent-48k-open
trap (aethersdr#2930).

## Scope notes / invariants

- Two stereo resampler strategies kept distinct: **PreservePan** (RX,
dual L/R, VITA-49 pan aethersdr#2403/aethersdr#2459) vs **MonoCollapse** (RADE/TCI-TX).
- macOS telephony/HFP **output** substitution (aethersdr#1705) unchanged (device
selection, before the ladder).
- **Windows mic left as-is** — already matches the factory's force-48k +
probe-at-open policy and carries the mono-only USB-mic channel clamp
(aethersdr#2929) that needs its own soak. That + the device-following
`AudioOutputRouter`, TCI-TX client-rate, and PipeWire DAX are the
remaining increments (see `docs/audio-sink-factory.md`).

## Test / build

- `audio_format_negotiation_test` 25/25, `audio_device_negotiator_test`
8/8.
- Full macOS app builds & links clean (RelWithDebInfo); deployed to the
macOS test box. Soak on Windows/Linux requested before un-drafting.

Refs aethersdr#3306

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
@wa2n-code wa2n-code deleted the fix/audio-pan-stereo-resample branch June 28, 2026 04:06
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.

Windows L-R pan has not worked since build 9.2.1, not a direct dupe of #1603

2 participants