Skip to content

[bug] Route CW sidetone to selected audio output#2899

Merged
ten9876 merged 2 commits into
aethersdr:mainfrom
jensenpat:aether/cw-sidetone-audio-device
May 23, 2026
Merged

[bug] Route CW sidetone to selected audio output#2899
ten9876 merged 2 commits into
aethersdr:mainfrom
jensenpat:aether/cw-sidetone-audio-device

Conversation

@jensenpat

@jensenpat jensenpat commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fix CW sidetone routing so the local sidetone sink follows AetherSDR's selected PC audio output instead of silently drifting to whatever PortAudio considers the default output.

Root Cause

The Qt sidetone fallback already accepted a QAudioDevice, but the PortAudio sidetone backend ignored the selected Qt device and always opened PortAudio's default output. That meant users who selected a non-default PC audio output in preferences, or who accepted a new output device from the audio device dialog, could still have CW sidetone routed to the wrong physical endpoint.

There was a second related default-following gap: when AetherSDR was configured to follow the system default output, the audio monitor only restarted local audio paths when device IDs were added/removed. If the OS changed the default output without removing the previous device, an existing RX/sidetone sink could stay bound to the old endpoint.

Changes

  • Map selected Qt output device names to PortAudio output devices for the CW sidetone backend.
  • Treat missing or ambiguous PortAudio mappings as a backend failure so AudioEngine falls back to the Qt QAudioSink sidetone path on the selected device.
  • Log selected-device, partial-match, ambiguity, and fallback routing decisions with qCWarning so support bundles capture the sidetone route without refactoring global logging behavior.
  • Keep the existing low-latency PortAudio default/JACK behavior when AetherSDR is following the system default and PortAudio can match that default output.
  • Pass the current Qt output device object into the sidetone backend startup path instead of letting PortAudio choose independently.
  • Track Qt default input/output IDs in MainWindow and restart default-following local audio paths when the OS default changes, even when no device was removed.
  • Document the default-device restart behavior and sidetone backend routing/fallback behavior in docs/audio-pipeline.md.

User Impact

Switching PC audio output from app settings or from the new audio-device dialog now restarts CW sidetone on the same output device as RX audio. Users following the OS default output also get a restart when the OS changes that default, keeping sidetone, RX audio, and the title-bar PC audio labels aligned.

Validation

Local macOS validation:

  • cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
  • cmake --build build --parallel 22
  • cmake --build build --target AetherSDR --parallel 22
  • ./build/cw_sidetone_test
  • git diff --check

Built app: build/AetherSDR.app

Windows validation by @nigelfenton:

  • Environment: Windows 11, Flex 6700, paddle on the radio's HWCW key jack.
  • PC audio endpoints present: TOSHIBA-TV (NVIDIA HDMI), Headphones (Realtek(R) Audio), USB Audio, Oculus Virtual, and FlexRadio DAX cables.
  • Built clean with MSVC, filling the skipped check-windows CI gap; no errors or new warnings on the four source files this PR touches.
  • Explicit-output bug-fix test:
    • AetherSDR PC audio output set to Headphones (Realtek(R) Audio).
    • Windows default playback set to TOSHIBA-TV (NVIDIA HDMI).
    • Local CW sidetone via the CWX panel stayed on the Realtek headphones; the TV stayed silent.
  • Hotplug test:
    • Plugging Realtek headphones in mid-session opened the Audio Device Detected dialog.
    • Accepting the dialog moved both RX audio and CW sidetone to the new device cleanly.
  • Explicit selection vs OS default test:
    • While AetherSDR was explicitly set to Realtek, changing the Windows default back to TOSHIBA-TV did not restart or reroute AetherSDR audio.
    • CW sidetone correctly stayed on Realtek.
  • Not exercised on Nigel's machine: partial-match and ambiguity branches of findPortAudioOutputDevice; his Realtek mapping was unambiguous.

👨🏼‍💻 Generated with OpenAI Codex (GPT-5.5 Pro 4/23) and tested by @jensenpat

