Skip to content

fix(cw): prefer WASAPI host API for CW sidetone on Windows (#3193)#3241

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
M7HNF-Ian:fix/3193-cw-sidetone-wasapi-preference
May 28, 2026
Merged

fix(cw): prefer WASAPI host API for CW sidetone on Windows (#3193)#3241
ten9876 merged 1 commit into
aethersdr:mainfrom
M7HNF-Ian:fix/3193-cw-sidetone-wasapi-preference

Conversation

@M7HNF-Ian
Copy link
Copy Markdown
Contributor

Standalone split of Fix #2 from #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

…#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
@M7HNF-Ian M7HNF-Ian requested a review from a team as a code owner May 28, 2026 06:48
Copy link
Copy Markdown
Contributor

@aethersdr-agent aethersdr-agent Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. The paInDevelopment sentinel 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::apiType field is populated on every platform but only read inside #ifdef Q_OS_WIN. Trivially wasteful (one PaHostApiTypeId per match candidate, on a list that's almost always tiny). Not worth changing.
  • partials.size() - 1 in the qCInfo log expression is fine — we only enter that block when partials.size() >= 2, so the value can't be negative.

Scope / conventions

  • Single file changed, exactly as described.
  • No QSettings / AppSettings touched; no RAII surface; no new resources to leak.
  • No null-deref risks introduced — every Pa_GetHostApiInfo / Pa_GetDeviceInfo return is checked before deref.
  • The #ifdef Q_OS_WIN guards 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

@ten9876 ten9876 merged commit 1613fe8 into aethersdr:main May 28, 2026
6 checks passed
ten9876 added a commit that referenced this pull request May 28, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PHONE applet: DEXP commands rejected on firmware v1.4.0.0

2 participants