Skip to content

HOLD FOR TESTING - fix(audio): reduce Windows USB audio latency for RX and CW sidetone (#3193)#3194

Closed
M7HNF-Ian wants to merge 1 commit into
aethersdr:mainfrom
M7HNF-Ian:fix/3193-wasapi-usb-latency
Closed

HOLD FOR TESTING - fix(audio): reduce Windows USB audio latency for RX and CW sidetone (#3193)#3194
M7HNF-Ian wants to merge 1 commit into
aethersdr:mainfrom
M7HNF-Ian:fix/3193-wasapi-usb-latency

Conversation

@M7HNF-Ian

Copy link
Copy Markdown
Contributor

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)

QAudioSink is constructed and start() called with no prior setBufferSize(). 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 of m_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 to paNoDevice. The result is CW timing jitter on fast keying.

Fix

AudioEngine.cpp — add setBufferSize(100 ms) before start() 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 in CwSidetoneQAudioSink.

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 returning paNoDevice with 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_WIN guards 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

…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
@M7HNF-Ian M7HNF-Ian requested a review from a team as a code owner May 26, 2026 17:33
@jensenpat jensenpat changed the title fix(audio): reduce Windows USB audio latency for RX and CW sidetone (#3193) HOLD FOR TESTING - fix(audio): reduce Windows USB audio latency for RX and CW sidetone (#3193) May 26, 2026
@jensenpat

Copy link
Copy Markdown
Collaborator

Thanks Ian, since this touches the audio stack for all users on Windows, I want to give this some soak time for testing.

@jensenpat jensenpat marked this pull request as draft May 26, 2026 19:09
@jensenpat

jensenpat commented May 26, 2026

Copy link
Copy Markdown
Collaborator

@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.

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Defensive review — Windows audio sink history & risk assessment

Pat, 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

# Title Date Outcome Touches
#1149 Crash: UAF in onTxAudioReady after long runtime (macOS) Apr 2026 closed inspired the WASAPI silent-stop watchdog referenced at AudioEngine.cpp:1073-1078
#1303 Audio Loss After Idle (Win) Apr 2026 closed original WASAPI IdleState restart logic
#1405 No audio in PC Audio v0.8.12.2 Apr 2026 closed removed IdleState restart loop that broke Win playback; replaced with zombie-sink watchdog
#1505 RX buffer cap merged shipped introduced m_rxBufferCapMs (default 200 ms, 50–1000 ms) — the engine-side ring, separate from the WASAPI device ring this PR targets
#1705 Telephony (HFP/SCO) output substitution merged shipped macOS-only telephony heuristic at lines 991-1033 — unaffected here
#2120 NR artifact on Win 24 kHz WASAPI merged shipped the existing #ifdef Q_OS_WIN 48 kHz preference block this PR edits
#2528 Ver 0.9.8 TX→RX delay when stop sending CW (Win) May 10 open overlaps symptoms with #3193's "blip on TX→RX" — this PR may partially address
#2583 Audio device hotplug merged shipped every device change re-runs startRxStream(), so the new setBufferSize cap applies on every hotplug
#2629 CWX keying: no sidetone monitor heard (Win/VB-Cable) May 13 closed introduced the Int16 fallback in CwSidetoneQAudioSink:53 — relevant precedent for Win sink negotiation oddities
#2694 CWX sidetone latency + distorted tone, FLEX-8400 (Win) May 15 open distortion + latency — this PR may help latency, will not fix distortion
#2703 USB CW not working on Mac May 15 closed mac-only
#2899 / #2901 Route CW sidetone to selected audio output May 21 merged introduced the PortAudio sidetone sink and the substring-matching device resolver this PR fixes
#2929 WASAPI mono-only USB PnP mic silent-open recovery merged shipped precedent for Win-specific WASAPI quirk handling; pattern is "guard with #ifdef Q_OS_WIN, fall back cleanly" — this PR follows it
#2973 Default audio summary logging merged shipped new logging surface that should now report the 100 ms cap so we can verify field behaviour
#3071 No PC sidetone for HWCW source May 24 closed sidetone routing edge case
#3193 External USB audio interface + Win 11 latency (Scarlett Solo Gen4) May 26 open the root report this PR addresses

Fix #1 (RX setBufferSize(100ms)) — concerns

The change is correctly placed before start() (Qt requires this) and is correctly inside the existing #ifdef Q_OS_WIN block. Buffer math is right for both 48 kHz and 24 kHz paths. But:

  1. setBufferSize is a hint, not a contract. Qt's WASAPI backend may round it up to the device period or ignore it for devices that report a fixed minimum. So worst case it's a no-op, not a regression — but means we cannot guarantee the latency win advertised in the PR description without per-device testing.

  2. 100 ms is aggressive for the long tail of Windows audio endpoints. WASAPI shared-mode period is typically 10 ms on Win10/11 (10× headroom is reasonable), but several endpoint classes can struggle:

    • Bluetooth A2DP outputs (LDAC/aptX) routinely need 150–250 ms.
    • Virtual cables (VB-Cable, VoiceMeeter) — these were the cause of CWX keying with PC Audio: No CW sidetone monitor heard #2629's Int16 fallback. They should tolerate 100 ms but warrant explicit testing.
    • USB 1.1 / UAC1 class-compliant devices (older USB-audio dongles, some headsets) often need 200 ms+ to avoid dropouts.
    • Realtek HDA onboard — typically OK at 100 ms but varies by chipset/driver.

    If any of these underrun, the user gets choppy RX with no obvious diagnostic.

  3. No fallback / no exposure to the existing user-tunable cap. m_rxBufferCapMs is user-adjustable (50–1000 ms, default 200) precisely because users on VPN/SmartLink need to raise it. The new WASAPI ring cap is a hard-coded 100 ms with no escape hatch. If a user raises m_rxBufferCapMs to 500 ms hoping to fix dropouts, the 100 ms WASAPI ring underneath may still underrun. Worth considering tying the WASAPI cap to a fraction of m_rxBufferCapMs, or at least making it adjustable.

  4. No regression for the Audio Loss After Idle #1303/No audio in PC Audio V0.8.12.2 #1405/idle-watchdog path. The state-changed handler at line 1079+ is intact; setBufferSize doesn't change idle/sleep behaviour. Good.

  5. 24 kHz fallback path: math correct, but the fallback exists for a reason — devices that reject 48 kHz are by definition fussy. Applying a 100 ms cap to them as well is the riskier of the two new calls.

Recommended pre-merge test matrix:

Fix #2 (PortAudio sidetone WASAPI preference) — concerns

Much lower risk. Mirrors the existing Linux JACK preference cleanly. Two minor notes:

  • paInDevelopment is used as an "unknown" sentinel for apiType when Pa_GetHostApiInfo returns null. Functionally fine (it will never equal paWASAPI), but a bit opaque — a comment or explicit sentinel would read better.
  • The partials.size() - 1 count in the qCInfo log is safe in the only branch that reaches it (size ≥ 2 by construction).

This change should also help #2528 (TX→RX delay on CW stop) and the latency component of #2694, since both flow through the same sidetone path. Worth cross-linking in the PR body.

Recommendation

Fix #2: ship it after the minor logging tidy.

Fix #1: I'd hold for the test matrix above, and consider whether the WASAPI cap should be derived from m_rxBufferCapMs (e.g., min(m_rxBufferCapMs / 2, 100ms)) rather than hard-coded — that way users on jittery links can still buy themselves headroom by raising the existing knob. The hard-coded 100 ms is fine for the Scarlett/Focusrite class the reporter is on, but the audio stack serves a much broader hardware population than that.

73, Jeremy KK7GWY & Claude (AI dev partner)


🤖 aethersdr-agent · cost: $7.2190 · model: claude-opus-4-7

@jensenpat

Copy link
Copy Markdown
Collaborator

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!

@jensenpat jensenpat closed this May 28, 2026
ten9876 pushed a commit that referenced this pull request May 28, 2026
…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)
ten9876 pushed a commit that referenced this pull request Jun 6, 2026
…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>
ten9876 added a commit that referenced this pull request Jun 6, 2026
…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>
@M7HNF-Ian M7HNF-Ian deleted the fix/3193-wasapi-usb-latency branch June 7, 2026 14:58
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…#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)
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…#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>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…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>
jensenpat added a commit to jensenpat/AetherSDR that referenced this pull request Jun 29, 2026
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>
jensenpat added a commit to jensenpat/AetherSDR that referenced this pull request Jun 30, 2026
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>
ten9876 pushed a commit that referenced this pull request Jun 30, 2026
…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>
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.

External USB Audio Interface and Windows 11, latency is a huge issue

2 participants