Co-authored-by: Codex <noreply@openai.com>
@jensenpat jensenpat changed the title Route CW sidetone to selected audio output [bug] Route CW sidetone to selected audio output May 21, 2026
@jensenpat jensenpat marked this pull request as ready for review May 21, 2026 00:59
@jensenpat jensenpat added the priority: high High priority label May 21, 2026

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @jensenpat — well-scoped fix, and the docs update is a nice touch.

A few observations, none blocking:

CwSidetonePortAudioSink::findPortAudioOutputDevice — partial-match false positives

The fallback candidate.contains(target) || target.contains(candidate) widens the search beyond exact matches, with the ambiguity guard catching multi-hits. That's a good safety net, but a single accidental substring match still slips through silently. For example, a Qt description of "Speakers" would partial-match a PortAudio device named "Speakers (Conf Room)" if no other candidate exists — and the user would never see why sidetone went somewhere unexpected.

Two cheap mitigations worth considering:

  • Log the partial-match path at info/warning level (something like "Qt 'X' partially matched PortAudio 'Y'"), so a misroute is at least discoverable in the support bundle.
  • Or tighten the partial match to only candidate.contains(target) (PortAudio name strictly contains Qt description, which is the common case for "Pulse vs ALSA hw:N" style names) and skip the reverse direction — the reverse direction is the one most likely to false-match.

AudioEngine.cpp line 1049 — dev = d vs dev = m_outputDevice

Good catch on using the freshly-enumerated d. Worth a one-line comment noting why — otherwise this looks like a no-op refactor and may get "fixed" back in a future cleanup pass.

MainWindow.cpp default-ID tracking — defaultInputChanged evaluation order

Logic looks right. One small thing: currentDefaultInputId/currentDefaultOutputId are read unconditionally, but defaultInputChanged/defaultOutputChanged are only consulted in the currentInput.isNull() / currentOutput.isNull() branches. Not a bug, just an observation — the two QMediaDevices::default* calls happen on every device-list-change tick now.

Scope / conventions

  • All five touched files are in scope for the stated fix.
  • No QSettings usage introduced; RAII boundaries unchanged.
  • Logging consistently uses lcAudio via qCWarning/qCInfo.

LGTM modulo the partial-match observability concern. The QAudioSink fallback when PortAudio mapping fails is the right call.

@nigelfenton

Copy link
Copy Markdown
Contributor

