Skip to content

Consolidate audio sink sample-rate negotiation into one shared component #3306

Description

@jensenpat

Problem

Every audio sink/source in AetherSDR negotiates its device/downstream sample rate independently, each with its own fallback ladder, per-OS branches, and resampler choice. The internal canonical rate is a single value — 24000 Hz (src/core/AudioEngine.h:71, the radio VITA-49 narrowband rate) — and the project has exactly one high-quality resampler (AetherSDR::Resampler, r8brain CDSPResampler24, src/core/Resampler.h:20). Yet the negotiation of "what rate does this device/peer want, and do I need to resample to reach it" is reimplemented ~9 times, and the implementations have drifted apart per-OS. That drift is the root of a cluster of platform-specific audio bugs.

Current state — sink inventory

Sink Owning class / file Target rate(s) Negotiation Resampler Per-OS branches
Local RX speaker AudioEngine::startRxStream() AudioEngine.cpp:759 24000; up to 48000 if device prefers QAudioFormat + isFormatSupported() dual L/R Resampler(24000,48000) :1151 Win force 48000 (:832); Mac prefer 48000 w/ BT-HFP guard (:785-825); Linux prefer 24000 (:908)
PC mic (TX source) AudioEngine::startTxStream() :3690 →24000 QAudioFormat Int16 + isFormatSupported() Resampler(inputRate,24000) :3778 Mac BT-native ladder (:231); Win skip check, probe-at-open :3870; Linux try {24,48,44.1}k :3737
DAX bridge — Linux PipeWireAudioBridge RX 48000 / TX 24000 (pinned) pins PipeWire node rates :189 hand-rolled 2× linear interp :336-342 Linux only
DAX bridge — macOS VirtualAudioBridge 24000 fixed none (SHM ring hardwired 24k) .h:20 none macOS only
TCI audio TciServer RX client-negotiated {8,12,24,48}k :441; TX 48000→24000 TCI audio_samplerate: cmd per-ch Resampler :961; TX hardcoded Resampler(48000,24000) :1296 none
RADE / FreeDV RADEEngine 8/16k modem, 24k I/O fixed 4 fixed Resamplers :68-71 none
CW sidetone CwSidetone{PortAudio,QAudio}Sink desired 48000 PortAudio Pa_IsFormatSupported/device default; QAudio ladder {desired,48000,44100,24000} none (regenerates at rate) PortAudio on Lin/Mac, QAudio fallback
Quindar monitor QuindarLocalSink 48000 fixed, no fallback :37-40 isFormatSupported then bail none none
QSO playback QsoRecorder 24000 else 48000 :362 isFormatSupported dual L/R Resampler :329 comment notes Win WASAPI 300ms buffer :375
DAX IQ DaxIqModel radio-authoritative daxiq_rate radio status none none

Duplication / divergence findings

  • Device-rate negotiation reimplemented in ≥6 places, each with a different fallback ladder: RX speaker (AudioEngine.cpp:832/889-922), PC mic (:3734-3759/3870), QAudio sidetone (CwSidetoneQAudioSink.cpp:35), PortAudio sidetone (CwSidetonePortAudioSink.cpp:93), Quindar (QuindarLocalSink.cpp:37), QSO playback (QsoRecorder.cpp:362). No shared helper.
  • Per-OS preference order diverges between sinks that should agree: RX speaker prefers 48000 on Win/Mac but 24000 on Linux; PC mic likewise; CW QAudio uses {desired,48000,44100,24000} with no OS guard; Quindar has no fallback at all — it fails on a 44.1k-only device while the RX path copes.
  • 44.1 kHz handling is inconsistent: PC mic and CW list 44100 as a candidate; RX speaker, Quindar, and QSO playback never try 44100, so a 44.1k-only device works for sidetone but silently fails RX/Quindar.
  • Three different resampler strategies for the same 24k↔48k conversion: r8brain dual-L/R (RX, QSO), r8brain mono-collapse processStereoToStereo (TCI, RADE — docs/audio-pipeline.md:661 warns it collapses to mono), and a hand-rolled linear interpolator (PipeWire :336).
  • macOS DAX (24k) vs Linux DAX (48k) run the bridge at different rates, so downstream DAX rate is OS-dependent and only Linux does an in-bridge upsample.
  • TCI TX resampler is hardcoded 48000→24000 (TciServer.cpp:1296) even though TCI RX honors a client-negotiated rate that can be 8/12/24/48k — a latent mis-resample if a client negotiates non-48k then transmits.

