You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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: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 rulesstructNegotiatedFormat { 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)
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
enumclassTargetOs { Windows, Linux, MacOS };
enumclassDirection { Sink, Source };
// Injected capability snapshot — what we'd otherwise read from the device.structDeviceCaps {
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 inputbool isFormatSupportedReliable = true; // false => Windows "probe at open" rule
};
structNegotiatedFormat { 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)
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.
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, r8brainCDSPResampler24,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
AudioEngine::startRxStream()AudioEngine.cpp:759QAudioFormat+isFormatSupported()Resampler(24000,48000):1151:832); Mac prefer 48000 w/ BT-HFP guard (:785-825); Linux prefer 24000 (:908)AudioEngine::startTxStream():3690QAudioFormatInt16 +isFormatSupported()Resampler(inputRate,24000):3778:231); Win skip check, probe-at-open:3870; Linux try {24,48,44.1}k:3737PipeWireAudioBridge:189:336-342VirtualAudioBridge.h:20TciServer:441; TX 48000→24000audio_samplerate:cmdResampler:961; TX hardcodedResampler(48000,24000):1296RADEEngineResamplers:68-71CwSidetone{PortAudio,QAudio}SinkPa_IsFormatSupported/device default; QAudio ladder {desired,48000,44100,24000}QuindarLocalSink:37-40isFormatSupportedthen bailQsoRecorder:362isFormatSupportedResampler:329:375DaxIqModeldaxiq_rateDuplication / divergence findings
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.processStereoToStereo(TCI, RADE —docs/audio-pipeline.md:661warns it collapses to mono), and a hand-rolled linear interpolator (PipeWire:336).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— QtisFormatSupported()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 ownsDEFAULT_SAMPLE_RATE, both QAudio negotiation blocks, andresampleStereo()):It would own, in one place:
isFormatSupported, probe at open" rule,Sinks 1, 2, 7, 8, 9 share the QAudio ladder; the fixed-rate paths (DAX bridges, DAX-IQ, RADE) consume only the
Resamplerfactory. The two spots that should migrate onto the sharedResamplerfor 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
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:
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 viaadd_test).1. Make the policy pure and OS-parameterized
The thin live wrappers (
negotiateDeviceFormat(QAudioDevice, …)) just build aDeviceCapsfrom the real device and callnegotiateRate(). 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:isFormatSupportedReliable=falseisBluetoothHfpTciServer.cpp:1296bug)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
Resamplerfactory 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 ofnegotiateRate(), not a per-caller choice. (Resampleris 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 canonicalDeviceCapsfixture, 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
add_test(NAME audio_rate_negotiation_test …)so it runs under CTest on all three CI legs.DeviceCapsfromQAudioDevice::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.