Skip to content

fix(audio): silent SSB/voice TX on macOS — honour mic native rate + start capture for remote_audio_tx#2930

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
pepefrog1234:fix/macos-tx-mic-native-rate
May 23, 2026
Merged

fix(audio): silent SSB/voice TX on macOS — honour mic native rate + start capture for remote_audio_tx#2930
ten9876 merged 1 commit into
aethersdr:mainfrom
pepefrog1234:fix/macos-tx-mic-native-rate

Conversation

@pepefrog1234

Copy link
Copy Markdown
Contributor

Summary

On macOS, USB/LSB/AM/FM voice TX produced no modulating audio (carrier up, dead air) even though DAX digital-mode TX worked fine. Two independent defects stacked together; both are fixed here.

Defect 1 — mic capture never started without a dax_tx stream

MainWindow's mic_selection handler only started QAudioSource capture when m_audio->txStreamId() != 0:

if (!m_audio->isTxStreaming() && m_audio->txStreamId() != 0) {
    audioStartTx(...);
}

txStreamId() is the dax_tx stream id. Voice TX flows over remote_audio_tx (Opus), a different stream. When no DAX bridge is running there is no dax_tx stream, so switching mic_selection to PC for plain SSB never started mic capture — onTxAudioReady() never fired and the radio received no PC audio.

onTxAudioReady() itself already accepts either stream (if (m_txStreamId == 0 && m_remoteTxStreamId == 0) return;), so only the capture-start gate was wrong.

Fix: add AudioEngine::hasAnyTxStream() (dax_tx or remote_audio_tx) and gate capture start on that.

Defect 2 — QAudioSource opened at 48 kHz on a 16 kHz-native device

macTxInputRateCandidates() tried 48000 first. macOS CoreAudio reports isFormatSupported(48000) == true for many capture devices that actually run at a lower native rate (e.g. USB webcam mics at 16 kHz). QAudioSource then "opens" successfully — state=Active, error=NoError — but the device delivers zero samples:

  • QAudioSource::processedUSecs() stays 0
  • the push-mode QBuffer write position never advances (pos() stuck at 0)
  • TX is silent

Fix: query QAudioDevice::preferredFormat().sampleRate() and try it first. The existing resampler converts the device-native rate to 24 kHz radio-native. 48000/44100/24000 remain as fallbacks for devices that report no usable preferred rate.

Diagnosis

Instrumented the TX path on the affected machine:

Probe Observation
onTxAudioReady() entry fired continuously, but m_micBuffer->pos() stuck at 0
QAudioSource health @1s state=Active error=NoError processedUSecs=0
system_profiler SPAudioDataType affected mic (4K SlimFit Cam) Current SampleRate: 16000

processedUSecs=0 with state=Active is the tell-tale of CoreAudio accepting a format the device can't actually source.

Test plan

  • macOS + FLEX-8600 fw 4.x, mic_selection=PC, USB/LSB
  • 16 kHz USB webcam mic — voice TX now modulates (resampled 16→24 kHz)
  • 48 kHz USB audio interface — unchanged, still works
  • DAX digital-mode TX (VARA) — unaffected (separate dax_tx path)
  • Reviewer: Linux/Windows regression check — macTxInputRateCandidates() is #ifdef Q_OS_MAC; the hasAnyTxStream() gate change is cross-platform and should be verified on a Windows/Linux voice TX setup.

🤖 Generated with Claude Code

…tart capture for remote_audio_tx

Two independent defects left USB/LSB/AM/FM voice TX with no modulating
audio on macOS while DAX digital-mode TX worked fine.

1. Mic capture never started without a dax_tx stream
   The mic_selection=PC handler in MainWindow only started QAudioSource
   capture when `m_audio->txStreamId() != 0`. txStreamId() is the *dax_tx*
   stream id, but voice TX flows over *remote_audio_tx*. With no DAX bridge
   running there is no dax_tx stream, so switching mic_selection to PC for
   plain SSB never started mic capture — onTxAudioReady never fired and the
   radio got no PC audio. Add AudioEngine::hasAnyTxStream() (dax_tx OR
   remote_audio_tx) and gate capture start on that instead.

2. QAudioSource opened at 48 kHz on a device that runs at 16 kHz
   macTxInputRateCandidates() tried 48000 first. CoreAudio reports
   isFormatSupported(48000)==true for capture devices that actually run at
   a lower native rate (e.g. USB webcam mics at 16 kHz), so QAudioSource
   "opened" successfully — state=Active, error=NoError — but the device
   delivered zero samples. processedUSecs stayed 0, the push-mode QBuffer
   never advanced (pos stuck at 0), and TX was silent. Query the device's
   preferredFormat().sampleRate() and try it first; the existing resampler
   then converts to 24 kHz radio-native. 48000/44100/24000 remain as
   fallbacks for devices with no usable preferred rate.

