From b9672d704e2c4b6fb8fc4dc2cebcc3ecfec5dbeb Mon Sep 17 00:00:00 2001 From: Ian M7HNF Date: Tue, 26 May 2026 18:32:41 +0100 Subject: [PATCH] fix(audio): reduce Windows USB audio latency for RX and CW sidetone (#3193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes contributed to audible latency on class-compliant USB interfaces (Scarlett Solo Gen4 and similar) on Windows: 1. AudioEngine::startRxStream() created the QAudioSink without calling setBufferSize(), so WASAPI shared mode applied its default ring buffer (100–300 ms for USB devices), stacking on top of m_rxBufferCapMs and producing 500 ms+ speaker latency. Fix: cap the WASAPI ring to 100 ms (≈38 KB at 48 kHz Float32 stereo) before start(), mirroring the explicit 50 ms buffer already set in CwSidetoneQAudioSink. 2. CwSidetonePortAudioSink picked devices by first-match, which on Windows resolves to an MME device (50–150 ms OS buffering) rather than WASAPI (≈10 ms). This caused CW timing jitter on fast keying. Fix: defaultPortAudioOutputDevice() now prefers the WASAPI host API on Windows (mirroring the Linux JACK preference already in place), and findPortAudioOutputDevice() resolves ambiguous multi-API partial matches by selecting the WASAPI candidate over MME/DirectSound alternatives. Fixes #3193 --- src/core/AudioEngine.cpp | 15 ++++++ src/core/CwSidetonePortAudioSink.cpp | 77 +++++++++++++++++++++------- 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 4ffcc052a..7637a5023 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -1043,6 +1043,15 @@ bool AudioEngine::startRxStream() m_resampleTo48k = true; m_audioSink = new QAudioSink(dev, fmt, this); m_audioSink->setVolume(m_muted.load() ? 0.0f : m_rxVolume.load()); + // Cap the WASAPI ring buffer to 100 ms. Class-compliant USB interfaces + // (Scarlett, Focusrite, etc.) advertise a 100–300 ms default ring that + // stacks on m_rxBufferCapMs and produces 500 ms+ speaker latency. (#3193) + { + constexpr int kWasapiTargetMs = 100; + const int bufBytes = (fmt.sampleRate() * fmt.channelCount() + * fmt.bytesPerSample() * kWasapiTargetMs) / 1000; + m_audioSink->setBufferSize(bufBytes); + } m_audioDevice = m_audioSink->start(); if (!m_audioDevice) { const QString firstError = audioErrorName(m_audioSink->error()); @@ -1054,6 +1063,12 @@ bool AudioEngine::startRxStream() m_resampleTo48k = false; m_audioSink = new QAudioSink(dev, fmt, this); m_audioSink->setVolume(m_muted.load() ? 0.0f : m_rxVolume.load()); + { + constexpr int kWasapiTargetMs = 100; + const int bufBytes = (fmt.sampleRate() * fmt.channelCount() + * fmt.bytesPerSample() * kWasapiTargetMs) / 1000; + m_audioSink->setBufferSize(bufBytes); + } m_audioDevice = m_audioSink->start(); if (!m_audioDevice) { const QString secondError = audioErrorName(m_audioSink->error()); diff --git a/src/core/CwSidetonePortAudioSink.cpp b/src/core/CwSidetonePortAudioSink.cpp index e11a454a5..334007ba2 100644 --- a/src/core/CwSidetonePortAudioSink.cpp +++ b/src/core/CwSidetonePortAudioSink.cpp @@ -30,45 +30,63 @@ PaDeviceIndex findPortAudioOutputDevice(const QAudioDevice& device) return paNoDevice; } - PaDeviceIndex partialMatch = paNoDevice; - QString partialMatchName; - bool partialMatchAmbiguous = false; + // Collect all partial-match candidates. On Windows a single physical + // device appears under multiple host APIs (MME, DirectSound, WASAPI); + // the partial-match list lets us prefer WASAPI instead of giving up. (#3193) + struct Candidate { PaDeviceIndex idx; QString rawName; PaHostApiTypeId apiType; }; + QList partials; + for (PaDeviceIndex i = 0; i < count; ++i) { const PaDeviceInfo* info = Pa_GetDeviceInfo(i); if (!info || info->maxOutputChannels <= 0 || !info->name) continue; const QString rawName = QString::fromUtf8(info->name); - const QString candidate = - normalizedDeviceName(rawName); + const QString candidate = normalizedDeviceName(rawName); if (candidate == target) return i; if (candidate.contains(target) || target.contains(candidate)) { - if (partialMatch == paNoDevice) { - partialMatch = i; - partialMatchName = rawName; - } else { - partialMatchAmbiguous = true; - } + PaHostApiTypeId apiType = paInDevelopment; + if (const PaHostApiInfo* api = Pa_GetHostApiInfo(info->hostApi)) + apiType = api->type; + partials.append({i, rawName, apiType}); } } - if (partialMatchAmbiguous) { - qCWarning(lcAudio) << "CwSidetonePortAudioSink: selected Qt output device" - << device.description() - << "matched multiple PortAudio outputs"; + if (partials.isEmpty()) return paNoDevice; - } - if (partialMatch != paNoDevice) { + if (partials.size() == 1) { qCWarning(lcAudio) << "CwSidetonePortAudioSink: selected Qt output device" << device.description() << "partially matched PortAudio output" - << partialMatchName + << partials[0].rawName << "by name substring"; + return partials[0].idx; + } + +#ifdef Q_OS_WIN + // Multiple matches — prefer WASAPI to avoid MME/DirectSound latency. (#3193) + QList wasapiCandidates; + for (const Candidate& c : partials) { + if (c.apiType == paWASAPI) + wasapiCandidates.append(c); + } + if (wasapiCandidates.size() == 1) { + qCInfo(lcAudio) << "CwSidetonePortAudioSink: selected Qt output device" + << device.description() + << "resolved to WASAPI output" + << wasapiCandidates[0].rawName + << "(preferred over" << partials.size() - 1 << "other host API(s))"; + return wasapiCandidates[0].idx; } - return partialMatch; +#endif + + qCWarning(lcAudio) << "CwSidetonePortAudioSink: selected Qt output device" + << device.description() + << "matched multiple PortAudio outputs"; + return paNoDevice; } PaDeviceIndex defaultPortAudioOutputDevice() @@ -89,6 +107,27 @@ PaDeviceIndex defaultPortAudioOutputDevice() } } } +#endif +#ifdef Q_OS_WIN + // Prefer WASAPI over MME/DirectSound/WDM-KS for lower CW timing jitter. + // Pa_GetDefaultOutputDevice() on Windows typically returns an MME device + // (first enumerated host API), which has 50–150 ms OS-level buffering. + // WASAPI shared mode runs at ~10 ms, eliminating the timing jitter that + // makes CW sidetone sound uneven on fast keying. (#3193) + if (devIdx == paNoDevice) { + const PaHostApiIndex apiCount = Pa_GetHostApiCount(); + for (PaHostApiIndex i = 0; i < apiCount; ++i) { + const PaHostApiInfo* api = Pa_GetHostApiInfo(i); + if (!api || !api->name) continue; + if (qstrncmp(api->name, "Windows WASAPI", 14) == 0 + && api->defaultOutputDevice != paNoDevice) { + devIdx = api->defaultOutputDevice; + qCInfo(lcAudio) << "CwSidetonePortAudioSink: using WASAPI host API" + << "(device" << devIdx << ")"; + break; + } + } + } #endif if (devIdx == paNoDevice) devIdx = Pa_GetDefaultOutputDevice();