[bug] Route CW sidetone to selected audio output#2899
Conversation
Co-authored-by: Codex <noreply@openai.com>
There was a problem hiding this comment.
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
QSettingsusage introduced; RAII boundaries unchanged. - Logging consistently uses
lcAudioviaqCWarning/qCInfo.
LGTM modulo the partial-match observability concern. The QAudioSink fallback when PortAudio mapping fails is the right call.
|
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 Distinctive bug-fix test:
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: 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 Re: @aethersdr-agent's observability concern — it's worse than the source reads. Two small (non-blocking) follow-up ideas:
LGTM on the user-visible fix. |
|
Confirmed @nigelfenton's diagnosis after reading the code on Why
Q_LOGGING_CATEGORY(lcAudio, "aether.audio", QtWarningMsg)That sets the category's compiled-in minimum level to That makes the second follow-up the clear win: one-line fix, surfaces every existing Suggested patch — 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 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 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 73, Jeremy KK7GWY & Claude (AI dev partner) |
Co-authored-by: Codex <noreply@openai.com>
|
Claude here — merged. Thanks @jensenpat, this fix has the kind of What stood out on review: The fallback chain is defense-in-depth done right. PortAudio @nigelfenton — appreciate the Windows 11 + Flex 6700 testing on a The default-following restart hook closes the other gap quietly — Ships in v26.5.3. 73, |
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
…#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>
…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>
…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>
## 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>
…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>
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
AudioEnginefalls back to the QtQAudioSinksidetone path on the selected device.qCWarningso support bundles capture the sidetone route without refactoring global logging behavior.MainWindowand restart default-following local audio paths when the OS default changes, even when no device was removed.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=RelWithDebInfocmake --build build --parallel 22cmake --build build --target AetherSDR --parallel 22./build/cw_sidetone_testgit diff --checkBuilt app:
build/AetherSDR.appWindows validation by @nigelfenton:
TOSHIBA-TV (NVIDIA HDMI),Headphones (Realtek(R) Audio), USB Audio, Oculus Virtual, and FlexRadio DAX cables.check-windowsCI gap; no errors or new warnings on the four source files this PR touches.Headphones (Realtek(R) Audio).TOSHIBA-TV (NVIDIA HDMI).findPortAudioOutputDevice; his Realtek mapping was unambiguous.👨🏼💻 Generated with OpenAI Codex (GPT-5.5 Pro 4/23) and tested by @jensenpat