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
15 changes: 15 additions & 0 deletions src/core/AudioEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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());
Expand Down
77 changes: 58 additions & 19 deletions src/core/CwSidetonePortAudioSink.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<Candidate> 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<Candidate> 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()
Expand All @@ -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();
Expand Down
Loading