Diagnosed by instrumenting onTxAudioReady (mic buffer pos stuck at 0 with
QAudioSource state=Active) and QAudioSource::processedUSecs (stuck at 0),
then confirming the device's native rate via system_profiler
(Current SampleRate: 16000 on the affected webcam mic).

Tested on macOS with FLEX-8600 fw 4.x: USB voice TX now produces audio
with both a 16 kHz webcam mic and a 48 kHz USB audio interface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

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

Nice diagnosis on both defects — the processedUSecs=0 with state=Active symptom is exactly the kind of CoreAudio quiet-failure that's hard to track down, and the fix is appropriately surgical.

Defect 1 (hasAnyTxStream()): The new gate correctly mirrors the consumer-side guard in onTxAudioReady() (AudioEngine.cpp:3970 and :3978), which already accepts either stream. Capture-start and capture-consume now agree on which streams matter, which is the right invariant.

One thing worth double-checking on Linux/Windows (already called out in your test plan): the remoteTxStreamReady handler at MainWindow.cpp:2192 only gates on isTxStreaming(), so on those platforms remote_audio_tx becoming ready while mic_selection=PC was already starting capture even before this PR. The new gate in the micStateChanged handler is the symmetric fix for the reverse order (stream ready first, then mic switched to PC). Behavior should be a strict superset of the old gate.

Defect 2 (preferred rate): Ordering puts preferredFormat().sampleRate() after macBluetoothNativeInputRate(), which is the right call — Bluetooth telephony rates are more authoritative for those devices, and macOS sometimes reports preferred=48000 for SCO inputs too. The > 0 && !contains() guard handles the "no usable preferred rate" case cleanly, and the existing m_txResampler (AudioEngine.cpp:4001-4023) does the rate→24kHz conversion regardless of the chosen candidate, so 16 kHz / 32 kHz / etc. all just work.

The change is #ifdef Q_OS_MAC so Linux/Windows are untouched for that piece.

Thanks for the thorough diagnostic write-up — the probe table makes it easy to reproduce and verify. Happy to leave the Linux/Windows voice-TX regression check to a reviewer with that hardware.

@nigelfenton

Copy link
Copy Markdown
Contributor

As mentioned — happy to take a look on the Mac side.

Worth a cross-link with #2982 (just opened as a draft): same file (AudioEngine), same territory (macOS mic capture + TX audio path), opposite direction. That patch suppresses the local mic capture while a TCI client is feeding TX audio, to stop ambient mic packets from drowning out the TCI tone in the dax_tx packet stream. Different functions, no textual conflict expected — they should stack cleanly:

I've got the same class of webcam-default input device here (Mac mini Apple Silicon, macOS 26.x, FLEX 6300, AE on build/cal-2814 ≈ tx_gain+ALC #2950 + #2814 fix + #2982 stacked) so I can give defect #2 a direct check — will cherry-pick this on top and report back after I've worked through the Linux/Windows TCI Monitor cross-check today.

73, Nigel G0JKN

@nigelfenton

Copy link
Copy Markdown
Contributor

Verified on Mac mini Apple Silicon, macOS 26.5.0, FLEX-6300 — both defects fixed end-to-end.

Defect #2 (preferred sample rate) — caught directly in our TX-stream startup log:

AudioEngine: TX stream started -> 10.0.0.32 : 4991 streamId: 84000000
device: "HD Pro Webcam C920"
id: "AppleUSBAudioEngine:...:HD Pro Webcam C920:60CF881F:3"
rate: 32000 ch: 2 resample: true

C920's native rate is 32 kHz; Qt/CoreAudio previously claimed isFormatSupported(48000) == true but delivered zero samples. The preferredFormat() path picks 32 kHz, the resampler converts to 24 kHz internal. Clean.

Defect #1 (capture-start gate) — also fixed. Capture started despite there being no dax_tx stream for plain voice TX (the path used to require txStreamId() != 0).

End-to-end voice TX: slice in USB, mic_selection=PC, MOX → talked into the webcam → 9 W forward into the dummy load, clean SSB modulation visible in the panadapter (audio passband peak above the suppressed carrier), Phone/CW Level meter green throughout, PA warmed to 32.6 °C. Real transmit, not just keying.

Looks ready for review/merge from the macOS angle — happy to provide more detail or test variations on request.

