HOLD FOR TESTING - fix(audio): reduce Windows USB audio latency for RX and CW sidetone (#3193)#3194
HOLD FOR TESTING - fix(audio): reduce Windows USB audio latency for RX and CW sidetone (#3193)#3194M7HNF-Ian wants to merge 1 commit into
Conversation
…ethersdr#3193) 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 aethersdr#3193
|
Thanks Ian, since this touches the audio stack for all users on Windows, I want to give this some soak time for testing. |
|
@AetherClaude please provide a defensive review of this fix with full Cw and audio sink history on Windows. include a table of previous issues and PRs. because of the audio stack impact I'm especially concerned about fix 1 and it's impact on system and USB devices. |
Defensive review — Windows audio sink history & risk assessmentPat, here's a defensive look at PR #3194 against the full Win audio/CW history. TL;DR: Fix #2 (PortAudio sidetone) is low-risk and well-scoped. Fix #1 (RX WASAPI ring cap) is correct in intent but has device-specific failure modes worth shaking out before merge. Historical context — Windows RX / CW sink issues & PRs
Fix #1 (RX
|
|
Ian, please roll up CW fix 2 in a standalone PR and I will approve. I am not going to apply fix 1 for now because it impacts buffer sizes all for interface types, not just USB. On these sensitive audio stack changes, it's important to roll separate PRs as changes to the audio stack require extensive testing. Thanks for the fixes! |
…3241) 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 - Partially addresses #3193 (CW sidetone latency component) - May also help #2528 (TX→RX delay when stopping CW) and the latency component of #2694 - Sibling of the held #3194 Fix #1 (RX WASAPI ring cap — separate review track)
…3397) ## What First, **pure-addition** step of the consolidated audio sink factory (#3306): a single shared, OS-parameterized format/rate **negotiation policy** that every audio sink and source will migrate onto, replacing the ~9 divergent per-sink fallback ladders and per-OS `#ifdef` branches that are the root of a recurring class of platform audio bugs. Zero behaviour change — this is the maintainer-endorsed *"land the helper first, migrate sinks incrementally"* approach (#3306, and the #3194 thread's "sensitive audio-stack changes must be separate PRs with soak time"). ## Why this shape The historical bugs escaped CI because the per-OS rate ladders were compiled behind `Q_OS_*`, so a Linux runner could only ever exercise the Linux path. The new policy is a **pure function over an injected `DeviceCaps` snapshot with `TargetOs` as a parameter, not an `#ifdef`** — so one headless, hardware-free test binary exercises the Windows, macOS *and* Linux ladders on any runner. ## Contents - **`src/core/AudioFormatNegotiator.{h,cpp}`** — dependency-free policy (links only `Qt6::Core`). One ladder owns: - Windows force-48k + r8brain (#2120/#2123), macOS 48k/A2DP (#1705), Linux native-24k — divergences preserved as **data**, not `#ifdef`. - The **universal 44.1 kHz rung** that RX/Quindar/QSO are missing today (#3385). - `Int16`↔`Float32` + `preferredFormat()` catch-all (#2669 / #1090 / #3231). - macOS mic preferred-rate-first (#2930) and Bluetooth-HFP native rate (#2615); Windows probe-at-open (#2929). - The two stereo resampler strategies — **PreservePan** (RX/QSO) vs **MonoCollapse** (TCI-TX/RADE) — kept deliberately distinct (#2403/#2459). - **`tests/audio_format_negotiation_test.cpp`** — table-driven golden matrix, registered under CTest. **25/25 pass**, including the 44.1k-only-device regression guard. - **`docs/audio-sink-factory.md`** — the full three-layer design (pure policy → live Qt wrapper → device-ownership router), the device-following **"uncoupling"** fix (CW / RADE / Aetherial-Audio "Pudu" recorder following the selected output), the regression-guard invariants, and the one-sink-per-PR migration plan. ## Test ``` $ ./build/audio_format_negotiation_test ... 25/25 checks passed ``` ## Follow-ups (per the migration plan in the doc) Live Qt wrapper → migrate RX speaker → migrate PC mic → `AudioOutputRouter` (closes the uncoupling class) → TCI-TX client-rate → PipeWire DAX shared resampler. Each a separate, soakable PR. Refs #3306 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat Co-authored-by: Codex <noreply@openai.com>
…ry (#3306) (#3398) > **Stacks on #3397** (the pure foundation). The first two commits here are #3397's (foundation + live wrapper); the last two are the RX and TX-mic migrations. Merge #3397 first and this collapses to just those deltas. Kept as separate commits per the maintainer's one-change-at-a-time / soak rule (#3194 thread). ## What Layer 2 + the first two sink migrations of the consolidated audio sink factory (#3306): 1. **Live Qt-Multimedia wrapper** `AudioDeviceNegotiator` — the thin, and only, platform-specific bridge. `probe(QAudioDevice)` → pure `DeviceCaps`; `negotiate()`/`formatLadder()` → openable `QAudioFormat`s. Smoke-tested against the real default devices (`audio_device_negotiator_test`, 8/8). 2. **RX speaker** — `startRxStream()` walks the factory's Float ladder with real `start()` attempts instead of two forked per-OS `#ifdef` blocks (**net −87 lines**). `m_resampleTo48k` (bool) → `m_rxOutputRate` (int). 3. **PC mic (TX), macOS + Linux** — `startTxStream()` drives mic rate/format from the factory's Int16 Input ladder (stereo-then-mono), removing the per-OS rate lists and the bespoke `macTxInputRateCandidates()` helper. ## Behaviour (preserved) - **RX:** Windows/macOS → 48k (WASAPI 24k-resampler artifacts #2120; macOS A2DP off HFP), Linux → 24k native. New strictly-additive wins: a 44.1k-only output now works (#3306/#3385); the Quindar local monitor now opens on Windows (old branch `return`ed before `startQuindarLocalSink()`). - **TX mic:** standard macOS mic → 48k; Linux → 24k native; **Bluetooth-HFP mic → native low rate first (#2615)**, fed in via `macBluetoothNativeInputRate()` + the wrapper's new `preferredRateOverride`; preferred-rate-first avoids the silent-48k-open trap (#2930). ## Scope notes / invariants - Two stereo resampler strategies kept distinct: **PreservePan** (RX, dual L/R, VITA-49 pan #2403/#2459) vs **MonoCollapse** (RADE/TCI-TX). - macOS telephony/HFP **output** substitution (#1705) unchanged (device selection, before the ladder). - **Windows mic left as-is** — already matches the factory's force-48k + probe-at-open policy and carries the mono-only USB-mic channel clamp (#2929) that needs its own soak. That + the device-following `AudioOutputRouter`, TCI-TX client-rate, and PipeWire DAX are the remaining increments (see `docs/audio-sink-factory.md`). ## Test / build - `audio_format_negotiation_test` 25/25, `audio_device_negotiator_test` 8/8. - Full macOS app builds & links clean (RelWithDebInfo); deployed to the macOS test box. Soak on Windows/Linux requested before un-drafting. Refs #3306 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.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)
…#3306) (aethersdr#3397) ## What First, **pure-addition** step of the consolidated audio sink factory (aethersdr#3306): a single shared, OS-parameterized format/rate **negotiation policy** that every audio sink and source will migrate onto, replacing the ~9 divergent per-sink fallback ladders and per-OS `#ifdef` branches that are the root of a recurring class of platform audio bugs. Zero behaviour change — this is the maintainer-endorsed *"land the helper first, migrate sinks incrementally"* approach (aethersdr#3306, and the aethersdr#3194 thread's "sensitive audio-stack changes must be separate PRs with soak time"). ## Why this shape The historical bugs escaped CI because the per-OS rate ladders were compiled behind `Q_OS_*`, so a Linux runner could only ever exercise the Linux path. The new policy is a **pure function over an injected `DeviceCaps` snapshot with `TargetOs` as a parameter, not an `#ifdef`** — so one headless, hardware-free test binary exercises the Windows, macOS *and* Linux ladders on any runner. ## Contents - **`src/core/AudioFormatNegotiator.{h,cpp}`** — dependency-free policy (links only `Qt6::Core`). One ladder owns: - Windows force-48k + r8brain (aethersdr#2120/aethersdr#2123), macOS 48k/A2DP (aethersdr#1705), Linux native-24k — divergences preserved as **data**, not `#ifdef`. - The **universal 44.1 kHz rung** that RX/Quindar/QSO are missing today (aethersdr#3385). - `Int16`↔`Float32` + `preferredFormat()` catch-all (aethersdr#2669 / aethersdr#1090 / aethersdr#3231). - macOS mic preferred-rate-first (aethersdr#2930) and Bluetooth-HFP native rate (aethersdr#2615); Windows probe-at-open (aethersdr#2929). - The two stereo resampler strategies — **PreservePan** (RX/QSO) vs **MonoCollapse** (TCI-TX/RADE) — kept deliberately distinct (aethersdr#2403/aethersdr#2459). - **`tests/audio_format_negotiation_test.cpp`** — table-driven golden matrix, registered under CTest. **25/25 pass**, including the 44.1k-only-device regression guard. - **`docs/audio-sink-factory.md`** — the full three-layer design (pure policy → live Qt wrapper → device-ownership router), the device-following **"uncoupling"** fix (CW / RADE / Aetherial-Audio "Pudu" recorder following the selected output), the regression-guard invariants, and the one-sink-per-PR migration plan. ## Test ``` $ ./build/audio_format_negotiation_test ... 25/25 checks passed ``` ## Follow-ups (per the migration plan in the doc) Live Qt wrapper → migrate RX speaker → migrate PC mic → `AudioOutputRouter` (closes the uncoupling class) → TCI-TX client-rate → PipeWire DAX shared resampler. Each a separate, soakable PR. Refs aethersdr#3306 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat Co-authored-by: Codex <noreply@openai.com>
…ry (aethersdr#3306) (aethersdr#3398) > **Stacks on aethersdr#3397** (the pure foundation). The first two commits here are aethersdr#3397's (foundation + live wrapper); the last two are the RX and TX-mic migrations. Merge aethersdr#3397 first and this collapses to just those deltas. Kept as separate commits per the maintainer's one-change-at-a-time / soak rule (aethersdr#3194 thread). ## What Layer 2 + the first two sink migrations of the consolidated audio sink factory (aethersdr#3306): 1. **Live Qt-Multimedia wrapper** `AudioDeviceNegotiator` — the thin, and only, platform-specific bridge. `probe(QAudioDevice)` → pure `DeviceCaps`; `negotiate()`/`formatLadder()` → openable `QAudioFormat`s. Smoke-tested against the real default devices (`audio_device_negotiator_test`, 8/8). 2. **RX speaker** — `startRxStream()` walks the factory's Float ladder with real `start()` attempts instead of two forked per-OS `#ifdef` blocks (**net −87 lines**). `m_resampleTo48k` (bool) → `m_rxOutputRate` (int). 3. **PC mic (TX), macOS + Linux** — `startTxStream()` drives mic rate/format from the factory's Int16 Input ladder (stereo-then-mono), removing the per-OS rate lists and the bespoke `macTxInputRateCandidates()` helper. ## Behaviour (preserved) - **RX:** Windows/macOS → 48k (WASAPI 24k-resampler artifacts aethersdr#2120; macOS A2DP off HFP), Linux → 24k native. New strictly-additive wins: a 44.1k-only output now works (aethersdr#3306/aethersdr#3385); the Quindar local monitor now opens on Windows (old branch `return`ed before `startQuindarLocalSink()`). - **TX mic:** standard macOS mic → 48k; Linux → 24k native; **Bluetooth-HFP mic → native low rate first (aethersdr#2615)**, fed in via `macBluetoothNativeInputRate()` + the wrapper's new `preferredRateOverride`; preferred-rate-first avoids the silent-48k-open trap (aethersdr#2930). ## Scope notes / invariants - Two stereo resampler strategies kept distinct: **PreservePan** (RX, dual L/R, VITA-49 pan aethersdr#2403/aethersdr#2459) vs **MonoCollapse** (RADE/TCI-TX). - macOS telephony/HFP **output** substitution (aethersdr#1705) unchanged (device selection, before the ladder). - **Windows mic left as-is** — already matches the factory's force-48k + probe-at-open policy and carries the mono-only USB-mic channel clamp (aethersdr#2929) that needs its own soak. That + the device-following `AudioOutputRouter`, TCI-TX client-rate, and PipeWire DAX are the remaining increments (see `docs/audio-sink-factory.md`). ## Test / build - `audio_format_negotiation_test` 25/25, `audio_device_negotiator_test` 8/8. - Full macOS app builds & links clean (RelWithDebInfo); deployed to the macOS test box. Soak on Windows/Linux requested before un-drafting. Refs aethersdr#3306 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Two independent contributors to RX speaker latency are addressed: 1. App-side RX buffer cap default lowered 200ms -> 100ms. The cap was originally set to 200ms (aethersdr#1505) as a conservative cushion for high-jitter links. For the common wired/LAN case that is avoidable latency. The setting remains user-adjustable (50-1000ms) for VPN/SmartLink users who need a larger cushion. 2. Explicit WASAPI shared-mode ring buffer on the Windows RX path. The RX QAudioSink was started with no setBufferSize(), so class-compliant USB interfaces inherited their driver's default ring (typically 100-300ms), which stacked on top of the app-side cap and produced 300-500ms+ speaker latency. We now request a 50ms device buffer before start() on Windows, comfortably fed by the existing 10ms RX drain timer and mirroring the explicit buffers already used by the sidetone and Quindar sinks. The Windows device-buffer approach revives the RX half of the original fix proposed in aethersdr#3194 by Ian M7HNF, which was never merged pending on-hardware validation. Defaults updated consistently in the engine, the MainWindow settings load, and the Radio Setup dialog. Co-Authored-By: Ian M7HNF <idimmock@gmail.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two independent contributors to RX speaker latency are addressed: 1. App-side RX buffer cap default lowered 200ms -> 100ms. The cap was originally set to 200ms (aethersdr#1505) as a conservative cushion for high-jitter links. For the common wired/LAN case that is avoidable latency. The setting remains user-adjustable (50-1000ms) for VPN/SmartLink users who need a larger cushion. 2. Explicit WASAPI shared-mode ring buffer on the Windows RX path. The RX QAudioSink was started with no setBufferSize(), so class-compliant USB interfaces inherited their driver's default ring (typically 100-300ms), which stacked on top of the app-side cap and produced 300-500ms+ speaker latency. We now request a 50ms device buffer before start() on Windows, comfortably fed by the existing 10ms RX drain timer and mirroring the explicit buffers already used by the sidetone and Quindar sinks. The Windows device-buffer approach revives the RX half of the original fix proposed in aethersdr#3194 by Ian M7HNF, which was never merged pending on-hardware validation. Defaults updated consistently in the engine, the MainWindow settings load, and the Radio Setup dialog. Co-Authored-By: Ian M7HNF <idimmock@gmail.com> Co-Authored-By: Claude Opus 4.8 <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>
Problem
On Windows with class-compliant USB audio interfaces (Scarlett Solo Gen4, Focusrite, etc.), two independent code paths contribute to audible latency:
1. RX audio —
AudioEngine::startRxStream()(AudioEngine.cpp)QAudioSinkis constructed andstart()called with no priorsetBufferSize(). WASAPI shared mode then applies the device's default ring buffer, which for USB class-compliant interfaces is typically 100–300 ms. This stacks on top ofm_rxBufferCapMs(200 ms), producing 500 ms+ speaker latency.2. CW sidetone —
CwSidetonePortAudioSink(CwSidetonePortAudioSink.cpp)Pa_GetDefaultOutputDevice()on Windows returns an MME device (the first enumerated host API), which carries 50–150 ms OS-level buffering. When a user selects a specific output and the name is matched by substring against PortAudio's device list, the same physical device appears under MME, DirectSound, and WASAPI — the old code treated this as an ambiguous match and fell back topaNoDevice. The result is CW timing jitter on fast keying.Fix
AudioEngine.cpp— addsetBufferSize(100 ms)beforestart()for both the 48 kHz path and the 24 kHz fallback path inside#ifdef Q_OS_WIN, mirroring the explicit 50 ms buffer already set inCwSidetoneQAudioSink.CwSidetonePortAudioSink.cpp— two changes:defaultPortAudioOutputDevice(): prefer the WASAPI host API on Windows (mirrors the existing Linux JACK preference).findPortAudioOutputDevice(): collect all partial-match candidates; when multiple host APIs expose the same device, resolve to the WASAPI candidate instead of returningpaNoDevicewith an "ambiguous" warning. Falls back to the existing warning if multiple WASAPI matches exist.Testing
Tested locally on macOS (no Windows-path regression —
#ifdef Q_OS_WINguards both changes). Windows testing with a Scarlett Solo Gen4 is needed to confirm latency improvement; the reporter in #3193 should be able to verify.Fixes #3193