fix(cw): prefer WASAPI host API for CW sidetone on Windows (#3193)#3241
Conversation
…#3193) Two related changes to CwSidetonePortAudioSink, both Windows-only: 1. defaultPortAudioOutputDevice(): Pa_GetDefaultOutputDevice() on Windows typically returns an MME device (first enumerated host API), which carries 50–150 ms OS-level buffering. Add a WASAPI preference pass before the MME fallback, mirroring the existing Linux JACK preference. WASAPI shared mode runs at ~10 ms, eliminating the timing jitter that makes CW sidetone sound uneven on fast keying. 2. findPortAudioOutputDevice(): a single physical device appears under multiple PortAudio host APIs on Windows (MME, DirectSound, WASAPI). The old first-match logic would either pick MME (first enumerated) or return paNoDevice with an "ambiguous" warning when all three matched. Rewrite to collect all partial-match candidates and resolve to the WASAPI entry when exactly one exists, with a clear log message. Falls back to the existing ambiguous-match warning if multiple WASAPI entries are somehow present. No change to AudioEngine.cpp or any non-Windows code path. Refs aethersdr#3193
There was a problem hiding this comment.
Thanks @M7HNF-Ian — this is a well-scoped, well-explained fix. The two changes mirror each other nicely and the comments in the code make the why obvious (Windows multi-host-API enumeration, MME latency rationale). A few notes:
Logic LGTM
findPortAudioOutputDevice()— the refactor from "first match + ambiguity flag" to "collect all + pick WASAPI" is straightforward, and the behaviour for non-Windows or exactly-one-match cases is preserved. ThepaInDevelopmentsentinel is a neat trick and the inline comment justifies it.defaultPortAudioOutputDevice()— the WASAPI preference pass on Windows is a clean mirror of the existing Linux JACK block. Nice symmetry.
One small observation (non-blocking)
start() at CwSidetonePortAudioSink.cpp:161–167 re-inspects the device's host API after defaultPortAudioOutputDevice() returns and sets m_fallbackOccurred = true / m_fallbackReason = "backend selected JACK default output" so the summary logger surfaces JACK selection as a backend substitution. The new WASAPI default-selection path doesn't get the same surfacing.
I think that's actually defensible — on Windows, WASAPI is the expected low-latency target, not an override the operator needs flagged the way JACK is on Linux — and you do emit a qCInfo at the selection site. Mentioning it only so you can confirm it's intentional. If you wanted parity, you'd add a qstrncmp(api->name, "Windows WASAPI", 14) == 0 arm to the same if (device.isNull()) block, but I don't think it's necessary.
Minor / cosmetic
- The
Candidate::apiTypefield is populated on every platform but only read inside#ifdef Q_OS_WIN. Trivially wasteful (onePaHostApiTypeIdper match candidate, on a list that's almost always tiny). Not worth changing. partials.size() - 1in theqCInfolog expression is fine — we only enter that block whenpartials.size() >= 2, so the value can't be negative.
Scope / conventions
- Single file changed, exactly as described.
- No
QSettings/AppSettingstouched; no RAII surface; no new resources to leak. - No null-deref risks introduced — every
Pa_GetHostApiInfo/Pa_GetDeviceInforeturn is checked before deref. - The
#ifdef Q_OS_WINguards around the WASAPI-specific code keep this from affecting Linux/macOS builds.
Looks ready to me. 73 from a fellow CW operator-in-spirit — fast-keying jitter is a real annoyance and this should make a noticeable difference. Thanks for splitting it out of #3194 as requested.
🤖 aethersdr-agent · cost: $5.5824 · model: claude-opus-4-7
## Summary Drop the \`check-paths\` allow-list job that gated \`check-windows\` and \`check-macos\` based on a hand-maintained set of files. Both cross-platform CI builds now run on every PR. ## Why The path-filter approach has been a chronic leaker. Every time a new file gained a platform-guarded branch (\`#ifdef Q_OS_WIN\`, \`#ifdef Q_OS_MAC\`, \`HAVE_PIPEWIRE\`, etc.) that wasn't on the allow-list, CI silently skipped the relevant cross-platform check and the regression surfaced at release tag time. The follow-up was always to add another path to the filter — and then the same class of issue would resurface elsewhere a few PRs later. Pattern receipts: - #796 — original \"MSVC issue slipped past CI\" lesson - #2633 → #2662 — macOS \`m_daxBridge\` reference inside macOS-only guard, missed - #2670 — Windows behavior change not exercised by CI - #2671 — added MainWindow.cpp/.h to filter after a miss - #2929 → #3052 — WASAPI mono-mic recovery landed unverified; added AudioEngine.cpp/.h - #3210 — re-added AudioEngine after a relapse - v26.5.3 — ClientPhaseRotator.cpp \`M_PI\` MSVC break slipped through - #3241 (yesterday) — CwSidetonePortAudioSink WASAPI fix; entire \`#ifdef Q_OS_WIN\` block never compiled by CI because the file wasn't on the filter ## Trade-off Cost: ~15-20 min of GitHub Actions runner time per PR (Windows + macOS in parallel, both cached aggressively — cold ~5 min, warm ~2-3 min). Benefit: a whole class of release-time regression we've been paying for repeatedly. ## Test plan - [x] YAML valid (\`python3 -c 'import yaml; yaml.safe_load(...)'\`) - [ ] Confirm \`check-windows\` and \`check-macos\` actually run on this PR (no longer marked \`skipping\`) - [ ] Both pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#3193) (aethersdr#3241) Standalone split of Fix aethersdr#2 from aethersdr#3194, as requested by @jensenpat. ## Problem `CwSidetonePortAudioSink` picks audio output devices via PortAudio, but on Windows: 1. **`defaultPortAudioOutputDevice()`** falls straight through to `Pa_GetDefaultOutputDevice()`, which returns an MME device (the first host API PortAudio enumerates). MME carries 50–150 ms OS-level buffering, producing audible CW timing jitter on fast keying. 2. **`findPortAudioOutputDevice()`** matches devices by name substring. On Windows, the same physical device is enumerated three times under MME, DirectSound, and WASAPI. The old code treated this as an ambiguous match and returned `paNoDevice` — causing the sidetone to fall back to `CwSidetoneQAudioSink` and lose low-latency timing. When it did resolve, it picked whichever host API appeared first (MME). ## Fix **`defaultPortAudioOutputDevice()`** — add a WASAPI preference pass before the `Pa_GetDefaultOutputDevice()` fallback, mirroring the existing Linux JACK preference exactly. WASAPI shared mode runs at ~10 ms, vs 50–150 ms for MME. **`findPortAudioOutputDevice()`** — collect all partial-match candidates, then resolve to the WASAPI entry when exactly one exists. Logs a clear `qCInfo` message showing which host API won. Falls back to the existing "matched multiple" warning only if multiple WASAPI entries are somehow present (pathological case). ## Scope `CwSidetonePortAudioSink.cpp` only. No changes to `AudioEngine.cpp`, no RX path changes, no buffer size changes. ## Related - Partially addresses aethersdr#3193 (CW sidetone latency component) - May also help aethersdr#2528 (TX→RX delay when stopping CW) and the latency component of aethersdr#2694 - Sibling of the held aethersdr#3194 Fix aethersdr#1 (RX WASAPI ring cap — separate review track)
## Summary Drop the \`check-paths\` allow-list job that gated \`check-windows\` and \`check-macos\` based on a hand-maintained set of files. Both cross-platform CI builds now run on every PR. ## Why The path-filter approach has been a chronic leaker. Every time a new file gained a platform-guarded branch (\`#ifdef Q_OS_WIN\`, \`#ifdef Q_OS_MAC\`, \`HAVE_PIPEWIRE\`, etc.) that wasn't on the allow-list, CI silently skipped the relevant cross-platform check and the regression surfaced at release tag time. The follow-up was always to add another path to the filter — and then the same class of issue would resurface elsewhere a few PRs later. Pattern receipts: - aethersdr#796 — original \"MSVC issue slipped past CI\" lesson - aethersdr#2633 → aethersdr#2662 — macOS \`m_daxBridge\` reference inside macOS-only guard, missed - aethersdr#2670 — Windows behavior change not exercised by CI - aethersdr#2671 — added MainWindow.cpp/.h to filter after a miss - aethersdr#2929 → aethersdr#3052 — WASAPI mono-mic recovery landed unverified; added AudioEngine.cpp/.h - aethersdr#3210 — re-added AudioEngine after a relapse - v26.5.3 — ClientPhaseRotator.cpp \`M_PI\` MSVC break slipped through - aethersdr#3241 (yesterday) — CwSidetonePortAudioSink WASAPI fix; entire \`#ifdef Q_OS_WIN\` block never compiled by CI because the file wasn't on the filter ## Trade-off Cost: ~15-20 min of GitHub Actions runner time per PR (Windows + macOS in parallel, both cached aggressively — cold ~5 min, warm ~2-3 min). Benefit: a whole class of release-time regression we've been paying for repeatedly. ## Test plan - [x] YAML valid (\`python3 -c 'import yaml; yaml.safe_load(...)'\`) - [ ] Confirm \`check-windows\` and \`check-macos\` actually run on this PR (no longer marked \`skipping\`) - [ ] Both pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…3897) ## Problem RX speaker latency is higher than it needs to be, most visibly for Windows users on external class-compliant USB audio interfaces (#3193), where the TX→RX transition and general monitoring lag by 300–500 ms+. Two independent code paths contribute: 1. **App-side RX buffer cap is conservative.** `m_rxBufferCapMs` has defaulted to **200 ms** since it was introduced (#1505) as a cushion for high-jitter links. For the common wired/LAN case that is avoidable latency. 2. **The Windows RX sink never constrains its device buffer.** `startRxStream()` constructs the RX `QAudioSink` and calls `start()` with no prior `setBufferSize()`. WASAPI shared mode then applies the device's *default* ring buffer, which for USB interfaces (Scarlett Solo, Focusrite, etc.) is typically **100–300 ms**. That stacks on top of the app-side cap. ## Changes **1. Lower the RX buffer cap default 200 ms → 100 ms.** The setting remains fully user-adjustable (50–1000 ms) for VPN/SmartLink users who need a larger cushion — only the default changes, and existing users who have set their own value keep it. Updated consistently in three places: the engine default (`AudioEngine.h`), the settings load (`MainWindow.cpp`), and the Radio Setup dialog field (`RadioSetupDialog.cpp`). **2. Set an explicit 50 ms device buffer on the Windows RX path.** Before `start()`, on `Q_OS_WIN`, we call `setBufferSize(candidate.bytesForDuration(50 ms))`. 50 ms is comfortably fed by the existing 10 ms RX drain timer and mirrors the explicit buffers already used by the sidetone and Quindar sinks. macOS/Linux are unchanged (guarded by `#ifdef`). Combined, these take the worst-case Windows USB path from ~300–500 ms toward ~150 ms, without touching the high-jitter escape hatch. ## Credit The Windows device-buffer approach (change 2) revives the RX half of the original fix proposed by **@M7HNF-Ian** in #3194, which was never merged pending on-hardware validation. Ian is credited as a co-author on the commit. (The CW-sidetone half of #3194 shipped separately as #3241.) ## Testing - Built clean on macOS (RelWithDebInfo, Qt 6 / Homebrew). The Windows device-buffer block is `#ifdef Q_OS_WIN` and not exercised by the macOS build. - **Needs Windows on-hardware validation** with a USB interface (the #3193 reporter's Scarlett Solo Gen4 is ideal) to confirm the latency reduction and that 50 ms does not introduce underruns. Left as **draft** pending that. Addresses #3193. 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat Co-authored-by: Ian M7HNF <idimmock@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Standalone split of Fix #2 from #3194, as requested by @jensenpat.
Problem
CwSidetonePortAudioSinkpicks audio output devices via PortAudio, but on Windows:defaultPortAudioOutputDevice()falls straight through toPa_GetDefaultOutputDevice(), which returns an MME device (the first host API PortAudio enumerates). MME carries 50–150 ms OS-level buffering, producing audible CW timing jitter on fast keying.findPortAudioOutputDevice()matches devices by name substring. On Windows, the same physical device is enumerated three times under MME, DirectSound, and WASAPI. The old code treated this as an ambiguous match and returnedpaNoDevice— causing the sidetone to fall back toCwSidetoneQAudioSinkand lose low-latency timing. When it did resolve, it picked whichever host API appeared first (MME).Fix
defaultPortAudioOutputDevice()— add a WASAPI preference pass before thePa_GetDefaultOutputDevice()fallback, mirroring the existing Linux JACK preference exactly. WASAPI shared mode runs at ~10 ms, vs 50–150 ms for MME.findPortAudioOutputDevice()— collect all partial-match candidates, then resolve to the WASAPI entry when exactly one exists. Logs a clearqCInfomessage showing which host API won. Falls back to the existing "matched multiple" warning only if multiple WASAPI entries are somehow present (pathological case).Scope
CwSidetonePortAudioSink.cpponly. No changes toAudioEngine.cpp, no RX path changes, no buffer size changes.Related