73, Nigel G0JKN

@ten9876 ten9876 merged commit 07ea5e6 into aethersdr:main May 23, 2026
5 checks passed
@ten9876

ten9876 commented May 23, 2026

Copy link
Copy Markdown
Collaborator

Merged as 07ea5e6. Thanks @pepefrog1234 — the processedUSecs=0 / state=Active instrumentation table was the kind of evidence that turns a hard-to-reproduce 'no audio on macOS' bug into a two-line fix, and the layered diagnosis (capture-start gate vs. native-rate trap) was textbook. Each defect would have been hours of head-scratching on its own; both at once would have been hopeless without the probe data.

Stale-code audit against current main (today saw merges across the audio path — #2982 TCI mic suppression, #2973 audio summary logging, #2978 BYPASS flag, #2926 audio device prompts, #3004 limiter default) — none touched the buggy site at MainWindow.cpp:2353 or the macTxInputRateCandidates insertion point. 3-way merge applied cleanly. Cross-platform impact verified by walking every audioStartTx/startTxStream call site to confirm the micStateChanged gate was the sole buggy one (other sites either had the streamId just set or were restart-after-device-change paths).

Independent verification by @nigelfenton on his Mac mini + FLEX-6300 + C920 setup (32 kHz native webcam mic) was the gold-standard test — 9 W forward power into a dummy load with clean SSB modulation, both defects fixed end-to-end. That kind of cross-tester hardware confirmation makes the merge call easy.

Linux/Windows TX-voice users on a strict 'no DAX, plain SSB' setup will benefit too — the bug was just much less likely to surface on those platforms because most users have DAX enabled (which set txStreamId to non-zero and bypassed the buggy gate by accident).

73,
Jeremy KK7GWY & Claude (AI dev partner)

jensenpat added a commit to jensenpat/AetherSDR that referenced this pull request Jun 5, 2026
…ethersdr#3306)

macOS + Linux: AudioEngine::startTxStream() now drives the mic rate/format
selection from the factory's Int16 Input ladder (AudioDeviceNegotiator),
walking stereo-then-mono to preserve the existing channel fallback. This
removes the per-OS #ifdef rate lists and the bespoke macTxInputRateCandidates()
helper — its policy (preferred-rate-first to dodge the silent-48k-open trap
aethersdr#2930; Bluetooth-HFP native rate first aethersdr#2615) now lives in the one factory
ladder. The macOS CoreAudio-HAL detection the factory can't derive
(macBluetoothNativeInputRate) is fed in via a new preferredRateOverride on the
wrapper, so a BT-HFP mic still opens at its native low rate.

Behaviour preserved: standard macOS mic -> preferred 48k (then 44.1k/24k/16k);
Linux -> native 24k (then 48k/44.1k); BT-HFP mic -> native low rate first. The
existing TX resampler (m_txInputRate -> 24k) and the push/pull open modes,
watchdog, and metering are unchanged.

Windows mic is intentionally left as-is: it already matches the factory's
Windows policy (force 48k + WASAPI probe-at-open) and carries the mono-only
USB-mic channel clamp (aethersdr#2929) that warrants its own soak — the remaining mic
increment.

Builds & links clean into the full app; both negotiation tests still 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 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
…ethersdr#3306)

macOS + Linux: AudioEngine::startTxStream() now drives the mic rate/format
selection from the factory's Int16 Input ladder (AudioDeviceNegotiator),
walking stereo-then-mono to preserve the existing channel fallback. This
removes the per-OS #ifdef rate lists and the bespoke macTxInputRateCandidates()
helper — its policy (preferred-rate-first to dodge the silent-48k-open trap
aethersdr#2930; Bluetooth-HFP native rate first aethersdr#2615) now lives in the one factory
ladder. The macOS CoreAudio-HAL detection the factory can't derive
(macBluetoothNativeInputRate) is fed in via a new preferredRateOverride on the
wrapper, so a BT-HFP mic still opens at its native low rate.

Behaviour preserved: standard macOS mic -> preferred 48k (then 44.1k/24k/16k);
Linux -> native 24k (then 48k/44.1k); BT-HFP mic -> native low rate first. The
existing TX resampler (m_txInputRate -> 24k) and the push/pull open modes,
watchdog, and metering are unchanged.

Windows mic is intentionally left as-is: it already matches the factory's
Windows policy (force 48k + WASAPI probe-at-open) and carries the mono-only
USB-mic channel clamp (aethersdr#2929) that warrants its own soak — the remaining mic
increment.

Builds & links clean into the full app; both negotiation tests still 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
…ethersdr#3306)

macOS + Linux: AudioEngine::startTxStream() now drives the mic rate/format
selection from the factory's Int16 Input ladder (AudioDeviceNegotiator),
walking stereo-then-mono to preserve the existing channel fallback. This
removes the per-OS #ifdef rate lists and the bespoke macTxInputRateCandidates()
helper — its policy (preferred-rate-first to dodge the silent-48k-open trap
aethersdr#2930; Bluetooth-HFP native rate first aethersdr#2615) now lives in the one factory
ladder. The macOS CoreAudio-HAL detection the factory can't derive
(macBluetoothNativeInputRate) is fed in via a new preferredRateOverride on the
wrapper, so a BT-HFP mic still opens at its native low rate.

Behaviour preserved: standard macOS mic -> preferred 48k (then 44.1k/24k/16k);
Linux -> native 24k (then 48k/44.1k); BT-HFP mic -> native low rate first. The
existing TX resampler (m_txInputRate -> 24k) and the push/pull open modes,
watchdog, and metering are unchanged.

Windows mic is intentionally left as-is: it already matches the factory's
Windows policy (force 48k + WASAPI probe-at-open) and carries the mono-only
USB-mic channel clamp (aethersdr#2929) that warrants its own soak — the remaining mic
increment.

Builds & links clean into the full app; both negotiation tests still 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
…tart capture for remote_audio_tx (aethersdr#2930)

## Summary

On macOS, USB/LSB/AM/FM **voice** TX produced no modulating audio
(carrier up, dead air) even though DAX digital-mode TX worked fine. Two
independent defects stacked together; both are fixed here.

## Defect 1 — mic capture never started without a dax_tx stream

`MainWindow`'s `mic_selection` handler only started `QAudioSource`
capture when `m_audio->txStreamId() != 0`:

```cpp
if (!m_audio->isTxStreaming() && m_audio->txStreamId() != 0) {
    audioStartTx(...);
}
```

`txStreamId()` is the **dax_tx** stream id. Voice TX flows over
**remote_audio_tx** (Opus), a different stream. When no DAX bridge is
running there is no dax_tx stream, so switching `mic_selection` to PC
for plain SSB never started mic capture — `onTxAudioReady()` never fired
and the radio received no PC audio.

`onTxAudioReady()` itself already accepts either stream (`if
(m_txStreamId == 0 && m_remoteTxStreamId == 0) return;`), so only the
capture-start gate was wrong.

**Fix:** add `AudioEngine::hasAnyTxStream()` (dax_tx **or**
remote_audio_tx) and gate capture start on that.

## Defect 2 — QAudioSource opened at 48 kHz on a 16 kHz-native device

`macTxInputRateCandidates()` tried `48000` first. macOS CoreAudio
reports `isFormatSupported(48000) == true` for many capture devices that
actually run at a lower native rate (e.g. USB webcam mics at 16 kHz).
`QAudioSource` then "opens" successfully — `state=Active`,
`error=NoError` — but the device delivers **zero samples**:

- `QAudioSource::processedUSecs()` stays `0`
- the push-mode `QBuffer` write position never advances (`pos()` stuck
at `0`)
- TX is silent

**Fix:** query `QAudioDevice::preferredFormat().sampleRate()` and try it
first. The existing resampler converts the device-native rate to 24 kHz
radio-native. `48000/44100/24000` remain as fallbacks for devices that
report no usable preferred rate.

## Diagnosis

Instrumented the TX path on the affected machine:

| Probe | Observation |
|---|---|
| `onTxAudioReady()` entry | fired continuously, but
`m_micBuffer->pos()` stuck at `0` |
| `QAudioSource` health @1s | `state=Active error=NoError
processedUSecs=0` |
| `system_profiler SPAudioDataType` | affected mic (`4K SlimFit Cam`)
`Current SampleRate: 16000` |

`processedUSecs=0` with `state=Active` is the tell-tale of CoreAudio
accepting a format the device can't actually source.

## Test plan

- [x] macOS + FLEX-8600 fw 4.x, mic_selection=PC, USB/LSB
- [x] 16 kHz USB webcam mic — voice TX now modulates (resampled 16→24
kHz)
- [x] 48 kHz USB audio interface — unchanged, still works
- [x] DAX digital-mode TX (VARA) — unaffected (separate dax_tx path)
- [ ] Reviewer: Linux/Windows regression check —
`macTxInputRateCandidates()` is `#ifdef Q_OS_MAC`; the
`hasAnyTxStream()` gate change is cross-platform and should be verified
on a Windows/Linux voice TX setup.

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.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>
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.

3 participants