Skip to content
Closed
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).
is compiled on your machine and the panel opens immediately. Apple Silicon
Macs also stop paying a several-second delay the first time ASR touches the
GPU on each cold start.
- **AetherVoice playback, CW sidetone, and Quindar tones now work on class-
compliant multichannel USB audio interfaces on Windows (#4641)** — on
devices like the Akai EIE, WASAPI's shared-mode format query reported the
device as unable to play any of the negotiated formats, even though the
same device was already playing RX audio fine. The three affected paths
now open the device the same way the RX sink always has: try each
candidate format for real and keep the first one that actually starts,
instead of asking the query for permission first. Previously, pressing
Record in AetherVoice on an affected device would mute audio and silently
never play the recording back.

## [v26.7.4.1] — 2026-07-27

Expand Down
139 changes: 68 additions & 71 deletions src/core/ClientPuduMonitor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -228,15 +228,23 @@ void ClientPuduMonitor::startPlayback()
// supplies, in one place, the per-OS preferred rate (Win/Mac 48k to dodge
// the WASAPI 24k resampler artifacts #2120 — same policy as the RX sink;
// Linux native 24k) plus the 44.1k and preferredFormat fallbacks the old
// hand-rolled ladder enumerated by hand. We walk it with isFormatSupported
// (trusted here — that is exactly how #3231's Float-only devices are
// detected, by Int16 being correctly rejected).
// hand-rolled ladder enumerated by hand.
//
// Each rung is tried with a real QAudioSink::start(), not
// isFormatSupported() (#4641): on Windows/WASAPI that query answers
// against the shared-mode mix format and false-negatives on class-
// compliant multichannel USB interfaces (Scarlett, Focusrite, Akai EIE)
// that accept the format fine once actually opened — see AudioEngine's
// RX sink (AudioEngine.cpp) and AudioDeviceNegotiator::probe()'s
// isFormatSupportedReliable comment for the same finding. Trusting the
// query here meant every rung failed on affected hardware and playback
// silently never started.
QAudioFormat fmt;
int sinkRate = kSampleRate;
bool fallbackOccurred = false;
QStringList fallbackReasons;
QStringList attemptedFormats;
bool haveFormat = false;
QString lastOpenError;
const QList<QAudioFormat> ladder = AudioDeviceNegotiator::formatLadder(
dev, AudioFormatNegotiator::Direction::Output,
AudioFormatNegotiator::ResamplerPolicy::PreservePan,
Expand All @@ -250,87 +258,76 @@ void ClientPuduMonitor::startPlayback()
attemptedFormats << QStringLiteral("%1Hz %2ch %3")
.arg(c.sampleRate()).arg(c.channelCount())
.arg(AudioSummaryLogger::sampleFormatName(c.sampleFormat()));
if (dev.isFormatSupported(c)) {
fmt = c;
sinkRate = c.sampleRate();
haveFormat = true;
if (c.sampleRate() != kSampleRate || c.sampleFormat() != QAudioFormat::Int16) {
fallbackOccurred = true;
fallbackReasons << QStringLiteral("negotiated %1Hz %2")
.arg(sinkRate)
.arg(AudioSummaryLogger::sampleFormatName(c.sampleFormat()));

// preparePlaybackPcm() resamples from the recorded 24 kHz buffer to
// this rung's rate; must run per-rung since the rate varies across
// the ladder and the sink is opened in pull mode against the result.
if (!preparePlaybackPcm(c.sampleRate())) continue;
if (c.sampleFormat() == QAudioFormat::Float) {
const int frames = m_playPcm.size() / kBytesPerFrame;
QByteArray floatPcm(frames * kChannels * static_cast<int>(sizeof(float)), '\0');
const auto* src16 = reinterpret_cast<const int16_t*>(m_playPcm.constData());
auto* dstF = reinterpret_cast<float*>(floatPcm.data());
for (int i = 0; i < frames * kChannels; ++i) {
dstF[i] = src16[i] / 32768.0f;
}
break;
m_playPcm = std::move(floatPcm);
}
}
if (!haveFormat) {
AudioSummaryLogger::OpenFailureSummary failure;
failure.path = QStringLiteral("Aetherial monitor playback");
failure.backend = QStringLiteral("QAudioSink");
failure.deviceDescription = dev.description();
failure.attemptedFormats = attemptedFormats.join(QStringLiteral("; "));
failure.failureReason = QStringLiteral("device supports no rung in the negotiation ladder");
failure.fallbackReason = fallbackReasons.join(QStringLiteral("; "));
AudioSummaryLogger::logOpenFailure(failure);
bail(); return;
}

if (!preparePlaybackPcm(sinkRate)) { bail(); return; }

// If the sink negotiated a non-Int16 format (e.g. Float32 via WASAPI
// shared mode), convert the Int16 payload from preparePlaybackPcm()
// to the required sample format. Only Float32 is handled here since
// that is the only format preferredFormat() returns in practice on
// WASAPI and CoreAudio devices that reject Int16.
if (fmt.sampleFormat() == QAudioFormat::Float) {
const int frames = m_playPcm.size() / kBytesPerFrame;
QByteArray floatPcm(frames * kChannels * static_cast<int>(sizeof(float)), '\0');
const auto* src = reinterpret_cast<const int16_t*>(m_playPcm.constData());
auto* dst = reinterpret_cast<float*>(floatPcm.data());
for (int i = 0; i < frames * kChannels; ++i) {
dst[i] = src[i] / 32768.0f;
// ── QBuffer → QAudioSink (pull mode) ───────────────────────
// Sink pulls from QBuffer at its own cadence. No timer, no
// feedDecodedSpeech, no RX-buffer routing — the sink's internal
// ring buffer absorbs scheduler jitter so the audio comes out
// cleanly on every platform. When QBuffer hits end-of-data the
// sink transitions to IdleState and we stop cleanly.
m_playBuffer.close();
m_playBuffer.setBuffer(&m_playPcm);
if (!m_playBuffer.open(QIODevice::ReadOnly)) continue;

auto* sink = new QAudioSink(dev, c, this);
// Ask for a generous 300 ms internal ring buffer before start().
// Qt's defaults are ~40-80 ms which is fine on Linux/macOS but
// chops on Windows when the main event loop hiccups (painting,
// UI events, GC) — WASAPI shared-mode pulls on a tight 10 ms
// schedule and if we miss a refill the device inserts silence.
// 300 ms gives ~30 pulls of margin. Backend may clamp to the
// device's period granularity; not an error if the effective
// size is slightly smaller.
sink->setBufferSize(c.bytesForDuration(300'000));
sink->start(&m_playBuffer);
if (sink->state() == QAudio::StoppedState && sink->error() != QAudio::NoError) {
lastOpenError = QString::number(static_cast<int>(sink->error()));
delete sink;
m_playBuffer.close();
continue;
}
m_playPcm = std::move(floatPcm);
}

// ── QBuffer → QAudioSink (pull mode) ───────────────────────────
// Sink pulls from QBuffer at its own cadence. No timer, no
// feedDecodedSpeech, no RX-buffer routing — the sink's internal
// ring buffer absorbs scheduler jitter so the audio comes out
// cleanly on every platform. When QBuffer hits end-of-data the
// sink transitions to IdleState and we stop cleanly.
m_playBuffer.close();
m_playBuffer.setBuffer(&m_playPcm);
if (!m_playBuffer.open(QIODevice::ReadOnly)) { bail(); return; }

m_playSink = new QAudioSink(dev, fmt, this);
// Ask for a generous 300 ms internal ring buffer before start().
// Qt's defaults are ~40-80 ms which is fine on Linux/macOS but
// chops on Windows when the main event loop hiccups (painting,
// UI events, GC) — WASAPI shared-mode pulls on a tight 10 ms
// schedule and if we miss a refill the device inserts silence.
// 300 ms gives ~30 pulls of margin. Backend may clamp to the
// device's period granularity; not an error if the effective
// size is slightly smaller.
m_playSink->setBufferSize(fmt.bytesForDuration(300'000));
connect(m_playSink, &QAudioSink::stateChanged,
this, &ClientPuduMonitor::onPlaybackSinkState);
m_playSink->start(&m_playBuffer);
if (m_playSink->state() == QAudio::StoppedState
&& m_playSink->error() != QAudio::NoError) {
fmt = c;
sinkRate = c.sampleRate();
m_playSink = sink;
if (c.sampleRate() != kSampleRate || c.sampleFormat() != QAudioFormat::Int16) {
fallbackOccurred = true;
fallbackReasons << QStringLiteral("negotiated %1Hz %2")
.arg(sinkRate)
.arg(AudioSummaryLogger::sampleFormatName(c.sampleFormat()));
}
break;
}
if (!m_playSink) {
AudioSummaryLogger::OpenFailureSummary failure;
failure.path = QStringLiteral("Aetherial monitor playback");
failure.backend = QStringLiteral("QAudioSink");
failure.deviceDescription = dev.description();
failure.attemptedFormats = attemptedFormats.join(QStringLiteral("; "));
failure.failureReason = QStringLiteral("QAudioSink stopped immediately after start (error %1)")
.arg(static_cast<int>(m_playSink->error()));
failure.failureReason = lastOpenError.isEmpty()
? QStringLiteral("device supports no rung in the negotiation ladder")
: QStringLiteral("QAudioSink stopped immediately after start (error %1)").arg(lastOpenError);
failure.fallbackReason = fallbackReasons.join(QStringLiteral("; "));
AudioSummaryLogger::logOpenFailure(failure);
delete m_playSink;
m_playSink = nullptr;
bail(); return;
}
connect(m_playSink, &QAudioSink::stateChanged,
this, &ClientPuduMonitor::onPlaybackSinkState);
AudioSummaryLogger::AuxiliarySinkSummary summary;
summary.sinkName = QStringLiteral("Aetherial monitor playback");
summary.deviceDescription = dev.description();
Expand Down
58 changes: 27 additions & 31 deletions src/core/CwSidetoneQAudioSink.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,16 @@ bool CwSidetoneQAudioSink::start(const QAudioDevice& device,
// Negotiate the output format via the shared factory (#3306, Phase 6c). The
// sidetone generator retunes to the negotiated rate (RegenerateAtRate) and
// the tick handles both Float and Int16, so we walk the default Float-first
// ladder — Int16 is the VB-Audio / Int16-only-WASAPI fallback (#2629) — and
// take the first rung the device supports. The factory supplies the per-OS
// preferred rate plus the 44.1k and preferredFormat fallbacks in one place.
QAudioFormat fmt;
// ladder — Int16 is the VB-Audio / Int16-only-WASAPI fallback (#2629). The
// factory supplies the per-OS preferred rate plus the 44.1k and
// preferredFormat fallbacks in one place.
//
// Each rung is tried with a real QAudioSink::start(), not
// isFormatSupported() (#4641): on Windows/WASAPI that query answers
// against the shared-mode mix format and false-negatives on class-
// compliant multichannel USB interfaces (Akai EIE and similar) that
// accept the format fine once actually opened — matches AudioEngine's
// RX sink, which never trusted the query to begin with.
int chosenRate = 0;
QAudioFormat::SampleFormat chosenFmt = QAudioFormat::Unknown;
const QList<QAudioFormat> ladder = AudioDeviceNegotiator::formatLadder(
Expand All @@ -57,12 +63,24 @@ bool CwSidetoneQAudioSink::start(const QAudioDevice& device,
c.setChannelCount(2);
if (c.sampleFormat() != QAudioFormat::Float && c.sampleFormat() != QAudioFormat::Int16)
continue; // the sidetone tick only knows Float / Int16
if (dev.isFormatSupported(c)) {
fmt = c;
chosenRate = c.sampleRate();
chosenFmt = c.sampleFormat();
break;

auto* sink = new QAudioSink(dev, c, this);
constexpr int kSidetoneBufferMs = 50;
const int sampleBytes = (c.sampleFormat() == QAudioFormat::Float)
? static_cast<int>(sizeof(float))
: static_cast<int>(sizeof(int16_t));
sink->setBufferSize(c.sampleRate() * 2 * sampleBytes * kSidetoneBufferMs / 1000);
QIODevice* io = sink->start();
if (!io) {
delete sink;
continue;
}

chosenRate = c.sampleRate();
chosenFmt = c.sampleFormat();
m_sink = sink;
m_device = io;
break;
}
if (chosenRate == 0) {
qCWarning(lcAudio) << "CwSidetoneQAudioSink: no supported float/int16-stereo rate on device"
Expand All @@ -83,31 +101,9 @@ bool CwSidetoneQAudioSink::start(const QAudioDevice& device,
: m_fallbackReason + QStringLiteral("; ") + detail;
}

m_sink = new QAudioSink(dev, fmt, this);
// 50 ms buffer — Pulse/PipeWire happily honour ≥40 ms; <30 ms causes
// pull-mode Idle/Active flapping and audible chop. Real perceived
// latency stays low (~25 ms typical) because we keep the buffer about
// half-full via the 2 ms timer, not because the buffer itself is small.
constexpr int kSidetoneBufferMs = 50;
const int sampleBytes = (chosenFmt == QAudioFormat::Float)
? static_cast<int>(sizeof(float))
: static_cast<int>(sizeof(int16_t));
const int sidetoneBufBytes =
chosenRate * 2 * sampleBytes * kSidetoneBufferMs / 1000;
m_sink->setBufferSize(sidetoneBufBytes);

m_generator = generator;
m_generator->setSampleRateHz(chosenRate);

m_device = m_sink->start();
if (!m_device) {
qCWarning(lcAudio) << "CwSidetoneQAudioSink: sink failed to start at" << chosenRate;
delete m_sink;
m_sink = nullptr;
m_generator = nullptr;
return false;
}

if (!m_timer) {
m_timer = new QTimer(this);
m_timer->setTimerType(Qt::PreciseTimer);
Expand Down
71 changes: 35 additions & 36 deletions src/core/QuindarLocalSink.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,19 @@

// Negotiate the output format via the shared factory (#3306). The Quindar
// tone is generated in Float, so walk only the Float rungs of the ladder —
// which now gives this sink, in one place, the per-OS preferred rate plus
// the 44.1 kHz and device-preferredFormat fallbacks it previously lacked (it
// which gives this sink, in one place, the per-OS preferred rate plus the
// 44.1 kHz and device-preferredFormat fallbacks it previously lacked (it
// failed outright on a 44.1k-only output, with only 48k + preferred tried).
//
// Each rung is tried with a real QAudioSink::start(), not
// isFormatSupported() (#4641): on Windows/WASAPI that query answers
// against the shared-mode mix format and false-negatives on class-
// compliant multichannel USB interfaces (Akai EIE and similar) that
// accept the format fine once actually opened — matches AudioEngine's
// RX sink, which never trusted the query to begin with.
QStringList attemptedFormats;
QAudioFormat fmt;
bool haveFormat = false;
QString lastOpenError;
const QList<QAudioFormat> ladder = AudioDeviceNegotiator::formatLadder(
dev, AudioFormatNegotiator::Direction::Output,
AudioFormatNegotiator::ResamplerPolicy::RegenerateAtRate);
Expand All @@ -66,53 +73,45 @@
continue; // the tone is generated in Float
attemptedFormats << QStringLiteral("%1Hz %2ch Float")
.arg(cand.sampleRate()).arg(cand.channelCount());
if (dev.isFormatSupported(cand)) {
fmt = cand;
haveFormat = true;
if (cand.sampleRate() != 48000) {
fallbackOccurred = true;
fallbackReasons << QStringLiteral("negotiated %1Hz Float").arg(cand.sampleRate());
}
break;

auto* sink = new QAudioSink(dev, cand, this);
// Match CwSidetoneQAudioSink's 50 ms buffer — required to keep
// Pulse/PipeWire push-mode happy. Net latency ~25 ms; fine for
// 250 ms Quindar tones.
const int bytesPerFrame = cand.channelCount() * sizeof(float);
sink->setBufferSize(50 * cand.sampleRate() / 1000 * bytesPerFrame);

Check failure

Code scanning / CodeQL

Multiplication result converted to larger type High

Multiplication result may overflow 'int' before it is converted to 'qsizetype'.
QIODevice* io = sink->start();
if (!io) {
lastOpenError = QString::number(static_cast<int>(sink->error()));
delete sink;
continue;
}

fmt = cand;
m_sink = sink;
m_device = io;
if (cand.sampleRate() != 48000) {
fallbackOccurred = true;
fallbackReasons << QStringLiteral("negotiated %1Hz Float").arg(cand.sampleRate());
}
break;
}
if (!haveFormat) {
if (!m_sink) {
qCWarning(lcAudio) << "QuindarLocalSink: device supports no Float stereo rate"
<< dev.description();
AudioSummaryLogger::OpenFailureSummary failure;
failure.path = QStringLiteral("Quindar local sink");
failure.backend = QStringLiteral("QAudioSink");
failure.deviceDescription = dev.description();
failure.attemptedFormats = attemptedFormats.join(QStringLiteral("; "));
failure.failureReason = QStringLiteral("no Float stereo rung supported in the negotiation ladder");
failure.failureReason = lastOpenError.isEmpty()
? QStringLiteral("no Float stereo rung supported in the negotiation ladder")
: QStringLiteral("QAudioSink::start returned null (error %1)").arg(lastOpenError);
failure.fallbackReason = fallbackReasons.join(QStringLiteral("; "));
AudioSummaryLogger::logOpenFailure(failure);
return false;
}
m_actualRate = fmt.sampleRate();

m_sink = new QAudioSink(dev, fmt, this);
// Match CwSidetoneQAudioSink's 50 ms buffer — required to keep
// Pulse/PipeWire push-mode happy. Net latency ~25 ms; fine for
// 250 ms Quindar tones.
const int bytesPerFrame = fmt.channelCount() * sizeof(float);
m_sink->setBufferSize(50 * fmt.sampleRate() / 1000 * bytesPerFrame);
m_device = m_sink->start();
if (!m_device) {
qCWarning(lcAudio) << "QuindarLocalSink: failed to start sink";
AudioSummaryLogger::OpenFailureSummary failure;
failure.path = QStringLiteral("Quindar local sink");
failure.backend = QStringLiteral("QAudioSink");
failure.deviceDescription = dev.description();
failure.attemptedFormats = attemptedFormats.join(QStringLiteral("; "));
failure.failureReason = QStringLiteral("QAudioSink::start returned null (error %1)")
.arg(static_cast<int>(m_sink->error()));
failure.fallbackReason = fallbackReasons.join(QStringLiteral("; "));
AudioSummaryLogger::logOpenFailure(failure);
delete m_sink;
m_sink = nullptr;
return false;
}
m_tone = tone;

if (!m_timer) {
Expand Down
Loading