Known issue-numbered workarounds living in the rate paths

  • AudioEngine.cpp:826-831 (Garbled RNN audio when RNN is accessed from AetherSDR #2120) — Win WASAPI 24k artifacts → force 48000 + r8brain.
  • AudioEngine.cpp:790-792 (Cannot change Audio Ouput on MacOs, Macbook Pro M2. #1705) — some CoreAudio devices report 48k unsupported on newer Qt.
  • AudioEngine.cpp:3740-3743 — Qt isFormatSupported() false-negatives on Voicemeeter/FlexRadio DAX → Win probes at open instead.
  • AudioEngine.cpp:177-229/3727-3732 — macOS BT-HFP headsets exposing only 8/16/24k capture.
  • PipeWireAudioBridge.cpp:180-189 — rates pinned to 48000 so PipeWire doesn't insert its own (CPU-heavy) resampler.

Proposed direction

Introduce a single audio sink rate-negotiation component (natural home: AudioEngine, which already owns DEFAULT_SAMPLE_RATE, both QAudio negotiation blocks, and resampleStereo()):

// static helper — one ladder, one set of per-OS rules
struct NegotiatedFormat { QAudioFormat format; bool needsResample; int deviceRate; };
NegotiatedFormat negotiateDeviceFormat(const QAudioDevice& dev,
                                       int internalRate /*=24000*/,
                                       QAudioFormat::SampleFormat fmt,
                                       Direction dir /*Sink|Source*/);
// + a Resampler factory that makes the pan-preserving dual-L/R vs mono-collapse
//   choice explicit rather than per-caller

It would own, in one place:

  • the per-OS preferred-rate ladder (currently forked across RX/mic/CW and absent in Quindar),
  • the 44.1k fallback (currently inconsistent),
  • the Windows "skip isFormatSupported, probe at open" rule,
  • the macOS BT-HFP/telephony guard,
  • the resampler-strategy selection.

Sinks 1, 2, 7, 8, 9 share the QAudio ladder; the fixed-rate paths (DAX bridges, DAX-IQ, RADE) consume only the Resampler factory. The two spots that should migrate onto the shared Resampler for consistency: the hand-rolled PipeWire interpolator (PipeWireAudioBridge.cpp:336) and the hardcoded TCI TX rate (TciServer.cpp:1296).

Out of scope (radio/client-authoritative — pass in as target, don't choose): DAX IQ daxiq_rate, TCI client-requested RX rate.

Related issues (symptoms a unified path would help close or de-risk)

Notes

  • Architecture change (new shared component + migrating each sink onto it) → maintainer design call before implementation. Suggest landing the helper first with the RX/mic paths, then migrating sinks incrementally behind it so each move is independently testable per-OS.

Testing strategy — one suite that covers every OS boundary × every sink

The whole point of consolidation is that the negotiation policy becomes testable in one headless place, so the entire class of "44.1k device silently fails on sink X / OS Y" bugs is caught on CI rather than in the field. The key design constraint that makes this possible:

Negotiation must be a pure function over injected capability data — never a live QAudioDevice/PortAudio/PipeWire query — and the target OS must be a parameter, not #ifdef.

Today the per-OS behavior is compiled in with Q_OS_* guards, so a Linux CI runner can only ever exercise the Linux ladder. If we instead pass the OS in, a single test binary built once exercises all three OSes' policies regardless of the host. This matches the project's existing lightweight free-function test style (cf. tests/tx_mic_channel_normalizer_test.cpp, tests/cw_sidetone_test.cpp — standalone executables registered via add_test).

1. Make the policy pure and OS-parameterized

enum class TargetOs { Windows, Linux, MacOS };
enum class Direction { Sink, Source };

// Injected capability snapshot — what we'd otherwise read from the device.
struct DeviceCaps {
    QList<int> supportedRates;          // e.g. {44100} for a 44.1k-only device
    QList<QAudioFormat::SampleFormat> formats;
    int channels;
    bool isBluetoothHfp = false;        // macOS BT-HFP guard input
    bool isFormatSupportedReliable = true; // false => Windows "probe at open" rule
};

struct NegotiatedFormat { int deviceRate; bool needsResample; QAudioFormat::SampleFormat fmt; ResamplerKind resampler; };

// PURE: no Qt device I/O, no platform calls. Fully unit-testable.
NegotiatedFormat negotiateRate(TargetOs os, Direction dir, int internalRate /*24000*/, const DeviceCaps& caps);

The thin live wrappers (negotiateDeviceFormat(QAudioDevice, …)) just build a DeviceCaps from the real device and call negotiateRate(). Only the wrappers are platform-specific; all the policy lives in the pure function.

2. Single table-driven golden matrix

One test (tests/audio_rate_negotiation_test.cpp) drives a table of {TargetOs, Direction, DeviceCaps} -> expected NegotiatedFormat. Each row is a documented scenario; the same matrix runs the same way on every CI runner:

Scenario OS dir device caps expected
Standard 48k DAC all 3 Sink {48000} rate 48000, resample 24→48 dual-L/R
24k-capable device all 3 Sink {24000,48000} Win/Mac→48000; Linux→24000 (documents the intended divergence, if kept)
44.1k-only device all 3 Sink {44100} rate 44100, resample 24→44.1 — today RX/Quindar fail this; the regression guard
WASAPI false-negative Win Sink {} + isFormatSupportedReliable=false force 48000, probe-at-open path (#2120 / Voicemeeter/DAX)
macOS BT-HFP capture Mac Source {8000,16000,24000} + isBluetoothHfp native-rate path, no hidden →48k (#1705 / BT guard)
TCI client negotiated 12k then TX all 3 Source target=12000 TX resamples 12000→24000, not hardcoded 48000→24000 (TciServer.cpp:1296 bug)
Mono-only device all 3 Sink channels=1 documented downmix choice

Because OS is data, every cell runs on every runner — Linux CI proves the Windows and macOS ladders too.

3. Resampler correctness, decoupled from device policy

Separately assert the Resampler factory picks the right strategy and is numerically sound (the three-strategies-for-one-job problem): feed a known sine at the source rate, resample, and check output rate, length, and that the pan-preserving dual-L/R path keeps L≠R while the mono-collapse path is intentional. This kills the "PipeWire hand-rolled linear interpolator vs r8brain" divergence by making the strategy an asserted output of negotiateRate(), not a per-caller choice. (Resampler is already directly unit-tested elsewhere, so this slots into the existing pattern.)

4. Per-sink contract test (one parameterized loop, not N suites)

Enumerate the sinks as data — {name, Direction, internalRate, allowedResamplerKinds} — and assert each, when handed each canonical DeviceCaps fixture, produces a valid negotiation (never "no fallback → fail" like Quindar does today on 44.1k). Adding a future sink (e.g. ASIO, #3242) means adding one row, and it immediately inherits the full OS × capability matrix. This is the mechanism that prevents a 10th bespoke path from reintroducing the class.

5. CI wiring

  • Register as add_test(NAME audio_rate_negotiation_test …) so it runs under CTest on all three CI legs.
  • It is headless and hardware-free (pure function + injected caps), so it needs no audio device on the runner — the reason the current bugs escape CI.
  • Keep a thin, OS-gated smoke test for the live wrapper (build a DeviceCaps from QAudioDevice::defaultOutputDevice() and assert it round-trips), but that's the only platform-specific test; all the policy coverage is in the portable matrix above.

What this buys us

The recurring failure mode — "rate ladder differs per sink/OS, some path has no 44.1k fallback, some path resamples wrong" — becomes a single table. A new device class or sink is a new row. A regression flips a documented golden value. The class of issues in the Related issues list above stops being field-only.

Metadata

Metadata

Assignees

Labels

audioAudio engine and streamingenhancementImprovement to existing featuremaintainer-reviewRequires maintainer review before any action is takenpriority: mediumMedium priorityrefactorCode cleanup, restructuring, or consolidation — no user-visible behavior change

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions