diff --git a/CHANGELOG.md b/CHANGELOG.md index a48c5d2f9..78b6aa930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/core/ClientPuduMonitor.cpp b/src/core/ClientPuduMonitor.cpp index 53d04cfef..320c0835c 100644 --- a/src/core/ClientPuduMonitor.cpp +++ b/src/core/ClientPuduMonitor.cpp @@ -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 ladder = AudioDeviceNegotiator::formatLadder( dev, AudioFormatNegotiator::Direction::Output, AudioFormatNegotiator::ResamplerPolicy::PreservePan, @@ -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(sizeof(float)), '\0'); + const auto* src16 = reinterpret_cast(m_playPcm.constData()); + auto* dstF = reinterpret_cast(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(sizeof(float)), '\0'); - const auto* src = reinterpret_cast(m_playPcm.constData()); - auto* dst = reinterpret_cast(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(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(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(); diff --git a/src/core/CwSidetoneQAudioSink.cpp b/src/core/CwSidetoneQAudioSink.cpp index b9b795ef3..c57a90256 100644 --- a/src/core/CwSidetoneQAudioSink.cpp +++ b/src/core/CwSidetoneQAudioSink.cpp @@ -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 ladder = AudioDeviceNegotiator::formatLadder( @@ -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(sizeof(float)) + : static_cast(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" @@ -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(sizeof(float)) - : static_cast(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); diff --git a/src/core/QuindarLocalSink.cpp b/src/core/QuindarLocalSink.cpp index 37da720b6..56e4068e2 100644 --- a/src/core/QuindarLocalSink.cpp +++ b/src/core/QuindarLocalSink.cpp @@ -52,12 +52,19 @@ bool QuindarLocalSink::start(const QAudioDevice& device, // 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 ladder = AudioDeviceNegotiator::formatLadder( dev, AudioFormatNegotiator::Direction::Output, AudioFormatNegotiator::ResamplerPolicy::RegenerateAtRate); @@ -66,17 +73,30 @@ bool QuindarLocalSink::start(const QAudioDevice& device, 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); + QIODevice* io = sink->start(); + if (!io) { + lastOpenError = QString::number(static_cast(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; @@ -84,35 +104,14 @@ bool QuindarLocalSink::start(const QAudioDevice& device, 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(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) {