Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,8 @@ set(CORE_SOURCES
src/core/TgxlConnection.cpp
src/core/CommandParser.cpp
src/core/AudioSummaryLogger.cpp
src/core/AudioFormatNegotiator.cpp
src/core/AudioDeviceNegotiator.cpp
src/core/AudioEngine.cpp
src/core/TxMicChannelNormalizer.cpp
src/core/ChannelStripPresets.cpp
Expand Down Expand Up @@ -1712,6 +1714,18 @@ target_include_directories(audio_format_negotiation_test PRIVATE src)
target_link_libraries(audio_format_negotiation_test PRIVATE Qt6::Core)
add_test(NAME audio_format_negotiation_test COMMAND audio_format_negotiation_test)

# Smoke test for the live Qt-Multimedia wrapper (AudioDeviceNegotiator): probes
# the real default devices and round-trips to an openable QAudioFormat. Tolerant
# of headless runners with no audio hardware.
add_executable(audio_device_negotiator_test
tests/audio_device_negotiator_test.cpp
src/core/AudioDeviceNegotiator.cpp
src/core/AudioFormatNegotiator.cpp
)
target_include_directories(audio_device_negotiator_test PRIVATE src)
target_link_libraries(audio_device_negotiator_test PRIVATE Qt6::Core Qt6::Multimedia)
add_test(NAME audio_device_negotiator_test COMMAND audio_device_negotiator_test)

add_executable(profile_transfer_test
tests/profile_transfer_test.cpp
)
Expand Down
30 changes: 22 additions & 8 deletions docs/audio-sink-factory.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Consolidated audio sink factory

> Status: **in progress** — foundation landed (pure negotiator + golden tests).
> Status: **in progress** — foundation + live wrapper + RX-speaker migration landed.
> Tracking issue: [#3306](https://github.com/aethersdr/AetherSDR/issues/3306).
> Companion doc: [`audio-pipeline.md`](audio-pipeline.md).

Expand Down Expand Up @@ -141,13 +141,27 @@ rate ones; the others are enforced in the router/wrapper.

1. **Foundation** ✅ — `AudioFormatNegotiator` + golden matrix
(`audio_format_negotiation_test`). Pure addition, zero behaviour change.
2. **Live wrapper** — `AudioDeviceNegotiator`, with a unit smoke test against the
default device. Still no sink uses it yet.
3. **RX speaker** onto the wrapper — behaviour identical for normal devices; the
*only* visible change is that a 44.1k-only output now works instead of
failing (the #3306 regression the foundation guards). Soak on Win/Mac/Linux.
4. **PC mic (TX)** onto the wrapper — preserves macOS preferred-first / BT-HFP /
Windows probe-at-open.
2. **Live wrapper** ✅ — `AudioDeviceNegotiator` + smoke test against the real
default devices (`audio_device_negotiator_test`).
3. **RX speaker** ✅ — `AudioEngine::startRxStream()` now walks the factory's
Float ladder with real `start()` attempts instead of two forked per-OS
`#ifdef` blocks. The `m_resampleTo48k` bool was generalized to an
`m_rxOutputRate` `std::atomic<int>` so a 44.1k device resamples 24k→44.1k correctly instead
of failing. Behaviour is identical for normal devices (Win/Mac→48k,
Linux→24k native); the visible changes are (a) a 44.1k-Float-only output now
works, and (b) the Quindar local monitor now opens on Windows too (the old
Windows branch `return`ed before `startQuindarLocalSink()`). Soak on
Win/Mac/Linux.
4. **PC mic (TX)** ✅ (macOS + Linux) — `AudioEngine::startTxStream()` now drives
the mic rate/format selection from the factory's Int16 Input ladder, walking
stereo-then-mono. macOS BT-HFP native rate (#2615) is fed in via the existing
`macBluetoothNativeInputRate()` HAL detection (new `preferredRateOverride`
on the wrapper), and preferred-rate-first (#2930) is the ladder's macOS rule.
`macTxInputRateCandidates()` is removed (its logic now lives in the factory).
The **Windows** mic path is deliberately left as-is for now: it already
matches the factory's Windows policy (force 48k + probe-at-open) and carries
the mono-only USB-mic channel clamp (#2929) that needs its own soak — that's
the remaining mic increment.
5. **`AudioOutputRouter`** + migrate the three uncoupled sinks (CW sidetone,
Pudu monitor, QSO playback) and Quindar onto it — closes the uncoupling
class so a future sink can't re-open it.
Expand Down
126 changes: 126 additions & 0 deletions src/core/AudioDeviceNegotiator.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#include "core/AudioDeviceNegotiator.h"

namespace AetherSDR {
namespace AudioDeviceNegotiator {

namespace AFN = AudioFormatNegotiator;

QAudioFormat::SampleFormat toQt(AFN::SampleFmt f)
{
switch (f) {
case AFN::SampleFmt::Int16: return QAudioFormat::Int16;
case AFN::SampleFmt::Float32: return QAudioFormat::Float;
}
return QAudioFormat::Float;
}

AFN::SampleFmt fromQt(QAudioFormat::SampleFormat f)
{
// Only Int16 and Float32 are modelled; everything else negotiates as Float.
return (f == QAudioFormat::Int16) ? AFN::SampleFmt::Int16 : AFN::SampleFmt::Float32;
}

QAudioFormat makeFormat(int rate, AFN::SampleFmt fmt, int channels)
{
QAudioFormat f;
f.setSampleRate(rate);
f.setChannelCount(channels);
f.setSampleFormat(toQt(fmt));
return f;
}

AFN::DeviceCaps probe(const QAudioDevice& dev, AFN::Direction dir, AFN::TargetOs os,
bool bluetoothHfp, int preferredRateOverride)
{
AFN::DeviceCaps caps;

if (dev.isNull()) {
// Nothing to probe — treat as unknown so reliable backends fail cleanly
// and probe-at-open backends still take their preferred rung.
caps.isFormatSupportedReliable = (os != AFN::TargetOs::Windows);
if (preferredRateOverride > 0) caps.preferredRate = preferredRateOverride;
caps.isBluetoothHfp = bluetoothHfp;
return caps;
}

const QAudioFormat pref = dev.preferredFormat();
const int channels = pref.channelCount() >= 1 ? pref.channelCount() : 2;
caps.channels = channels;
caps.preferredRate = preferredRateOverride > 0 ? preferredRateOverride : pref.sampleRate();
caps.preferredFormat = fromQt(pref.sampleFormat());
caps.isBluetoothHfp = bluetoothHfp;

// WASAPI's isFormatSupported() returns false-negatives for many valid
// devices (Voicemeeter, FlexRadio DAX, shared-mix mismatches), so on Windows
// the policy must probe-at-open instead of trusting the query (#2120/#2929).
caps.isFormatSupportedReliable = (os != AFN::TargetOs::Windows);

// Probe the candidate rates × the two modelled formats against the device.
static const int kCandidateRates[] = {8000, 16000, 24000, 44100, 48000};
const AFN::SampleFmt fmts[] = {AFN::SampleFmt::Float32, AFN::SampleFmt::Int16};

QList<int> rates;
QList<AFN::SampleFmt> supportedFmts;
for (int rate : kCandidateRates) {
bool rateOk = false;
for (AFN::SampleFmt sf : fmts) {
QAudioFormat test = makeFormat(rate, sf, channels);
if (dev.isFormatSupported(test)) {
rateOk = true;
if (!supportedFmts.contains(sf)) supportedFmts.append(sf);
}
}
if (rateOk) rates.append(rate);
}

// Always include the device's own preferred rate/format as supported — it is
// by definition openable even when isFormatSupported() is conservative.
if (caps.preferredRate > 0 && !rates.contains(caps.preferredRate)) {
rates.append(caps.preferredRate);
}
if (!supportedFmts.contains(caps.preferredFormat)) {
supportedFmts.append(caps.preferredFormat);
}

caps.supportedRates = rates;
if (!supportedFmts.isEmpty()) {
caps.supportedFormats = supportedFmts;
}
return caps;
}

Result negotiate(const QAudioDevice& dev, AFN::Direction dir, AFN::ResamplerPolicy policy,
AFN::TargetOs os, int internalRate, bool bluetoothHfp)
{
const AFN::DeviceCaps caps = probe(dev, dir, os, bluetoothHfp);
const AFN::NegotiatedFormat n = AFN::negotiate(os, dir, caps, policy, internalRate);

Result r;
r.ok = n.ok;
r.resampler = n.resampler;
r.fellBack = n.fellBack;
r.reason = n.reason;
if (n.ok) {
r.format = makeFormat(n.rate, n.fmt, n.channels);
}
return r;
}

QList<QAudioFormat> formatLadder(const QAudioDevice& dev, AFN::Direction dir,
AFN::ResamplerPolicy policy, AFN::TargetOs os,
int internalRate, bool bluetoothHfp, int preferredRateOverride)
{
const AFN::DeviceCaps caps = probe(dev, dir, os, bluetoothHfp, preferredRateOverride);
const QList<AFN::FormatCandidate> ladder =
AFN::buildLadder(os, dir, caps, policy, internalRate);

QList<QAudioFormat> out;
out.reserve(ladder.size());
for (const AFN::FormatCandidate& c : ladder) {
out.append(makeFormat(c.rate, c.fmt, c.channels));
}
return out;
}

} // namespace AudioDeviceNegotiator
} // namespace AetherSDR
73 changes: 73 additions & 0 deletions src/core/AudioDeviceNegotiator.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#pragma once

// ─── Live Qt-Multimedia wrapper around the pure AudioFormatNegotiator ─────────
//
// This is the ONLY platform-specific part of the audio format/rate negotiation
// stack. It builds a DeviceCaps snapshot from a real QAudioDevice and runs the
// pure policy (AudioFormatNegotiator) against it, returning a ready-to-open
// QAudioFormat plus the resampler strategy. Sinks/sources call this instead of
// hand-rolling their own per-OS ladder (issue #3306).
//
// Keep all live device I/O here; the policy itself stays pure and headless-
// testable in AudioFormatNegotiator. See docs/audio-sink-factory.md.

#include "core/AudioFormatNegotiator.h"

#include <QAudioDevice>
#include <QAudioFormat>
#include <QList>

namespace AetherSDR {
namespace AudioDeviceNegotiator {

// Probe a real device into the pure policy's injected capability snapshot.
// `bluetoothHfp` is supplied by the caller (the CoreAudio-HAL detection that
// already lives in AudioEngine), since it can't be derived from QAudioDevice.
// `preferredRateOverride` (>0) replaces the device's reported preferred rate —
// used on macOS to put a Bluetooth-HFP mic's HAL-native rate first (#2615),
// which QAudioDevice::preferredFormat() does not expose.
AudioFormatNegotiator::DeviceCaps probe(
const QAudioDevice& dev,
AudioFormatNegotiator::Direction dir,
AudioFormatNegotiator::TargetOs os = AudioFormatNegotiator::hostTargetOs(),
bool bluetoothHfp = false,
int preferredRateOverride = 0);

struct Result {
bool ok = false;
QAudioFormat format; // hand straight to QAudioSink/Source
AudioFormatNegotiator::ResamplerKind resampler = AudioFormatNegotiator::ResamplerKind::None;
bool fellBack = false;
QString reason;
};

// Negotiate against a real device (reliable backends resolve fully here; for
// probe-at-open backends the returned format is the preferred first rung — use
// formatLadder() to walk the fallbacks at open).
Result negotiate(
const QAudioDevice& dev,
AudioFormatNegotiator::Direction dir,
AudioFormatNegotiator::ResamplerPolicy policy,
AudioFormatNegotiator::TargetOs os = AudioFormatNegotiator::hostTargetOs(),
int internalRate = AudioFormatNegotiator::kInternalRate,
bool bluetoothHfp = false);

// The full ordered Qt-format ladder, for backends where isFormatSupported()
// is unreliable and the caller must try-at-open (Windows WASAPI).
QList<QAudioFormat> formatLadder(
const QAudioDevice& dev,
AudioFormatNegotiator::Direction dir,
AudioFormatNegotiator::ResamplerPolicy policy,
AudioFormatNegotiator::TargetOs os = AudioFormatNegotiator::hostTargetOs(),
int internalRate = AudioFormatNegotiator::kInternalRate,
bool bluetoothHfp = false,
int preferredRateOverride = 0);

QAudioFormat::SampleFormat toQt(AudioFormatNegotiator::SampleFmt f);
AudioFormatNegotiator::SampleFmt fromQt(QAudioFormat::SampleFormat f);

// Build a concrete QAudioFormat from a negotiator candidate/result.
QAudioFormat makeFormat(int rate, AudioFormatNegotiator::SampleFmt fmt, int channels);

} // namespace AudioDeviceNegotiator
} // namespace AetherSDR
Loading
Loading