Windows validation (Win11, Flex 6700, paddle on radio's HWCW key jack, several PC audio endpoints: TOSHIBA-TV via NVIDIA HDMI, Realtek motherboard audio, USB Audio, Oculus Virtual, plus the usual FlexRadio DAX cables).

Built clean with MSVC — fills the SKIPPED check-windows CI gap. No errors or new warnings on any of the 4 source files this PR touches.

Distinctive bug-fix test:

AetherSDR PC audio output (explicit) Headphones (Realtek(R) Audio)
Windows default playback TOSHIBA-TV (NVIDIA HDMI)
Local CW sidetone via CWX panel Stayed on Realtek headphones; TV silent

That's the exact scenario this PR fixes — pre-PR, PortAudio's default-output would have routed sidetone to the TV regardless of AetherSDR's selection. Post-PR, the Qt → PortAudio mapping correctly wins.

Audio-device hotplug: plugging Realtek headphones in mid-session fired the "Audio Device Detected" dialog. Accepting moved both RX and sidetone to the new device cleanly:

DBG aether.audio: AudioEngine: output device set to "Headphones (Realtek(R) Audio)"
WRN aether.audio: AudioEngine: RX stream started at 48000 Hz device: "Headphones (Realtek(R) Audio)"

Default change while explicit selection set: flipped Windows default back to TOSHIBA-TV while AetherSDR was explicit on Realtek. No restart fired (correct — explicit selection should win), sidetone stayed on Realtek.

Not exercised: the partial-match / ambiguity branches of findPortAudioOutputDevice — my Realtek mapping was unambiguous on this machine. Would need a device with substring-overlapping PortAudio names to exercise that path.

Re: @aethersdr-agent's observability concern — it's worse than the source reads. CwSidetonePortAudioSink::start logs the success/mapping line at qCInfo(lcAudio), but LogManager::applyFilterRules (src/core/LogManager.cpp:118) only adds <category>.debug=true for enabled categories — .info stays off. So the success/mapping diagnostic is invisible in support bundles even with the audio log category turned on. A silent partial-match misroute would have no forensic trail at all.

Two small (non-blocking) follow-up ideas:

  • Log the partial-match path at qCWarning (or a distinctly-worded qCInfo saying "partial-match: X → Y") so substring matches always surface
  • Or extend LogManager::applyFilterRules to also emit <category>.info=true for enabled categories — broader observability win beyond this PR

LGTM on the user-visible fix.

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Confirmed @nigelfenton's diagnosis after reading the code on main — and it's slightly worse than your summary suggests, in a way that strengthens your "extend applyFilterRules" recommendation over the per-call-site qCWarning rewrite.

Why qCInfo is invisible — two gates, not one

lcAudio is declared at src/core/LogManager.cpp:20:

Q_LOGGING_CATEGORY(lcAudio, "aether.audio", QtWarningMsg)

That sets the category's compiled-in minimum level to QtWarningMsg, which short-circuits qCInfo(lcAudio) before Qt's filter rules are even consulted. Even if applyFilterRules did emit aether.audio.info=true, info messages would still need that minimum level overridden via the filter rule — which setFilterRules() does in fact do, so the extension is the right lever. But note that 18 of 22 categories in that file are declared at QtWarningMsg (lines 20–39), so this isn't a sidetone-specific problem — it's a project-wide observability gap. Any support bundle today is missing every qCInfo line from those categories.

That makes the second follow-up the clear win: one-line fix, surfaces every existing qCInfo call (including the JACK selection at CwSidetonePortAudioSink.cpp:65 and the 48k-fallback at line 99, both currently silent in production).

Suggested patch — src/core/LogManager.cpp:113

void LogManager::applyFilterRules()
{
    QStringList rules;
    rules << "aether.*.debug=false";
    rules << "aether.*.info=false";
    for (const auto& c : m_categories) {
        if (c.enabled) {
            rules << QString("%1.debug=true").arg(c.id);
            rules << QString("%1.info=true").arg(c.id);
        }
    }
    QLoggingCategory::setFilterRules(rules.join('\n'));
}

The explicit aether.*.info=false baseline matters: without it, the four categories declared at QtDebugMsg (lcDiscovery/lcConnection/lcProtocol, lines 17–19) would start emitting info unconditionally regardless of the UI toggle.

Separately — the partial-match forensic gap

Even after the logging fix above, a silent partial-match misroute is the scarier failure mode because the user has no symptom until they key up. I'd still log the partial-match branch of findPortAudioOutputDevice at qCWarning (not qCInfo) — it's a "we made a choice you might not expect" event, not a routine status line, and warnings ride for free without any category toggle. Concretely something like:

qCWarning(lcAudio) << "CwSidetonePortAudioSink: Qt device" << qtDeviceName
                   << "matched PortAudio device" << paDeviceName
                   << "by substring — verify routing if sidetone is misrouted";

That gives support bundles the forensic trail nigelfenton called out, independent of any per-user log-category preferences.

Re: process — this issue isn't currently carrying the aetherclaude-eligible label (only priority: high). A maintainer can authorize the orchestrator to land either the logging extension, the partial-match warning, or both as a follow-up PR by adding the aetherclaude-eligible label. The user-visible sidetone routing fix that nigelfenton LGTM'd is independent of these observability items.

73, Jeremy KK7GWY & Claude (AI dev partner)

Co-authored-by: Codex <noreply@openai.com>
@jensenpat jensenpat linked an issue May 21, 2026 that may be closed by this pull request
@ten9876 ten9876 merged commit 42c8186 into aethersdr:main May 23, 2026
5 checks passed
@ten9876

ten9876 commented May 23, 2026

Copy link
Copy Markdown
Collaborator

Claude here — merged. Thanks @jensenpat, this fix has the kind of
discipline that makes the difference between "sidetone works when
nothing weird happens" and "sidetone follows my output choice."

What stood out on review:

The fallback chain is defense-in-depth done right. PortAudio
exact-match → PortAudio substring-match (if unique) → `paNoDevice` →
QAudioSink path with Qt device IDs. Ambiguity is explicitly treated
as failure rather than a guess. Even if PortAudio's enumeration
makes exact-name matching impossible on some platform, the user
still gets their selected device via the Qt path — never silently
falls back to the OS default when a specific device was requested.

@nigelfenton — appreciate the Windows 11 + Flex 6700 testing on a
machine with five physical audio endpoints. Verifying the
explicit-output stays put when the OS default changes is exactly
the regression you'd want this fix to prevent. And being explicit
about what wasn't exercised (partial-match and ambiguity branches)
is honest test reporting.

The default-following restart hook closes the other gap quietly —
operators who follow the OS default get their sinks moved when the
OS changes default, not just when a device disappears.

Ships in v26.5.3.

73,
Jeremy KK7GWY & Claude (AI dev partner)

ten9876 added a commit to jensenpat/AetherSDR that referenced this pull request May 23, 2026
Main extracted findPortAudioOutputDevice / defaultPortAudioOutputDevice
into namespace-anonymous helpers (PR aethersdr#2899/aethersdr#2901 era), which moves the
JACK-default-output selection out of start() into a helper. Weave the
PR's new state-tracking calls (m_deviceDescription, m_fallbackOccurred,
m_fallbackReason) into main's post-extraction start(), and detect the
JACK-host-API selection by inspecting devInfo->hostApi after the
helper returns so we can still mark it as a fallback for the summary.

# Conflicts:
#	src/core/CwSidetonePortAudioSink.cpp
ten9876 added a commit that referenced this pull request May 23, 2026
…#2973)

## Summary

Implements #2971 by adding a dedicated default-on `aether.audio.summary`
logging path for support-bundle-friendly audio routing and negotiation
summaries.

This adds compact, deduped summary blocks for:

- startup audio environment: selected/default input and output devices,
saved-device presence, PC Audio state, and TX mic route intent
- RX sink startup: actual output device, sample rate, channel count,
sample format, resampling state, and fallback/substitution history
- TX source startup: actual input device, negotiated rate/channels,
mono/stereo state, sample format, resampling-to-24 kHz state, and
fallback history
- CW sidetone startup: backend, actual/backend-selected device, rate,
backend substitution, and fallback history
- auxiliary local sinks when instantiated: Quindar local sink and
Aetherial monitor playback sink
- final open failures: attempted rates/channels/formats, backend,
device, failure reason, and fallback history

## Why

The normal support log already caught many warning paths, but it did not
reliably capture successful runtime sink/source negotiation. That left
recurring audio/CW reports without the details needed to distinguish
wrong endpoint, sample-rate mismatch, resampler path, backend fallback,
and complete open failure cases.

## Important Safety Detail

The startup summary uses a new shallow
`DeviceDiagnostics::buildAudioStartupSnapshot(...)` helper. It
intentionally does not call support-bundle-grade capability probes such
as `preferredFormat()` or `isFormatSupported()` across devices at app
startup. Full device capability probing remains in
`buildAudioDevicesSnapshot(...)` for explicit diagnostics/support flows.

Failure summaries only report negotiation work the audio path already
attempted; they do not add extra probing.

## Historical Cases Covered

The summary path is designed around recurring issue/PR classes
including:

- macOS Bluetooth/HFP route substitution and 24 kHz vs 48 kHz behavior
(#1486, #2634)
- CommonRadioAudio / Float32 vs Int16 format issues (#1070, #1090)
- CW sidetone endpoint/backend routing (#2072, #2075, #2899, #2901)
- CW sidetone missing/distorted reports (#2629, #2694)
- no-audio-with-active-meters / output negotiation support gaps (#1855)

## Implementation Notes

- Adds `AudioSummaryLogger` with canonical one-block formatters and
dedupe by event key.
- Adds `aether.audio.summary` as a default-on info category without
enabling the full `aether.audio` info/debug stream.
- Keeps detailed existing `aether.audio` diagnostics intact.
- Documents the summary logging policy in `docs/audio-pipeline.md`.

## Validation

- `cmake --build build -j22`
- `./build/device_diagnostics_test`
- `./build/cw_sidetone_test`
- `git diff --check`
- Built and deployed a macOS test bundle; the primary test-machine
replacement path is currently blocked by a Desktop delete ACL, so a
separate quarantine-cleared test bundle was prepared.

👨🏼‍💻 Generated with OpenAI Codex (GPT-5.5 Pro 4/23) and tested by
@jensenpat

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Jeremy Fielder <kk7gwy@aethersdr.com>
jensenpat added a commit to jensenpat/AetherSDR that referenced this pull request Jun 16, 2026
…ethersdr#3306)

Replace the hand-wired "connect each external playback sink to
outputDeviceChanged" lambda in MainWindow with a single registry. External
sinks that own their own QAudioSink (the post-DSP Pudu monitor, QSO playback)
already followed the user-selected output device correctly — every device-change
path (user selection, device removal, OS-default change) flows through
AudioEngine::setOutputDevice() -> outputDeviceChanged. The risk was purely
structural: each NEW external sink had to remember to join that lambda or it
would "uncouple" and keep playing on the old/default endpoint (the recurring
class behind aethersdr#2899 / aethersdr#3361 / aethersdr#3378).

AudioOutputRouter (src/core/AudioOutputRouter.{h,cpp}) makes following a
registration instead of hand-wiring:
- addFollower(sink) takes any object with setOutputDevice(QAudioDevice) (or a
  std::function); the follower is seeded immediately and re-seeded on every
  change, QPointer-guarded against early destruction.
- One QueuedConnection forwards outputDeviceChanged to setCurrentDevice(), which
  fans out to all followers on the GUI thread (matching prior behaviour) over a
  snapshot so a follower mutating the list mid-fan-out can't invalidate it.
- Depends only on Qt Core/Multimedia, so the registry/fan-out is unit-tested
  headless (tests/audio_output_router_test, 11/11) without a live AudioEngine,
  including the QPointer-guard and reentrancy paths.

MainWindow registers m_finalMonitor + m_qsoRecorder; a future external sink
follows correctly just by registering — no new connect to forget. Behaviour-
identical hardening. Intentionally NOT routed (documented): the AudioEngine-
internal sinks (RX speaker, CW sidetone, Quindar — follow via the RX restart),
and the WFM demodulator's WaveOutWriter (plays to its own WfmDeviceDialog device
by design, aethersdr#3407).

Builds & links clean into the full app; all three audio tests pass under CTest.

Refs aethersdr#3306

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ten9876 pushed a commit that referenced this pull request Jun 19, 2026
…3306) (#3630)

## What

Layer 3 of the consolidated audio sink factory (#3306): a single
**registry** for playback sinks that must follow the user-selected
output device, replacing the hand-wired per-sink
`connect`-to-`outputDeviceChanged` lambda in `MainWindow`.

## Why

The external playback sinks that own their own `QAudioSink` — the
post-DSP Pudu monitor (`ClientPuduMonitor`) and QSO playback
(`QsoRecorder`) — already followed the selected device correctly: every
device-change path (user selection, device removal, OS-default change)
flows through `AudioEngine::setOutputDevice()` → `outputDeviceChanged`,
and a `MainWindow` lambda re-seeded both. The risk was purely
**structural** — every *new* external sink had to remember to join that
lambda, or it would "uncouple" and keep playing on the old/default
endpoint. That's the recurring class behind #2899 (CW sidetone) and
#3361/#3378 (monitor + QSO).

## How

`AudioOutputRouter` (`src/core/AudioOutputRouter.{h,cpp}`) turns
following into registration:

- `addFollower(sink)` accepts any object exposing `setOutputDevice(const
QAudioDevice&)` (or a `std::function`). The follower is **seeded
immediately** and **re-seeded on every change**, `QPointer`-guarded
against early destruction.
- One `QueuedConnection` forwards `AudioEngine::outputDeviceChanged` →
`setCurrentDevice()`, which fans out to all followers on the GUI thread
(matching the previous behaviour).
- Depends only on Qt Core/Multimedia, so the registry/fan-out is
**unit-tested headless** (`tests/audio_output_router_test`, 9/9) without
a live `AudioEngine` — including the QPointer-guard path.

`MainWindow` now registers `m_finalMonitor` + `m_qsoRecorder` with the
router instead of hand-wiring; a future external sink follows correctly
just by registering. The AudioEngine-internal sinks (RX speaker, CW
sidetone, Quindar) already follow via the RX restart and are
intentionally **not** routed here.

## Behaviour

**Identical** to the hand-wired version — this is the *hardening* that
stops the uncoupling class from silently reappearing, not a behaviour
change.

## Test / build

- `audio_output_router_test` **9/9**; `audio_format_negotiation_test`
25/25; `audio_device_negotiator_test` 8/8 — all green under CTest.
- Full macOS app builds & links clean (RelWithDebInfo).

Refs #3306

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
## Summary

Fix CW sidetone routing so the local sidetone sink follows AetherSDR's
selected PC audio output instead of silently drifting to whatever
PortAudio considers the default output.

## Root Cause

The Qt sidetone fallback already accepted a `QAudioDevice`, but the
PortAudio sidetone backend ignored the selected Qt device and always
opened PortAudio's default output. That meant users who selected a
non-default PC audio output in preferences, or who accepted a new output
device from the audio device dialog, could still have CW sidetone routed
to the wrong physical endpoint.

There was a second related default-following gap: when AetherSDR was
configured to follow the system default output, the audio monitor only
restarted local audio paths when device IDs were added/removed. If the
OS changed the default output without removing the previous device, an
existing RX/sidetone sink could stay bound to the old endpoint.

## Changes

- Map selected Qt output device names to PortAudio output devices for
the CW sidetone backend.
- Treat missing or ambiguous PortAudio mappings as a backend failure so
`AudioEngine` falls back to the Qt `QAudioSink` sidetone path on the
selected device.
- Log selected-device, partial-match, ambiguity, and fallback routing
decisions with `qCWarning` so support bundles capture the sidetone route
without refactoring global logging behavior.
- Keep the existing low-latency PortAudio default/JACK behavior when
AetherSDR is following the system default and PortAudio can match that
default output.
- Pass the current Qt output device object into the sidetone backend
startup path instead of letting PortAudio choose independently.
- Track Qt default input/output IDs in `MainWindow` and restart
default-following local audio paths when the OS default changes, even
when no device was removed.
- Document the default-device restart behavior and sidetone backend
routing/fallback behavior in `docs/audio-pipeline.md`.

## User Impact

Switching PC audio output from app settings or from the new audio-device
dialog now restarts CW sidetone on the same output device as RX audio.
Users following the OS default output also get a restart when the OS
changes that default, keeping sidetone, RX audio, and the title-bar PC
audio labels aligned.

## Validation

Local macOS validation:

- `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo`
- `cmake --build build --parallel 22`
- `cmake --build build --target AetherSDR --parallel 22`
- `./build/cw_sidetone_test`
- `git diff --check`

Built app: `build/AetherSDR.app`

Windows validation by @nigelfenton:

- Environment: Windows 11, Flex 6700, paddle on the radio's HWCW key
jack.
- PC audio endpoints present: `TOSHIBA-TV (NVIDIA HDMI)`, `Headphones
(Realtek(R) Audio)`, USB Audio, Oculus Virtual, and FlexRadio DAX
cables.
- Built clean with MSVC, filling the skipped `check-windows` CI gap; no
errors or new warnings on the four source files this PR touches.
- Explicit-output bug-fix test:
  - AetherSDR PC audio output set to `Headphones (Realtek(R) Audio)`.
  - Windows default playback set to `TOSHIBA-TV (NVIDIA HDMI)`.
- Local CW sidetone via the CWX panel stayed on the Realtek headphones;
the TV stayed silent.
- Hotplug test:
- Plugging Realtek headphones in mid-session opened the Audio Device
Detected dialog.
- Accepting the dialog moved both RX audio and CW sidetone to the new
device cleanly.
- Explicit selection vs OS default test:
- While AetherSDR was explicitly set to Realtek, changing the Windows
default back to TOSHIBA-TV did not restart or reroute AetherSDR audio.
  - CW sidetone correctly stayed on Realtek.
- Not exercised on Nigel's machine: partial-match and ambiguity branches
of `findPortAudioOutputDevice`; his Realtek mapping was unambiguous.

👨🏼‍💻 Generated with OpenAI Codex (GPT-5.5 Pro 4/23) and tested 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
…aethersdr#2973)

## Summary

Implements aethersdr#2971 by adding a dedicated default-on `aether.audio.summary`
logging path for support-bundle-friendly audio routing and negotiation
summaries.

This adds compact, deduped summary blocks for:

- startup audio environment: selected/default input and output devices,
saved-device presence, PC Audio state, and TX mic route intent
- RX sink startup: actual output device, sample rate, channel count,
sample format, resampling state, and fallback/substitution history
- TX source startup: actual input device, negotiated rate/channels,
mono/stereo state, sample format, resampling-to-24 kHz state, and
fallback history
- CW sidetone startup: backend, actual/backend-selected device, rate,
backend substitution, and fallback history
- auxiliary local sinks when instantiated: Quindar local sink and
Aetherial monitor playback sink
- final open failures: attempted rates/channels/formats, backend,
device, failure reason, and fallback history

## Why

The normal support log already caught many warning paths, but it did not
reliably capture successful runtime sink/source negotiation. That left
recurring audio/CW reports without the details needed to distinguish
wrong endpoint, sample-rate mismatch, resampler path, backend fallback,
and complete open failure cases.

## Important Safety Detail

The startup summary uses a new shallow
`DeviceDiagnostics::buildAudioStartupSnapshot(...)` helper. It
intentionally does not call support-bundle-grade capability probes such
as `preferredFormat()` or `isFormatSupported()` across devices at app
startup. Full device capability probing remains in
`buildAudioDevicesSnapshot(...)` for explicit diagnostics/support flows.

Failure summaries only report negotiation work the audio path already
attempted; they do not add extra probing.

## Historical Cases Covered

The summary path is designed around recurring issue/PR classes
including:

- macOS Bluetooth/HFP route substitution and 24 kHz vs 48 kHz behavior
(aethersdr#1486, aethersdr#2634)
- CommonRadioAudio / Float32 vs Int16 format issues (aethersdr#1070, aethersdr#1090)
- CW sidetone endpoint/backend routing (aethersdr#2072, aethersdr#2075, aethersdr#2899, aethersdr#2901)
- CW sidetone missing/distorted reports (aethersdr#2629, aethersdr#2694)
- no-audio-with-active-meters / output negotiation support gaps (aethersdr#1855)

## Implementation Notes

- Adds `AudioSummaryLogger` with canonical one-block formatters and
dedupe by event key.
- Adds `aether.audio.summary` as a default-on info category without
enabling the full `aether.audio` info/debug stream.
- Keeps detailed existing `aether.audio` diagnostics intact.
- Documents the summary logging policy in `docs/audio-pipeline.md`.

## Validation

- `cmake --build build -j22`
- `./build/device_diagnostics_test`
- `./build/cw_sidetone_test`
- `git diff --check`
- Built and deployed a macOS test bundle; the primary test-machine
replacement path is currently blocked by a Desktop delete ACL, so a
separate quarantine-cleared test bundle was prepared.

👨🏼‍💻 Generated with OpenAI Codex (GPT-5.5 Pro 4/23) and tested by
@jensenpat

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Jeremy Fielder <kk7gwy@aethersdr.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: high High priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Track CW sidetone routing to selected PC audio output

3 participants