From ebb35cfd20892431758fd8a165b7c66357b74090 Mon Sep 17 00:00:00 2001 From: Ozy311 Date: Mon, 5 Oct 2026 18:41:10 -0700 Subject: [PATCH] Rebuild analog WFM on typed receive dispatch. Principle XI. --- CMakeLists.txt | 2 + docs/architecture/aetherd-touchpoints.md | 12 +- docs/architecture/radio-capabilities-map.md | 6 + docs/audio-engine-rate-domains.md | 19 +- docs/rtl-m1-runtime.md | 26 +- docs/rtl-wfm.md | 217 +++++ src/core/AudioEngine.cpp | 197 ++++- src/core/AudioEngine.h | 20 + src/core/AudioFormatNegotiator.cpp | 15 +- src/core/RtlSliceSettings.cpp | 12 + src/core/RtlSliceSettings.h | 3 + src/core/WfmReceptionDiagnostics.h | 35 + src/core/backends/IRadioBackend.h | 10 + src/core/backends/RadioCapabilities.h | 7 + src/core/backends/ReceiveCommand.h | 13 + src/core/backends/SliceDelta.h | 11 + src/core/backends/anan/AnanBackend.cpp | 1 + src/core/backends/flex/FlexBackend.cpp | 1 + src/core/backends/hl2/Hl2Backend.cpp | 1 + src/core/backends/icom/IcomCivBackend.cpp | 1 + src/core/backends/rtl/RtlAudioMixer.cpp | 9 +- src/core/backends/rtl/RtlAudioMixer.h | 7 + .../backends/rtl/RtlCaptureTransaction.cpp | 1 + src/core/backends/rtl/RtlCaptureTransaction.h | 2 + src/core/backends/rtl/RtlReceivePipeline.cpp | 156 +++- src/core/backends/rtl/RtlReceivePipeline.h | 54 +- src/core/backends/rtl/RtlReceiverRegistry.cpp | 99 ++- src/core/backends/rtl/RtlReceiverRegistry.h | 28 + src/core/backends/rtl/RtlRfExtractor.cpp | 106 ++- src/core/backends/rtl/RtlRfExtractor.h | 33 + src/core/backends/rtl/RtlSdrBackend.cpp | 299 ++++++- src/core/backends/rtl/RtlSdrBackend.h | 18 + src/core/backends/rtl/RtlSdrWorker.h | 1 + src/core/backends/sim/SimBackend.cpp | 1 + src/core/dsp/WdspChannel.cpp | 80 +- src/core/dsp/WdspChannel.h | 27 +- src/gui/AppletPanel.cpp | 47 +- src/gui/AppletPanel.h | 4 + src/gui/ControlAvailabilityRegistry.cpp | 13 +- src/gui/ControlAvailabilityRegistry.h | 6 +- src/gui/MainWindow_Wiring.cpp | 2 + src/gui/ModeFilterPresets.cpp | 21 +- src/gui/ModeFilterPresets.h | 3 +- src/gui/WfmApplet.cpp | 479 ++++++++++ src/gui/WfmApplet.h | 64 ++ src/gui/WfmLockScope.cpp | 224 +++++ src/gui/WfmLockScope.h | 47 + src/gui/containers/ContainerWidget.cpp | 21 + src/gui/containers/ContainerWidget.h | 15 + .../containers/FloatingContainerWindow.cpp | 39 +- src/gui/containers/FloatingContainerWindow.h | 9 + src/models/RadioModel.cpp | 36 + src/models/RadioModel.h | 3 + src/models/SliceModel.cpp | 57 ++ src/models/SliceModel.h | 18 + tests/audio_engine_pcm_lifetime_test.cpp | 8 +- tests/audio_engine_rates_test.cpp | 289 +++++- tests/audio_format_negotiation_test.cpp | 79 ++ tests/container_widget_test.cpp | 140 ++- tests/mode_filter_presets_test.cpp | 21 + tests/qso_recorder_rates_test.cpp | 57 ++ tests/rtl_audio_mixer_test.cpp | 33 +- tests/rtl_capture_worker_test.cpp | 22 +- tests/rtl_model_acceptance_test.cpp | 191 +++- tests/rtl_receive_pipeline_test.cpp | 203 +++++ tests/rtl_receiver_registry_test.cpp | 62 +- tests/rtl_rf_extractor_test.cpp | 162 ++++ tests/rtl_runtime_settings_test.cpp | 200 ++++- tests/rtl_slice_settings_test.cpp | 57 ++ tests/rtl_usb_controls_test.cpp | 3 +- tests/rtl_wfm_pipeline_test.cpp | 638 ++++++++++++++ tests/tests.cmake | 37 + tests/wdsp_wbfm_test.cpp | 302 ++++++- tests/wfm_controls_test.cpp | 832 ++++++++++++++++++ tests/workspace_controller_test.cpp | 48 + third_party/wdsp/AETHERSDR-PATCHES.md | 132 +++ third_party/wdsp/README.md | 3 + .../wdsp/include/aether_wbfm_observation.h | 17 + third_party/wdsp/include/aether_wdsp.h | 26 + third_party/wdsp/port/wdsp_port.c | 104 +++ third_party/wdsp/upstream/channel.c | 30 +- third_party/wdsp/upstream/channel.h | 3 + third_party/wdsp/upstream/iobuffs.c | 59 +- third_party/wdsp/upstream/wbfm.c | 112 ++- third_party/wdsp/upstream/wbfm.h | 8 + third_party/wdsp/upstream/wdsp.h | 3 + 86 files changed, 6277 insertions(+), 242 deletions(-) create mode 100644 docs/rtl-wfm.md create mode 100644 src/core/WfmReceptionDiagnostics.h create mode 100644 src/gui/WfmApplet.cpp create mode 100644 src/gui/WfmApplet.h create mode 100644 src/gui/WfmLockScope.cpp create mode 100644 src/gui/WfmLockScope.h create mode 100644 tests/rtl_wfm_pipeline_test.cpp create mode 100644 tests/wfm_controls_test.cpp create mode 100644 third_party/wdsp/include/aether_wbfm_observation.h diff --git a/CMakeLists.txt b/CMakeLists.txt index be9ad0419c..b727f6470f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1367,6 +1367,8 @@ set(GUI_SOURCES src/gui/WaveformScopeModel.cpp src/gui/WaveformWidget.cpp src/gui/AetherClockApplet.cpp + src/gui/WfmApplet.cpp + src/gui/WfmLockScope.cpp src/gui/ClockAlignmentWidget.cpp src/gui/ClientEqApplet.cpp src/gui/ClientEqCurveWidget.cpp diff --git a/docs/architecture/aetherd-touchpoints.md b/docs/architecture/aetherd-touchpoints.md index a3eeff44f6..4eb7b7d7af 100644 --- a/docs/architecture/aetherd-touchpoints.md +++ b/docs/architecture/aetherd-touchpoints.md @@ -17,7 +17,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `core/AetherRxProfiles.h` | 1 | universal — Named-profile save/recall/import-export for the receive DSP chain; operates on core-profile DSP state only, same precedent as core/ChannelStripPresets.h, whose RX capture/apply it shares. | unconverted | | `core/AetherTxProfiles.h` | 1 | universal — Named-profile save/recall/import-export for the transmit DSP chain; operates on core-profile DSP state only. Mirror of core/AetherRxProfiles.h, and shares ChannelStripPresets' TX capture/apply. | unconverted | | `core/AgcTCalibrator.h` | 1 | universal — Engine algo sweeping slice AGC threshold vs audio RMS/S-meter to recommend a value; only canonical state. | unconverted | -| `core/AppSettings.h` | 107 | ui-support — Client-side XML settings store (SSDR.settings-style key/value persistence); app plumbing, not radio state. | unconverted | +| `core/AppSettings.h` | 108 | ui-support — Client-side XML settings store (SSDR.settings-style key/value persistence); app plumbing, not radio state. | unconverted | | `core/AudioEngine.h` | 47 | mixed(flex) — Client audio I/O + full RX/TX DSP chain (universal) fused with Flex VITA-49/DAX/Opus TX and Kiwi buffering | unconverted | | `core/AudioOutputRouter.h` | 1 | ui-support — Registry fanning the user-selected QAudioDevice to local playback sinks; OS device plumbing, no radio state | unconverted | | `core/AutomationBridgeSettings.h` | 3 | ui-support — Owned configuration for the agent automation bridge (enabled / txAllowed / txAck / readOnly) under one nested key; the secret token lives in the OS keychain, not here. Dev and test tooling — same home decision as core/AutomationServer.h. NOTE for RFC §6: txAllowed() is read as a TX-guard input by BOTH AutomationServer and Hl2Backend, so this header is a step-4 consolidation target. | unconverted | @@ -149,7 +149,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `core/SystemInventory.h` | 1 | ui-support — Startup and support-bundle inventory of host CPU, SIMD and RAM capabilities. Process diagnostics, not radio state. | unconverted | | `core/TciServer.h` | 3 | mixed(flex) — TCI WebSocket server for WSJT-X et al: protocol surface is canonical radio state, but audio/IQ rides Flex DAX | unconverted | | `core/TgxlConnection.h` | 3 | peripheral(4o3a) — Direct TCP client for the 4O3A Tuner Genius XL (port 9010, relay/autotune), reverse-engineered from the 4O3A management app — a standalone accessory transport, not SmartSDR. Not radio-family wire; a peripheral accessory, NOT behind the IRadioBackend radio seam (reclassified from vendor(flex), #4087 follow-up). | unconverted | -| `core/ThemeManager.h` | 165 | ui-support — Qt token-based theming singleton (colors/fonts/QSS, theme files, editor hooks) — pure client GUI plumbing, no radio state. | unconverted | +| `core/ThemeManager.h` | 167 | ui-support — Qt token-based theming singleton (colors/fonts/QSS, theme files, editor hooks) — pure client GUI plumbing, no radio state. | unconverted | | `core/ThreadCpuRing.h` | 2 | ui-support — Short host-thread CPU history used by Runtime Monitor peak and sparkline presentation. Diagnostic UI support, not radio state. | unconverted | | `core/TimeFrameVoter.h` | 1 | universal — Shared AetherClock time-frame types plus confidence-weighted cross-frame bit voting over a sliding window. Map-agnostic pure DSP/logic — no Qt, no GUI, no vendor ties. | unconverted | | `core/TxKeyingMarker.h` | 14 | ui-support — QWidget property marker guarding TX-keying controls from the automation bridge; GUI-shell plumbing, no radio state. | unconverted | @@ -179,7 +179,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `core/backends/HealthSnapshotMerge.h` | 1 | universal — The snapshot merge rule, family-neutral by construction: a key the winner declares but omits from `values` means "not reported" and must not erase a base value. Moved out of backends/hl2/Hl2TelemetrySource.h (which keeps hl2MergeHealth as a forwarder) because AutomationServer::doHealth() and RadioHealthDialog both need it, and neither may include a family header to get it. Names no family. | unconverted | | `core/backends/IRadioBackend.h` | 2 | universal — THE radio seam (RFC §5.5) — the canonical intent verbs, typed deltas and normalized signals every family implements. Universal by definition: the UI reaching this header is the seam working as designed, not coupling. Everything below it in core/backends// is family-private. | unconverted | | `core/backends/NoiseFloorAutoAdjustGate.h` | 2 | universal — The ONE predicate deciding whether the noise-floor auto-adjust may move the display reference level, as an OR over two backend-neutral properties (a real dBm-range echo, or absolute spectrum bins). Deliberately free of Qt and of RadioCapabilities so the widget and its test read the same function rather than two copies; the UI reaching it is the seam working, not coupling. Family backends populate the capability it is called with. | unconverted | -| `core/backends/RadioCapabilities.h` | 10 | universal — Backend-neutral capability descriptor consumed above the radio seam. Universal by definition; family implementations populate it. | unconverted | +| `core/backends/RadioCapabilities.h` | 11 | universal — Backend-neutral capability descriptor consumed above the radio seam. Universal by definition; family implementations populate it. | unconverted | | `core/backends/SquelchLevelScale.h` | 2 | universal — Where a slice's squelch level sits on the panadapter's vertical axis, as a backend-published record (RadioCapabilities::squelchLevelScale) plus the mode filter and Auto-SQL predicate both the widget and MainWindow read, so the SQL line and Auto SQL share one gate. No family name or wire concept; each backend declares its own map or leaves it absent (#6092). | unconverted | | `core/backends/anan/AnanDiscovery.h` | 2 | vendor(anan) — openHPSDR Protocol 2 discovery and ANAN-G2 identity handling. Family-specific discovery belongs below the ANAN backend seam. | unconverted | | `core/backends/anan/AnanSettings.h` | 1 | ui-support — Client-side connection and ADC configuration for the ANAN backend. Persisted setup plumbing, not live radio state. | unconverted | @@ -232,15 +232,15 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `models/ModelCapabilities.h` | 1 | mixed(flex) — Per-model capability table mirroring FlexLib ModelInfo.cs (RadioPlatform, has2Meters/has4Meters, loop A/B). This is the universal question 'what can this radio do', answered exclusively from a vendor table — and its band-availability half is precisely the band truth RFC §4.1 assigns to the capability descriptor. Consumed above the seam by SpectrumOverlayMenu, XvtrPolicy, WaveformInstallGate and MainWindow_Wiring. | unconverted | | `models/NetEntry.h` | 2 | ui-support — Net scheduler entry: RRULE/timezone/reminder metadata, operator-scoped local JSON; radio state only via embedded MemoryEntry | unconverted | | `models/Nr2SettingsModel.h` | 5 | universal — Process-wide owner of client-side NR2 configuration as one versioned object (Principle V), so every editing surface shares state. Engine DSP configuration; radio-agnostic. | unconverted | -| `models/PanadapterModel.h` | 4 | mixed(flex) — Per-pan display state is core-profile; DAX IQ ch, client_handle, VITA stream IDs, SmartSDR kv parsing are flex. | unconverted | +| `models/PanadapterModel.h` | 5 | mixed(flex) — Per-pan display state is core-profile; DAX IQ ch, client_handle, VITA stream IDs, SmartSDR kv parsing are flex. | unconverted | | `models/ProfileLoadCommand.h` | 1 | vendor(flex) — Parses SmartSDR 'profile global/tx/mic load' wire command + recall-hold timing/suppression sentinels; Flex-only | unconverted | -| `models/RadioModel.h` | 51 | mixed(flex) — Central radio aggregate: core slice/pan/TX/meter/memory state fused with Flex protocol, DAX, SmartLink, Multi-Flex | unconverted | +| `models/RadioModel.h` | 52 | mixed(flex) — Central radio aggregate: core slice/pan/TX/meter/memory state fused with Flex protocol, DAX, SmartLink, Multi-Flex | unconverted | | `models/RadioSession.h` | 1 | universal — Per-radio session aggregate: owns RadioModel + id/label; session concept is core-profile, no vendor surface | unconverted | | `models/RadioStatusOwnership.h` | 1 | vendor(flex) — SmartSDR status parsing helpers: Flex hex handles, client_handle ownership, remote_audio_rx, interlock gate | unconverted | | `models/ReceiverSlotCount.h` | 1 | universal — How many receiver letters (A, B, C, ...) the UI offers — the RX applet's slice tabs and the CAT applet's VFO targets — tracked from the connection/capability/slice edges. Family-agnostic: the backend's declared ceiling, floored by the slots live receivers occupy (#5775, #5776). | unconverted | | `models/Rn2SettingsModel.h` | 1 | universal — Process-wide owner of client-side RN2 configuration as one versioned object (Principle V). Engine DSP configuration; radio-agnostic. | unconverted | | `models/SliceLinkPolicy.h` | 2 | universal — Pure decision logic for Slice Link (cross-panadapter VFO link) — echo classification on integer Hz, no QObject, no I/O, no model access. Canonical slice state only. | unconverted | -| `models/SliceModel.h` | 34 | mixed(flex) — Slice state (freq/mode/filter/DSP) is core-profile; DAX, index_letter, SmartSDR status KVs are flex ext | unconverted | +| `models/SliceModel.h` | 36 | mixed(flex) — Slice state (freq/mode/filter/DSP) is core-profile; DAX, index_letter, SmartSDR status KVs are flex ext | unconverted | | `models/SpotModel.h` | 1 | universal — Panadapter spot store (callsign/freq/mode/lifetime/priority) on canonical state; kv ingest is trivially generic | unconverted | | `models/TnfModel.h` | 1 | universal — Tracking notch filter state (freq/width/depth/permanent, global enable) — generic DSP notch surface; kv parse is transport detail | unconverted | | `models/TransmitModel.h` | 17 | mixed(flex) — TX state model: power/MOX/VOX/CW/filter are core-profile; ATU, DAX, APD, profiles, interlock are Flex. | unconverted | diff --git a/docs/architecture/radio-capabilities-map.md b/docs/architecture/radio-capabilities-map.md index a16206d740..4423d0c6cc 100644 --- a/docs/architecture/radio-capabilities-map.md +++ b/docs/architecture/radio-capabilities-map.md @@ -55,6 +55,12 @@ semantics see [local frequency control](../aetherd-local-slice-frequency-control ## Wired and consumed +### Broadcast FM receiver controls + +| Record | Flex | HL2 | Sim | Icom | ANAN | RTL-SDR | Consumers | +|---|---|---|---|---|---|---|---| +| `broadcastFmReceive` | absent | absent | absent | absent | absent | de-emphasis 50/75 µs, force Mono, measured reception diagnostics | `WfmApplet` availability; `RadioModel::dispatchSliceWfm` validates the capability and routes `requestSliceWfm`, with explicit refusal reporting | + ### Local receive-control records The optional `receiveModeControl`, `receiveFilterControl`, `receiveAudioControl`, diff --git a/docs/audio-engine-rate-domains.md b/docs/audio-engine-rate-domains.md index 1b4b54bf47..ce3f1b63e6 100644 --- a/docs/audio-engine-rate-domains.md +++ b/docs/audio-engine-rate-domains.md @@ -5,6 +5,17 @@ device format remains independent: `m_rxProducerRate` describes main input; `m_rxOutputRate` describes device output. `DEFAULT_SAMPLE_RATE` stays 24000. Legacy byte input and every Kiwi auxiliary route retain 24 kHz stereo. +An accepted 48 kHz main producer asks the running RX speaker to prefer 48 kHz, +then 44.1 kHz, then 24 kHz through the consolidated `PreservePan` ladder. The +actual device rate remains separately reported; a 24 kHz fallback cannot retain +15 kHz audio. Default 24 kHz producer policy and the input/sidetone ladders are +unchanged. Requests are coalesced on the existing owner thread and checked +against the current producer lease and sink generation before opening. A +stopped speaker stays stopped. A brief retirement gap or same-rate replacement +retains the sink, including an already-negotiated fallback; real replacement +24 kHz PCM restores the legacy policy. Only the RX speaker reopens, preserving +the dedicated CW/Quindar sinks and producer-domain queues and processing state. + ## Queues and processing Raw queues and whole NR2 packets count time in their producer domain. Each @@ -70,10 +81,10 @@ fixed-rate contracts. RADE output converts from 24 kHz to the device rate, independently of the main producer. TX admission, cancellation and deferred release continue through the inherited #5591 coordinator and wiring. -The new RTL 48 kHz producer and multi-receiver runtime remain disabled. -Recording/container changes (A3), TCI conversion (A4), and decoder/clock -conversion (A5) remain separate; accepting test-injected speaker48 PCM does -not qualify those consumers for a live 48 kHz producer. +The single-receiver RTL WFM candidate now produces native 48 kHz stereo; its +scope and qualification limits are documented in [rtl-wfm.md](rtl-wfm.md). +Multi-receiver admission remains disabled. Recording/container, TCI and +decoder/clock consumers keep their separately defined conversion contracts. ## Software evidence diff --git a/docs/rtl-m1-runtime.md b/docs/rtl-m1-runtime.md index be80d821f7..60a3ab6d81 100644 --- a/docs/rtl-m1-runtime.md +++ b/docs/rtl-m1-runtime.md @@ -90,17 +90,25 @@ blocks to the backend thread, which constructs revocable typed PCM. Queue gaps are marked discontinuous. `AudioEngine::DEFAULT_SAMPLE_RATE` remains 24000 and the audio device's output rate remains independently negotiated. -WFM and the other existing non-FM modes retain their exclusive legacy DDC and -24 kHz format. Their demodulation is unchanged; monitor mute continues to clock -an independent pre-monitor tap. They cannot join a multi-FM bank. Selecting FM -with an incompatible inherited wide/sideband filter chooses a 16 kHz passband; -ordinary filter requests and saved restores are never resized. This mode -transition was approved in [the #5924 maintainer ruling](https://github.com/aethersdr/AetherSDR/pull/5924#issuecomment-5787852269). WFM stereo and normalization qualification -remain S1/S2; no replacement WFM claim is made here. +WFM uses an explicitly qualified 384 kHz IQ / 192 kHz WDSP / 48 kHz paired +stereo recipe with independently prepared RF filtering, 50/75 µs de-emphasis +and completed-block stereo observation. Its RF and audio limits, generated +reference results and remaining validation boundaries are documented in +[`rtl-wfm.md`](rtl-wfm.md). Manual WFM squelch remains unavailable with a stated +reason; WDSP's internal automatic squelch remains in use. Saved sessions use a +muted legacy bootstrap before adopting the original receiver recipe and ID. + +The other non-FM modes retain their exclusive legacy DDC and 24 kHz format. +Monitor mute continues to clock an independent pre-monitor tap. Production +admission remains one receiver. Selecting FM with an incompatible inherited +wide/sideband filter chooses a 16 kHz passband; ordinary filter requests and +saved restores are never resized. This mode transition was approved in +[the #5924 maintainer ruling](https://github.com/aethersdr/AetherSDR/pull/5924#issuecomment-5787852269). The desktop FM/FM-N presets use the existing DFM width ladder intersected with the backend's declared filter range; FMN and NFM share symmetric edge rules. -Fixed radio filter lists retain precedence. WFM filter and squelch controls -are unavailable with accessible reasons, and the backend refuses those edits. +WFM presets use the declared broadcast RF range. Fixed radio filter lists retain +precedence. WFM control placement and recipe choices remain subject to +maintainer review. ## FM squelch diff --git a/docs/rtl-wfm.md b/docs/rtl-wfm.md new file mode 100644 index 0000000000..98cf5beb51 --- /dev/null +++ b/docs/rtl-wfm.md @@ -0,0 +1,217 @@ +# RTL broadcast FM at 48 kHz + +Implementation of RFC #5468 section 4, layered on the capture/parking work in +draft #5924. Production receiver admission remains one. This does not qualify +multiple receivers, transmit, other radio families, or new architecture limits. +The free-pan amendment and placement/styling of the dedicated WFM applet remain +subject to maintainer review. The new recipe is enabled in this candidate after +generated-signal qualification. The previous installed WFM build is retained for +live comparison and rollback; this draft does not claim release qualification. + +## Signal path and RF meaning + +An active WFM receiver extracts paired 384 kHz IQ from the shared capture into +2048-frame blocks. An independently prepared WDSP BPS filter applies the saved +RF edges before WDSP converts to its documented 192 kHz broadcast decoder; +1024 DSP frames produce 256 independent L/R frames at 48 kHz. The BPS filter +uses 2049 Blackman-Harris coefficients and a 4096-point overlap-save FFT. Its +complete requested interval includes 3 kHz transition guards. The capture +policy checks actual captured RF, including when low input rates need bounded +interpolation; interpolation never expands captured coverage. + +Filter width is an RF selection, not an audio low-pass. The preserved default +200 kHz selection is not a flat 200 kHz usable-bandwidth claim: the fixed +192 kHz decoder conversion measured flat response at the sampled positive +offsets through +90 kHz, about -0.195 dB at +93 kHz, and -6.02 dB at +96 kHz. +The +100 kHz edge is already strongly attenuated. The corresponding negative +response is inferred from the symmetric filter; this sweep did not measure it. +Narrower filters intentionally trade modulation sidebands for adjacent-channel +rejection. Saved edges are never silently resized on +restore; entering WFM from a narrow incompatible mode selects the existing +200 kHz broadcast preset. Nonempty saved sessions bootstrap with muted, narrow +legacy AM before adopting their original receiver ID and recipe. This keeps the +sole native reservation free for sparse IDs and avoids FM's automatic DC +placement moving capture. +Old WFM could store narrow filters it did not apply. A saved WFM interval +outside the new declared range is preserved and refused with a configuration +warning, leaving the bootstrap muted. The operator must explicitly choose a +supported WFM filter and unmute; reconnect never silently widens the saved RF +selection. Validation may use an explicitly authorized 200 kHz selection. + +`WdspChannel::WbfmReceive` explicitly opts into this recipe. Existing WDSP +owners keep their configuration. The wrapper applies paired gain 0.25 before +the independent receiver tap and speaker mixer. WDSP's ordinary RX panel and +notch bandpass do not control WBFM, so this gain and RF filter have explicit +owners. There is no narrow-FM FFT squelch layered over broadcast FM: WDSP's +internal automatic squelch remains in use; the manual SQL control is unavailable. + +Two opted-in decoder corrections preserve stereo. Adjacent IQ phase differences +average instantaneous frequency over one sample, weakening the 38 kHz difference +channel. A bounded, preallocated 13-tap linear-phase FIR corrects that response +through 53 kHz. The existing DC blocker moves after the L/R matrix for this +recipe, with identical independent histories, so it cannot phase-shift the sum +channel relative to the difference channel. Legacy WBFM retains its previous +placement. See `third_party/wdsp/AETHERSDR-PATCHES.md` for source provenance, +coefficients, noise gain and refresh instructions. + +## Confirmed controls and observations + +The optional `broadcastFmReceive` capability declares supported de-emphasis +values and typed control/diagnostic support backed by backend verbs. The dedicated +WFM applet exposes 50 and 75 µs; +old schema-one settings without this optional per-slice field retain the previous +75 µs response. Changes become observed and persistent only after DSP adoption. +Unsupported values, stale model owners and foreign-thread requests do not dispatch. +The existing RtlSlices owner retains the setting across other modes and restart. +Pilot status is never persisted. + +The WFM applet follows the selected owned slice and is presented only while a +connected backend declares broadcast FM reception and that slice's accepted mode +is WFM. The standard applet container owns its dock/float placement, button and +open/closed choice. Temporary unavailability suppresses presentation without +closing the saved workspace item. Generic RX controls retain their behavior; +the broadcast-specific row is removed from RX. + +The single Mono/Stereo button requests a prepared receiver recipe. Mono selects +real L+R mono output; Stereo selects automatic pilot-based stereo with ordinary +mono fallback. The accepted per-slice choice persists in RtlSlices and defaults +to the previous automatic behavior for older settings. The button cannot claim +accepted state before DSP adoption. Decoder observations remain separate from +that selection, with current stereo shown through a success theme token and +plain accessible text. A missing pilot alone is not a poor-signal verdict. + +The applet Settings drawer independently persists Lock Scope and Diagnostics +visibility in one nested client UI object. The scope plots measured 19 kHz pilot +magnitude and the detector's actual acquire/release thresholds in relative +normalized discriminator units. It retains at most 160 observations and up to +40 seconds; hiding it clears history and stops its plotting/history work without +stopping the receiver. Receiver changes and stale observations clear the scope. +This is not a PLL, SNR, calibrated RF-power or channel-separation instrument. + +Diagnostics describe the measured pilot detector's consecutive-block hysteresis, +lock duration, loss/reacquisition counts and up to five seconds of unchanged +pilot state. Durations come from completed decoder samples. The optional C +snapshot is coherently read with bounded atomic retries, carried by the existing +receiver PCM lifetime fences and published to the model at most four times per +second plus actual status changes. Unknown or stale measurements are unavailable; +no quality percentage is invented. Configuration and processing-health readouts +are distinct from reception measurements. + +Native NRSC-5/HD FM is a separately requested next checkpoint and RFC amendment, +not covered by the existing analog architecture approval or the qualification +below. The analog checkpoint exposes no ineffective HD state or empty metadata +controls. Future HD program/ID3 metadata and analog RDS have different decoder +sources; RDS remains separate from the requested native HD implementation. + +The stereo indicator reports the last completed decoder block carried by a +current-session/current-revision independent audio packet. It is unavailable +outside receiving WFM, acquiring after adoption, and mono or stereo only after +current decoder evidence. Every accepted revision clears the previous claim, +including repair of an unchanged recipe. A 500 ms absence of accepted decoder +observations returns it to acquiring. Park, removal, mode change and disconnect +retire the corresponding stream. The model drives accessible status text; +selecting WFM alone cannot establish stereo. + +RF/de-emphasis changes prepare replacements away from acquisition and publish +new receiver and speaker epochs. Startup positions align to the final 48 kHz +sample lattice, including nonzero and coprime capture-rate origins. Each packet +declares 48 kHz, stereo layout and actual frame count. Monitor mute, gain and +balance affect the speaker mix after the independent slice tap. Queued stale PCM +and observations remain fenced by capture/session/receiver lifetime. The mixer +starts each new epoch at the same exact capture/audio lattice point as extraction; +it does not demand rounded-down samples that the extractor cannot produce. + +Nonblocking opted-in WFM prepares eight WDSP exchange slots. The seven initial +output credits cover the largest single 8192-complex-sample USB callback burst +at the admitted capture rates. This is finite headroom, not a guarantee against +arbitrary queued callbacks or worker starvation. WDSP still reports a real +underrun when exhausted, and the receiver still withdraws immediately. Output +bytes and their write cursor are complete before WDSP publishes output credit. +Blocking WFM and legacy channels retain depth two, including reused channel IDs. + +The prepared WFM exchange contributes 2048 audio frames (42.667 ms) of transport +delay, including the prior-output worker stage, versus 512 frames (10.667 ms) +at depth two. The extra delay is 32 ms; filter, device and other DSP delays are +additional. Ring storage increases from 72 to 288 KiB per opted-in channel. +The mixer's unchanged 2048-frame missing-contribution deadline is a separate +capture-position bound, not a total audio-latency limit. + +The `aether.rtl.receive` debug category is disabled by default. When explicitly +enabled, owner-thread records identify the first failing process result, +extractor reason, capture/IQ positions and exact missing mixer frames. Its fixed +queue reports overflow separately; acquisition performs no logging or allocation. +Health status also exposes the existing accepted/requested capture revisions and +trace-drop count, allowing bounded observation with detailed logging disabled. + +Accepted 48 kHz speaker PCM also drives RX device negotiation: prefer 48 kHz, +then 44.1 kHz, then 24 kHz through the shared stereo-preserving policy. A 24 kHz +fallback is usable but cannot retain 15 kHz audio, and the actual device rate +is reported independently of the producer. Deferred requests check the current +producer lease and sink generation. Same-rate epochs and brief retirement gaps +do not renegotiate the sink rate. Legacy 24 kHz policy and fixed CW/Quindar/RADE +contracts remain unchanged; speaker-only changes retain producer queues and effects. + +## Qualification evidence and limits + +`wdsp_wbfm_test` uses analytical continuous-FM IQ (75 kHz peak deviation scale, +10% pilot, independent left/right tones). Rectangular phase accumulation at the +DSP rate is not a valid reference because it cancels the discriminator defect. +It checks the documented 1/2 kHz reference against at least 40 dB separation, +plus low/high audio references, pilot acquisition/loss, mono fallback, both +de-emphasis responses, 15 kHz bandwidth, RF selection/polarity, no-signal noise, +rate/pilot offsets, output geometry, allocation scope and lifetime. + +Before the fixes, filtering had no measured effect, the 90% mono reference +peaked at 3.258 with clipping, and 50/75 µs selections had identical response. +The filter/headroom correction measured over 91 dB adjacent-channel selection, +peak about 0.815 without clipping, a 15 kHz 50/75 µs amplitude ratio of 1.483, +and over 86 dB rejection 3 kHz beyond the 60 kHz RF filter's edges. These are +generated-signal measurements, not antenna selectivity or hardware reception proof. + +A 97.5% modulation stress vector reports separation separately and requires +finite, unclipped headroom. Its separation is limited by high-order RF sidebands +outside the 192 kHz decoder input, so the 40 dB reference result does not claim +40 dB for every modulation waveform. A future remedy would require preserving +those sidebands through discrimination; neither gain nor relabeling sample rates +can recover them. Live strong/weak-station listening remains separate evidence. + +`rtl_wfm_pipeline_test` drives production extraction, immutable receiver banks, +paired 48 kHz taps and the speaker mixer with generated IQ. It verifies positions, +frame counts, independent monitor routing, parking/resume and repair epochs. +Injected-device model/runtime tests verify accepted controls, persistence, +capture guards, low-rate reconnect and stale status. `wfm_controls_test` verifies +capability gating, confirmed selection, keyboard behavior and accessible state. +No new test binds a socket or represents third-party firmware. + +The no-device `audio_engine_rates_test` admits real typed PCM, dispatches the +queued speaker-rate change, and checks actual final output at 48/44.1 kHz and +the 24 kHz fallback. It checks 15 kHz content, independent channels, stale +requests, retirement gaps and retained auxiliary queues. The injected boundary +is device opening; physical sink behavior still requires live validation. + +The opt-in `AETHER_WFM_BENCHMARK=1` fixture additionally runs production capture +DDC, the 65,536-bin display FFT at 25 FPS, extraction, WDSP, paired taps and mixer. +Four RF seconds at 2.4 MS/s completed in 1.905 seconds (2.10×), with 100 FFT frames +and waterfall rows, 192,000 frames on each 48 kHz output, and zero observed repair, +drop, late-frame or mixer-error counters. The feed is capped at 2.1×: this is a +sustained lower bound, not maximum speed. Preparation, USB byte conversion, +backend timers, AudioEngine and GUI are outside that timed measurement. + +Mutation checks demonstrate that wrong IQ-to-audio positions fail the pipeline +test and a hidden 48→24→48 bottleneck fails the actual WAV spectral test even +when rate/duration metadata remain unchanged. Source restoration is followed by +passing rebuilds and tests. + +Native validation is on Nobara in this work order. Hosted checks cover their +configured platforms; no local Mac/Windows/ARM hardware result is implied. +Production planning uses its real persistent wisdom path, never the test planner +time limit. Receiver admission remains one while multi-receiver, long-duration, +architecture-specific and operator acceptance work remains separate. + +## Future digital reception + +HD Radio/nrsc5 implementation is deferred until the current RTL sprint finishes. +Its future branch needs wide capture IQ before this analog filter, its own +station-centered extraction and 744187.5 Hz conversion, and explicit paired +44.1→48 kHz PCM conversion. RADE's demodulated 24 kHz speech input is unsuitable. +No nrsc5 dependency, speculative digital framework or automatic fallback is added. diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 217e708dbd..cffbebba7d 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -2174,8 +2174,10 @@ void AudioEngine::drainRxAudio(qsizetype freeBytes) m_rxZombieTickCount = 0; qCWarning(lcAudio) << "AudioEngine: sink appears zombie (bytesFree stuck at 0 for" << kZombieTickThreshold * 10 << "ms), restarting RX (#1361)"; - QMetaObject::invokeMethod(this, [this]() { - if (!m_audioSink) return; + QMetaObject::invokeMethod(this, [this, generation = m_rxSinkGeneration]() { + if (!m_audioSink || generation != m_rxSinkGeneration) { + return; + } stopRxStream(); startRxStream(); }, Qt::QueuedConnection); @@ -2203,8 +2205,10 @@ void AudioEngine::drainRxAudio(qsizetype freeBytes) qCWarning(lcAudio) << "AudioEngine: no audio data received for" << m_lastAudioFeedTime.elapsed() << "ms, restarting RX (#1411)"; m_lastAudioFeedTime.start(); // prevent repeated rapid restarts - QMetaObject::invokeMethod(this, [this]() { - if (!m_audioSink) return; + QMetaObject::invokeMethod(this, [this, generation = m_rxSinkGeneration]() { + if (!m_audioSink || generation != m_rxSinkGeneration) { + return; + } stopRxStream(); startRxStream(); }, Qt::QueuedConnection); @@ -2738,8 +2742,10 @@ void AudioEngine::drainRxAudio(qsizetype freeBytes) qCWarning(lcAudio) << "AudioEngine: sink appears stale (processedUSecs stuck at" << processed << "for" << kStaleTickThreshold * 10 << "ms), restarting RX (#1569)"; - QMetaObject::invokeMethod(this, [this]() { - if (!m_audioSink) return; + QMetaObject::invokeMethod(this, [this, generation = m_rxSinkGeneration]() { + if (!m_audioSink || generation != m_rxSinkGeneration) { + return; + } stopRxStream(); startRxStream(); }, Qt::QueuedConnection); @@ -4116,6 +4122,35 @@ bool AudioEngine::startRxStream() m_lastProcessedUSecs = 0; m_lastAudioFeedTime.start(); // initialize liveness watchdog (#1411) + const int producerRate = mainPcmSourceOwnsDisplay() + ? m_rxProducerRate.load() : DEFAULT_SAMPLE_RATE; + if (!openRxSink(producerRate)) { + return false; + } + // Open the dedicated sidetone + Quindar local sinks alongside RX. Cheap when + // disabled (timers write silence to a tiny primed buffer). NOTE: the old + // Windows branch returned before startQuindarLocalSink(), so the Quindar + // local monitor never opened on Windows — unifying the path fixes that. + startSidetoneStream(); + startQuindarLocalSink(); + emit rxStarted(); + return true; +} + +bool AudioEngine::openRxSink(int producerRate) +{ + ++m_rxSinkGeneration; + m_rxZombieTickCount = 0; + m_rxStaleTickCount = 0; + m_lastProcessedUSecs = 0; + const bool opened = m_rxSinkOpener + ? m_rxSinkOpener(producerRate) : openRxSinkDevice(producerRate); + m_rxSinkProducerRate = opened ? producerRate : 0; + return opened; +} + +bool AudioEngine::openRxSinkDevice(int producerRate) +{ QAudioDevice dev = QMediaDevices::defaultAudioOutput(); bool rxFallbackOccurred = false; QStringList rxFallbackReasons; @@ -4192,14 +4227,15 @@ bool AudioEngine::startRxStream() // Negotiate the output format via the consolidated factory (#3306). RX audio // is written as Float PCM, so we walk only the Float rungs of the ladder — // but the ladder supplies, in ONE place with no per-OS #ifdef: the preferred - // rate (Windows/macOS 48k to dodge the WASAPI 24k resampler artifacts #2120 - // and keep macOS A2DP devices off the HFP/telephony route; Linux native 24k), + // rate (native 48k producers first; otherwise the existing per-OS 24k + // producer policy, including Windows/macOS 48k), // the universal 44.1 kHz fallback (#3385), and the device preferredFormat // catch-all. Each rung is tried with a real start(), so reliable backends and // WASAPI's probe-at-open are handled identically. const QList rxLadder = AudioDeviceNegotiator::formatLadder( dev, AudioFormatNegotiator::Direction::Output, - AudioFormatNegotiator::ResamplerPolicy::PreservePan); + AudioFormatNegotiator::ResamplerPolicy::PreservePan, + AudioFormatNegotiator::hostTargetOs(), producerRate); m_audioSink = nullptr; m_audioDevice = nullptr; @@ -4266,8 +4302,8 @@ bool AudioEngine::startRxStream() // (#1149 / #1303). IdleState restart removed — it looped on Windows (#1405); // the zombie-sink watchdog handles stale WASAPI sessions after idle/sleep. connect(m_audioSink, &QAudioSink::stateChanged, this, - [this](QAudio::State state) { - if (state != QAudio::StoppedState) { + [this, generation = m_rxSinkGeneration](QAudio::State state) { + if (generation != m_rxSinkGeneration || state != QAudio::StoppedState) { return; } m_audioDevice = nullptr; @@ -4280,8 +4316,10 @@ bool AudioEngine::startRxStream() << error; return; } - QMetaObject::invokeMethod(this, [this]() { - if (!m_audioSink) return; + QMetaObject::invokeMethod(this, [this, generation]() { + if (!m_audioSink || generation != m_rxSinkGeneration) { + return; + } qCWarning(lcAudio) << "AudioEngine: QAudioSink stopped unexpectedly, restarting RX (#1303)"; stopRxStream(); startRxStream(); @@ -4299,16 +4337,87 @@ bool AudioEngine::startRxStream() summary.fallbackOccurred = rxFallbackOccurred; summary.fallbackReason = rxFallbackReasons.join(QStringLiteral("; ")); AudioSummaryLogger::logRxSink(summary); - // Open the dedicated sidetone + Quindar local sinks alongside RX. Cheap when - // disabled (timers write silence to a tiny primed buffer). NOTE: the old - // Windows branch returned before startQuindarLocalSink(), so the Quindar - // local monitor never opened on Windows — unifying the path fixes that. - startSidetoneStream(); - startQuindarLocalSink(); - emit rxStarted(); return true; } +void AudioEngine::requestRxSinkRate() +{ + // Device lifecycle runs only on the existing AudioEngine owner thread. + // Retirement can also be noticed by an auxiliary ingress caller. + if (QThread::currentThread() != thread()) { + QMetaObject::invokeMethod(this, &AudioEngine::requestRxSinkRate, + Qt::QueuedConnection); + return; + } + if (!m_audioDevice || m_rxSinkProducerRate == 0) { + return; // receiving PCM must not start a stopped speaker + } + const int producerRate = m_rxProducerRate.load(); + if (producerRate == m_rxSinkProducerRate) { + m_pendingRxSinkRate.reset(); + return; + } + const QList ladder = + AudioFormatNegotiator::buildLadder( + AudioFormatNegotiator::hostTargetOs(), + AudioFormatNegotiator::Direction::Output, {}, + AudioFormatNegotiator::ResamplerPolicy::PreservePan, producerRate); + if (!ladder.isEmpty() && ladder.first().rate == m_rxOutputRate.load()) { + // Windows/macOS may already have opened the preferred 48 kHz sink + // before the first native48 frame arrived. Preserve that live sink. + m_rxSinkProducerRate = producerRate; + m_pendingRxSinkRate.reset(); + return; + } + RxSinkRateRequest request{producerRate, m_rxSinkGeneration, std::nullopt}; + if (mainPcmSourceOwnsDisplay()) { + request.lease = m_mainPcmFrame->epochLease(); + } + m_pendingRxSinkRate = std::move(request); + if (!m_rxSinkRateChangeQueued) { + m_rxSinkRateChangeQueued = true; + QMetaObject::invokeMethod(this, &AudioEngine::applyPendingRxSinkRate, + Qt::QueuedConnection); + } +} + +void AudioEngine::applyPendingRxSinkRate() +{ + m_rxSinkRateChangeQueued = false; + const std::optional pending = std::move(m_pendingRxSinkRate); + m_pendingRxSinkRate.reset(); + if (!pending || !m_audioDevice || m_rxSinkProducerRate == 0 + || pending->sinkGeneration != m_rxSinkGeneration + || pending->producerRate != m_rxProducerRate.load()) { + return; + } + if (pending->lease) { + if (!pending->lease->current() || !mainPcmSourceOwnsDisplay() + || pending->lease->stream() != m_mainPcmFrame->stream()) { + return; + } + } else if (mainPcmSourceOwnsDisplay() || pending->producerRate != DEFAULT_SAMPLE_RATE) { + return; + } + // Only the speaker changes format. Dedicated CW/Quindar sinks and their + // timers remain live; setRxDeviceRate expires old device-domain queues. + const int previousRate = m_rxSinkProducerRate; + closeRxSink(); + if (openRxSink(pending->producerRate)) { + return; + } + qCWarning(lcAudio) << "AudioEngine: speaker renegotiation failed, retrying previous policy at" + << previousRate << "Hz"; + if (openRxSink(previousRate)) { + // The restored device still resamples the current producer. Remember + // its accepted domain so later epochs do not repeat this failed reopen. + m_rxSinkProducerRate = pending->producerRate; + } else { + qCWarning(lcAudio) << "AudioEngine: previous speaker policy also failed;" + " reconnect or select an output device to retry"; + } +} + void AudioEngine::stopRxStream() { stopSidetoneStream(); @@ -4340,22 +4449,32 @@ void AudioEngine::stopRxStream() m_rxBufferSampleRate.store(DEFAULT_SAMPLE_RATE); m_rxPlaybackQueuedMs.store(0, std::memory_order_relaxed); + closeRxSink(); + emit rxStopped(); +} + +void AudioEngine::closeRxSink() +{ + ++m_rxSinkGeneration; + m_pendingRxSinkRate.reset(); + m_rxSinkProducerRate = 0; + m_audioDevice = nullptr; + m_rxPlaybackQueuedMs.store(0, std::memory_order_relaxed); if (m_audioSink) { // Null out m_audioSink BEFORE stopping so that the stateChanged - // handler's "if (!m_audioSink) return" guard prevents a cascading + // handler's generation/sink guard prevents a cascading // restart loop. Without this, stop() emits stateChanged(StoppedState) // synchronously while m_audioSink is still non-null, causing the // handler to queue another stopRx+startRx — which repeats // indefinitely and prevents audio from ever playing. (#1441) auto* sink = m_audioSink; m_audioSink = nullptr; - m_audioDevice = nullptr; // Guard: same stale-device-handle crash can occur on the RX side (#1059). - if (sink->state() != QAudio::StoppedState) + if (sink->state() != QAudio::StoppedState) { sink->stop(); + } delete sink; } - emit rxStopped(); } void AudioEngine::setRxVolume(float v) @@ -4823,39 +4942,35 @@ bool AudioEngine::retireInvalidPcmSources() flushRxDevice(); updateRxBufferStats(); } + // A revoked epoch can be a brief filter/park transition. Keep the sink + // policy until an accepted replacement or actual legacy PCM adopts a rate. return retired; } void AudioEngine::setRxDeviceRate(int rate) { - // Called only after successful output negotiation. The producer rate is - // unchanged; old device-format bytes and converter history expire. The NR chain - // is deliberately not rebuilt: this runs on every sink open, including the - // #1361/#1411 watchdog recoveries on the GUI thread, and NR belongs to the - // producer domain; a model load here would freeze the UI. + // Only already-converted bytes and converter histories belong to the old + // device format. Raw queues, packet tails and effect/NR histories remain + // in each producer's domain and survive a speaker-only renegotiation. + // Full start/stop and source retirement reset their own producer state. std::lock_guard lock(m_dspMutex); m_rxOutputRate.store(rate); - resetMainPcmState(m_rxProducerRate.load(), /*rebuildDsp=*/false); + m_rxBufferSampleRate.store(rate); + m_rxOutputBuffer.clear(); + m_rxResampler.reset(); + m_rxResamplerR.reset(); m_radeRxBuffer.clear(); m_radeRxResampler.reset(); - m_kiwiSdrRxBuffer.clear(); - m_kiwiSdrRxPackets.clear(); m_kiwiSdrOutputBuffer.clear(); m_kiwiSdrRxResampler.reset(); m_kiwiSdrRxResamplerR.reset(); - resetLegacyKiwiDspState(); for (const auto& source : m_externalKiwiSources) { if (!source) { continue; } - source->rxBuffer.clear(); - source->rxPackets.clear(); source->outputBuffer.clear(); - source->nr2Output.clear(); source->rxResampler.reset(); source->rxResamplerR.reset(); - resetExternalKiwiDspState(*source); - source->prebuffering = true; } updateRxBufferStats(); } @@ -4882,6 +4997,9 @@ void AudioEngine::feedPcmFrame(const PcmFrame& frame) flushRxDevice(); } m_mainPcmFrame = frame; + if (transition) { + requestRxSinkRate(); + } const int channels = frame.stream().format.channels(); QByteArray pcm(frame.frameCount() * 2 * sizeof(float), Qt::Uninitialized); auto* output = reinterpret_cast(pcm.data()); @@ -4964,6 +5082,9 @@ void AudioEngine::feedAudioData(const QByteArray& pcm) if (m_rxProducerRate.load() != DEFAULT_SAMPLE_RATE) { resetMainPcmState(DEFAULT_SAMPLE_RATE); } + if (pcm.size() >= 2 * static_cast(sizeof(float))) { + requestRxSinkRate(); + } captureAutomationAudio(QStringLiteral("raw"), QStringLiteral("flex"), QString(), pcm, DEFAULT_SAMPLE_RATE, 2); processRxAudioData(pcm, true); diff --git a/src/core/AudioEngine.h b/src/core/AudioEngine.h index bc164c7911..2b01139599 100644 --- a/src/core/AudioEngine.h +++ b/src/core/AudioEngine.h @@ -1041,6 +1041,26 @@ private slots: // RX QAudioSink* m_audioSink{nullptr}; QPointer m_audioDevice; // sink-owned device, may vanish on hot-unplug + // A negotiated fallback still satisfies one producer-domain attempt. Keep + // this separate from m_rxOutputRate so a 48 -> 24 fallback cannot reopen on + // every packet or same-rate epoch. Zero means no managed speaker is open. + int m_rxSinkProducerRate{0}; + quint64 m_rxSinkGeneration{0}; + struct RxSinkRateRequest { + int producerRate{DEFAULT_SAMPLE_RATE}; + quint64 sinkGeneration{0}; + std::optional lease; + }; + std::optional m_pendingRxSinkRate; + bool m_rxSinkRateChangeQueued{false}; + // The existing friend fixture injects just the external device-open + // boundary. An empty callback uses the real QAudioSink opener below. + std::function m_rxSinkOpener; + bool openRxSink(int producerRate); + bool openRxSinkDevice(int producerRate); + void closeRxSink(); + void requestRxSinkRate(); + void applyPendingRxSinkRate(); // Dedicated low-latency sink for the local CW sidetone — kept separate // from the RX sink so the RX path keeps its 100 ms jitter cushion. diff --git a/src/core/AudioFormatNegotiator.cpp b/src/core/AudioFormatNegotiator.cpp index 28851e721d..6a44897fb0 100644 --- a/src/core/AudioFormatNegotiator.cpp +++ b/src/core/AudioFormatNegotiator.cpp @@ -16,9 +16,16 @@ namespace { // 44.1k rung and the device-preferred rung are appended by buildLadder() so // every sink gets the SAME complete fallback set (no more "Quindar has no // fallback / RX never tries 44.1k" divergence — #3306, #3385). -QList primaryRateOrder(TargetOs os, Direction dir, int internalRate) +QList primaryRateOrder(TargetOs os, Direction dir, + ResamplerPolicy policy, int internalRate) { if (dir == Direction::Output) { + if (internalRate == 48000 && policy == ResamplerPolicy::PreservePan) { + // Preserve a native 48 kHz stereo producer's bandwidth when possible. + // Keep 24 kHz as an explicit last fallback: an advertised preferred + // 48 kHz format does not guarantee that opening it will succeed. + return {48000, 44100, 24000}; + } switch (os) { // Windows: force 48k — WASAPI's shared-mode resampler adds artifacts at // 24k that become audible once radio-side NR removes the noise floor; @@ -29,8 +36,8 @@ QList primaryRateOrder(TargetOs os, Direction dir, int internalRate) case TargetOs::MacOS: return {48000, internalRate}; // Linux: native 24k is fine (no WASAPI resampler in the path) — avoid an // unnecessary upsample. Deliberate, documented divergence from Win/Mac. - // RX is still canonically 24 kHz, so this stays 24k-first even though - // the Linux *input* ladder now leads with 48k for the TX voice strip. + // Callers using the default 24 kHz producer retain this order even + // though the Linux input ladder leads with 48k for the TX voice strip. case TargetOs::Linux: return {internalRate, 48000}; } } else { // Input (mic / TX capture) @@ -206,7 +213,7 @@ QList buildLadder(TargetOs os, } // Main per-OS rate order × format order. - const QList rates = primaryRateOrder(os, dir, internalRate); + const QList rates = primaryRateOrder(os, dir, policy, internalRate); for (int rate : rates) { for (SampleFmt fmt : fmts) { QString reason = (rate == rates.first()) diff --git a/src/core/RtlSliceSettings.cpp b/src/core/RtlSliceSettings.cpp index 63d2ec4dff..416b4ea822 100644 --- a/src/core/RtlSliceSettings.cpp +++ b/src/core/RtlSliceSettings.cpp @@ -53,6 +53,8 @@ QJsonObject encodeSlice(const RtlSliceSettings::Slice& slice, QJsonObject previo previous.insert(QStringLiteral("audioGain"), slice.audioGain); previous.insert(QStringLiteral("audioMute"), slice.audioMute); previous.insert(QStringLiteral("audioPan"), slice.audioPan); + previous.insert(QStringLiteral("wfmDeemphasisUs"), slice.wfmDeemphasisUs); + previous.insert(QStringLiteral("wfmForceMono"), slice.wfmForceMono); return previous; } @@ -112,6 +114,16 @@ bool RtlSliceSettings::decode(const QJsonObject& object, Document& output, QStri || !entry.value(QStringLiteral("squelch")).isObject()) { return false; } + if (entry.contains(QStringLiteral("wfmDeemphasisUs"))) { + double microseconds = 0; + if (!number(entry, QStringLiteral("wfmDeemphasisUs"), 50, 75, microseconds, true) + || (microseconds != 50 && microseconds != 75)) { return false; } + slice.wfmDeemphasisUs = static_cast(microseconds); + } + if (entry.contains(QStringLiteral("wfmForceMono"))) { + if (!entry.value(QStringLiteral("wfmForceMono")).isBool()) { return false; } + slice.wfmForceMono = entry.value(QStringLiteral("wfmForceMono")).toBool(); + } const QJsonObject squelch = entry.value(QStringLiteral("squelch")).toObject(); if (!squelch.value(QStringLiteral("enabled")).isBool() || !percent(squelch, QStringLiteral("level"), slice.squelchLevel)) { diff --git a/src/core/RtlSliceSettings.h b/src/core/RtlSliceSettings.h index e6b15bddb3..f8a5d3049a 100644 --- a/src/core/RtlSliceSettings.h +++ b/src/core/RtlSliceSettings.h @@ -31,6 +31,9 @@ class RtlSliceSettings { int audioGain = 50; bool audioMute = false; int audioPan = 50; + // Optional in schema 1; old documents retain the legacy 75 us default. + int wfmDeemphasisUs = 75; + bool wfmForceMono = false; }; struct Document { double captureCenterHz = 0; diff --git a/src/core/WfmReceptionDiagnostics.h b/src/core/WfmReceptionDiagnostics.h new file mode 100644 index 0000000000..89827e17cb --- /dev/null +++ b/src/core/WfmReceptionDiagnostics.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace AetherSDR { +// Last completed WDSP decoder block, carried under the receiver PCM lifetime. +// The pilot indicator is a hysteretic coherent-magnitude detector, NOT a PLL. +// Magnitude is in normalized FM-discriminator units (75 kHz peak deviation = +// unity), measured after the 19 kHz filter and before pilot AGC. Its exact +// double value is neither RF strength, SNR nor a quality score. +// Durations use processed DSP samples, not wall-clock/audio-device time. +// Counts exclude the first acquisition, saturate at INT32_MAX, and reset on +// decoder construction/flush. Stable duration is capped at a five-second +// window and resets on either pilot-indicator transition. No pilot is normal +// mono fallback, not evidence of poor reception. Invalid snapshots carry no +// current reception claim. The backend expires them after 500 ms without data. +struct WfmReceptionDiagnostics { + bool valid = false; + double pilotMagnitude = 0.0; + bool pilotLocked = false; + std::uint32_t lockDurationMs = 0; + std::uint32_t lockLossCount = 0; + std::uint32_t reacquisitionCount = 0; + std::uint32_t observationDurationMs = 0; + std::uint32_t stableDurationMs = 0; + double pilotEngageThreshold = 0.0; + double pilotReleaseThreshold = 0.0; + std::uint32_t consecutiveHighBlocks = 0; + std::uint32_t consecutiveLowBlocks = 0; + std::uint32_t engageBlocks = 0; + std::uint32_t releaseBlocks = 0; + std::uint32_t observationSequence = 0; // changes only after a completed DSP block + bool operator==(const WfmReceptionDiagnostics&) const = default; +}; +} // namespace AetherSDR diff --git a/src/core/backends/IRadioBackend.h b/src/core/backends/IRadioBackend.h index f264105e97..0f909e0b21 100644 --- a/src/core/backends/IRadioBackend.h +++ b/src/core/backends/IRadioBackend.h @@ -233,6 +233,8 @@ class IRadioBackend : public QObject { { return ReceiveDispatch::Unsupported; } virtual ReceiveDispatch requestSliceAudio(int, const SliceAudioRequest&) { return ReceiveDispatch::Unsupported; } + virtual ReceiveDispatch requestSliceWfm(int, const SliceWfmRequest&) + { return ReceiveDispatch::Unsupported; } virtual ReceiveDispatch requestSliceSquelch(int, const SliceSquelchRequest&) { return ReceiveDispatch::Unsupported; } virtual ReceiveDispatch requestSliceRxAntenna(int, const QString&) @@ -674,6 +676,14 @@ class IRadioBackend : public QObject { { Q_UNUSED(sliceId); Q_UNUSED(on); Q_UNUSED(level); } + // BroadcastFmReceive declares supported values; accepted state returns in + // SliceDelta only after the receiver adopts the requested configuration. + virtual void setSliceWfmForceMono(int sliceId, bool forceMono) + { Q_UNUSED(sliceId); Q_UNUSED(forceMono); } + virtual void setSliceWfmDeemphasis(int sliceId, int microseconds) + { + Q_UNUSED(sliceId); Q_UNUSED(microseconds); + } // CW audio peaking filter (capabilities().hasAudioPeakingFilter): the slice's // enable and 0..100 apf_level together; the backend owns the centre (its CW // pitch) and whether it runs in the current mode. Flex does not override it diff --git a/src/core/backends/RadioCapabilities.h b/src/core/backends/RadioCapabilities.h index b63fe79375..7b63a76554 100644 --- a/src/core/backends/RadioCapabilities.h +++ b/src/core/backends/RadioCapabilities.h @@ -63,6 +63,12 @@ struct ReceiveAudioControl { SliceFrequencyControl::Authority authority{SliceFrequencyControl::Authority::Unknown}; // Both gain (0..100) and mute must act on the shared slice's RX audio. }; +// Broadcast FM controls are available only when their typed request is supported. +struct BroadcastFmReceive { + QVector deemphasisUs; + bool forceMonoControl = false; + bool receptionDiagnostics = false; +}; struct ReceivePanRangeControl { SliceFrequencyControl::Authority authority{SliceFrequencyControl::Authority::Unknown}; qint64 minimumHz{0}; @@ -288,6 +294,7 @@ struct RadioCapabilities { std::optional receiveModeControl; std::optional receiveFilterControl; std::optional receiveAudioControl; + std::optional broadcastFmReceive; std::optional receivePanCenterControl; std::optional receivePanBandwidthControl; // Engaged when the radio can deliver a wideband converter view; see the diff --git a/src/core/backends/ReceiveCommand.h b/src/core/backends/ReceiveCommand.h index b480eabae9..8bcb8f274c 100644 --- a/src/core/backends/ReceiveCommand.h +++ b/src/core/backends/ReceiveCommand.h @@ -74,6 +74,18 @@ struct SliceAudioRequest { } }; +struct SliceWfmRequest { + enum class Field { ForceMono, Deemphasis }; + Field field{Field::ForceMono}; + int value{0}; + + bool valid() const + { + return (field == Field::ForceMono && (value == 0 || value == 1)) + || (field == Field::Deemphasis && (value == 50 || value == 75)); + } +}; + struct SliceSquelchRequest { bool enabled{false}; int level{0}; @@ -91,3 +103,4 @@ Q_DECLARE_METATYPE(AetherSDR::SliceAgcRequest) Q_DECLARE_METATYPE(AetherSDR::SliceDspRequest) Q_DECLARE_METATYPE(AetherSDR::SliceAudioRequest) Q_DECLARE_METATYPE(AetherSDR::SliceSquelchRequest) +Q_DECLARE_METATYPE(AetherSDR::SliceWfmRequest) diff --git a/src/core/backends/SliceDelta.h b/src/core/backends/SliceDelta.h index f16c005112..9f8d32afee 100644 --- a/src/core/backends/SliceDelta.h +++ b/src/core/backends/SliceDelta.h @@ -1,6 +1,7 @@ #pragma once #include +#include "core/WfmReceptionDiagnostics.h" #include #include @@ -13,6 +14,10 @@ namespace AetherSDR { // Existing backends retain their historical optimistic setter behavior. enum class ReceiveControlPolicy { Optimistic, Confirmed }; +// Observed broadcast output state: forced mono is Mono even with an acquired +// pilot. Independent pilot observations live in WfmReceptionDiagnostics. +enum class WfmStereoStatus { Unavailable, Acquiring, Mono, Stereo }; + // Normalized slice-status delta (aetherd RFC 2.3). A backend populates only the // fields the wire reported (engaged optional == present); SliceModel::applyChanges // applies exactly those. Canonical value types; the FlexBackend decode owns the @@ -93,6 +98,10 @@ struct SliceDelta { std::optional agcOffLevel; std::optional squelchOn; std::optional squelchLevel; + std::optional wfmDeemphasisUs; + std::optional wfmForceMono; + std::optional wfmReceptionDiagnostics; + std::optional wfmStereoStatus; std::optional ritOn; std::optional ritFreq; std::optional xitOn; @@ -133,3 +142,5 @@ struct SliceDelta { // to a synchronous DirectConnection, but the registration keeps it correct if a // backend is ever moved to a worker thread.) Q_DECLARE_METATYPE(AetherSDR::SliceDelta) +Q_DECLARE_METATYPE(AetherSDR::WfmStereoStatus) +Q_DECLARE_METATYPE(AetherSDR::WfmReceptionDiagnostics) diff --git a/src/core/backends/anan/AnanBackend.cpp b/src/core/backends/anan/AnanBackend.cpp index 9502a86149..3943aa796c 100644 --- a/src/core/backends/anan/AnanBackend.cpp +++ b/src/core/backends/anan/AnanBackend.cpp @@ -371,6 +371,7 @@ AnanBackend::~AnanBackend() RadioCapabilities AnanBackend::capabilities() const { RadioCapabilities c; + c.broadcastFmReceive = std::nullopt; c.family = QStringLiteral("anan"); // No setTune() implementation, so no tune generator to select a mode on. c.twoToneGenerator = std::nullopt; diff --git a/src/core/backends/flex/FlexBackend.cpp b/src/core/backends/flex/FlexBackend.cpp index 21c0eb5b0e..c47cbe930d 100644 --- a/src/core/backends/flex/FlexBackend.cpp +++ b/src/core/backends/flex/FlexBackend.cpp @@ -194,6 +194,7 @@ void FlexBackend::setRadioReportedCapacity(int maxSlices, int maxPanadapters) RadioCapabilities FlexBackend::capabilities() const { RadioCapabilities caps; + caps.broadcastFmReceive = std::nullopt; // FlexLib 4.2.18 Slice.Freq delegates range refusal to firmware; its old // bounds are commented out. Do not guess coverage (including transverters). caps.sliceFrequencyControl = {SliceFrequencyControl::Authority::Radio, 0, 0}; diff --git a/src/core/backends/hl2/Hl2Backend.cpp b/src/core/backends/hl2/Hl2Backend.cpp index cd1903f67d..8a7c7d0d81 100644 --- a/src/core/backends/hl2/Hl2Backend.cpp +++ b/src/core/backends/hl2/Hl2Backend.cpp @@ -1731,6 +1731,7 @@ AetherSDR::WidebandConverterView widebandConverterViewRecord() noexcept RadioCapabilities Hl2Backend::capabilities() const { RadioCapabilities c; + c.broadcastFmReceive = std::nullopt; c.canReboot = false; c.hasRemoteOnControl = false; c.canUpgradeFirmware = false; diff --git a/src/core/backends/icom/IcomCivBackend.cpp b/src/core/backends/icom/IcomCivBackend.cpp index ac2cff5fe4..04c32183c2 100644 --- a/src/core/backends/icom/IcomCivBackend.cpp +++ b/src/core/backends/icom/IcomCivBackend.cpp @@ -289,6 +289,7 @@ RadioCapabilities IcomCivBackend::capabilities() const const IcomModel& m = *m_model; const IcomModelProfile& profile = profileFor(m); RadioCapabilities c; + c.broadcastFmReceive = std::nullopt; c.fmDtcsCodes = {}; c.family = QStringLiteral("icom"); c.manufacturer = QStringLiteral("Icom"); diff --git a/src/core/backends/rtl/RtlAudioMixer.cpp b/src/core/backends/rtl/RtlAudioMixer.cpp index 56aa1908b2..6d6414ff90 100644 --- a/src/core/backends/rtl/RtlAudioMixer.cpp +++ b/src/core/backends/rtl/RtlAudioMixer.cpp @@ -83,13 +83,20 @@ void RtlAudioMixer::drain(std::uint64_t captureClock, Sink& sink) noexcept const float level = slot.input.mute ? 0 : slot.input.gain / static_cast(m_count); const float leftGain = level * std::min(1.0f, 2 * (1 - slot.input.pan)); const float rightGain = level * std::min(1.0f, 2 * slot.input.pan); + MissingMask missing{}; for (std::size_t i = 0; i < kQuantum; ++i) { Frame& frame = slot.frames[(m_next + i) % kCapacity]; if (frame.valid && frame.position == m_next + i) { m_output[2 * i] += frame.left * leftGain; m_output[2 * i + 1] += frame.right * rightGain; frame.valid = false; - } else { ++m_late; } + } else { + ++m_late; + missing[i / 64] |= std::uint64_t{1} << (i % 64); + } + } + if (missing[0] != 0 || missing[1] != 0) { + sink.missingFrames(slot.input, m_next, captureClock, missing); } } for (float& value : m_output) { value = std::clamp(value, -1.0f, 1.0f); } diff --git a/src/core/backends/rtl/RtlAudioMixer.h b/src/core/backends/rtl/RtlAudioMixer.h index 66f78ab974..fde7b73187 100644 --- a/src/core/backends/rtl/RtlAudioMixer.h +++ b/src/core/backends/rtl/RtlAudioMixer.h @@ -22,11 +22,18 @@ class RtlAudioMixer final { float pan = 0.5f; bool mute = false; }; + using MissingMask = std::array; + static_assert(kQuantum == 128); class Sink { public: virtual ~Sink() = default; virtual void speakerBlock(std::uint64_t firstSample, std::span interleaved, bool discontinuity) noexcept = 0; + // Acquisition-owned fixed data only. Bit n names the exact missing + // contribution at firstSample+n; reporting cannot affect the deadline. + virtual void missingFrames(const Input&, std::uint64_t firstSample, + std::uint64_t captureClock, const MissingMask&) noexcept + { (void)firstSample; (void)captureClock; } }; bool configure(std::uint64_t session, std::uint64_t captureGeneration, std::span inputs, std::uint64_t firstSample) noexcept; diff --git a/src/core/backends/rtl/RtlCaptureTransaction.cpp b/src/core/backends/rtl/RtlCaptureTransaction.cpp index 03db08e814..70a83339ee 100644 --- a/src/core/backends/rtl/RtlCaptureTransaction.cpp +++ b/src/core/backends/rtl/RtlCaptureTransaction.cpp @@ -242,6 +242,7 @@ RtlCaptureTransaction::Submission RtlCaptureTransaction::submit(const Desired& d || receiver.audioGain < 0 || receiver.audioGain > 100 || receiver.audioPan < 0 || receiver.audioPan > 100 || receiver.squelchLevel < 0 || receiver.squelchLevel > 100 + || (receiver.wfmDeemphasisUs != 50 && receiver.wfmDeemphasisUs != 75) || (receiver.squelchEnabled && receiver.mode != Mode::Fm && receiver.mode != Mode::Fmn)) { return {{}, Policy::Error::InvalidNumber}; } diff --git a/src/core/backends/rtl/RtlCaptureTransaction.h b/src/core/backends/rtl/RtlCaptureTransaction.h index 0c822220ea..81b6697480 100644 --- a/src/core/backends/rtl/RtlCaptureTransaction.h +++ b/src/core/backends/rtl/RtlCaptureTransaction.h @@ -31,6 +31,8 @@ class RtlCaptureTransaction final { bool audioMute = false; bool squelchEnabled = false; int squelchLevel = 20; + int wfmDeemphasisUs = 75; // broadcast standard; validated 50 or 75 only + bool wfmForceMono = false; // selected decoder matrix policy, not observed pilot bool operator==(const Receiver&) const = default; }; struct Desired { diff --git a/src/core/backends/rtl/RtlReceivePipeline.cpp b/src/core/backends/rtl/RtlReceivePipeline.cpp index 2039a70c76..c9096a0693 100644 --- a/src/core/backends/rtl/RtlReceivePipeline.cpp +++ b/src/core/backends/rtl/RtlReceivePipeline.cpp @@ -2,9 +2,28 @@ #include #include #include +#include namespace AetherSDR::rtl { namespace { +std::optional mixerOrigin(std::uint64_t firstSample, double rateHz) noexcept +{ + // The extractor accepts only integral, bounded achieved rates. Validate + // before casting and share its alignment so these clocks cannot diverge. + if (!std::isfinite(rateHz) || rateHz != std::floor(rateHz) + || rateHz < 225001 || rateHz > 3000000) { return std::nullopt; } + const auto rate = static_cast(rateHz); + const auto captureFirst = RtlRfExtractor::alignedCaptureFirst(firstSample, rate, 48000); + if (!captureFirst) { return std::nullopt; } + const std::uint64_t common = std::gcd(rate, std::uint64_t{48000}); + const std::uint64_t periods = *captureFirst / (rate / common); + const std::uint64_t framesPerPeriod = 48000 / common; + constexpr std::uint64_t margin = RtlAudioMixer::kQuantum + RtlAudioMixer::kDeadlineFrames; + if (periods > (std::numeric_limits::max() - margin) / framesPerPeriod) { + return std::nullopt; + } + return periods * framesPerPeriod; +} WdspChannel::Mode dspMode(RtlCaptureTransaction::Mode mode) { using M = RtlCaptureTransaction::Mode; @@ -12,6 +31,7 @@ WdspChannel::Mode dspMode(RtlCaptureTransaction::Mode mode) case M::Am: return WdspChannel::Mode::Am; case M::Sam: return WdspChannel::Mode::Sam; case M::Fm: case M::Fmn: return WdspChannel::Mode::Fm; + case M::Wfm: return WdspChannel::Mode::Wbfm; case M::Lsb: return WdspChannel::Mode::Lsb; case M::Cw: return WdspChannel::Mode::Cwu; case M::Cwr: return WdspChannel::Mode::Cwl; @@ -19,8 +39,8 @@ WdspChannel::Mode dspMode(RtlCaptureTransaction::Mode mode) } } } -RtlReceivePipeline::RtlReceivePipeline(std::size_t capacity) - : m_registry({8, capacity, std::min(32, capacity * 2)}) +RtlReceivePipeline::RtlReceivePipeline(std::size_t capacity, bool enableWfm) + : m_enableWfm(enableWfm), m_registry({8, capacity, std::min(32, capacity * 2)}) { for (auto& monitor : m_monitor) { monitor.store(100 | (50 << 8)); } } @@ -34,13 +54,19 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( std::array validated{}; for (const auto& receiver : state.receivers) { const int id = receiver.passband.stableId; - if (id < 0 || id >= 8 || validated[id] || receiver.audioGain < 0 + if (id < 0 || id >= 8 || validated[id] + || receiver.mode < Transaction::Mode::Am || receiver.mode > Transaction::Mode::Cwr + || receiver.audioGain < 0 || receiver.audioGain > 100 || receiver.audioPan < 0 || receiver.audioPan > 100 - || receiver.squelchLevel < 0 || receiver.squelchLevel > 100) { return Submission::Failed; } + || receiver.squelchLevel < 0 || receiver.squelchLevel > 100 + || (receiver.wfmDeemphasisUs != 50 && receiver.wfmDeemphasisUs != 75) + || (receiver.mode == Transaction::Mode::Wfm && receiver.squelchEnabled)) { return Submission::Failed; } validated[id] = true; } + const unsigned faults = m_faults.load(std::memory_order_acquire); std::array receiving{}; bool legacy = false; + bool wfmTransition = false; std::uint8_t receivingMask = 0; for (int id : state.receivingIds) { if (id < 0 || id >= 8 || receiving[id] || !validated[id]) { return Submission::Failed; } @@ -50,12 +76,29 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( return value.passband.stableId == id; }); legacy = legacy || (receiver->mode != Transaction::Mode::Fm - && receiver->mode != Transaction::Mode::Fmn); + && receiver->mode != Transaction::Mode::Fmn + && !(m_enableWfm && receiver->mode == Transaction::Mode::Wfm)); if (legacy && receiver->squelchEnabled) { return Submission::Failed; } + const auto& prior = m_specs[id]; + const bool wide = m_enableWfm && receiver->mode == Transaction::Mode::Wfm; + if (wide || prior.dsp.wbfmReceive) { + const auto deemphasis = receiver->wfmDeemphasisUs == 50 + ? WdspChannel::WbfmReceive::Deemphasis::Us50 + : WdspChannel::WbfmReceive::Deemphasis::Us75; + wfmTransition |= (wide && (faults & (1u << id))) + || !wide || !prior.dsp.wbfmReceive + || prior.passband != receiver->passband + || prior.dsp.wbfmReceive->deemphasis != deemphasis + || prior.dsp.wbfmReceive->forceMono != receiver->wfmForceMono; + } } if (legacy && (state.receivers.size() != 1 || state.receivingIds.size() != 1)) { return Submission::Failed; } + // WFM changes the causal decoder graph even when final PCM stays 48 kHz. + // Give the speaker a discontinuity too, retiring the previous graph's + // queued waveform and client-effect history instead of appending its tail. + // A withdrawn WFM receiver has lost that graph even if its recipe is unchanged. const bool advanceCaptureEpoch = resetCapture || legacy != m_legacy - || receivingMask != m_receivingMask; + || receivingMask != m_receivingMask || wfmTransition; if (advanceCaptureEpoch && m_nextEpoch == std::numeric_limits::max()) { return Submission::Failed; } auto epochs = m_epochs; auto specs = m_specs; @@ -64,7 +107,6 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( m_reader = m_registry.attachReader(); if (!m_session || !m_reader) { return Submission::Failed; } } - const unsigned faults = m_faults.load(std::memory_order_acquire); std::array desired; std::array used{}; std::size_t count = 0; @@ -88,8 +130,21 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( spec.handle = *m_handles[id]; spec.passband = receiver.passband; spec.capture = state.capture; spec.extractRf = true; spec.dsp.mode = dspMode(receiver.mode); - spec.dsp.fmReceive = WdspChannel::FmReceive{}; - spec.dsp.fmDeviationHz = receiver.mode == Transaction::Mode::Fmn ? 2500.0 : 5000.0; + if (receiver.mode == Transaction::Mode::Wfm) { + spec.dsp.inputSampleRate = 384000; + spec.dsp.inputBlockSize = 2048; + spec.dsp.dspSampleRate = 192000; + spec.dsp.dspBlockSize = 1024; + spec.dsp.outputSampleRate = 48000; + spec.dsp.wbfmReceive = WdspChannel::WbfmReceive{}; + spec.dsp.wbfmReceive->forceMono = receiver.wfmForceMono; + spec.dsp.wbfmReceive->deemphasis = receiver.wfmDeemphasisUs == 50 + ? WdspChannel::WbfmReceive::Deemphasis::Us50 + : WdspChannel::WbfmReceive::Deemphasis::Us75; + } else { + spec.dsp.fmReceive = WdspChannel::FmReceive{}; + spec.dsp.fmDeviationHz = receiver.mode == Transaction::Mode::Fmn ? 2500.0 : 5000.0; + } spec.dsp.filterLowHz = receiver.passband.filterLowHz; spec.dsp.filterHighHz = receiver.passband.filterHighHz; if (resetCapture || (faults & (1u << id)) || m_specs[id].handle != spec.handle @@ -130,6 +185,9 @@ RtlReceivePipeline::Preparation RtlReceivePipeline::service() bool RtlReceivePipeline::adopt() noexcept { if (!m_reader.adoptPrepared(m_session, m_nextCapture, m_prepared)) { return false; } + if (m_token.session != m_nextToken.session || m_captureEpoch != m_nextEpoch) { + m_mixerOrigin.reset(); + } const bool resetSquelch = m_captureEpoch != m_nextEpoch; for (std::size_t slot = 0; slot < m_monitor.size(); ++slot) { m_monitor[slot].store(m_nextMonitor[slot], std::memory_order_relaxed); @@ -170,18 +228,24 @@ void RtlReceivePipeline::process(const RtlReceiverRegistry::SampleBlock& block, for (std::size_t i = 0; i < views.size(); ++i) { const auto& spec = *views[i].spec; const unsigned monitor = m_monitor[spec.handle.slot].load(std::memory_order_relaxed); + m_traceStableIds[spec.handle.slot] = spec.passband.stableId; inputs[i] = {spec.handle.slot, spec.handle.instance, spec.epoch, (monitor & 255) / 100.0f, ((monitor >> 8) & 255) / 100.0f, (monitor & (1 << 16)) != 0}; } const auto clock = [this](std::uint64_t sample) { return static_cast(static_cast(sample) * 48000 / m_capture.achievedSampleRateHz); }; - if (!m_mixer.configure(m_token.session, m_captureEpoch, - std::span(inputs).first(views.size()), clock(block.firstSample))) { + m_traceCaptureFirst = block.firstSample; + m_traceCaptureFrames = block.samples.size(); + m_traceCaptureClock = clock(block.firstSample + block.samples.size()); + if (!m_mixerOrigin) { m_mixerOrigin = mixerOrigin(block.firstSample, m_capture.achievedSampleRateHz); } + if (!m_mixerOrigin || !m_mixer.configure(m_token.session, m_captureEpoch, + std::span(inputs).first(views.size()), *m_mixerOrigin)) { // No stale map/audio may survive a rejected configuration. This is a // defensive invariant failure: normal registry handles and validated // transaction tokens cannot reach it. Repair through the existing owner. m_mixer.reset(); + m_mixerOrigin.reset(); m_mixerConfigurationFailures.fetch_add(1, std::memory_order_relaxed); m_observed.store(true, std::memory_order_release); m_faults.fetch_or(0xff, std::memory_order_release); @@ -194,7 +258,7 @@ void RtlReceivePipeline::process(const RtlReceiverRegistry::SampleBlock& block, gate.configure(m_squelchConfig[slot].enabled, m_squelchConfig[slot].level, true); m_squelchEpoch[slot] = view.spec->epoch; } - if (m_spectrumFresh) { + if (m_spectrumFresh && !view.spec->dsp.wbfmReceive) { const auto& passband = view.spec->passband; const double binHz = m_capture.achievedSampleRateHz / m_spectrum.size(); const double offset = passband.carrierHz - m_capture.centerHz; @@ -212,11 +276,19 @@ void RtlReceivePipeline::process(const RtlReceiverRegistry::SampleBlock& block, gate.observe(peak, clock(m_spectrumFirstSample)); } if (!view.receiver->processCapture(block, *this)) { - m_faults.fetch_or(1u << view.spec->handle.slot, std::memory_order_release); + const unsigned bit = 1u << slot; + if (!(m_faults.fetch_or(bit, std::memory_order_release) & bit)) { + TraceEvent event = traceContext(); + event.slot = slot; event.stableId = view.spec->passband.stableId; + event.instance = view.spec->handle.instance; + event.receiverEpoch = view.spec->epoch; + event.failure = view.receiver->processingFailure(); + enqueueTrace(event); + } } } m_spectrumFresh = false; - m_mixer.drain(clock(block.firstSample + block.samples.size()), *this); + m_mixer.drain(m_traceCaptureClock, *this); m_mixerLate.store(m_mixer.lateFrames(), std::memory_order_relaxed); m_mixerRejected.store(m_mixer.rejectedBlocks(), std::memory_order_relaxed); m_observed.store(true, std::memory_order_release); @@ -226,20 +298,34 @@ RtlReceivePipeline::Diagnostics RtlReceivePipeline::diagnostics() const noexcept return {m_observed.load(std::memory_order_acquire), m_drops.load(std::memory_order_relaxed), m_mixerLate.load(std::memory_order_relaxed), m_mixerRejected.load(std::memory_order_relaxed), - m_mixerConfigurationFailures.load(std::memory_order_relaxed)}; + m_mixerConfigurationFailures.load(std::memory_order_relaxed), + m_traceDrops.load(std::memory_order_relaxed)}; } void RtlReceivePipeline::audioBlock(const RtlReceiverRegistry::ReceiverSpec& spec, std::uint64_t first, std::span left, std::span right, bool discontinuity) noexcept +{ + audioBlockWithStatus(spec, first, left, right, discontinuity, std::nullopt); +} +void RtlReceivePipeline::audioBlockWithStatus(const RtlReceiverRegistry::ReceiverSpec& spec, + std::uint64_t first, std::span left, std::span right, + bool discontinuity, std::optional reception) noexcept { Packet packet; packet.token = m_token; packet.captureEpoch = m_captureEpoch; packet.instance = spec.handle.instance; packet.receiverEpoch = spec.epoch; packet.slot = spec.handle.slot; packet.firstSample = first; packet.frames = left.size(); packet.discontinuity = discontinuity; + packet.wfmReception = reception; + if (reception) { + packet.wfmStereoDetected = reception->pilotLocked + && !(spec.dsp.wbfmReceive && spec.dsp.wbfmReceive->forceMono); + } if (left.size() != right.size() || left.size() > 1024) { return; } std::array gatedLeft{}, gatedRight{}; for (std::size_t i = 0; i < left.size(); ++i) { - const float gain = m_squelch[packet.slot].gain(first + i); + // WBFM already applies WDSP's internal automatic squelch and the + // qualified paired-channel gain before this independent tap. + const float gain = spec.dsp.wbfmReceive ? 1.0f : m_squelch[packet.slot].gain(first + i); gatedLeft[i] = left[i] * gain; gatedRight[i] = right[i] * gain; packet.samples[2 * i] = gatedLeft[i]; packet.samples[2 * i + 1] = gatedRight[i]; } @@ -255,6 +341,44 @@ void RtlReceivePipeline::speakerBlock(std::uint64_t first, std::span wfmStereoDetected; + std::optional wfmReception; std::array samples{}; }; - explicit RtlReceivePipeline(std::size_t capacity = 1); + // Bounded acquisition observations; no formatting, clock reads, allocation, + // or logging on the sample callback. The owner drains across bank adoption + // so repair cannot erase the fault that requested it. + struct TraceEvent { + enum class Kind { ReceiverFailure, MixerMissing }; + Kind kind = Kind::ReceiverFailure; + Transaction::Token token; + std::uint64_t hardwareGeneration = 0; + std::uint64_t captureEpoch = 0; + int slot = -1; + int stableId = -1; + std::uint64_t instance = 0; + std::uint64_t receiverEpoch = 0; + std::uint64_t captureFirst = 0; + std::uint64_t captureFrames = 0; + std::uint64_t captureClock = 0; // end of current capture block, at 48 kHz + std::uint64_t quantumFirst = 0; + RtlAudioMixer::MissingMask missingMask{}; + std::optional failure; + }; + static constexpr unsigned kTraceEvents = 64; + bool takeTraceEvent(TraceEvent& output) noexcept; + // One internal build gate: qualification fixtures opt in explicitly. The + // backend uses the same constant for its controls and legacy publication. + static constexpr bool kQualifiedWfmEnabled = true; + explicit RtlReceivePipeline(std::size_t capacity = 1, + bool enableWfm = kQualifiedWfmEnabled); enum class Submission { Accepted, RetryRetiringSlot, RetryPlannerBusy, Failed }; Submission prepareDetailed(const Transaction::State& state, bool resetCapture = false, bool verifiedRollback = false); @@ -63,8 +96,15 @@ class RtlReceivePipeline final : private RtlReceiverRegistry::BlockProcessor, void process(const RtlReceiverRegistry::SampleBlock&, std::span) noexcept override; void audioBlock(const RtlReceiverRegistry::ReceiverSpec&, std::uint64_t, std::span, std::span, bool) noexcept override; + void audioBlockWithStatus(const RtlReceiverRegistry::ReceiverSpec&, std::uint64_t, + std::span, std::span, bool, std::optional) noexcept override; void speakerBlock(std::uint64_t, std::span, bool) noexcept override; bool enqueue(const Packet&) noexcept; + void missingFrames(const RtlAudioMixer::Input&, std::uint64_t, std::uint64_t, + const RtlAudioMixer::MissingMask&) noexcept override; + void enqueueTrace(const TraceEvent&) noexcept; + TraceEvent traceContext() const noexcept; + const bool m_enableWfm; RtlReceiverRegistry m_registry; RtlReceiverRegistry::SampleReader m_reader; std::uint64_t m_session = 0; @@ -84,6 +124,18 @@ class RtlReceivePipeline final : private RtlReceiverRegistry::BlockProcessor, bool m_legacy = true; std::uint8_t m_receivingMask = 0; RtlAudioMixer m_mixer; + // Set on the first capture block of a new mixer epoch, then retained. + // Unchanged epochs must keep their existing sample coordinate and buffers. + std::optional m_mixerOrigin; + std::array m_traceStableIds{}; + std::uint64_t m_traceCaptureFirst = 0; + std::uint64_t m_traceCaptureFrames = 0; + std::uint64_t m_traceCaptureClock = 0; + // Independent SPSC trace queue: overflow is counted and never delays PCM. + std::array m_traceEvents; + alignas(64) std::atomic m_traceWrite{0}; + alignas(64) std::atomic m_traceRead{0}; + std::atomic m_traceDrops{0}; std::atomic m_faults{0}; std::array, 8> m_monitor; std::array m_nextMonitor{}; diff --git a/src/core/backends/rtl/RtlReceiverRegistry.cpp b/src/core/backends/rtl/RtlReceiverRegistry.cpp index 651b270552..63fa225872 100644 --- a/src/core/backends/rtl/RtlReceiverRegistry.cpp +++ b/src/core/backends/rtl/RtlReceiverRegistry.cpp @@ -39,6 +39,18 @@ bool validCapture(const Registry::Capture& capture) bool boundedDsp(const WdspChannel::Config& config) { + if (config.wbfmReceive) { + const auto& wide = *config.wbfmReceive; + if (config.mode != WdspChannel::Mode::Wbfm || config.fmReceive + || config.inputSampleRate != 384000 || config.inputBlockSize != 2048 + || config.dspSampleRate != 192000 || config.dspBlockSize != 1024 + || config.outputSampleRate != 48000 + || !std::isfinite(wide.outputGain) || wide.outputGain < 0 || wide.outputGain > 1 + || (wide.deemphasis != WdspChannel::WbfmReceive::Deemphasis::Us50 + && wide.deemphasis != WdspChannel::WbfmReceive::Deemphasis::Us75) + || config.filterLowHz < -0.45 * config.inputSampleRate + || config.filterHighHz > 0.45 * config.inputSampleRate) { return false; } + } if (config.fmReceive && (config.mode != WdspChannel::Mode::Fm || !std::isfinite(config.fmDeviationHz) || config.fmDeviationHz <= 0 || config.fmDeviationHz >= config.dspSampleRate / 2.0)) { return false; } @@ -71,8 +83,8 @@ bool boundedDsp(const WdspChannel::Config& config) static_cast(config.mode) <= static_cast(WdspChannel::Mode::Wbfm) && std::isfinite(config.filterLowHz) && std::isfinite(config.filterHighHz) && config.filterLowHz < config.filterHighHz && - config.filterLowHz >= -config.dspSampleRate / 2.0 && - config.filterHighHz <= config.dspSampleRate / 2.0 && + config.filterLowHz >= -(config.wbfmReceive ? config.inputSampleRate : config.dspSampleRate) / 2.0 && + config.filterHighHz <= (config.wbfmReceive ? config.inputSampleRate : config.dspSampleRate) / 2.0 && config.filterTaps >= 64 && config.filterTaps <= 16384 && (config.filterTaps & (config.filterTaps - 1)) == 0 && // WDSP derives again = rate / (fmDeviationHz * TWOPI) from this, so an @@ -100,29 +112,60 @@ class WdspReceiver final : public Registry::Receiver, private RtlRfExtractor::Si { if (spec.extractRf) { m_extractor = std::make_unique(RtlRfExtractor::Config{ - spec.capture, spec.passband, spec.dsp.inputSampleRate, spec.dsp.inputBlockSize}); + spec.capture, spec.passband, spec.dsp.inputSampleRate, spec.dsp.inputBlockSize, + spec.dsp.outputSampleRate}); } } bool valid() const noexcept { return !m_extractor || m_extractor->valid(); } bool processCapture(const Registry::SampleBlock& block, Registry::AudioSink& sink) noexcept override { - if (!m_extractor || block.session != m_spec.handle.session) { return false; } + if (!m_extractor || block.session != m_spec.handle.session) { + if (!m_failure) { + m_failure.emplace(); + m_failure->reason = !m_extractor ? Registry::ProcessingFailureReason::NoExtractor + : Registry::ProcessingFailureReason::SessionMismatch; + m_failure->captureFirst = block.firstSample; + m_failure->captureFrames = block.samples.size(); + } + return false; + } + const bool hadFailure = m_failure.has_value(); m_sink = &sink; const bool success = m_extractor->process(block.capture, block.firstSample, block.samples, *this, block.discontinuity); m_sink = nullptr; + if (!success && !hadFailure) { + if (!m_failure) { m_failure.emplace(); } + m_failure->extraction = m_extractor->failure(); + if (m_failure->extraction) { + const auto& failure = *m_failure->extraction; + m_failure->expectedCaptureFirst = failure.expectedCaptureFirst; + m_failure->captureFirst = failure.captureFirst; + m_failure->captureFrames = failure.captureFrames; + m_failure->hasExpectedCaptureFirst = failure.hasExpectedCaptureFirst; + m_failure->iqFirst = failure.iqFirst; + m_failure->iqFrames = failure.iqFrames; + m_failure->hasIqFirst = failure.hasIqFirst; + } + } return success; } WdspChannel::ProcessResult processIq(std::span i, std::span q) noexcept override { - if (i.data() == nullptr || q.data() == nullptr) { - return WdspChannel::ProcessResult::InvalidBuffer; + const WdspChannel::ProcessResult result = i.data() == nullptr || q.data() == nullptr + ? WdspChannel::ProcessResult::InvalidBuffer : m_channel->processIq(i, q, m_left, m_right); + if (result != WdspChannel::ProcessResult::Ok && !m_failure) { + m_failure.emplace(); + m_failure->reason = Registry::ProcessingFailureReason::DspProcess; + m_failure->processResult = result; + m_failure->iqFrames = i.size(); } - return m_channel->processIq(i, q, m_left, m_right); + return result; } std::span left() const noexcept override { return m_left; } std::span right() const noexcept override { return m_right; } + std::optional processingFailure() const noexcept override { return m_failure; } private: std::unique_ptr m_channel; std::vector m_left; @@ -131,14 +174,38 @@ class WdspReceiver final : public Registry::Receiver, private RtlRfExtractor::Si std::unique_ptr m_extractor; Registry::AudioSink* m_sink = nullptr; bool m_first = true; + std::optional m_failure; bool iqBlock(std::span i, std::span q, std::uint64_t firstSample) noexcept override { // Nonblocking WDSP fexchange2 advances its ring even on underrun. // Withdraw this instance rather than later publishing a stale ring // position as current audio. Its replacement is prepared off-thread. - if (processIq(i, q) != WdspChannel::ProcessResult::Ok) { return false; } - m_sink->audioBlock(m_spec, firstSample, m_left, m_right, m_first); + const bool hadFailure = m_failure.has_value(); + if (processIq(i, q) != WdspChannel::ProcessResult::Ok) { + if (!hadFailure) { + m_failure->iqFirst = firstSample; + m_failure->hasIqFirst = true; + } + return false; + } + // The extractor positions name IQ frames. The sink and mixer name + // final audio frames. Preparation aligns the first IQ block with that + // slower lattice; every fixed block advances it by an exact ratio. + const auto ratio = static_cast( + m_spec.dsp.inputSampleRate / m_spec.dsp.outputSampleRate); + if (ratio == 0 || firstSample % ratio != 0) { + if (!m_failure) { + m_failure.emplace(); + m_failure->reason = Registry::ProcessingFailureReason::AudioLattice; + m_failure->iqFirst = firstSample; + m_failure->iqFrames = i.size(); + m_failure->hasIqFirst = true; + } + return false; + } + m_sink->audioBlockWithStatus(m_spec, firstSample / ratio, m_left, m_right, m_first, + m_channel->wbfmReceptionDiagnostics()); m_first = false; return true; } @@ -594,9 +661,17 @@ RtlReceiverRegistry::Result RtlReceiverRegistry::submitImpl(const Capture& captu if (slot < 0 || static_cast(slot) >= state.limits.slotCount || used[slot] || spec.handle.session != state.session.load() || state.reservations[slot] != spec.handle || spec.passband.stableId != slot || !boundedDsp(spec.dsp) - || (spec.extractRf && (spec.dsp.inputSampleRate != 48000 - || spec.dsp.dspSampleRate != 48000 || spec.dsp.outputSampleRate != 48000 - || spec.dsp.inputBlockSize != 1024 || spec.dsp.dspBlockSize != 1024))) { + || (spec.dsp.wbfmReceive + && (spec.passband.guardLowHz < WdspChannel::WbfmReceive::kRfTransitionGuardHz + || spec.passband.guardHighHz < WdspChannel::WbfmReceive::kRfTransitionGuardHz)) + || (spec.extractRf && !((spec.dsp.inputSampleRate == 48000 + && spec.dsp.dspSampleRate == 48000 && spec.dsp.outputSampleRate == 48000 + && spec.dsp.inputBlockSize == 1024 && spec.dsp.dspBlockSize == 1024 + && !spec.dsp.wbfmReceive) + || (spec.dsp.inputSampleRate == 384000 && spec.dsp.dspSampleRate == 192000 + && spec.dsp.outputSampleRate == 48000 && spec.dsp.inputBlockSize == 2048 + && spec.dsp.dspBlockSize == 1024 && spec.dsp.mode == WdspChannel::Mode::Wbfm + && spec.dsp.wbfmReceive && !spec.dsp.fmReceive)))) { return Result::Invalid; } used[slot] = true; diff --git a/src/core/backends/rtl/RtlReceiverRegistry.h b/src/core/backends/rtl/RtlReceiverRegistry.h index 517cfe52a9..ca227377df 100644 --- a/src/core/backends/rtl/RtlReceiverRegistry.h +++ b/src/core/backends/rtl/RtlReceiverRegistry.h @@ -1,6 +1,7 @@ #pragma once #include "core/SharedCapturePolicy.h" +#include "core/backends/rtl/RtlRfExtractor.h" #include "core/dsp/WdspChannel.h" #include @@ -53,6 +54,21 @@ class RtlReceiverRegistry final bool operator==(const ReceiverSpec&) const = default; }; + enum class ProcessingFailureReason { NoExtractor, SessionMismatch, Extraction, DspProcess, AudioLattice }; + struct ProcessingFailure { + ProcessingFailureReason reason = ProcessingFailureReason::Extraction; + WdspChannel::ProcessResult processResult = WdspChannel::ProcessResult::Ok; + std::uint64_t expectedCaptureFirst = 0; + std::uint64_t captureFirst = 0; + std::uint64_t captureFrames = 0; + std::uint64_t iqFirst = 0; + std::uint64_t iqFrames = 0; + bool hasExpectedCaptureFirst = false; + bool hasIqFirst = false; + std::optional extraction; + bool operator==(const ProcessingFailure&) const = default; + }; + // Prepared fixed-block DSP, with no allocating setters exposed. M1 owns // the RF extraction/conversion feeding these exact-size planar blocks. // Returned output spans are borrowed until the next processIq() call. @@ -64,12 +80,24 @@ class RtlReceiverRegistry final virtual std::span left() const noexcept = 0; virtual std::span right() const noexcept = 0; virtual bool processCapture(const SampleBlock&, AudioSink&) noexcept { return false; } + // Fixed-size first-failure observation; acquisition context only, or + // after acquisition has stopped. Reading does not acknowledge/reset it. + virtual std::optional processingFailure() const noexcept { return std::nullopt; } }; class AudioSink { public: virtual ~AudioSink() = default; virtual void audioBlock(const ReceiverSpec& spec, std::uint64_t firstSample, std::span left, std::span right, bool discontinuity) noexcept = 0; + // Optional decoder observation for the same live receiver. The decoder + // publishes its latest completed DSP block, not an RF arrival timestamp. + virtual void audioBlockWithStatus(const ReceiverSpec& spec, std::uint64_t firstSample, + std::span left, std::span right, bool discontinuity, + std::optional reception) noexcept + { + (void)reception; + audioBlock(spec, firstSample, left, right, discontinuity); + } }; // Injection is for deterministic preparation failures/delays. The default // constructs real WDSP channels and output buffers on the Qt worker pool. diff --git a/src/core/backends/rtl/RtlRfExtractor.cpp b/src/core/backends/rtl/RtlRfExtractor.cpp index 73e65f80ac..d802c09d44 100644 --- a/src/core/backends/rtl/RtlRfExtractor.cpp +++ b/src/core/backends/rtl/RtlRfExtractor.cpp @@ -16,8 +16,12 @@ RtlRfExtractor::RtlRfExtractor(Config config) : m_config(std::move(config)) if (!std::isfinite(capture.achievedSampleRateHz) || capture.achievedSampleRateHz != std::floor(capture.achievedSampleRateHz) || capture.achievedSampleRateHz < 225001 || capture.achievedSampleRateHz > 3000000 - || m_config.outputRateHz < 48000 || m_config.outputRateHz > 192000 - || m_config.outputRateHz > capture.achievedSampleRateHz + || m_config.outputRateHz < 48000 || m_config.outputRateHz > 384000 + || m_config.outputRateHz > 2 * capture.achievedSampleRateHz + || m_config.alignmentRateHz < 0 + || (m_config.alignmentRateHz != 0 + && (m_config.alignmentRateHz > m_config.outputRateHz + || m_config.outputRateHz % m_config.alignmentRateHz != 0)) || m_config.blockSize < 64 || m_config.blockSize > 4096 || (m_config.blockSize & (m_config.blockSize - 1)) != 0 || slice.filterLowHz < -0.45 * m_config.outputRateHz @@ -35,28 +39,66 @@ RtlRfExtractor::RtlRfExtractor(Config config) : m_config(std::move(config)) // The 10% transition leaves the declared +/-45% DSP-rate passband intact. m_i = std::make_unique(capture.achievedSampleRateHz, m_config.outputRateHz, kInputChunk, 10.0); m_q = std::make_unique(capture.achievedSampleRateHz, m_config.outputRateHz, kInputChunk, 10.0); - // Downsampling only; room includes r8brain's burst output from its staging. + // At most 2x upsampling; room includes r8brain's staging burst. The RF + // fit still uses actual capture coverage, never the interpolated IQ rate. m_convertedI.reserve(16384 * sizeof(float)); m_convertedQ.reserve(16384 * sizeof(float)); m_blockI.resize(m_config.blockSize); m_blockQ.resize(m_config.blockSize); m_valid = true; } +std::optional RtlRfExtractor::alignedCaptureFirst(std::uint64_t firstSample, + std::uint64_t captureRateHz, std::uint64_t alignmentRateHz) noexcept +{ + if (captureRateHz < 225001 || captureRateHz > 3000000 + || alignmentRateHz == 0 || alignmentRateHz > 384000) { return std::nullopt; } + const std::uint64_t period = captureRateHz / std::gcd(captureRateHz, alignmentRateHz); + const std::uint64_t skip = (period - firstSample % period) % period; + if (firstSample > std::numeric_limits::max() - skip) { return std::nullopt; } + return firstSample + skip; +} RtlRfExtractor::~RtlRfExtractor() = default; int RtlRfExtractor::groupDelayInputFrames() const noexcept { return m_i ? m_i->groupDelayInputFrames() : 0; } bool RtlRfExtractor::process(const SharedCapturePolicy::CaptureDescriptor& capture, std::uint64_t firstSample, std::span> input, Sink& sink, bool discontinuity) noexcept { - if (!m_valid || m_withdrawn || capture != m_config.capture) { return false; } - if (input.empty() || !input.data() || input.size() > kMaxInput - || firstSample > std::numeric_limits::max() - input.size() - || (m_seenInput && (discontinuity || firstSample != m_nextInput)) - || !std::ranges::all_of(input, [](const auto& sample) { - return std::isfinite(sample.real()) && std::isfinite(sample.imag()); - })) { - m_withdrawn = true; + const std::uint64_t captureFrames = input.size(); + const std::uint64_t expectedCaptureFirst = m_nextInput; + const bool hasExpectedCaptureFirst = m_seenInput; + const auto fail = [&](FailureReason reason, bool withdraw = true, + int convertedI = 0, int convertedQ = 0) { + if (!m_failure) { + Failure failure; + failure.reason = reason; + failure.expectedCaptureFirst = expectedCaptureFirst; + failure.captureFirst = firstSample; + failure.captureFrames = captureFrames; + failure.hasExpectedCaptureFirst = hasExpectedCaptureFirst; + failure.iqFrames = m_stagedOutput; + failure.hasIqFirst = m_started + && m_outputOrigin <= std::numeric_limits::max() - m_outputFrames; + if (failure.hasIqFirst) { failure.iqFirst = m_outputOrigin + m_outputFrames; } + failure.convertedI = convertedI; + failure.convertedQ = convertedQ; + m_failure = failure; + } + if (withdraw) { m_withdrawn = true; } return false; + }; + if (!m_valid) { return fail(FailureReason::InvalidConfiguration, false); } + if (m_withdrawn) { return false; } + if (capture != m_config.capture) { return fail(FailureReason::CaptureMismatch, false); } + if (input.empty()) { return fail(FailureReason::EmptyInput); } + if (!input.data()) { return fail(FailureReason::NullInput); } + if (input.size() > kMaxInput) { return fail(FailureReason::InputTooLarge); } + if (firstSample > std::numeric_limits::max() - input.size()) { + return fail(FailureReason::CapturePositionOverflow); } + if (m_seenInput && discontinuity) { return fail(FailureReason::Discontinuity); } + if (m_seenInput && firstSample != m_nextInput) { return fail(FailureReason::CapturePositionMismatch); } + if (!std::ranges::all_of(input, [](const auto& sample) { + return std::isfinite(sample.real()) && std::isfinite(sample.imag()); + })) { return fail(FailureReason::NonFiniteInput); } if (!m_seenInput) { // New receivers join the SAME rational sample lattice as siblings. // Wait for the next capture/output coincidence, rather than rounding @@ -64,24 +106,29 @@ bool RtlRfExtractor::process(const SharedCapturePolicy::CaptureDescriptor& captu // All supported rates are integral hardware readbacks. This wait is // bounded by one second even for coprime rates; it consumes no storage. const auto rate = static_cast(capture.achievedSampleRateHz); - const std::uint64_t period = rate / std::gcd(rate, static_cast(m_config.outputRateHz)); - const std::uint64_t skip = (period - firstSample % period) % period; - if (firstSample > std::numeric_limits::max() - skip) { - m_withdrawn = true; return false; - } - m_startInput = firstSample + skip; + const int alignmentRate = m_config.alignmentRateHz != 0 + ? m_config.alignmentRateHz : m_config.outputRateHz; + const auto start = alignedCaptureFirst(firstSample, rate, static_cast(alignmentRate)); + if (!start) { return fail(FailureReason::AlignmentOverflow); } + m_startInput = *start; m_seenInput = true; } m_nextInput = firstSample + input.size(); if (!m_started) { if (m_nextInput <= m_startInput) { return true; } input = input.subspan(static_cast(m_startInput - firstSample)); - const long double origin = static_cast(m_startInput) - * m_config.outputRateHz / capture.achievedSampleRateHz; - if (origin > std::numeric_limits::max() - kMaxInput) { - m_withdrawn = true; return false; + const auto rate = static_cast(capture.achievedSampleRateHz); + const int alignmentRate = m_config.alignmentRateHz != 0 + ? m_config.alignmentRateHz : m_config.outputRateHz; + const std::uint64_t common = std::gcd(rate, static_cast(alignmentRate)); + const std::uint64_t periods = m_startInput / (rate / common); + const std::uint64_t framesPerPeriod = static_cast(m_config.outputRateHz) / common; + if (periods > (std::numeric_limits::max() - kMaxInput) / framesPerPeriod) { + return fail(FailureReason::OutputOriginOverflow); } - m_outputOrigin = static_cast(std::round(origin)); + // Exact integer time mapping also holds beyond floating-point's exact + // integer range. The RF oscillator separately retains its phase model. + m_outputOrigin = periods * framesPerPeriod; const long double phase = std::remainder(-2 * std::numbers::pi_v * (m_config.slice.carrierHz + m_config.slice.translationHz - capture.centerHz) * m_startInput / capture.achievedSampleRateHz, 2 * std::numbers::pi_v); @@ -98,18 +145,23 @@ bool RtlRfExtractor::process(const SharedCapturePolicy::CaptureDescriptor& captu const int countI = m_i->process(m_inputI.data(), kInputChunk, m_convertedI); const int countQ = m_q->process(m_inputQ.data(), kInputChunk, m_convertedQ); m_stagedInput = 0; - if (countI != countQ || countI < 0 || countI > 16384) { m_withdrawn = true; return false; } + if (countI != countQ) { return fail(FailureReason::ConvertedCountMismatch, true, countI, countQ); } + if (countI < 0 || countI > 16384) { + return fail(FailureReason::ConvertedCountOutOfRange, true, countI, countQ); + } for (int index = 0; index < countI; ++index) { std::memcpy(&m_blockI[m_stagedOutput], m_convertedI.constData() + index * sizeof(float), sizeof(float)); std::memcpy(&m_blockQ[m_stagedOutput], m_convertedQ.constData() + index * sizeof(float), sizeof(float)); if (!std::isfinite(m_blockI[m_stagedOutput]) || !std::isfinite(m_blockQ[m_stagedOutput])) { - m_withdrawn = true; return false; + return fail(FailureReason::NonFiniteOutput, true, countI, countQ); } if (++m_stagedOutput != m_config.blockSize) { continue; } if (m_outputFrames > std::numeric_limits::max() - m_config.blockSize - || m_outputOrigin > std::numeric_limits::max() - m_outputFrames - m_config.blockSize - || !sink.iqBlock(m_blockI, m_blockQ, m_outputOrigin + m_outputFrames)) { - m_withdrawn = true; return false; + || m_outputOrigin > std::numeric_limits::max() - m_outputFrames - m_config.blockSize) { + return fail(FailureReason::OutputPositionOverflow); + } + if (!sink.iqBlock(m_blockI, m_blockQ, m_outputOrigin + m_outputFrames)) { + return fail(FailureReason::SinkRejected); } m_outputFrames += m_config.blockSize; m_stagedOutput = 0; diff --git a/src/core/backends/rtl/RtlRfExtractor.h b/src/core/backends/rtl/RtlRfExtractor.h index e1b947dd90..5683e2ec95 100644 --- a/src/core/backends/rtl/RtlRfExtractor.h +++ b/src/core/backends/rtl/RtlRfExtractor.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -20,11 +21,35 @@ namespace rtl { // the callback. Configuration and capture readback are immutable for its life. class RtlRfExtractor final { public: + enum class FailureReason { + InvalidConfiguration, CaptureMismatch, EmptyInput, NullInput, InputTooLarge, + CapturePositionOverflow, Discontinuity, CapturePositionMismatch, NonFiniteInput, + AlignmentOverflow, OutputOriginOverflow, ConvertedCountMismatch, + ConvertedCountOutOfRange, NonFiniteOutput, OutputPositionOverflow, SinkRejected + }; + struct Failure { + FailureReason reason = FailureReason::InvalidConfiguration; + std::uint64_t expectedCaptureFirst = 0; + std::uint64_t captureFirst = 0; + std::uint64_t captureFrames = 0; + // The next/attempted IQ block, including any samples already staged. + // hasIqFirst is false before an exact output origin is established. + std::uint64_t iqFirst = 0; + std::uint64_t iqFrames = 0; + bool hasExpectedCaptureFirst = false; + bool hasIqFirst = false; + int convertedI = 0; + int convertedQ = 0; + bool operator==(const Failure&) const = default; + }; struct Config { SharedCapturePolicy::CaptureDescriptor capture; SharedCapturePolicy::SliceDescriptor slice; int outputRateHz = 48000; std::size_t blockSize = 1024; + // The final audio lattice can be slower than the extracted IQ clock. + // Zero preserves the existing outputRateHz alignment. + int alignmentRateHz = 0; }; class Sink { public: @@ -32,12 +57,19 @@ class RtlRfExtractor final { virtual bool iqBlock(std::span i, std::span q, std::uint64_t firstSample) noexcept = 0; }; + // First integral capture sample coinciding with the requested sample + // lattice. Fixed integer arithmetic; nullopt preserves overflow refusal. + static std::optional alignedCaptureFirst(std::uint64_t firstSample, + std::uint64_t captureRateHz, std::uint64_t alignmentRateHz) noexcept; static constexpr std::size_t kInputChunk = 256; static constexpr std::size_t kMaxInput = 65536; explicit RtlRfExtractor(Config config); ~RtlRfExtractor(); bool valid() const noexcept { return m_valid; } bool withdrawn() const noexcept { return m_withdrawn; } + // Acquisition context only, or after it stops. The first failure survives + // later calls, including attempts to use an already withdrawn instance. + std::optional failure() const noexcept { return m_failure; } const Config& config() const noexcept { return m_config; } int groupDelayInputFrames() const noexcept; std::uint64_t outputFrames() const noexcept { return m_outputFrames; } @@ -51,6 +83,7 @@ class RtlRfExtractor final { const Config m_config; bool m_valid = false; bool m_withdrawn = false; + std::optional m_failure; bool m_started = false; bool m_seenInput = false; std::uint64_t m_startInput = 0; diff --git a/src/core/backends/rtl/RtlSdrBackend.cpp b/src/core/backends/rtl/RtlSdrBackend.cpp index 83df945b61..2a9c96795a 100644 --- a/src/core/backends/rtl/RtlSdrBackend.cpp +++ b/src/core/backends/rtl/RtlSdrBackend.cpp @@ -21,6 +21,8 @@ namespace AetherSDR::rtl { namespace { +Q_LOGGING_CATEGORY(lcRtlReceive, "aether.rtl.receive", QtWarningMsg) + constexpr double kMinTuneHz = 24'000.0; constexpr double kMaxTuneHz = 1'766'000'000.0; @@ -208,6 +210,12 @@ RadioCapabilities RtlSdrBackend::capabilities() const c.receiveFilterControl = ReceiveFilterControl{SliceFrequencyControl::Authority::Engine, {{QStringLiteral("FM"), -21600, -1, 1, 21600, 2, 43200}, {QStringLiteral("FMN"), -21600, -1, 1, 21600, 2, 43200}}}; + c.broadcastFmReceive = std::nullopt; + if (RtlReceivePipeline::kQualifiedWfmEnabled) { + c.receiveFilterControl->modes.append({QStringLiteral("WFM"), + -100000, -15000, 15000, 100000, 30000, 200000}); + c.broadcastFmReceive = BroadcastFmReceive{{50, 75}, true, true}; + } c.receiveAudioControl = ReceiveAudioControl{SliceFrequencyControl::Authority::Engine}; c.panSpanModel = PanSpanModel{false, false}; if (m_lastPublished && hasReceivingNarrowFm(*m_lastPublished)) { @@ -378,16 +386,34 @@ void RtlSdrBackend::connectRadio(const RadioConnectRequest& request) } } m_requested.hardware.gainTenths = nearestGainTenths(m_tunerGainsTenths, requestedGain * 10); - m_requested.receivers = {{{0, double(m_requested.hardware.centerHz), - double(m_sliceFilterLow), double(m_sliceFilterHigh), 0, 0, 0}, captureMode(m_sliceMode)}}; + m_requested.receivers = {initialReceiver()}; m_modelName = m_product; startCapture(std::make_unique(m_device, nullptr, m_receiverCapacity)); } +RtlCaptureTransaction::Receiver RtlSdrBackend::initialReceiver() const +{ + const double guard = (m_sliceMode == QLatin1String("FM") || m_sliceMode == QLatin1String("FMN") + || (RtlReceivePipeline::kQualifiedWfmEnabled && m_sliceMode == QLatin1String("WFM"))) ? 3000.0 : 0.0; + RtlCaptureTransaction::Receiver receiver{{0, double(m_requested.hardware.centerHz), + double(m_sliceFilterLow), double(m_sliceFilterHigh), 0, guard, guard}, captureMode(m_sliceMode)}; + // Saved receiver restoration prepares asynchronously after this provisional + // receiver connects. Keep its speaker muted until the actual saved monitor + // state adopts; a saved muted session must not briefly play bootstrap audio. + receiver.audioMute = m_savedSettings.status == RtlSliceSettings::ReadStatus::Ready + && !m_savedSettings.document.slices.isEmpty(); + return receiver; +} + void RtlSdrBackend::startCapture(std::unique_ptr worker) { m_worker = std::move(worker); m_diagnostics = {}; + m_wfmStatus.fill(WfmStereoStatus::Unavailable); + m_wfmReception.fill({}); + m_wfmLastObservation.fill(std::nullopt); + for (auto& age : m_wfmPublicationAge) { age.invalidate(); } + for (auto& age : m_wfmObservationAge) { age.invalidate(); } m_capture.beginSession(); m_published = {}; m_lastPublished.reset(); @@ -459,6 +485,11 @@ void RtlSdrBackend::disconnectRadio() { retirePcmStreams(); retireNativeAudio(); + m_wfmStatus.fill(WfmStereoStatus::Unavailable); + m_wfmReception.fill({}); + m_wfmLastObservation.fill(std::nullopt); + for (auto& age : m_wfmPublicationAge) { age.invalidate(); } + for (auto& age : m_wfmObservationAge) { age.invalidate(); } m_captureTimer.stop(); m_capture.endSession(); m_published = {}; @@ -509,18 +540,20 @@ IRadioBackend::HealthSnapshot RtlSdrBackend::healthSnapshot() const if (!m_connected) { return {}; } HealthSnapshot snapshot; snapshot.order = {QStringLiteral("rtlQueueDrops"), QStringLiteral("rtlMixerLateFrames"), - QStringLiteral("rtlMixerRejectedBlocks"), QStringLiteral("rtlMixerConfigurationFailures")}; + QStringLiteral("rtlMixerRejectedBlocks"), QStringLiteral("rtlMixerConfigurationFailures"), + QStringLiteral("rtlReceiveTraceDrops")}; snapshot.sections.insert(snapshot.order.front(), tr("RTL receive pipeline (since connect)")); snapshot.labels = {{snapshot.order[0], tr("Audio queue dropped packets")}, {snapshot.order[1], tr("Mixer missing receiver frames at deadline")}, {snapshot.order[2], tr("Mixer rejected audio blocks")}, - {snapshot.order[3], tr("Mixer configuration failures")}}; + {snapshot.order[3], tr("Mixer configuration failures")}, + {snapshot.order[4], tr("Receive diagnostic records dropped")}}; // Legacy-only sessions have not observed this pipeline. Missing values // report "not reported", never a fabricated successful zero measurement. if (m_diagnostics.observed) { - const std::array counters{m_diagnostics.droppedPackets, + const std::array counters{m_diagnostics.droppedPackets, m_diagnostics.mixerLateFrames, m_diagnostics.mixerRejectedBlocks, - m_diagnostics.mixerConfigurationFailures}; + m_diagnostics.mixerConfigurationFailures, m_diagnostics.droppedTraceEvents}; for (int i = 0; i < snapshot.order.size(); ++i) { snapshot.values.insert(snapshot.order[i], QVariant::fromValue(counters[i])); } @@ -529,7 +562,9 @@ IRadioBackend::HealthSnapshot RtlSdrBackend::healthSnapshot() const const auto& capture = m_lastPublished->capture; const QStringList keys{QStringLiteral("rtlCaptureCenterHz"), QStringLiteral("rtlCaptureRateHz"), QStringLiteral("rtlCaptureLowHz"), QStringLiteral("rtlCaptureHighHz"), - QStringLiteral("rtlCaptureDcClear"), QStringLiteral("rtlCaptureRequest")}; + QStringLiteral("rtlCaptureDcClear"), QStringLiteral("rtlCaptureRequest"), + QStringLiteral("rtlCaptureSession"), QStringLiteral("rtlCaptureRevision"), + QStringLiteral("rtlCaptureRequestedRevision")}; snapshot.sections.insert(keys.front(), tr("RTL accepted capture")); snapshot.order.append(keys); snapshot.labels.insert(keys[0], tr("Capture center / converter DC (Hz)")); @@ -538,6 +573,9 @@ IRadioBackend::HealthSnapshot RtlSdrBackend::healthSnapshot() const snapshot.labels.insert(keys[3], tr("Usable capture high edge (Hz)")); snapshot.labels.insert(keys[4], tr("FM receivers clear of converter DC")); snapshot.labels.insert(keys[5], tr("Last capture request")); + snapshot.labels.insert(keys[6], tr("Capture session")); + snapshot.labels.insert(keys[7], tr("Accepted capture revision")); + snapshot.labels.insert(keys[8], tr("Requested capture revision")); snapshot.values.insert(keys[0], capture.centerHz); snapshot.values.insert(keys[1], capture.achievedSampleRateHz); snapshot.values.insert(keys[2], std::max(0.0, capture.centerHz - capture.usableLeftHz)); @@ -546,6 +584,11 @@ IRadioBackend::HealthSnapshot RtlSdrBackend::healthSnapshot() const ? QVariant(RtlCaptureTransaction::dcClear(*m_lastPublished)) : QVariant(tr("No receiving FM or FM-N slice"))); snapshot.values.insert(keys[5], m_captureStatus); + // Read existing owner-thread tokens so steady-state validation can see + // repair requests/publications even when detailed logging is disabled. + snapshot.values.insert(keys[6], QVariant::fromValue(m_published.session)); + snapshot.values.insert(keys[7], QVariant::fromValue(m_published.revision)); + snapshot.values.insert(keys[8], QVariant::fromValue(m_capture.requested().revision)); } return snapshot; } @@ -664,12 +707,21 @@ void RtlSdrBackend::setSliceMode(int sliceId, const QString& mode) // passband; restores and ordinary filter requests never resize it. receiver->passband.filterLowHz = -8000; receiver->passband.filterHighHz = 8000; } + const bool nativeWfm = RtlReceivePipeline::kQualifiedWfmEnabled + && modeValue == RtlCaptureTransaction::Mode::Wfm; + if (nativeWfm && (receiver->passband.filterLowHz > -15000 + || receiver->passband.filterHighHz < 15000)) { + // Entering broadcast FM from a narrow mode selects its existing + // 200 kHz RF width. Ordinary edits and saved restores are never resized. + receiver->passband.filterLowHz = -100000; + receiver->passband.filterHighHz = 100000; + } receiver->mode = modeValue; desired.followReceiverId = sliceId; desired.avoidDc = narrowFm; if (!narrowFm) { receiver->squelchEnabled = false; } - receiver->passband.guardLowHz = narrowFm ? 3000 : 0; - receiver->passband.guardHighHz = narrowFm ? 3000 : 0; + receiver->passband.guardLowHz = (narrowFm || nativeWfm) ? 3000 : 0; + receiver->passband.guardHighHz = (narrowFm || nativeWfm) ? 3000 : 0; requestCapture(desired); } @@ -680,9 +732,9 @@ void RtlSdrBackend::setSliceFilter(int sliceId, int lowHz, int highHz) const auto receiver = std::ranges::find_if(desired.receivers, [sliceId](const auto& value) { return value.passband.stableId == sliceId; }); if (receiver == desired.receivers.end()) { return; } if (receiver->mode == RtlCaptureTransaction::Mode::Wfm) { - // Legacy WFM does not consume these edges. Do not acknowledge or save - // a cosmetic filter change while its qualified DSP path is deferred. - return; + if (!RtlReceivePipeline::kQualifiedWfmEnabled || lowHz > -15000 || highHz < 15000) { + return; + } } if ((receiver->mode == RtlCaptureTransaction::Mode::Fm || receiver->mode == RtlCaptureTransaction::Mode::Fmn) @@ -692,6 +744,31 @@ void RtlSdrBackend::setSliceFilter(int sliceId, int lowHz, int highHz) requestCapture(desired); } +void RtlSdrBackend::setSliceWfmForceMono(int sliceId, bool forceMono) +{ + if (!RtlReceivePipeline::kQualifiedWfmEnabled || !hasAcceptedSlice(sliceId)) { return; } + auto desired = m_requested; + const auto receiver = std::ranges::find_if(desired.receivers, + [sliceId](const auto& value) { return value.passband.stableId == sliceId; }); + if (receiver == desired.receivers.end() || receiver->mode != RtlCaptureTransaction::Mode::Wfm + || receiver->wfmForceMono == forceMono) { return; } + receiver->wfmForceMono = forceMono; + requestCapture(desired); // Same prepare/adopt/rollback contract as the filter. +} + +void RtlSdrBackend::setSliceWfmDeemphasis(int sliceId, int microseconds) +{ + if (!RtlReceivePipeline::kQualifiedWfmEnabled || !hasAcceptedSlice(sliceId) + || (microseconds != 50 && microseconds != 75)) { return; } + auto desired = m_requested; + const auto receiver = std::ranges::find_if(desired.receivers, + [sliceId](const auto& value) { return value.passband.stableId == sliceId; }); + if (receiver == desired.receivers.end() || receiver->mode != RtlCaptureTransaction::Mode::Wfm + || receiver->wfmDeemphasisUs == microseconds) { return; } + receiver->wfmDeemphasisUs = microseconds; + requestCapture(desired); // Only adopted state is published and persisted. +} + void RtlSdrBackend::setSliceAgc(int sliceId, const QString& mode, int thresholdDb) { Q_UNUSED(sliceId); @@ -836,6 +913,27 @@ ReceiveDispatch RtlSdrBackend::requestSliceAudio(int sliceId, const SliceAudioRe return ReceiveDispatch::Dispatched; } +ReceiveDispatch RtlSdrBackend::requestSliceWfm(int sliceId, const SliceWfmRequest& request) +{ + if (!request.valid() || !RtlReceivePipeline::kQualifiedWfmEnabled + || !hasAcceptedSlice(sliceId)) { + return ReceiveDispatch::Unsupported; + } + const auto receiver = std::ranges::find_if(m_requested.receivers, [sliceId](const auto& value) { + return value.passband.stableId == sliceId; + }); + if (receiver == m_requested.receivers.end() + || receiver->mode != RtlCaptureTransaction::Mode::Wfm) { + return ReceiveDispatch::Unsupported; + } + if (request.field == SliceWfmRequest::Field::ForceMono) { + setSliceWfmForceMono(sliceId, request.value != 0); + } else { + setSliceWfmDeemphasis(sliceId, request.value); + } + return ReceiveDispatch::Dispatched; +} + ReceiveDispatch RtlSdrBackend::requestSliceSquelch(int sliceId, const SliceSquelchRequest& request) { if (!hasAcceptedSlice(sliceId) || !request.valid()) { @@ -998,6 +1096,8 @@ QVector RtlSdrBackend::acceptedSettings() const } slice.audioGain = m_monitors[id].gain; slice.audioMute = m_monitors[id].mute; slice.audioPan = m_monitors[id].pan; slice.squelchEnabled = receiver.squelchEnabled; slice.squelchLevel = receiver.squelchLevel; + slice.wfmDeemphasisUs = receiver.wfmDeemphasisUs; + slice.wfmForceMono = receiver.wfmForceMono; output.append(slice); } return output; @@ -1042,29 +1142,43 @@ void RtlSdrBackend::restoreAcceptedSlices() QSet seen; auto desired = m_requested; desired.receivers.clear(); + bool unsupportedWfmFilter = false; for (const auto& slice : saved.slices) { const bool wide = slice.mode != QLatin1String("FM") && slice.mode != QLatin1String("FMN"); + const bool nativeWfm = RtlReceivePipeline::kQualifiedWfmEnabled + && slice.mode == QLatin1String("WFM"); + const double guard = (!wide || nativeWfm) ? 3000.0 : 0.0; const SharedCapturePolicy::SliceDescriptor descriptor{slice.id, slice.frequencyHz, - slice.filterLowHz, slice.filterHighHz, 0, wide ? 0.0 : 3000.0, - wide ? 0.0 : 3000.0}; + slice.filterLowHz, slice.filterHighHz, 0, guard, guard}; m_omittedSettings.insert(slice.id); if (slice.id < 0 || slice.id >= 8 || seen.contains(slice.id) || !SharedCapturePolicy::occupiedInterval(descriptor).interval || !isKnownMode(slice.mode) || desired.receivers.size() >= static_cast(m_receiverCapacity)) { continue; } seen.insert(slice.id); + if (nativeWfm && (slice.filterLowHz < -100000 || slice.filterHighHz > 100000 + || slice.filterLowHz > -15000 || slice.filterHighHz < 15000)) { + unsupportedWfmFilter = true; + continue; + } if ((!wide && (slice.filterLowHz < -21600 || slice.filterHighHz > 21600 || slice.filterLowHz >= 0 || slice.filterHighHz <= 0)) || (wide && !desired.receivers.empty()) || (!desired.receivers.empty() && desired.receivers.front().mode != RtlCaptureTransaction::Mode::Fm && desired.receivers.front().mode != RtlCaptureTransaction::Mode::Fmn)) { continue; } desired.receivers.push_back({descriptor, captureMode(slice.mode), slice.audioGain, slice.audioPan, - slice.audioMute, !wide && slice.squelchEnabled, slice.squelchLevel}); + slice.audioMute, !wide && slice.squelchEnabled, slice.squelchLevel, slice.wfmDeemphasisUs, slice.wfmForceMono}); } // Preserve a valid configured receiver even when it is parked. The // transaction derives DSP membership from the confirmed capture; a // reconnect never silently retunes or loses an out-of-capture station. - if (desired.receivers.empty()) { return; } + if (desired.receivers.empty()) { + if (unsupportedWfmFilter) { + emit configurationWarning(tr("Saved WFM filter is not supported by the broadcast decoder. " + "The saved filter is preserved. Select a supported WFM filter and unmute to resume audio.")); + } + return; + } if (requestCapture(desired)) { m_restoreToken = m_capture.requested(); } } @@ -1126,10 +1240,19 @@ void RtlSdrBackend::applyRestoredState(const RestoredRadioState& state) m_sampleRateHz = static_cast(saved.sampleRateHz); } m_sliceFreqHz = m_panCenterHz; - // Use a valid initial receiver before fitting the saved set. At VLF a - // 200 kHz wide initial receiver would extend through negative RF. - const bool narrowInitial = m_panCenterHz < 100000; - m_sliceMode = narrowInitial ? QStringLiteral("FM") : QStringLiteral("WFM"); + // A saved native receiver may use a sparse ID. A provisional native + // ID 0 would consume the sole reservation and prevent preparing it. + // Bootstrap with a muted legacy receiver (an empty native bank), then + // adopt the original saved recipe/ID through the normal transaction. + // AM also avoids FM's automatic converter-DC capture displacement. + const bool nativeRestore = RtlReceivePipeline::kQualifiedWfmEnabled + && !saved.slices.isEmpty(); + const double initialHalfWidth = RtlReceivePipeline::kQualifiedWfmEnabled ? 103000 : 100000; + const bool narrowInitial = nativeRestore || m_panCenterHz < initialHalfWidth + || 0.45 * m_sampleRateHz < initialHalfWidth; + m_sliceMode = narrowInitial + ? (RtlReceivePipeline::kQualifiedWfmEnabled ? QStringLiteral("AM") : QStringLiteral("FM")) + : QStringLiteral("WFM"); m_sliceFilterLow = narrowInitial ? -8000 : -100000; m_sliceFilterHigh = narrowInitial ? 8000 : 100000; } @@ -1230,7 +1353,35 @@ void RtlSdrBackend::serviceCapture() if (!m_worker) { return; } const QPointer producer(m_worker.get()); m_worker->serviceCancellation(); + const std::uint64_t previousTraceDrops = m_diagnostics.droppedTraceEvents; m_diagnostics = m_worker->diagnostics(); + if (m_diagnostics.droppedTraceEvents != previousTraceDrops) { + qCDebug(lcRtlReceive).nospace() << "RtlReceive ms=" << QDateTime::currentMSecsSinceEpoch() + << " kind=trace_overflow trace_drops=" << m_diagnostics.droppedTraceEvents; + } + RtlReceivePipeline::TraceEvent trace; + for (unsigned count = 0; count < RtlReceivePipeline::kTraceEvents + && m_worker->takeTraceEvent(trace); ++count) { + const auto failure = trace.failure.value_or(RtlReceiverRegistry::ProcessingFailure{}); + const auto extraction = failure.extraction.value_or(RtlRfExtractor::Failure{}); + qCDebug(lcRtlReceive).nospace() << "RtlReceive ms=" << QDateTime::currentMSecsSinceEpoch() + << " kind=" << (trace.kind == RtlReceivePipeline::TraceEvent::Kind::ReceiverFailure ? "failure" : "missing") + << " session=" << trace.token.session << " revision=" << trace.token.revision + << " generation=" << trace.hardwareGeneration << " capture_epoch=" << trace.captureEpoch + << " slot=" << trace.slot << " receiver=" << trace.stableId + << " instance=" << trace.instance << " receiver_epoch=" << trace.receiverEpoch + << " capture_first=" << trace.captureFirst << " capture_frames=" << trace.captureFrames + << " capture_clock=" << trace.captureClock << " quantum_first=" << trace.quantumFirst + << " missing_lo=" << QString::number(trace.missingMask[0], 16) + << " missing_hi=" << QString::number(trace.missingMask[1], 16) + << " has_failure=" << trace.failure.has_value() << " reason=" << int(failure.reason) + << " process_result=" << int(failure.processResult) + << " has_expected=" << failure.hasExpectedCaptureFirst << " expected_capture=" << failure.expectedCaptureFirst + << " has_iq=" << failure.hasIqFirst << " iq_first=" << failure.iqFirst << " iq_frames=" << failure.iqFrames + << " has_extraction=" << failure.extraction.has_value() << " extraction_reason=" << int(extraction.reason) + << " converted_i=" << extraction.convertedI << " converted_q=" << extraction.convertedQ + << " trace_drops=" << m_diagnostics.droppedTraceEvents; + } if (const auto result = m_worker->takeResult()) { const auto completion = m_capture.complete(*result); qCDebug(lcPerf).nospace() << "RtlCapture phase=complete ms=" << QDateTime::currentMSecsSinceEpoch() @@ -1271,7 +1422,11 @@ void RtlSdrBackend::serviceCapture() } if (m_worker) { drainAudio(); + if (!producer || producer.data() != m_worker.get()) { return; } + expireWfmObservations(); + if (!producer || producer.data() != m_worker.get()) { return; } if (!m_capture.busy() && m_worker->needsRepair()) { requestCapture(m_requested); } + if (!producer || producer.data() != m_worker.get()) { return; } if (const auto work = m_capture.takeWork()) { if (!m_worker->submit(*work)) { qFatal("RTL-SDR transaction mailbox ownership violated"); } } @@ -1293,7 +1448,9 @@ void RtlSdrBackend::publishLegacyPcm(const QByteArray& pcm, const QByteArray& pr || m_lastPublished->receivingIds.front() != m_lastPublished->receivers.front().passband.stableId || m_lastPublished->receivers.front().mode == RtlCaptureTransaction::Mode::Fm - || m_lastPublished->receivers.front().mode == RtlCaptureTransaction::Mode::Fmn) { return; } + || m_lastPublished->receivers.front().mode == RtlCaptureTransaction::Mode::Fmn + || (RtlReceivePipeline::kQualifiedWfmEnabled + && m_lastPublished->receivers.front().mode == RtlCaptureTransaction::Mode::Wfm)) { return; } const int id = m_lastPublished->receivers.front().passband.stableId; const auto token = m_published; for (int slot : {-1, id}) { @@ -1318,7 +1475,8 @@ void RtlSdrBackend::drainAudio() for (int count = 0; count < 128 && worker && worker.data() == m_worker.get() && worker->takeAudio(packet); ++count) { if (!acceptsFrame(packet.token.session, packet.token.revision) - || packet.frames == 0 || packet.frames > 1024 || packet.slot < -1 || packet.slot >= 8) { continue; } + || packet.frames == 0 || packet.frames > 1024 || packet.slot < -1 || packet.slot >= 8 + || packet.sampleRateHz != 48000 || packet.channelCount != 2) { continue; } if (!m_lastPublished || m_lastPublished->receivingIds.empty() || (packet.slot >= 0 && std::ranges::find(m_lastPublished->receivingIds, packet.slot) == m_lastPublished->receivingIds.end())) { continue; } @@ -1329,7 +1487,7 @@ void RtlSdrBackend::drainAudio() output = {}; output.producer = std::make_unique(); output.producer->start(packet.slot < 0 ? PcmPurpose::Speaker : PcmPurpose::Slice, - packet.slot, {48000, PcmLayout::Stereo}, packet.token.session, packet.instance); + packet.slot, {packet.sampleRateHz, PcmLayout::Stereo}, packet.token.session, packet.instance); output.captureEpoch = packet.captureEpoch; output.instance = packet.instance; output.receiverEpoch = packet.receiverEpoch; } @@ -1339,11 +1497,76 @@ void RtlSdrBackend::drainAudio() packet.discontinuity || packet.firstSample != output.nextSample); output.nextSample = packet.firstSample + packet.frames; if (frame) { + observeWfm(packet); + if (!worker || worker.data() != m_worker.get() + || !acceptsFrame(packet.token.session, packet.token.revision)) { return; } if (packet.slot < 0) { emit audioFrameReady(*frame); } else { emit sliceAudioFrameReady(packet.slot, *frame); } } } } +// Decoder observations travel with accepted PCM. A requested mode or a stale +// packet can never light the stereo indicator. Silence or a stopped producer +// expires the observation; there is no indefinitely retained pilot claim. +void RtlSdrBackend::observeWfm(const RtlReceivePipeline::Packet& packet) +{ + if (!RtlReceivePipeline::kQualifiedWfmEnabled || !packet.wfmReception + || !packet.wfmReception->valid || !packet.wfmStereoDetected + || packet.slot < 0 || packet.slot >= 8 || !m_lastPublished + || !acceptsFrame(packet.token.session, packet.token.revision)) { return; } + const auto receiver = std::ranges::find_if(m_lastPublished->receivers, + [&packet](const auto& value) { return value.passband.stableId == packet.slot; }); + if (receiver == m_lastPublished->receivers.end() || receiver->mode != RtlCaptureTransaction::Mode::Wfm + || std::ranges::find(m_lastPublished->receivingIds, packet.slot) + == m_lastPublished->receivingIds.end()) { return; } + const auto& reception = *packet.wfmReception; + if (!std::isfinite(reception.pilotMagnitude) || reception.pilotMagnitude < 0.0 + || !std::isfinite(reception.pilotEngageThreshold) || !std::isfinite(reception.pilotReleaseThreshold) + || reception.pilotReleaseThreshold < 0.0 + || reception.pilotEngageThreshold <= reception.pilotReleaseThreshold + || reception.engageBlocks == 0 || reception.releaseBlocks == 0 + || reception.stableDurationMs > 5000 + || reception.lockDurationMs > reception.observationDurationMs + || reception.stableDurationMs > reception.observationDurationMs + || (!reception.pilotLocked && reception.lockDurationMs != 0) + || (reception.pilotLocked && !receiver->wfmForceMono) != *packet.wfmStereoDetected) { return; } + const WfmObservationIdentity identity{packet.instance, packet.receiverEpoch, reception.observationSequence}; + if (m_wfmLastObservation[packet.slot] == identity) { return; } + m_wfmLastObservation[packet.slot] = identity; + m_wfmObservationAge[packet.slot].restart(); + const WfmStereoStatus status = *packet.wfmStereoDetected + ? WfmStereoStatus::Stereo : WfmStereoStatus::Mono; + const bool statusChanged = m_wfmStatus[packet.slot] != status + || (m_wfmReception[packet.slot].valid + && m_wfmReception[packet.slot].pilotLocked != reception.pilotLocked); + m_wfmStatus[packet.slot] = status; + if (packet.wfmReception && packet.wfmReception->valid) { + m_wfmReception[packet.slot] = *packet.wfmReception; + } + auto& publicationAge = m_wfmPublicationAge[packet.slot]; + if (!statusChanged && publicationAge.isValid() && publicationAge.elapsed() < 250) { return; } + publicationAge.restart(); + SliceDelta delta; delta.wfmStereoStatus = status; + delta.wfmReceptionDiagnostics = m_wfmReception[packet.slot]; + emit sliceChanged(packet.slot, delta); +} + +void RtlSdrBackend::expireWfmObservations() +{ + const auto token = m_published; + for (int id = 0; id < 8; ++id) { + if (!acceptsFrame(token.session, token.revision)) { return; } + if (!m_wfmObservationAge[id].isValid() || m_wfmObservationAge[id].elapsed() <= 500) { continue; } + m_wfmObservationAge[id].invalidate(); + m_wfmStatus[id] = WfmStereoStatus::Acquiring; + m_wfmReception[id] = {}; + m_wfmPublicationAge[id].invalidate(); + SliceDelta delta; delta.wfmStereoStatus = WfmStereoStatus::Acquiring; + delta.wfmReceptionDiagnostics = WfmReceptionDiagnostics{}; + emit sliceChanged(id, delta); + } +} + void RtlSdrBackend::emitSliceState(const RtlCaptureTransaction::Receiver& receiver) { const int id = receiver.passband.stableId; @@ -1356,6 +1579,10 @@ void RtlSdrBackend::emitSliceState(const RtlCaptureTransaction::Receiver& receiv delta.panId = QStringLiteral("0xe1000000"); delta.active = id == m_requested.receivers.front().passband.stableId; delta.inCapture = m_lastPublished && std::ranges::find(m_lastPublished->receivingIds, id) != m_lastPublished->receivingIds.end(); + delta.wfmDeemphasisUs = receiver.wfmDeemphasisUs; + delta.wfmForceMono = receiver.wfmForceMono; + delta.wfmReceptionDiagnostics = m_wfmReception[id]; + delta.wfmStereoStatus = m_wfmStatus[id]; delta.modeList = capabilities().receiveModeControl->modes; emit sliceChanged(id, delta); } @@ -1381,13 +1608,17 @@ void RtlSdrBackend::publishCapture() for (const auto& previous : m_lastPublished->receivers) { const int id = previous.passband.stableId; const auto current = std::ranges::find_if(state.receivers, [id](const auto& value) { return value.passband.stableId == id; }); - if (current == state.receivers.end() || current->passband != previous.passband || current->mode != previous.mode) { m_sliceAudio[id] = {}; } + if (current == state.receivers.end() || current->passband != previous.passband || current->mode != previous.mode + || current->wfmDeemphasisUs != previous.wfmDeemphasisUs + || current->wfmForceMono != previous.wfmForceMono) { m_sliceAudio[id] = {}; } } if (std::ranges::any_of(state.receivers, [&state](const auto& value) { return std::ranges::find(state.receivingIds, value.passband.stableId) != state.receivingIds.end() && value.mode != RtlCaptureTransaction::Mode::Fm - && value.mode != RtlCaptureTransaction::Mode::Fmn; + && value.mode != RtlCaptureTransaction::Mode::Fmn + && !(RtlReceivePipeline::kQualifiedWfmEnabled + && value.mode == RtlCaptureTransaction::Mode::Wfm); })) { retireNativeAudio(); } else { retirePcmStreams(); } } @@ -1417,6 +1648,24 @@ void RtlSdrBackend::publishCapture() } } } + for (int id = 0; id < 8; ++id) { + const auto receiver = std::ranges::find_if(state.receivers, + [id](const auto& value) { return value.passband.stableId == id; }); + const bool active = RtlReceivePipeline::kQualifiedWfmEnabled + && receiver != state.receivers.end() && receiver->mode == RtlCaptureTransaction::Mode::Wfm + && std::ranges::find(state.receivingIds, id) != state.receivingIds.end(); + // A newly accepted revision may repair an unchanged receiver recipe. + // Its first current-token PCM packet is the proof of decoder state; + // never carry a pilot claim across that publication boundary. + m_wfmStatus[id] = active ? WfmStereoStatus::Acquiring : WfmStereoStatus::Unavailable; + m_wfmReception[id] = {}; + // Retain the last observed decoder identity across control-only + // revisions: cached credits must not refresh a stalled decoder. + // A new prepared graph differs by instance/epoch even if its local + // publication sequence restarts at the same value. + m_wfmPublicationAge[id].invalidate(); + m_wfmObservationAge[id].invalidate(); + } m_lastPublished = state; for (const auto& receiver : state.receivers) { m_monitors[receiver.passband.stableId] = {receiver.audioGain, receiver.audioPan, receiver.audioMute}; diff --git a/src/core/backends/rtl/RtlSdrBackend.h b/src/core/backends/rtl/RtlSdrBackend.h index b987901eb9..ae2c5782a8 100644 --- a/src/core/backends/rtl/RtlSdrBackend.h +++ b/src/core/backends/rtl/RtlSdrBackend.h @@ -13,6 +13,7 @@ #include #include #include +#include #include // librtlsdr forward @@ -74,10 +75,13 @@ class RtlSdrBackend : public IRadioBackend { void setPanWeightedAverage(const QString& panId, bool on) override; void setSliceAudioMute(int sliceId, bool mute) override; ReceiveDispatch requestSliceAudio(int sliceId, const SliceAudioRequest& request) override; + ReceiveDispatch requestSliceWfm(int sliceId, const SliceWfmRequest& request) override; ReceiveDispatch requestSliceSquelch(int sliceId, const SliceSquelchRequest& request) override; void setSliceAudioGain(int sliceId, int gainPercent) override; void setSliceAudioPan(int sliceId, int panPercent) override; void setSliceSquelch(int sliceId, bool enabled, int level) override; + void setSliceWfmDeemphasis(int sliceId, int microseconds) override; + void setSliceWfmForceMono(int sliceId, bool forceMono) override; void setKeying(bool key, const AetherSDR::TxCoordinator::Operation& operation, const AetherSDR::TxCoordinator::Completion& completion = {}) override; void invokeExtension(const QString& ns, const QString& verb, quint64 requestId, const QVariant& arg = {}) override; @@ -114,6 +118,7 @@ class RtlSdrBackend : public IRadioBackend { int deviceIndexFromParams(const QVariantMap& params) const; QString serialFromParams(const QVariantMap& params) const; friend struct RtlCaptureBackendTestAccess; + RtlCaptureTransaction::Receiver initialReceiver() const; void startCapture(std::unique_ptr worker); void serviceCapture(); bool requestCapture(const RtlCaptureTransaction::Desired& desired, @@ -121,6 +126,8 @@ class RtlSdrBackend : public IRadioBackend { bool fromDrag = false); void publishCapture(); void drainAudio(); + void observeWfm(const RtlReceivePipeline::Packet& packet); + void expireWfmObservations(); void publishLegacyPcm(const QByteArray& pcm, const QByteArray& preMonitor); void retireNativeAudio(); void emitSliceState(const RtlCaptureTransaction::Receiver& receiver); @@ -218,6 +225,17 @@ class RtlSdrBackend : public IRadioBackend { // Worker thread (owns async USB reader & RtlSdrDdc engine) std::unique_ptr m_worker; + std::array m_wfmStatus{}; + std::array m_wfmObservationAge; + std::array m_wfmPublicationAge; + std::array m_wfmReception; + struct WfmObservationIdentity { + std::uint64_t instance = 0; + std::uint64_t epoch = 0; + std::uint32_t sequence = 0; + bool operator==(const WfmObservationIdentity&) const = default; + }; + std::array, 8> m_wfmLastObservation; RtlReceivePipeline::Diagnostics m_diagnostics; // owner-thread health cache RtlSdrDdc* ddc(); }; diff --git a/src/core/backends/rtl/RtlSdrWorker.h b/src/core/backends/rtl/RtlSdrWorker.h index e9a67fff88..d7dbad3e91 100644 --- a/src/core/backends/rtl/RtlSdrWorker.h +++ b/src/core/backends/rtl/RtlSdrWorker.h @@ -46,6 +46,7 @@ class RtlSdrWorker : public QThread { void serviceCancellation(); bool takeAudio(RtlReceivePipeline::Packet& packet) { return m_pipeline->takePacket(packet); } RtlReceivePipeline::Diagnostics diagnostics() const { return m_pipeline->diagnostics(); } + bool takeTraceEvent(RtlReceivePipeline::TraceEvent& event) { return m_pipeline->takeTraceEvent(event); } bool needsRepair() const { return m_pipeline->needsRepair(); } void setMonitor(int slot, int gain, int pan, bool mute) { m_pipeline->setMonitor(slot, gain, pan, mute); } diff --git a/src/core/backends/sim/SimBackend.cpp b/src/core/backends/sim/SimBackend.cpp index 59e81460ec..a62b3ef83c 100644 --- a/src/core/backends/sim/SimBackend.cpp +++ b/src/core/backends/sim/SimBackend.cpp @@ -247,6 +247,7 @@ QString SimBackend::familyName() { return QStringLiteral("sim"); } RadioCapabilities SimBackend::capabilities() const { RadioCapabilities caps; + caps.broadcastFmReceive = std::nullopt; // Synthetic receiver: this is the API's bounded numeric domain, not an // advertised hardware tuning range. Off-scene signals simply become silent. caps.sliceFrequencyControl = {SliceFrequencyControl::Authority::Engine, diff --git a/src/core/dsp/WdspChannel.cpp b/src/core/dsp/WdspChannel.cpp index bc1bc94a97..303db46a22 100644 --- a/src/core/dsp/WdspChannel.cpp +++ b/src/core/dsp/WdspChannel.cpp @@ -294,7 +294,10 @@ std::unique_ptr WdspChannel::create(const Config& config, return nullptr; } --reservation.m_count; - channel->open(); + if (!channel->open()) { + setError(error, "WDSP refused the prepared exchange depth"); + return nullptr; + } return channel; } @@ -496,8 +499,20 @@ WdspChannel::ProcessResult WdspChannel::processIq(std::span inputI, outputRight[sample] *= static_cast(m_wbfm->gain); } } - const bool stereo = wdspError == 0 && m_running.load(std::memory_order_relaxed) - && GetRXAWBFMStereoIndicator(m_channelId) != 0; + m_wbfmReception.reset(); + AetherWdspWbfmObservation observation{}; + if (wdspError == 0 && m_running.load(std::memory_order_relaxed) + && GetRXAWBFMReception(m_channelId, &observation) && observation.valid) { + m_wbfmReception = AetherSDR::WfmReceptionDiagnostics{ + true, observation.pilotMagnitude, observation.pilotLocked != 0, + observation.lockDurationMs, observation.lockLossCount, observation.reacquisitionCount, + observation.observationDurationMs, observation.stableDurationMs, + observation.pilotEngageThreshold, observation.pilotReleaseThreshold, + observation.consecutiveHighBlocks, observation.consecutiveLowBlocks, + observation.engageBlocks, observation.releaseBlocks, observation.observationSequence}; + } + const bool stereo = m_wbfmReception && m_wbfmReception->pilotLocked + && !m_config.wbfmReceive->forceMono; m_wbfmStereo.store(stereo ? 1 : 0, std::memory_order_relaxed); } const uint64_t allocationsAfter = wdspPortThreadAllocationSequence(); @@ -549,6 +564,7 @@ bool WdspChannel::setRunning(bool running) noexcept if (m_wbfm) { if (running) { flush_bps(m_wbfm->filter.get()); } m_wbfmStereo.store(0, std::memory_order_relaxed); + m_wbfmReception.reset(); } SetChannelState(m_channelId, running ? 1 : 0, 0); m_running.store(running, std::memory_order_relaxed); @@ -569,7 +585,11 @@ bool WdspChannel::reconfigure(const Config& config, std::string* error) noexcept close(); m_config = config; m_outputBlockSize = computeOutputBlockSize(m_config); - open(); + if (!open()) { + setError(error, "WDSP refused the prepared exchange depth"); + endControlOperation(); + return false; + } if (!wasRunning) { // Restore the state found, like WDSP's own rebuilds (channel.c // SetDSPBuffsize); open() always starts, so a stopped channel must be stopped @@ -1134,6 +1154,36 @@ void WdspChannel::setWorkerHandoffPauseForTest(unsigned microseconds) noexcept wdspPortSetHandoffPauseForTest(microseconds); } +void WdspChannel::setWorkerHandoffHoldForTest(bool enabled) noexcept +{ + wdspPortSetHandoffHoldForTest(enabled ? 1 : 0); +} + +bool WdspChannel::workerHandoffHeldForTest() noexcept +{ + return wdspPortHandoffHeldForTest() != 0; +} + +void WdspChannel::setWorkerOutputCopyHoldForTest(bool enabled) noexcept +{ + wdspPortSetOutputCopyHoldForTest(enabled ? 1 : 0); +} + +bool WdspChannel::workerOutputCopyHeldForTest() noexcept +{ + return wdspPortOutputCopyHeldForTest() != 0; +} + +int WdspChannel::outputSamplesReadyForTest() const noexcept +{ + return GetChannelOutputSamplesForTest(m_channelId); +} + +void WdspChannel::synchronizeWorkerForTest() const noexcept +{ + SynchronizeChannelWorkerForTest(m_channelId); +} + std::unique_lock WdspChannel::fftwSetupLock() { // Forwards, and keeps its name so Hl2Spectrum, AnanPanAnalyzer and @@ -1326,11 +1376,21 @@ void WdspChannel::setNoiseBlankerHold(bool hold) noexcept m_nbHold.store(hold, std::memory_order_relaxed); } -void WdspChannel::open() noexcept +bool WdspChannel::open() noexcept { const std::scoped_lock setupLock(g_setupMutex); loadWisdomOnce(); // import cached FFTW wisdom so PATIENT plans don't re-measure - OpenChannel(m_channelId, + // Seven exchanges can emerge from one approved 8192-sample RTL callback. + // Depth 8 prepares seven output credits plus the existing worker stage: + // 2048 frames / 42.667 ms exchange-stage delay, 32 ms more than depth 2. + // It does not cover arbitrary starvation; every non-OK still withdraws. + const bool preparedWbfm = m_config.wbfmReceive && !m_config.blockForOutput; + const auto openChannel = [preparedWbfm](auto... args) { + if (preparedWbfm) { return OpenChannelWithExchangeDepth(args..., 8) != 0; } + OpenChannel(args...); // Explicit legacy depth 2, including reused slots. + return true; + }; + if (!openChannel(m_channelId, static_cast(m_config.inputBlockSize), static_cast(m_config.dspBlockSize), m_config.inputSampleRate, @@ -1345,7 +1405,9 @@ void WdspChannel::open() noexcept 0, m_config.muteDelayUpSec, m_config.muteSlewUpSec, m_config.muteDelayDownSec, m_config.muteSlewDownSec, - m_config.blockForOutput ? 1 : 0); + m_config.blockForOutput ? 1 : 0)) { + return false; + } if (m_config.direction == Direction::Receive) { SetRXAMode(m_channelId, wdspMode(m_config.mode)); if (m_config.fmReceive) { @@ -1360,9 +1422,11 @@ void WdspChannel::open() noexcept if (m_config.wbfmReceive) { m_wbfm = std::make_unique(m_config); SetRXAWBFMDiscriminatorCompensation(m_channelId, 1); + SetRXAWBFMForceMono(m_channelId, m_config.wbfmReceive->forceMono ? 1 : 0); SetRXAWBFMdmph(m_channelId, 1, m_config.wbfmReceive->deemphasis == WbfmReceive::Deemphasis::Us50 ? 1 : 0); m_wbfmStereo.store(0, std::memory_order_relaxed); + m_wbfmReception.reset(); } else { SetRXABandpassFreqs(m_channelId, m_config.filterLowHz, m_config.filterHighHz); RXANBPSetFreqs(m_channelId, m_config.filterLowHz, m_config.filterHighHz); @@ -1411,6 +1475,7 @@ void WdspChannel::open() noexcept SetChannelState(m_channelId, 1, 0); m_running.store(true, std::memory_order_relaxed); m_open = true; + return true; } void WdspChannel::close() noexcept @@ -1438,6 +1503,7 @@ void WdspChannel::close() noexcept closeNoiseBlanker(); m_wbfm.reset(); m_wbfmStereo.store(-1, std::memory_order_relaxed); + m_wbfmReception.reset(); } m_open = false; } diff --git a/src/core/dsp/WdspChannel.h b/src/core/dsp/WdspChannel.h index 67ee24b360..0fbff3537f 100644 --- a/src/core/dsp/WdspChannel.h +++ b/src/core/dsp/WdspChannel.h @@ -3,6 +3,7 @@ #include #include +#include "core/WfmReceptionDiagnostics.h" #include #include #include @@ -58,6 +59,7 @@ class WdspChannel final enum class Deemphasis { Us75, Us50 }; Deemphasis deemphasis = Deemphasis::Us75; double outputGain = 0.25; + bool forceMono = false; static constexpr double kRfTransitionGuardHz = 3000.0; bool operator==(const WbfmReceive&) const = default; }; @@ -261,10 +263,15 @@ class WdspChannel final bool setMode(Mode mode) noexcept; bool setFilter(double lowHz, double highHz) noexcept; bool setWbfmDeemphasis(WbfmReceive::Deemphasis deemphasis) noexcept; - // Lock-free latest completed decoder observation, sampled by processIq(). + // Lock-free latest completed output-mode observation, sampled by processIq(). + // Forced Mono is false independently of reception diagnostics.pilotLocked. // It is not an exact PCM-ring timestamp. False after stop/open; absent for // channels that do not own the broadcast recipe. Caller owns the lifetime. [[nodiscard]] std::optional wbfmStereoDetected() const noexcept; + // Acquisition thread only, immediately after processIq; control may read + // after joining/quiescing acquisition. Never reaches into mutable WDSP. + [[nodiscard]] const std::optional& + wbfmReceptionDiagnostics() const noexcept { return m_wbfmReception; } // Runtime RX AGC change. agcMode is the WDSP RXA AGC mode (0 off, 1 long, // 2 slow, 3 medium, 4 fast); maximumGainDb is the AGC "top", the ceiling on // how much gain the AGC may apply. Receive channels only — returns false on @@ -434,6 +441,21 @@ class WdspChannel final // overwrote the input block the worker had not yet copied out. 0 restores // the shipping path. static void setWorkerHandoffPauseForTest(unsigned microseconds) noexcept; + // Test-only deterministic counterpart: arm the next worker handoff, then + // acknowledge that one worker is held until explicitly released. Fixtures + // serialize use, keep other channels idle, and release BEFORE destruction + // or reconfiguration. The default is inactive; this never holds acquisition. + static void setWorkerHandoffHoldForTest(bool enabled) noexcept; + [[nodiscard]] static bool workerHandoffHeldForTest() noexcept; + // Same serialized-fixture lifetime contract, before output bytes/credit. + static void setWorkerOutputCopyHoldForTest(bool enabled) noexcept; + [[nodiscard]] static bool workerOutputCopyHeldForTest() noexcept; + // Test/control thread only: locks existing exchange/output-count sections. The + // caller owns this open channel and excludes control/destruction. + [[nodiscard]] int outputSamplesReadyForTest() const noexcept; + // After output-count readiness, wait for the last hook and DSP body before + // arming another hold. Test/control only; NEVER call while a hold is armed. + void synchronizeWorkerForTest() const noexcept; // Test only: the next `count` control operations on THIS channel are // refused exactly as a racing processIq() callback would refuse them, so // a caller's refused-then-converges path can be driven deterministically. @@ -465,7 +487,7 @@ class WdspChannel final void applySquelchLocked(Mode mode) noexcept; static std::size_t computeOutputBlockSize(const Config& config) noexcept; - void open() noexcept; + [[nodiscard]] bool open() noexcept; void close() noexcept; // Create/destroy the ANB stage alongside the channel. The blanker's id IS // the channel id: nob.c keeps its own table of 32, WDSP's channel table is @@ -481,6 +503,7 @@ class WdspChannel final struct WbfmState; std::unique_ptr m_wbfm; std::atomic m_wbfmStereo {-1}; + std::optional m_wbfmReception; int m_channelId = -1; Config m_config; // Fixed for a given Config; cached at open()/reconfigure() so the real-time diff --git a/src/gui/AppletPanel.cpp b/src/gui/AppletPanel.cpp index f3f7a65c6d..da0e5be9c9 100644 --- a/src/gui/AppletPanel.cpp +++ b/src/gui/AppletPanel.cpp @@ -23,6 +23,7 @@ #include "PhoneApplet.h" #include "EqApplet.h" #include "AetherClockApplet.h" +#include "WfmApplet.h" #include "MiniPanApplet.h" #include "WaveApplet.h" #include "ClientEqApplet.h" @@ -164,7 +165,7 @@ constexpr int kStackBottomMargin = 8; } // namespace const QStringList AppletPanel::kDefaultOrder = { - "PWR", "RX", "TUN", "AMP", "TX", "PHNE", "P/CW", "EQ", "WAVE", "TXDSP", "CAT", "DAX", "TCI", "IQ", "MTR", "PROF", "KSDR", "HLTH", "AG", "SS", "GHE", "CLOCK" + "PWR", "RX", "WFM", "TUN", "AMP", "TX", "PHNE", "P/CW", "EQ", "WAVE", "TXDSP", "CAT", "DAX", "TCI", "IQ", "MTR", "PROF", "KSDR", "HLTH", "AG", "SS", "GHE", "CLOCK" }; // ── Drop-aware scroll area ────────────────────────────────────────────────── @@ -627,6 +628,9 @@ AppletPanel::AppletPanel(QWidget* parent) : QWidget(parent) // lowering = hide it. The manager owns the window // so we just toggle the container's visibility. if (c->isFloating()) { + if (c->isPresentationManaged()) { + c->setContainerVisible(checked); + } if (auto* w = c->window()) w->setVisible(checked); return; @@ -754,6 +758,16 @@ AppletPanel::AppletPanel(QWidget* parent) : QWidget(parent) m_rxApplet = new RxApplet; m_appletOrder.append(makeEntry("RX", "RX Controls", m_rxApplet, true, m_drawer, m_drawerLayout)); + // A cohesive broadcast receiver tile follows the selected slice. The + // container owns docking, floating, layout and the operator's open/closed + // choice; temporary mode/capability loss must not overwrite that choice. + m_wfmApplet = new WfmApplet; + m_appletOrder.append(makeEntry("WFM", "WFM", m_wfmApplet, true, + m_drawer, m_drawerLayout)); + markHardwareConditional("WFM"); + connect(m_wfmApplet, &WfmApplet::availabilityChanged, + this, &AppletPanel::setWfmAvailable); + // Tuner / Amp entries use makeEntry like everything else; // MainWindow toggles tray-button visibility via setTunerVisible / // setAmpVisible once the hardware reports its presence. Until @@ -1124,6 +1138,7 @@ AppletPanel::AppletPanel(QWidget* parent) : QWidget(parent) // Place the drawer toggle into the favorites row, then apply the // saved (or default) favorites layout to populate both strips. loadButtonLayout(); + setWfmAvailable(m_wfmApplet->isAvailable()); applyBarLayout(); // Restore drawer open/closed state (default closed). Signals blocked @@ -1397,6 +1412,7 @@ QList AppletPanel::appletCatalog() const // a category keep panel order. static const QMap kCategory = { {QStringLiteral("RX"), QStringLiteral("Receive")}, + {QStringLiteral("WFM"), QStringLiteral("Receive")}, {QStringLiteral("MPAN"), QStringLiteral("Receive")}, {QStringLiteral("KSDR"), QStringLiteral("Receive")}, {QStringLiteral("DEMO"), QStringLiteral("Receive")}, @@ -1507,7 +1523,7 @@ void AppletPanel::setAppletVisible(const QString& id, bool visible) w->setVisible(visible); // Keep the container itself shown: it is the window's content, // and the window is what visibility means for a floating tile. - c->setContainerVisible(true); + c->setContainerVisible(c->isPresentationManaged() ? visible : true); if (entry.btn) { QSignalBlocker b(entry.btn); entry.btn->setChecked(visible); @@ -1731,6 +1747,30 @@ void AppletPanel::applyCapabilityVisibility(const QString& id, applyBarLayout(); } +void AppletPanel::setWfmAvailable(bool available) +{ + // Availability suppresses presentation, never workspace membership or the + // requested open state. Generic capability hiding emits visibilityChanged + // and would incorrectly mark this temporarily unavailable tile as closed + // in the active workspace. The container/window gate also survives recall. + for (BarButton& button : m_barButtons) { + if (button.id != QLatin1String("WFM")) { + continue; + } + button.hardwareAvailable = available; + if (available && !m_buttonOrder.contains(button.id) + && !m_hiddenButtons.contains(button.id)) { + m_buttonOrder.append(button.id); + saveButtonLayout(); + } + break; + } + if (ContainerWidget* container = m_containerMgr->container(QStringLiteral("WFM"))) { + container->setPresentationAvailable(available); + } + applyBarLayout(); +} + void AppletPanel::setRadioFilterWidths(const QList& widthsHz) { if (m_rxApplet) @@ -1878,6 +1918,9 @@ void AppletPanel::setControlsLocked(bool locked) void AppletPanel::setSlice(SliceModel* slice) { m_rxApplet->setSlice(slice); + if (m_wfmApplet) { + m_wfmApplet->setSlice(slice); + } if (m_aetherClockApplet) m_aetherClockApplet->setSlice(slice); diff --git a/src/gui/AppletPanel.h b/src/gui/AppletPanel.h index 9f7be1f8cc..b9a2a744c0 100644 --- a/src/gui/AppletPanel.h +++ b/src/gui/AppletPanel.h @@ -46,6 +46,7 @@ class PhoneApplet; class EqApplet; class WaveApplet; class AetherClockApplet; +class WfmApplet; class MiniPanApplet; class ClientEqApplet; class ClientCompApplet; @@ -118,6 +119,7 @@ class AppletPanel : public QWidget { EqApplet* eqApplet() { return m_eqApplet; } WaveApplet* waveApplet() const { return m_waveApplet; } AetherClockApplet* aetherClockApplet() const { return m_aetherClockApplet; } + WfmApplet* wfmApplet() const { return m_wfmApplet; } MiniPanApplet* miniPanApplet() const { return m_miniPanApplet; } // Phase 7.1: each side has its own CEQ applet — clientEqTxApplet() // is the original "ceq" tile bound to TX, clientEqRxApplet() is @@ -385,6 +387,7 @@ class AppletPanel : public QWidget { const QString& appletKey, bool available); void markHardwareConditional(const QString& id); + void setWfmAvailable(bool available); void persistVuMeterSettings() const; void showStandardMeterContextMenu(QWidget* source, const QPoint& position); static const int kFavoriteCount = 5; @@ -425,6 +428,7 @@ class AppletPanel : public QWidget { EqApplet* m_eqApplet{nullptr}; WaveApplet* m_waveApplet{nullptr}; AetherClockApplet* m_aetherClockApplet{nullptr}; + WfmApplet* m_wfmApplet{nullptr}; MiniPanApplet* m_miniPanApplet{nullptr}; ClientEqApplet* m_clientEqTxApplet{nullptr}; ClientEqApplet* m_clientEqRxApplet{nullptr}; diff --git a/src/gui/ControlAvailabilityRegistry.cpp b/src/gui/ControlAvailabilityRegistry.cpp index aae227c468..da07996e7a 100644 --- a/src/gui/ControlAvailabilityRegistry.cpp +++ b/src/gui/ControlAvailabilityRegistry.cpp @@ -60,13 +60,15 @@ ControlAvailabilityRegistry::ControlAvailabilityRegistry(RadioModel& model, QObj void ControlAvailabilityRegistry::registerWidget(QWidget* widget, QString reason, AvailabilityPredicate available, - EngagedPredicate engaged) + EngagedPredicate engaged, + bool availableWhenDisconnected) { if (!widget || !available) { return; } Entry entry; entry.widget = widget; + entry.availableWhenDisconnected = availableWhenDisconnected; entry.reason = std::move(reason); entry.available = std::move(available); entry.engaged = std::move(engaged); @@ -111,11 +113,10 @@ void ControlAvailabilityRegistry::applyOne(Entry& entry, bool connected, const RadioCapabilities& caps) { - // PERMISSIVE ON DISCONNECT, matching every gate in applyCapabilitiesToUi: - // with no radio attached there is nothing to be honest about, and leaving a - // control dimmed after unplugging reads as a fault rather than as an absent - // capability. - const bool available = !connected || entry.available(connected, caps); + // Preserve the historical disconnected treatment unless a control explicitly + // requires a live receiver (for example observed broadcast-FM settings). + const bool available = connected ? entry.available(connected, caps) + : entry.availableWhenDisconnected; const bool engaged = connected && available && entry.engaged && entry.engaged(); entry.state = !available ? ControlAvailability::Unavailable : engaged ? ControlAvailability::Active diff --git a/src/gui/ControlAvailabilityRegistry.h b/src/gui/ControlAvailabilityRegistry.h index 0953f40cc2..88088d2b45 100644 --- a/src/gui/ControlAvailabilityRegistry.h +++ b/src/gui/ControlAvailabilityRegistry.h @@ -52,10 +52,13 @@ class ControlAvailabilityRegistry : public QObject { // with no reason is the accessibility defect this milestone exists to close // (#4896). Applied immediately, so a widget built after connect is correct // without a second push. + // Pass availableWhenDisconnected=false for controls requiring a live + // receiver; existing controls retain their permissive disconnected state. void registerWidget(QWidget* widget, QString reason, AvailabilityPredicate available, - EngagedPredicate engaged = {}); + EngagedPredicate engaged = {}, + bool availableWhenDisconnected = true); // Same contract for a menu entry or toolbar action. void registerAction(QAction* action, @@ -82,6 +85,7 @@ class ControlAvailabilityRegistry : public QObject { AvailabilityPredicate available; EngagedPredicate engaged; ControlAvailability state{ControlAvailability::Unavailable}; + bool availableWhenDisconnected = true; }; void applyAll(bool connected, const RadioCapabilities& caps); diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index d7eca147a9..5746db148d 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -33,6 +33,7 @@ #include "RadioSetupDialog.h" #include "GpsLocationDialog.h" #include "RxApplet.h" +#include "WfmApplet.h" #include "AmpApplet.h" #include "AcomApplet.h" #include "SpeApplet.h" @@ -7339,6 +7340,7 @@ void MainWindow::wireMeters() m_appletPanel->txApplet()->setRadioModel(&m_radioModel); m_appletPanel->txApplet()->setBandPlanManager(m_bandPlanMgr); m_appletPanel->rxApplet()->setRadioModel(&m_radioModel); + m_appletPanel->wfmApplet()->setRadioModel(&m_radioModel); m_appletPanel->rxApplet()->setKiwiSdrManager(m_kiwiSdrManager); m_appletPanel->rxApplet()->setTransmitModel(&m_radioModel.transmitModel()); connect(m_appletPanel->rxApplet(), &RxApplet::kiwiRxAntennaSelected, diff --git a/src/gui/ModeFilterPresets.cpp b/src/gui/ModeFilterPresets.cpp index 010651cb7d..dcac0b4910 100644 --- a/src/gui/ModeFilterPresets.cpp +++ b/src/gui/ModeFilterPresets.cpp @@ -15,15 +15,21 @@ bool isFmMode(const QString& mode) } namespace { +QString canonicalFmMode(const QString& mode) +{ + if (mode == "NFM") { return QStringLiteral("FMN"); } + if (mode == "WBFM") { return QStringLiteral("WFM"); } + return mode; +} + const ReceiveFilterMode* fmControlFor(const QString& mode, const ReceiveFilterControl* control) { if (!control || !isFmMode(mode)) { return nullptr; } - const QString canonical = mode == "NFM" ? QStringLiteral("FMN") : mode; + const QString canonical = canonicalFmMode(mode); for (const ReceiveFilterMode& candidate : control->modes) { - const QString candidateMode = candidate.mode == "NFM" - ? QStringLiteral("FMN") : candidate.mode; + const QString candidateMode = canonicalFmMode(candidate.mode); if (candidateMode == canonical) { return &candidate; } @@ -86,11 +92,16 @@ bool squelchAvailableInMode(const QString& mode, const SquelchLevelScale* exclus QVector widthsForMode(const QString& mode, const ReceiveFilterControl* control) { - if (mode != "FM" && mode != "FMN" && mode != "NFM") { + if (!isFmMode(mode)) { return widthsForMode(mode); } + // Broadcast FM uses RF channel widths, not the narrow-FM audio ladder. + // These are choices only; observed and saved valid edges remain intact. + static const QVector broadcast{100000, 120000, 140000, 160000, 180000, 200000}; + const QVector& candidates = canonicalFmMode(mode) == "WFM" + ? broadcast : widthsForMode(QStringLiteral("DFM")); QVector widths; - for (int width : widthsForMode(QStringLiteral("DFM"))) { + for (int width : candidates) { if (acceptsFmEdges(mode, control, edgesForWidth(mode, width, {}))) { widths.append(width); } diff --git a/src/gui/ModeFilterPresets.h b/src/gui/ModeFilterPresets.h index 8977157751..b1ef79d735 100644 --- a/src/gui/ModeFilterPresets.h +++ b/src/gui/ModeFilterPresets.h @@ -31,7 +31,8 @@ struct Edges { const QVector& widthsForMode(const QString& mode); // FM defaults above describe fixed radio filters. A receiver which declares -// adjustable FM edges can use the existing DFM ladder within those bounds. +// adjustable FM edges can use the DFM ladder, or the broadcast-width ladder +// for WFM/WBFM, intersected with its declared bounds. // The conservative headless record does not gate unrelated desktop modes. QVector widthsForMode(const QString& mode, const ReceiveFilterControl* control); // Analog FM spellings. DFM retains its existing separate preset behavior. diff --git a/src/gui/WfmApplet.cpp b/src/gui/WfmApplet.cpp new file mode 100644 index 0000000000..e29e6c6aa7 --- /dev/null +++ b/src/gui/WfmApplet.cpp @@ -0,0 +1,479 @@ +#include "WfmApplet.h" +#include "ComboStyle.h" +#include "ControlAvailabilityRegistry.h" +#include "GuardedSlider.h" +#include "ModeFilterPresets.h" +#include "WfmLockScope.h" +#include "core/AppSettings.h" +#include "core/ThemeManager.h" +#include "models/RadioModel.h" +#include "models/PanadapterModel.h" +#include "models/SliceModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace AetherSDR { +namespace { +QJsonObject preferences() +{ + return QJsonDocument::fromJson(AppSettings::instance() + .value(QStringLiteral("WfmApplet"), QString{}).toString().toUtf8()).object(); +} + +void setReadout(QLabel* label, const QString& text, const QString& name) +{ + if (label->text() == text && label->accessibleName() == name) { return; } + label->setText(text); + label->setAccessibleName(name); + QAccessibleEvent event(label, QAccessible::NameChanged); + QAccessible::updateAccessibility(&event); +} + +void announceCombo(QComboBox* combo, const QString& previous) +{ + if (previous != combo->currentText()) { + QAccessibleValueChangeEvent event(combo, combo->currentText()); + QAccessible::updateAccessibility(&event); + } +} + +void styleButton(QPushButton* button) +{ + ThemeManager::instance().applyStyleSheet(button, + "QPushButton { background: {{color.background.1}};" + " border: 1px solid {{color.border.subtle}}; border-radius: 3px;" + " color: {{color.text.primary}}; font-size: 10px; font-weight: bold; padding: 2px 4px; }" + "QPushButton:hover { background: {{color.background.2}}; }" + "QPushButton:disabled { color: {{color.control.unavailable}}; }"); +} +} // namespace + +WfmApplet::WfmApplet(QWidget* parent) : QWidget(parent) +{ + setObjectName(QStringLiteral("WfmApplet")); + setAccessibleName(tr("Broadcast FM")); + theme::setContainer(this, QStringLiteral("applet/wfm")); + auto* root = new QVBoxLayout(this); + root->setContentsMargins(2, 2, 2, 2); + root->setSpacing(3); + m_scope = new WfmLockScope(this); + m_scope->setObjectName(QStringLiteral("wfmLockScope")); + root->addWidget(m_scope); + + auto* statusRow = new QHBoxLayout; + statusRow->setSpacing(5); + m_audioMode = new QPushButton(this); + m_audioMode->setObjectName(QStringLiteral("wfmAudioMode")); + m_audioMode->setFocusPolicy(Qt::StrongFocus); + m_audioMode->setMinimumHeight(22); + styleButton(m_audioMode); + statusRow->addWidget(m_audioMode); + m_status = new QLabel(this); + m_status->setObjectName(QStringLiteral("wfmStereoStatus")); + m_status->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + ThemeManager::instance().applyStyleSheet(m_status, + "QLabel { color: {{color.text.secondary}}; font-size: 11px; }"); + statusRow->addWidget(m_status, 1); + root->addLayout(statusRow); + + auto* settings = new QGridLayout; + settings->setHorizontalSpacing(5); + settings->setVerticalSpacing(3); + m_deemphasis = new GuardedComboBox(this); + m_deemphasis->setObjectName(QStringLiteral("wfmDeemphasis")); + m_deemphasis->setAccessibleName(tr("Broadcast FM deemphasis")); + m_deemphasis->setPlaceholderText(tr("Unknown")); + m_deemphasis->setFocusPolicy(Qt::StrongFocus); + applyComboStyle(m_deemphasis); + m_bandwidth = new GuardedComboBox(this); + m_bandwidth->setObjectName(QStringLiteral("wfmBandwidth")); + m_bandwidth->setAccessibleName(tr("Broadcast FM bandwidth")); + m_bandwidth->setPlaceholderText(tr("Unavailable")); + m_bandwidth->setFocusPolicy(Qt::StrongFocus); + applyComboStyle(m_bandwidth); + auto* deemphasisLabel = new QLabel(tr("De-emphasis"), this); + deemphasisLabel->setBuddy(m_deemphasis); + auto* bandwidthLabel = new QLabel(tr("Bandwidth"), this); + bandwidthLabel->setBuddy(m_bandwidth); + for (QLabel* label : {deemphasisLabel, bandwidthLabel}) { + ThemeManager::instance().applyStyleSheet(label, + "QLabel { color: {{color.text.secondary}}; font-size: 11px; }"); + } + settings->addWidget(deemphasisLabel, 0, 0); + settings->addWidget(m_deemphasis, 0, 1); + settings->addWidget(bandwidthLabel, 1, 0); + settings->addWidget(m_bandwidth, 1, 1); + settings->setColumnStretch(1, 1); + root->addLayout(settings); + + m_settingsToggle = new QPushButton(tr("▸ Settings"), this); + m_settingsToggle->setObjectName(QStringLiteral("wfmSettingsToggle")); + m_settingsToggle->setCheckable(true); + m_settingsToggle->setAccessibleName(tr("Broadcast FM settings")); + m_settingsToggle->setFocusPolicy(Qt::StrongFocus); + styleButton(m_settingsToggle); + root->addWidget(m_settingsToggle); + m_settingsDrawer = new QFrame(this); + m_settingsDrawer->setObjectName(QStringLiteral("wfmSettingsDrawer")); + auto* drawer = new QVBoxLayout(m_settingsDrawer); + drawer->setContentsMargins(5, 4, 5, 5); + drawer->setSpacing(3); + m_showScope = new QCheckBox(tr("Show Lock Scope"), m_settingsDrawer); + m_showScope->setObjectName(QStringLiteral("wfmShowLockScope")); + m_showScope->setAccessibleName(tr("Show Broadcast FM lock scope")); + m_showDiagnostics = new QCheckBox(tr("Show Diagnostics"), m_settingsDrawer); + m_showDiagnostics->setObjectName(QStringLiteral("wfmShowDiagnostics")); + m_showDiagnostics->setAccessibleName(tr("Show Broadcast FM diagnostics")); + const QJsonObject ui = preferences().value(QStringLiteral("ui")).toObject(); + m_showScope->setChecked(ui.value(QStringLiteral("showLockScope")).toBool(true)); + m_showDiagnostics->setChecked(ui.value(QStringLiteral("showDiagnostics")).toBool(false)); + drawer->addWidget(m_showScope); + drawer->addWidget(m_showDiagnostics); + root->addWidget(m_settingsDrawer); + m_diagnostics = new QLabel(this); + m_diagnostics->setObjectName(QStringLiteral("wfmDiagnostics")); + m_diagnostics->setWordWrap(true); + m_diagnostics->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); + ThemeManager::instance().applyStyleSheet(m_diagnostics, + "QLabel { color: {{color.text.secondary}}; font-size: 10px;" + " background: {{color.background.0}}; border: 1px solid {{color.border.subtle}};" + " border-radius: 3px; padding: 4px; }"); + root->addWidget(m_diagnostics); + + connect(m_audioMode, &QPushButton::clicked, this, [this] { + if (m_available && m_slice && m_audioMode->isEnabled()) { + m_slice->setWfmForceMono(!m_slice->wfmForceMono()); + } + refresh(); + }); + connect(m_deemphasis, &QComboBox::activated, this, [this](int index) { + if (m_available && m_slice && m_deemphasis->isEnabled()) { + m_slice->setWfmDeemphasis(m_deemphasis->itemData(index).toInt()); + } + refresh(); + }); + connect(m_bandwidth, &QComboBox::activated, this, [this](int index) { + if (m_available && m_slice && m_bandwidth->isEnabled()) { + const int width = m_bandwidth->itemData(index).toInt(); + const ModeFilters::Edges edges = ModeFilters::edgesForWidth(QStringLiteral("WFM"), width, {}); + const RadioCapabilities caps = m_model->backendCapabilities(); + if (ModeFilters::acceptsFmEdges(QStringLiteral("WFM"), + caps.receiveFilterControl ? &*caps.receiveFilterControl : nullptr, edges)) { + m_slice->setFilterWidth(edges.lo, edges.hi); + } + } + refresh(); + }); + connect(m_settingsToggle, &QPushButton::toggled, this, &WfmApplet::setSettingsExpanded); + for (QCheckBox* option : {m_showScope, m_showDiagnostics}) { + connect(option, &QCheckBox::toggled, this, [this] { + applyUiPreferences(); + saveUiPreferences(); + }); + } + setSettingsExpanded(false); + applyUiPreferences(); + refresh(); +} + +WfmApplet::~WfmApplet() = default; +QSize WfmApplet::sizeHint() const { return {260, layout()->sizeHint().height()}; } +QSize WfmApplet::minimumSizeHint() const { return {220, layout()->minimumSize().height()}; } + +bool WfmApplet::ownsWfmSlice() const +{ + return m_model && m_slice && m_model->slice(m_slice->sliceId()) == m_slice + && m_slice->mode() == QLatin1String("WFM"); +} + +void WfmApplet::setRadioModel(RadioModel* model) +{ + for (const auto& connection : m_modelConnections) { disconnect(connection); } + m_modelConnections.clear(); + m_availability.reset(); + m_scope->clear(); + m_model = model; + m_connected = model && model->isConnected(); + m_caps = model ? model->backendCapabilities() : RadioCapabilities{}; + if (model) { + m_modelConnections.append(connect(model, &RadioModel::capabilitiesChanged, this, + [this](bool connected, const RadioCapabilities& caps) { + m_connected = connected; + m_caps = caps; + refresh(); + })); + m_modelConnections.append(connect(model, &RadioModel::connectionStateChanged, this, + [this](bool connected) { + m_connected = connected; + m_caps = m_model ? m_model->backendCapabilities() : RadioCapabilities{}; + refresh(); + })); + m_modelConnections.append(connect(model, &RadioModel::sliceRemoved, this, + [this](int id) { + if (m_slice && m_slice->sliceId() == id) { setSlice(nullptr); } + })); + m_modelConnections.append(connect(model, &QObject::destroyed, this, [this] { + m_availability.reset(); + m_model = nullptr; + m_connected = false; + m_caps = {}; + refresh(); + })); + registerControls(); + } + refresh(); +} + +void WfmApplet::setSlice(SliceModel* slice) +{ + for (const auto& connection : m_sliceConnections) { disconnect(connection); } + m_sliceConnections.clear(); + m_scope->clear(); + m_slice = slice; + if (slice) { + m_sliceConnections.append(connect(slice, &SliceModel::modeChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &SliceModel::filterChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &SliceModel::wfmDeemphasisChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &SliceModel::wfmForceMonoChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &SliceModel::wfmStereoStatusChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &SliceModel::wfmReceptionDiagnosticsChanged, this, [this] { + refreshDiagnostics(); + appendScopeSample(); + })); + m_sliceConnections.append(connect(slice, &SliceModel::frequencyChanged, this, [this] { + m_scope->clear(); + refreshDiagnostics(); + })); + m_sliceConnections.append(connect(slice, &SliceModel::inCaptureChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &SliceModel::externalReceiveReplacementChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &QObject::destroyed, this, [this] { + m_slice = nullptr; + refresh(); + })); + } + refresh(); +} + +void WfmApplet::registerControls() +{ + m_availability = std::make_unique(*m_model, this); + const auto supported = [this](bool connected, const RadioCapabilities& caps) { + return connected && caps.broadcastFmReceive && ownsWfmSlice() + && !m_slice->externalReceiveReplacementActive(); + }; + m_availability->registerWidget(m_audioMode, + tr("Mono selection is unavailable for this receiver or replacement audio source"), + [supported](bool connected, const RadioCapabilities& caps) { + return supported(connected, caps) && caps.broadcastFmReceive->forceMonoControl; + }, [] { return true; }, false); + m_availability->registerWidget(m_deemphasis, + tr("De-emphasis is unavailable for this receiver or replacement audio source"), + [supported](bool connected, const RadioCapabilities& caps) { + return supported(connected, caps) && !caps.broadcastFmReceive->deemphasisUs.isEmpty(); + }, [] { return true; }, false); + m_availability->registerWidget(m_bandwidth, + tr("Adjustable WFM bandwidth is unavailable for this receiver or replacement audio source"), + [supported](bool connected, const RadioCapabilities& caps) { + return supported(connected, caps) && !ModeFilters::widthsForMode(QStringLiteral("WFM"), + caps.receiveFilterControl ? &*caps.receiveFilterControl : nullptr).isEmpty(); + }, [] { return true; }, false); + for (QCheckBox* control : {m_showScope, m_showDiagnostics}) { + m_availability->registerWidget(control, tr("This receiver does not provide pilot measurements"), + [this](bool connected, const RadioCapabilities& caps) { + return connected && caps.broadcastFmReceive && caps.broadcastFmReceive->receptionDiagnostics + && ownsWfmSlice(); + }, [] { return true; }, false); + } +} + +void WfmApplet::refresh() +{ + const bool available = m_connected && m_caps.broadcastFmReceive && ownsWfmSlice(); + if (available != m_available) { + m_available = available; + if (!available) { m_scope->clear(); } + emit availabilityChanged(available); + } + if (m_availability) { + m_availability->refreshEngaged(); + } else { + for (QWidget* control : {static_cast(m_audioMode), static_cast(m_deemphasis), + static_cast(m_bandwidth), static_cast(m_showScope), + static_cast(m_showDiagnostics)}) { + control->setEnabled(false); + control->setAccessibleDescription(tr("Connect a broadcast FM receiver and select WFM")); + } + } + const bool forceMono = m_available && m_slice->wfmForceMono(); + const QString modeText = forceMono ? tr("Mono") : tr("Auto Stereo"); + if (m_audioMode->text() != modeText) { + m_audioMode->setText(modeText); + m_audioMode->setAccessibleName(tr("Broadcast FM audio mode: %1").arg(modeText)); + QAccessibleEvent event(m_audioMode, QAccessible::NameChanged); + QAccessible::updateAccessibility(&event); + } + if (m_audioMode->isEnabled()) { + m_audioMode->setAccessibleDescription(tr("Cycle between forced Mono and Auto Stereo. Auto Stereo falls back to mono when no stereo pilot is detected.")); + m_audioMode->setToolTip(m_audioMode->accessibleDescription()); + } + const QString oldDeemphasis = m_deemphasis->currentText(); + const QString oldBandwidth = m_bandwidth->currentText(); + { + const QSignalBlocker blocker(m_deemphasis); + m_deemphasis->clear(); + if (m_caps.broadcastFmReceive) { + for (int value : m_caps.broadcastFmReceive->deemphasisUs) { + m_deemphasis->addItem(tr("%1 µs").arg(value), value); + } + } + m_deemphasis->setCurrentIndex(m_available ? m_deemphasis->findData(m_slice->wfmDeemphasisUs()) : -1); + } + { + const QSignalBlocker blocker(m_bandwidth); + m_bandwidth->clear(); + const QVector widths = ModeFilters::widthsForMode(QStringLiteral("WFM"), + m_caps.receiveFilterControl ? &*m_caps.receiveFilterControl : nullptr); + for (int width : widths) { m_bandwidth->addItem(tr("%1 kHz").arg(width / 1000), width); } + int selected = -1; + if (m_available) { + const int low = m_slice->filterLow(); + const int high = m_slice->filterHigh(); + const int width = high - low; + if (low == -width / 2 && high == width / 2) { selected = m_bandwidth->findData(width); } + if (selected < 0) { + // Accepted custom edges remain exact; a display refresh must + // never round them to a nearby symmetric preset or send intent. + m_bandwidth->addItem(tr("%1 kHz (custom)").arg(width / 1000.0, 0, 'g', 5), 0); + selected = m_bandwidth->count() - 1; + } + } + m_bandwidth->setCurrentIndex(selected); + } + announceCombo(m_deemphasis, oldDeemphasis); + announceCombo(m_bandwidth, oldBandwidth); + refreshDiagnostics(); +} + +void WfmApplet::refreshDiagnostics() +{ + const bool receiving = m_available && m_slice->inCapture() + && !m_slice->externalReceiveReplacementActive(); + const WfmReceptionDiagnostics diagnostics = receiving && m_caps.broadcastFmReceive->receptionDiagnostics + ? m_slice->wfmReceptionDiagnostics() : WfmReceptionDiagnostics{}; + const bool forceMono = m_available && m_slice->wfmForceMono(); + const WfmStereoStatus status = receiving ? m_slice->wfmStereoStatus() : WfmStereoStatus::Unavailable; + QString text = tr("Unavailable"); + QString token = QStringLiteral("color.text.secondary"); + if (diagnostics.valid) { + if (forceMono) { + text = diagnostics.pilotLocked ? tr("Pilot detected") : tr("No pilot"); + } else if (status == WfmStereoStatus::Stereo && diagnostics.pilotLocked) { + text = tr("Stereo"); + if (!forceMono) { token = QStringLiteral("color.accent.success"); } + } else if (status == WfmStereoStatus::Acquiring) { + text = tr("Acquiring"); + if (!forceMono) { token = QStringLiteral("color.accent.warning"); } + } else if (status == WfmStereoStatus::Mono) { + text = tr("Mono fallback"); + } + if (!forceMono && diagnostics.lockLossCount > 0 && diagnostics.stableDurationMs < 5000) { + text += tr(" · Unstable"); + token = QStringLiteral("color.accent.warning"); + } + } else { + m_scope->clear(); + if (receiving && status == WfmStereoStatus::Acquiring) { + text = tr("Acquiring"); + if (!forceMono) { token = QStringLiteral("color.accent.warning"); } + } + } + setReadout(m_status, text, tr("Broadcast FM observed pilot status: %1").arg(text)); + m_status->setAccessibleDescription(tr("Measured stereo pilot indicator, independent of the selected audio mode.")); + ThemeManager::instance().setWidgetForegroundToken(m_status, token); + QString details = tr("No current receive measurements"); + if (diagnostics.valid) { + details = tr("Pilot indicator: %1\nPilot magnitude: %2 relative\nAcquire / release: %3 / %4 relative\nHigh blocks: %5 / %6 · Low blocks: %7 / %8\nLock duration: %9 s\nLock losses: %10 · Reacquisitions: %11\nObservation: %12 s\nPilot state unchanged for %13 s (5 s window)") + .arg(diagnostics.pilotLocked ? tr("Detected") : tr("Not detected")) + .arg(diagnostics.pilotMagnitude, 0, 'g', 4) + .arg(diagnostics.pilotEngageThreshold, 0, 'g', 4) + .arg(diagnostics.pilotReleaseThreshold, 0, 'g', 4) + .arg(diagnostics.consecutiveHighBlocks).arg(diagnostics.engageBlocks) + .arg(diagnostics.consecutiveLowBlocks).arg(diagnostics.releaseBlocks) + .arg(diagnostics.lockDurationMs / 1000.0, 0, 'f', 1) + .arg(diagnostics.lockLossCount).arg(diagnostics.reacquisitionCount) + .arg(diagnostics.observationDurationMs / 1000.0, 0, 'f', 1) + .arg(diagnostics.stableDurationMs / 1000.0, 0, 'f', 1); + } + if (m_available) { + const QString frequency = m_slice->frequencyReportedKnown() + ? tr("%1 MHz").arg(m_slice->reportedFrequency(), 0, 'f', 6) : tr("Unavailable"); + details += tr("\nFrequency: %1\nFilter: %2 to %3 kHz\nDe-emphasis: %4 µs") + .arg(frequency).arg(m_slice->filterLow() / 1000.0, 0, 'g', 5) + .arg(m_slice->filterHigh() / 1000.0, 0, 'g', 5).arg(m_slice->wfmDeemphasisUs()); + if (const PanadapterModel* pan = m_model->panadapter(m_slice->panId())) { + details += tr("\nRF gain: %1%2").arg(pan->rfGain()).arg(pan->rfGainUnitSuffix()); + } + } + setReadout(m_diagnostics, details, tr("Broadcast FM diagnostics: %1").arg(details)); + m_diagnostics->setAccessibleDescription(tr("Pilot magnitude uses relative discriminator units. The pilot indicator is not PLL lock, SNR, or an audio quality score. Measurements reset with the decoder and clear when stale.")); + m_diagnostics->setToolTip(m_diagnostics->accessibleDescription()); +} + +void WfmApplet::appendScopeSample() +{ + if (!m_available || !m_slice || !m_slice->inCapture() || m_slice->externalReceiveReplacementActive() + || !m_caps.broadcastFmReceive->receptionDiagnostics || !m_slice->wfmReceptionDiagnostics().valid) { + m_scope->clear(); + return; + } + if (!m_showScope->isChecked() || !m_scope->isVisible()) { return; } + const WfmReceptionDiagnostics& diagnostics = m_slice->wfmReceptionDiagnostics(); + m_scope->appendSample(diagnostics.pilotMagnitude, diagnostics.pilotEngageThreshold, + diagnostics.pilotReleaseThreshold, m_slice->wfmStereoStatus(), m_slice->wfmForceMono()); +} + +void WfmApplet::setSettingsExpanded(bool expanded) +{ + m_settingsDrawer->setVisible(expanded); + m_settingsToggle->setText(expanded ? tr("▾ Settings") : tr("▸ Settings")); + m_settingsToggle->setAccessibleDescription(expanded ? tr("Expanded") : tr("Collapsed")); + setMinimumHeight(minimumSizeHint().height()); + updateGeometry(); + if (QWidget* parent = parentWidget()) { parent->updateGeometry(); } +} + +void WfmApplet::applyUiPreferences() +{ + m_scope->setVisible(m_showScope->isChecked()); + if (!m_showScope->isChecked()) { m_scope->clear(); } + m_diagnostics->setVisible(m_showDiagnostics->isChecked()); + setMinimumHeight(minimumSizeHint().height()); + updateGeometry(); + if (QWidget* parent = parentWidget()) { parent->updateGeometry(); } +} + +void WfmApplet::saveUiPreferences() +{ + // Client presentation only. Accepted receiver controls persist through + // their model/backend path, never through this UI preference object. + QJsonObject document = preferences(); + QJsonObject ui = document.value(QStringLiteral("ui")).toObject(); + ui.insert(QStringLiteral("showLockScope"), m_showScope->isChecked()); + ui.insert(QStringLiteral("showDiagnostics"), m_showDiagnostics->isChecked()); + document.insert(QStringLiteral("ui"), ui); + AppSettings::instance().setValue(QStringLiteral("WfmApplet"), + QString::fromUtf8(QJsonDocument(document).toJson(QJsonDocument::Compact))); + AppSettings::instance().save(); +} +} // namespace AetherSDR diff --git a/src/gui/WfmApplet.h b/src/gui/WfmApplet.h new file mode 100644 index 0000000000..71a3910062 --- /dev/null +++ b/src/gui/WfmApplet.h @@ -0,0 +1,64 @@ +#pragma once + +#include "core/backends/RadioCapabilities.h" +#include +#include +#include +#include + +class QCheckBox; +class QComboBox; +class QFrame; +class QLabel; +class QPushButton; + +namespace AetherSDR { +class ControlAvailabilityRegistry; +class RadioModel; +class SliceModel; +class WfmLockScope; + +// Selected-slice broadcast FM controls. AppletPanel owns the visibility of +// the complete tile and floating window; this widget never hides itself. +class WfmApplet : public QWidget { + Q_OBJECT +public: + explicit WfmApplet(QWidget* parent = nullptr); + ~WfmApplet() override; + void setRadioModel(RadioModel* model); + void setSlice(SliceModel* slice); + bool isAvailable() const { return m_available; } + QSize sizeHint() const override; + QSize minimumSizeHint() const override; +signals: + void availabilityChanged(bool available); +private: + bool ownsWfmSlice() const; + void refresh(); + void refreshDiagnostics(); + void appendScopeSample(); + void registerControls(); + void setSettingsExpanded(bool expanded); + void applyUiPreferences(); + void saveUiPreferences(); + + QPointer m_model; + QPointer m_slice; + QVector m_modelConnections; + QVector m_sliceConnections; + std::unique_ptr m_availability; + RadioCapabilities m_caps; + bool m_connected{false}; + bool m_available{false}; + WfmLockScope* m_scope{nullptr}; + QPushButton* m_audioMode{nullptr}; + QLabel* m_status{nullptr}; + QComboBox* m_deemphasis{nullptr}; + QComboBox* m_bandwidth{nullptr}; + QPushButton* m_settingsToggle{nullptr}; + QFrame* m_settingsDrawer{nullptr}; + QCheckBox* m_showScope{nullptr}; + QCheckBox* m_showDiagnostics{nullptr}; + QLabel* m_diagnostics{nullptr}; +}; +} // namespace AetherSDR diff --git a/src/gui/WfmLockScope.cpp b/src/gui/WfmLockScope.cpp new file mode 100644 index 0000000000..ad790af98c --- /dev/null +++ b/src/gui/WfmLockScope.cpp @@ -0,0 +1,224 @@ +#include "WfmLockScope.h" + +#include "core/ThemeManager.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace AetherSDR { +namespace { +class WfmLockScopeAccessible final : public QAccessibleWidget { +public: + explicit WfmLockScopeAccessible(QWidget* widget) + : QAccessibleWidget(widget, QAccessible::Indicator) {} + QString text(QAccessible::Text kind) const override + { + if (kind == QAccessible::Value && widget()) { + return widget()->accessibleDescription(); + } + return QAccessibleWidget::text(kind); + } +}; + +QAccessibleInterface* scopeAccessibleFactory(const QString& key, QObject* object) +{ + if (key == QLatin1String("AetherSDR::WfmLockScope")) { + return new WfmLockScopeAccessible(qobject_cast(object)); + } + return nullptr; +} + +void publishAccessibleValue(QWidget* widget, const QString& value) +{ + if (widget->accessibleDescription() == value) { return; } + widget->setAccessibleDescription(value); + // Observation publication is bounded to 4 Hz plus real state changes. + QAccessibleValueChangeEvent event(widget, value); + QAccessible::updateAccessibility(&event); +} + +QString stateText(WfmStereoStatus state, bool forceMono) +{ + if (forceMono) { + return QStringLiteral("Forced mono"); + } + switch (state) { + case WfmStereoStatus::Stereo: return QStringLiteral("Stereo locked"); + case WfmStereoStatus::Mono: return QStringLiteral("Mono fallback"); + case WfmStereoStatus::Acquiring: return QStringLiteral("Acquiring"); + case WfmStereoStatus::Unavailable: return QStringLiteral("Unavailable"); + } + return QStringLiteral("Unavailable"); +} +} + +WfmLockScope::WfmLockScope(QWidget* parent) : QWidget(parent) +{ + static const bool factoryInstalled = [] { + QAccessible::installFactory(scopeAccessibleFactory); + return true; + }(); + Q_UNUSED(factoryInstalled); + setObjectName(QStringLiteral("wfmLockScope")); + setAccessibleName(QStringLiteral("WFM pilot lock scope")); + setToolTip(QStringLiteral( + "Measured 19 kHz pilot magnitude and the decoder's acquire/release " + "thresholds, in relative detector units. Stereo also requires consecutive " + "decoder blocks above or below the threshold. Up to 40 seconds of current " + "receiver observations; state strip shows actual stereo/mono output. " + "This is not SNR or calibrated RF power.")); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + setMinimumSize(minimumSizeHint()); + connect(&ThemeManager::instance(), &ThemeManager::themeChanged, + this, qOverload<>(&WfmLockScope::update)); + clear(); +} + +QSize WfmLockScope::sizeHint() const { return {260, 136}; } +QSize WfmLockScope::minimumSizeHint() const { return {210, 116}; } + +void WfmLockScope::appendSample(double pilotMagnitude, double acquireThreshold, + double releaseThreshold, WfmStereoStatus status, + bool forceMono) +{ + // Hidden scope means no history growth, timer reads or repaint requests. + // A show starts with fresh telemetry, never a retained green strip. + if (!isVisible()) { + return; + } + if (!std::isfinite(pilotMagnitude) || pilotMagnitude < 0.0 + || !std::isfinite(acquireThreshold) || acquireThreshold <= 0.0 + || !std::isfinite(releaseThreshold) || releaseThreshold < 0.0 + || releaseThreshold > acquireThreshold + || status == WfmStereoStatus::Unavailable) { + clear(); + return; + } + if (!m_clock.isValid()) { + m_clock.start(); + } + const qint64 now = m_clock.elapsed(); + while (!m_samples.isEmpty() + && (m_samples.size() >= kCapacity + || now - m_samples.first().milliseconds > kWindowMs)) { + m_samples.removeFirst(); + } + m_samples.append({now, pilotMagnitude, acquireThreshold, releaseThreshold, + status, forceMono}); + publishAccessibleValue(this, + QStringLiteral("19 kHz pilot %1 relative units. Acquire threshold %2; " + "release threshold %3. %4. %5 current receiver observations. " + "Threshold crossings require consecutive decoder blocks.") + .arg(pilotMagnitude, 0, 'g', 4).arg(acquireThreshold, 0, 'g', 4) + .arg(releaseThreshold, 0, 'g', 4).arg(stateText(status, forceMono)) + .arg(m_samples.size())); + update(); +} + +void WfmLockScope::clear() +{ + m_samples.clear(); + m_clock.invalidate(); + publishAccessibleValue(this, QStringLiteral("Awaiting current receiver pilot telemetry.")); + if (isVisible()) { + update(); + } +} + +void WfmLockScope::hideEvent(QHideEvent* event) +{ + clear(); + QWidget::hideEvent(event); +} + +void WfmLockScope::paintEvent(QPaintEvent*) +{ + QPainter painter(this); + ThemeManager& theme = ThemeManager::instance(); + const QColor text = theme.color(this, "color.text.secondary"); + const QColor trace = theme.color(this, "color.text.primary"); + const QColor success = theme.color(this, "color.accent.success"); + const QColor warning = theme.color(this, "color.accent.warning"); + const QColor neutral = theme.color(this, "color.text.disabled"); + painter.fillRect(rect(), theme.brush(this, "color.background.0", rect())); + painter.setPen(theme.color(this, "color.border.subtle")); + painter.drawRect(rect().adjusted(0, 0, -1, -1)); + painter.setPen(text); + const int lineHeight = fontMetrics().height(); + painter.drawText(QRect(5, 3, width() - 10, lineHeight), Qt::AlignLeft, + QStringLiteral("19 kHz pilot · relative units")); + if (m_samples.isEmpty()) { + painter.drawText(rect().adjusted(5, lineHeight + 5, -5, -5), + Qt::AlignCenter, QStringLiteral("Awaiting pilot telemetry")); + return; + } + const Sample& latest = m_samples.last(); + // Fixed, disjoint legend cells keep nearly equal thresholds readable; + // the guide lines below still use their actual measured y positions. + const int legendWidth = (width() - 12) / 2; + const QRect acquireLegend(6, lineHeight + 5, legendWidth, lineHeight); + const QRect releaseLegend(6 + legendWidth, lineHeight + 5, legendWidth, lineHeight); + painter.setPen(success); + painter.drawText(acquireLegend, Qt::AlignLeft | Qt::AlignVCenter, + fontMetrics().elidedText(QStringLiteral("Acquire %1").arg(latest.acquire, 0, 'g', 3), + Qt::ElideRight, legendWidth - 4)); + painter.setPen(warning); + painter.drawText(releaseLegend, Qt::AlignLeft | Qt::AlignVCenter, + fontMetrics().elidedText(QStringLiteral("Release %1").arg(latest.release, 0, 'g', 3), + Qt::ElideRight, legendWidth - 4)); + const QRectF plot(6, 2 * lineHeight + 11, width() - 12, + std::max(20, height() - 4 * lineHeight - 32)); + double maximum = latest.acquire; + for (const Sample& sample : m_samples) { + maximum = std::max(maximum, sample.magnitude); + maximum = std::max(maximum, sample.acquire); + } + const auto yOf = [&](double value) { + return plot.bottom() - std::clamp(value / maximum, 0.0, 1.0) * plot.height(); + }; + const auto xOf = [&](const Sample& sample) { + return plot.right() - double(latest.milliseconds - sample.milliseconds) + / double(kWindowMs) * plot.width(); + }; + // Threshold labels are textual as well as differently styled lines. + painter.setPen(QPen(success, 1, Qt::DashLine)); + painter.drawLine(QPointF(plot.left(), yOf(latest.acquire)), + QPointF(plot.right(), yOf(latest.acquire))); + painter.setPen(QPen(warning, 1, Qt::DotLine)); + painter.drawLine(QPointF(plot.left(), yOf(latest.release)), + QPointF(plot.right(), yOf(latest.release))); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(trace, 1.5)); + QPolygonF points; + points.reserve(m_samples.size()); + for (const Sample& sample : m_samples) { + points.append(QPointF(xOf(sample), yOf(sample.magnitude))); + } + painter.drawPolyline(points); + painter.drawEllipse(points.last(), 2.0, 2.0); + // Status is observed decoder output, not inferred from one amplitude. + const qreal stripY = plot.bottom() + 4; + for (qsizetype i = 0; i < m_samples.size(); ++i) { + const Sample& sample = m_samples[i]; + const QColor color = !sample.forceMono && sample.status == WfmStereoStatus::Stereo + ? success : (!sample.forceMono && sample.status == WfmStereoStatus::Acquiring + ? warning : neutral); + const qreal left = xOf(sample); + const qreal right = i + 1 < m_samples.size() + ? xOf(m_samples[i + 1]) : plot.right(); + painter.fillRect(QRectF(left, stripY, std::max(2.0, right - left), 4), color); + } + painter.setPen(text); + painter.drawText(QRect(6, int(stripY) + 8, width() - 12, lineHeight), + Qt::AlignLeft, stateText(latest.status, latest.forceMono)); + painter.drawText(QRect(6, height() - lineHeight - 2, width() - 12, lineHeight), + Qt::AlignRight, QStringLiteral("← up to 40 s · now")); +} + +} // namespace AetherSDR diff --git a/src/gui/WfmLockScope.h b/src/gui/WfmLockScope.h new file mode 100644 index 0000000000..86e9b7ad1e --- /dev/null +++ b/src/gui/WfmLockScope.h @@ -0,0 +1,47 @@ +#pragma once + +#include "models/SliceModel.h" + +#include +#include +#include + +namespace AetherSDR { + +// Low-rate, measured pilot history. No decoder access, sampling timer or FFT. +// The applet clears it whenever its selected receiver observation is retired. +class WfmLockScope : public QWidget { + Q_OBJECT +public: + explicit WfmLockScope(QWidget* parent = nullptr); + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + + // Magnitude and both thresholds share the decoder's relative scale. This + // is not calibrated RF power, SNR, PLL phase error or an audio level. + void appendSample(double pilotMagnitude, double acquireThreshold, + double releaseThreshold, WfmStereoStatus status, + bool forceMono); + void clear(); + int sampleCount() const { return int(m_samples.size()); } + +protected: + void paintEvent(QPaintEvent* event) override; + void hideEvent(QHideEvent* event) override; + +private: + struct Sample { + qint64 milliseconds; + double magnitude; + double acquire; + double release; + WfmStereoStatus status; + bool forceMono; + }; + static constexpr int kCapacity = 160; + static constexpr qint64 kWindowMs = 40000; + QList m_samples; + QElapsedTimer m_clock; +}; + +} // namespace AetherSDR diff --git a/src/gui/containers/ContainerWidget.cpp b/src/gui/containers/ContainerWidget.cpp index d1654853f8..712d1ca27b 100644 --- a/src/gui/containers/ContainerWidget.cpp +++ b/src/gui/containers/ContainerWidget.cpp @@ -136,10 +136,31 @@ void ContainerWidget::setContainerVisible(bool visible) emit visibilityChanged(visible); } +void ContainerWidget::setVisible(bool visible) +{ + m_requestedVisible = visible; + QWidget::setVisible(visible && (!m_presentationManaged || m_visible) + && (m_presentationAvailable || isFloating())); +} + +void ContainerWidget::setPresentationAvailable(bool available) +{ + if (m_presentationManaged && available == m_presentationAvailable) { return; } + m_presentationManaged = true; + m_presentationAvailable = available; + // Bypass the override: suppressing presentation must not become a new + // explicit hide request. Ancestor hide/show similarly leaves intent alone. + QWidget::setVisible(m_visible && m_requestedVisible && (available || isFloating())); + emit presentationAvailabilityChanged(available); +} + void ContainerWidget::setDockMode(DockMode mode) { if (mode == m_dockMode) return; m_dockMode = mode; + if (!m_presentationAvailable) { + QWidget::setVisible(m_visible && m_requestedVisible && isFloating()); + } if (m_titleBar) { // Order matters: setFloatingState() repaints the docked/floating // visuals, and setCanvasState() overrides them only when the new diff --git a/src/gui/containers/ContainerWidget.h b/src/gui/containers/ContainerWidget.h index 9b4289436a..f65f9f6ae8 100644 --- a/src/gui/containers/ContainerWidget.h +++ b/src/gui/containers/ContainerWidget.h @@ -83,6 +83,15 @@ class ContainerWidget : public QWidget { void setContainerVisible(bool visible); bool isContainerVisible() const { return m_visible; } + // Transient presentation only: unavailable applets retain their logical + // open/closed intent and workspace home. Direct show() calls from layout + // recall obey this gate too. Floating content stays shown inside its + // independently gated outer window. Other containers default available. + void setPresentationAvailable(bool available); + bool isPresentationAvailable() const { return m_presentationAvailable; } + bool isPresentationManaged() const { return m_presentationManaged; } + void setVisible(bool visible) override; + // Access to the titlebar — callers can hide its close button // for root containers or customise the title dynamically. ContainerTitleBar* titleBar() { return m_titleBar; } @@ -129,6 +138,9 @@ class ContainerWidget : public QWidget { // Fired after setContainerVisible() changes state. void visibilityChanged(bool visible); + // Does not emit visibilityChanged: availability is never a user close. + void presentationAvailabilityChanged(bool available); + // Fired after dockMode() changes — manager uses this to keep the // titlebar button label in sync. void dockModeChanged(DockMode mode); @@ -163,6 +175,9 @@ private slots: QWidget* m_content{nullptr}; DockMode m_dockMode{DockMode::PanelDocked}; bool m_visible{true}; + bool m_presentationAvailable{true}; + bool m_presentationManaged{false}; + bool m_requestedVisible{true}; QSize m_defaultFloatingSize; QHash m_savedMaxWidths; // child → docked maximumWidth (#3451) }; diff --git a/src/gui/containers/FloatingContainerWindow.cpp b/src/gui/containers/FloatingContainerWindow.cpp index 6af03e43f6..be4af91461 100644 --- a/src/gui/containers/FloatingContainerWindow.cpp +++ b/src/gui/containers/FloatingContainerWindow.cpp @@ -53,27 +53,54 @@ void FloatingContainerWindow::takeContainer(ContainerWidget* container) if (m_container == container) return; if (m_container) { - m_layout->removeWidget(m_container); - m_container->setParent(nullptr); + releaseContainer(); } m_container = container; if (m_container) { + m_presentationConnection = connect(m_container, + &ContainerWidget::presentationAvailabilityChanged, this, + [this](bool) { applyPresentation(); }); + m_containerVisibilityConnection = connect(m_container, + &ContainerWidget::visibilityChanged, this, [this](bool visible) { + if (m_container && m_container->isPresentationManaged()) { + setVisible(visible); + } + }); + m_containerDestroyedConnection = connect(m_container, &QObject::destroyed, + this, [this] { m_container = nullptr; }); m_container->setParent(this); m_layout->addWidget(m_container, 1); - m_container->show(); m_container->setDockMode(ContainerWidget::DockMode::Floating); + m_container->show(); setWindowTitle(m_container->title()); } + applyPresentation(); +} + +void FloatingContainerWindow::setVisible(bool visible) +{ + m_requestedVisible = visible; + applyPresentation(); +} + +void FloatingContainerWindow::applyPresentation() +{ + QWidget::setVisible(m_requestedVisible && (!m_container + || (m_container->isPresentationAvailable() + && (!m_container->isPresentationManaged() || m_container->isContainerVisible())))); } ContainerWidget* FloatingContainerWindow::releaseContainer() { ContainerWidget* c = m_container; if (c) { + QObject::disconnect(m_presentationConnection); + QObject::disconnect(m_containerVisibilityConnection); + QObject::disconnect(m_containerDestroyedConnection); + m_container = nullptr; m_layout->removeWidget(c); c->setParent(nullptr); c->setDockMode(ContainerWidget::DockMode::PanelDocked); - m_container = nullptr; } return c; } @@ -153,6 +180,7 @@ void FloatingContainerWindow::prepareShutdown() void FloatingContainerWindow::setFramelessMode(bool on) { const bool wasVisible = isVisible(); + const bool requestedVisible = m_requestedVisible; const QRect geom = geometry(); Qt::WindowFlags flags = windowFlags(); if (on) { @@ -161,6 +189,7 @@ void FloatingContainerWindow::setFramelessMode(bool on) flags &= ~Qt::FramelessWindowHint; } setWindowFlags(flags); + m_requestedVisible = requestedVisible; if (wasVisible) setGeometry(geom); if (wasVisible) show(); } @@ -170,6 +199,7 @@ void FloatingContainerWindow::setAlwaysOnTop(bool on) if (m_alwaysOnTop == on) return; m_alwaysOnTop = on; const bool wasVisible = isVisible(); + const bool requestedVisible = m_requestedVisible; const QRect geom = geometry(); Qt::WindowFlags flags = windowFlags(); if (on) { @@ -178,6 +208,7 @@ void FloatingContainerWindow::setAlwaysOnTop(bool on) flags &= ~Qt::WindowStaysOnTopHint; } setWindowFlags(flags); + m_requestedVisible = requestedVisible; if (wasVisible) setGeometry(geom); if (wasVisible) show(); } diff --git a/src/gui/containers/FloatingContainerWindow.h b/src/gui/containers/FloatingContainerWindow.h index 084bcdef53..0d30769b5b 100644 --- a/src/gui/containers/FloatingContainerWindow.h +++ b/src/gui/containers/FloatingContainerWindow.h @@ -32,6 +32,10 @@ class FloatingContainerWindow : public QWidget { ContainerWidget* releaseContainer(); ContainerWidget* container() const { return m_container; } + // Retain the outer window's explicit show/hide request while the hosted + // container is temporarily unavailable. The content itself stays shown. + void setVisible(bool visible) override; + // AppSettings key under which to store geometry (serialized as // base64 of QByteArray returned by saveGeometry()). Empty = no // persistence. @@ -71,8 +75,13 @@ class FloatingContainerWindow : public QWidget { private: void saveGeometryToKey() const; + void applyPresentation(); ContainerWidget* m_container{nullptr}; + QMetaObject::Connection m_presentationConnection; + QMetaObject::Connection m_containerVisibilityConnection; + QMetaObject::Connection m_containerDestroyedConnection; + bool m_requestedVisible{false}; QVBoxLayout* m_layout{nullptr}; QString m_geometryKey; bool m_restoring{false}; diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index 52970c243e..f07a2a72c7 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -2003,12 +2003,15 @@ RadioModel::RadioModel(QObject* parent) qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); + qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); @@ -6942,6 +6945,10 @@ void RadioModel::stageSessionModelsForReconnect() if (slice) { slice->invalidateSquelchState(); slice->invalidateFrequencyObservation(); + SliceDelta unavailableWfm; + unavailableWfm.wfmStereoStatus = WfmStereoStatus::Unavailable; + unavailableWfm.wfmReceptionDiagnostics = WfmReceptionDiagnostics{}; + slice->applyChanges(unavailableWfm); m_staleSlices.insert(slice->sliceId(), slice); } } @@ -9839,6 +9846,10 @@ void RadioModel::wireSliceReceiveIntentsToBackend(SliceModel* s) this, &RadioModel::dispatchSliceDsp, type); connect(s, &SliceModel::receiveAudioRequested, this, &RadioModel::dispatchSliceAudio, type); + connect(s, &SliceModel::wfmForceMonoRequested, + this, &RadioModel::dispatchSliceWfmForceMono, type); + connect(s, &SliceModel::wfmDeemphasisRequested, + this, &RadioModel::dispatchSliceWfmDeemphasis, type); connect(s, &SliceModel::receiveSquelchRequested, this, &RadioModel::dispatchSliceSquelch, type); connect(s, &SliceModel::receiveRxAntennaRequested, @@ -9931,6 +9942,31 @@ void RadioModel::dispatchSliceAudio(const SliceAudioRequest& request) } } +void RadioModel::dispatchSliceWfmForceMono(bool forceMono) +{ + dispatchSliceWfm({SliceWfmRequest::Field::ForceMono, int(forceMono)}); +} + +void RadioModel::dispatchSliceWfmDeemphasis(int microseconds) +{ + dispatchSliceWfm({SliceWfmRequest::Field::Deemphasis, microseconds}); +} + +void RadioModel::dispatchSliceWfm(const SliceWfmRequest& request) +{ + if (SliceModel* source = receiveCommandSource()) { + const std::optional feature = m_backend->capabilities().broadcastFmReceive; + const bool available = request.valid() && feature + && source->mode() == QLatin1String("WFM") + && !source->externalReceiveReplacementActive() + && (request.field == SliceWfmRequest::Field::ForceMono + ? feature->forceMonoControl : feature->deemphasisUs.contains(request.value)); + reportReceiveDispatch(available + ? m_backend->requestSliceWfm(source->sliceId(), request) + : ReceiveDispatch::Unsupported, QStringLiteral("broadcast FM")); + } +} + void RadioModel::dispatchSliceSquelch(const SliceSquelchRequest& request) { if (SliceModel* source = receiveCommandSource()) { diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index d986c52252..247c43a496 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -1746,6 +1746,9 @@ private slots: void dispatchSliceAgc(const SliceAgcRequest& request); void dispatchSliceDsp(const SliceDspRequest& request); void dispatchSliceAudio(const SliceAudioRequest& request); + void dispatchSliceWfmForceMono(bool forceMono); + void dispatchSliceWfmDeemphasis(int microseconds); + void dispatchSliceWfm(const SliceWfmRequest& request); void dispatchSliceSquelch(const SliceSquelchRequest& request); void dispatchSliceRxAntenna(const QString& antenna); void dispatchSliceLock(bool locked); diff --git a/src/models/SliceModel.cpp b/src/models/SliceModel.cpp index e58f0943a2..60ecf34e21 100644 --- a/src/models/SliceModel.cpp +++ b/src/models/SliceModel.cpp @@ -767,6 +767,19 @@ void SliceModel::setAgcOffLevel(int value) m_agcThreshold, value}); } +void SliceModel::setWfmForceMono(bool forceMono) +{ + // Selection is intent until the prepared decoder revision is accepted. + emit wfmForceMonoRequested(forceMono); +} + +void SliceModel::setWfmDeemphasis(int microseconds) +{ + if (microseconds != 50 && microseconds != 75) { return; } + // Requests never update observed state, even on optimistic legacy slices. + emit wfmDeemphasisRequested(microseconds); +} + void SliceModel::setSquelch(bool on, int level) { if (refuseOffThread(__func__)) { return; } @@ -1154,6 +1167,7 @@ void SliceModel::setExternalReceiveAudioReplacementMute(bool active, bool restoreMute) { const QPointer alive(this); + const bool previousReplacement = m_externalReceiveAudioReplacement; const bool previousVisibleMute = audioMute(); const float previousVisibleGain = audioGain(); const int previousVisiblePan = audioPan(); @@ -1238,6 +1252,9 @@ void SliceModel::setExternalReceiveAudioReplacementMute(bool active, if (m_externalReceiveAutoSquelch != previousExternalAutoSquelch) { emit externalReceiveAutoSquelchChanged(m_externalReceiveAutoSquelch); } + if (m_externalReceiveAudioReplacement != previousReplacement) { + emit externalReceiveReplacementChanged(m_externalReceiveAudioReplacement); + } } void SliceModel::prepareExternalReceiveAudioReplacementBandRecall( @@ -1768,6 +1785,46 @@ void SliceModel::applyChanges(const SliceDelta& d) const int v = *d.agcOffLevel; if (m_agcOffLevel != v) { m_agcOffLevel = v; emit agcOffLevelChanged(v); } } + if (d.wfmDeemphasisUs && (*d.wfmDeemphasisUs == 50 || *d.wfmDeemphasisUs == 75) + && m_wfmDeemphasisUs != *d.wfmDeemphasisUs) { + m_wfmDeemphasisUs = *d.wfmDeemphasisUs; + emit wfmDeemphasisChanged(m_wfmDeemphasisUs); + } + if (d.wfmForceMono && m_wfmForceMono != *d.wfmForceMono) { + m_wfmForceMono = *d.wfmForceMono; + emit wfmForceMonoChanged(m_wfmForceMono); + } + if (d.wfmStereoStatus) { + const WfmStereoStatus status = *d.wfmStereoStatus; + switch (status) { + case WfmStereoStatus::Unavailable: + case WfmStereoStatus::Acquiring: + case WfmStereoStatus::Mono: + case WfmStereoStatus::Stereo: + if (m_wfmStereoStatus != status) { + m_wfmStereoStatus = status; + emit wfmStereoStatusChanged(status); + } + break; + } + } + if (d.wfmReceptionDiagnostics) { + WfmReceptionDiagnostics value = *d.wfmReceptionDiagnostics; + if (!value.valid || !std::isfinite(value.pilotMagnitude) + || value.pilotMagnitude < 0.0 + || !std::isfinite(value.pilotEngageThreshold) || !std::isfinite(value.pilotReleaseThreshold) + || value.pilotReleaseThreshold < 0.0 + || value.pilotEngageThreshold <= value.pilotReleaseThreshold + || value.engageBlocks == 0 || value.releaseBlocks == 0 + || value.stableDurationMs > 5000 + || value.lockDurationMs > value.observationDurationMs + || value.stableDurationMs > value.observationDurationMs + || (!value.pilotLocked && value.lockDurationMs != 0)) { value = {}; } + if (m_wfmReceptionDiagnostics != value) { + m_wfmReceptionDiagnostics = value; + emit wfmReceptionDiagnosticsChanged(value); + } + } if (d.squelchOn.has_value() || d.squelchLevel.has_value()) { m_squelchOnKnown |= d.squelchOn.has_value(); m_squelchLevelKnown |= d.squelchLevel.has_value(); diff --git a/src/models/SliceModel.h b/src/models/SliceModel.h index 949b137fbc..28041f9fe5 100644 --- a/src/models/SliceModel.h +++ b/src/models/SliceModel.h @@ -175,6 +175,11 @@ class SliceModel : public QObject { return m_externalReceiveAutoSquelch; } int squelchLevel()const { return m_squelchLevel; } + // Zero means not observed; never present a default as adopted DSP state. + int wfmDeemphasisUs() const { return m_wfmDeemphasisUs; } + bool wfmForceMono() const { return m_wfmForceMono; } + const WfmReceptionDiagnostics& wfmReceptionDiagnostics() const { return m_wfmReceptionDiagnostics; } + WfmStereoStatus wfmStereoStatus() const { return m_wfmStereoStatus; } int flexSquelchLevel() const { return m_squelchLevel; } int receiveSquelchLevel() const { return m_externalReceiveAudioReplacement ? m_externalReceiveSquelchLevel @@ -315,6 +320,8 @@ class SliceModel : public QObject { void setAgcThreshold(int value); void setAgcOffLevel(int value); void setSquelch(bool on, int level); + void setWfmDeemphasis(int microseconds); + void setWfmForceMono(bool forceMono); // For genuine operator-driven manual squelch input only (a VFO flag's // own SQL controls, a controller-mapped squelch knob) — setSquelch() // plus recording the level as the operator's manual choice, in one @@ -486,7 +493,14 @@ class SliceModel : public QObject { void externalReceiveAgcThresholdChanged(int value); void externalReceiveAgcOffLevelChanged(int value); void externalReceiveAutoSquelchChanged(bool on); + void externalReceiveReplacementChanged(bool active); void squelchChanged(bool on, int level); + void wfmDeemphasisChanged(int microseconds); + void wfmForceMonoChanged(bool forceMono); + void wfmForceMonoRequested(bool forceMono); + void wfmReceptionDiagnosticsChanged(const AetherSDR::WfmReceptionDiagnostics& diagnostics); + void wfmStereoStatusChanged(AetherSDR::WfmStereoStatus status); + void wfmDeemphasisRequested(int microseconds); void externalReceiveSquelchChanged(bool on, int level); void stepChanged(int hz, const QVector& stepList); void ritChanged(bool on, int hz); @@ -673,6 +687,10 @@ class SliceModel : public QObject { bool m_squelchLevelKnown{false}; bool m_squelchOn{false}; int m_squelchLevel{20}; + int m_wfmDeemphasisUs{0}; + bool m_wfmForceMono{false}; + WfmReceptionDiagnostics m_wfmReceptionDiagnostics; + WfmStereoStatus m_wfmStereoStatus{WfmStereoStatus::Unavailable}; int m_manualSquelchLevel{20}; bool m_squelchEchoIsManual{true}; int m_stepHz{100}; diff --git a/tests/audio_engine_pcm_lifetime_test.cpp b/tests/audio_engine_pcm_lifetime_test.cpp index 342655e3e6..eaa1d17dd6 100644 --- a/tests/audio_engine_pcm_lifetime_test.cpp +++ b/tests/audio_engine_pcm_lifetime_test.cpp @@ -137,19 +137,21 @@ class AudioEngineRatesTestAccess { engine.m_rxPackets.push_back(queued(producerRate, 3)); engine.m_rxOutputBuffer = queued(deviceRate, 7); engine.m_kiwiSdrRxBuffer = queued(24000, 11); - engine.m_kiwiSdrRxPackets.push_back(queued(24000, 13)); + // Each invocation replaces this synthetic snapshot. A sink-rate + // change correctly preserves producer packets and historical peaks. + engine.m_kiwiSdrRxPackets = {queued(24000, 13)}; engine.m_kiwiSdrOutputBuffer = queued(deviceRate, 17); AudioEngine::ExternalRxAudioSourceState* first = engine.externalKiwiSource(QStringLiteral("duration-first"), true); first->enabled = true; first->rxBuffer = queued(24000, 19); - first->rxPackets.push_back(queued(24000, 23)); + first->rxPackets = {queued(24000, 23)}; first->outputBuffer = queued(deviceRate, 29); AudioEngine::ExternalRxAudioSourceState* second = engine.externalKiwiSource(QStringLiteral("duration-second"), true); second->enabled = true; second->rxBuffer = queued(24000, 31); - second->rxPackets.push_back(queued(24000, 37)); + second->rxPackets = {queued(24000, 37)}; second->outputBuffer = queued(deviceRate, 41); engine.m_radeRxBuffer = queued(deviceRate, 43); engine.updateRxBufferStats(); diff --git a/tests/audio_engine_rates_test.cpp b/tests/audio_engine_rates_test.cpp index 6d4da1756b..71451e3d48 100644 --- a/tests/audio_engine_rates_test.cpp +++ b/tests/audio_engine_rates_test.cpp @@ -2,6 +2,7 @@ // output device. No QAudioSink, socket peer, radio connection, or RF is used. #include "TestSettingsProfile.h" #include "core/AudioEngine.h" +#include "core/AudioFormatNegotiator.h" #include "core/ClientComp.h" #include "core/ClientDeEss.h" #include "core/ClientEq.h" @@ -13,6 +14,7 @@ #include #include +#include #include #include #include @@ -35,6 +37,20 @@ class AudioEngineRatesTestAccess { engine.setRxDeviceRate(deviceRate); } static void detach(AudioEngine& engine) { engine.m_audioDevice = nullptr; } + static void manageSink(AudioEngine& engine, std::function opener) + { + engine.m_rxSinkOpener = std::move(opener); + engine.m_rxSinkProducerRate = 24000; + ++engine.m_rxSinkGeneration; + } + static void dispatchRateChange(AudioEngine& engine) + { + QCoreApplication::sendPostedEvents(&engine, QEvent::MetaCall); + } + static void closeSink(AudioEngine& engine) { engine.closeRxSink(); } + static void replaceSinkGeneration(AudioEngine& engine) { ++engine.m_rxSinkGeneration; } + static bool openSink(AudioEngine& engine, int rate) { return engine.openRxSink(rate); } + static bool hasSink(const AudioEngine& engine) { return engine.m_audioDevice != nullptr; } static void drain(AudioEngine& engine, qsizetype bytes) { engine.drainRxAudio(bytes); } static qsizetype rawBytes(const AudioEngine& engine) { @@ -283,6 +299,7 @@ void bandwidthAndMono() { for (int deviceRate : {24000, 44100, 48000}) { Fixture fixture(deviceRate); + fixture.capture(); PcmProducer producer; producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); for (int tick = 0; tick < 100; ++tick) { @@ -290,14 +307,17 @@ void bandwidthAndMono() fixture.tick(); } fixture.finish(); - check(amplitude(fixture.output.data(), deviceRate, 1, 1000) > 0.15, - "wide producer preserves low-band stereo channel"); + const QByteArray final = fixture.captured(QStringLiteral("final"), QStringLiteral("mix")); + check(!final.isEmpty() && final == fixture.output.data(), + "final wideband capture is the exact PCM written to the speaker device"); + check(amplitude(final, deviceRate, 1, 1000) > 0.15, + "wide producer preserves low-band stereo channel at the final speaker output"); if (deviceRate >= 44100) { - check(amplitude(fixture.output.data(), deviceRate, 0, 15000) > 0.27, - "48 kHz producer retains 15 kHz audio bandwidth"); + check(amplitude(final, deviceRate, 0, 15000) > 0.27, + "48 kHz producer retains 15 kHz at the final speaker output"); } else { - check(amplitude(fixture.output.data(), deviceRate, 0, 9000) < 0.003, - "48 to 24 conversion rejects the 15 kHz alias"); + check(amplitude(final, deviceRate, 0, 9000) < 0.003, + "48 to 24 fallback rejects the 15 kHz alias at the final speaker output"); } } for (int producerRate : {24000, 48000}) { @@ -322,6 +342,253 @@ void bandwidthAndMono() } } +struct NegotiatingFixture : Fixture { + QList supportedRates; + QList requestedRates; + int streamEvents = 0; + int failedOpens = 0; + + explicit NegotiatingFixture(QList rates, int initialRate = 24000) + : Fixture(initialRate), supportedRates(std::move(rates)) + { + QObject::connect(&engine, &AudioEngine::rxStarted, &engine, + [this]() { ++streamEvents; }); + QObject::connect(&engine, &AudioEngine::rxStopped, &engine, + [this]() { ++streamEvents; }); + AudioEngineRatesTestAccess::manageSink(engine, [this](int producerRate) { + requestedRates.append(producerRate); + if (failedOpens > 0) { + --failedOpens; + return false; + } + AudioFormatNegotiator::DeviceCaps caps; + caps.supportedRates = supportedRates; + caps.supportedFormats = {AudioFormatNegotiator::SampleFmt::Float32}; + caps.preferredRate = 48000; + const auto selected = AudioFormatNegotiator::negotiate( + AudioFormatNegotiator::hostTargetOs(), + AudioFormatNegotiator::Direction::Output, caps, + AudioFormatNegotiator::ResamplerPolicy::PreservePan, producerRate); + if (!selected.ok) { + return false; + } + deviceRate = selected.rate; + AudioEngineRatesTestAccess::attach(engine, output, deviceRate); + return true; + }); + } + ~NegotiatingFixture() + { + // AudioEngine stops streams in its destructor, after this fixture's + // counters have ended their lifetime. + QObject::disconnect(&engine, nullptr, &engine, nullptr); + } + void dispatch() { AudioEngineRatesTestAccess::dispatchRateChange(engine); } +}; + +void negotiatedSpeakerOutput() +{ + for (int workingRate : {48000, 44100, 24000}) { + NegotiatingFixture fixture({workingRate}); + PcmProducer producer; + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, QVector(960, 0.0f)); + check(fixture.requestedRates.isEmpty() && fixture.deviceRate == 24000, + "accepted native48 queues device negotiation without opening inline"); + fixture.dispatch(); + check(fixture.requestedRates == QList{48000} + && fixture.deviceRate == workingRate + && AudioEngineRatesTestAccess::producerRate(fixture.engine) == 48000, + "accepted producer drives explicit native48 speaker negotiation and fallback"); + fixture.finish(); + fixture.output.buffer().clear(); + fixture.output.seek(0); + fixture.capture(); + for (int tick = 0; tick < 100; ++tick) { + feed(fixture.engine, producer, tone(48000, 480, tick * 480, 15000, 1000)); + fixture.tick(); + } + fixture.finish(); + const QByteArray final = fixture.captured(QStringLiteral("final"), QStringLiteral("mix")); + check(final == fixture.output.data() && finite(final) + && std::abs(final.size() / kFrameBytes - workingRate) <= 512, + "negotiated speaker writes one second of finite PCM at its actual rate"); + check(amplitude(final, workingRate, 1, 1000) > 0.15, + "negotiated speaker retains the independent right audio channel"); + if (workingRate >= 44100) { + check(amplitude(final, workingRate, 0, 15000) > 0.27 + && amplitude(final, workingRate, 1, 15000) < 0.002, + "native48 adoption retains 15 kHz at final speaker output without channel folding"); + } else { + check(amplitude(final, workingRate, 0, 9000) < 0.003, + "refused high-rate speaker falls back to24 with correct anti-aliasing"); + } + // Same-rate discontinuities and fresh epochs are legitimate source + // transitions, but a refused 48 kHz sink must not be retried each time. + fixture.engine.feedPcmFrame(*producer.produce(QVector(960, 0.0f), + std::nullopt, true)); + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, QVector(960, 0.0f)); + fixture.dispatch(); + check(fixture.requestedRates.size() == 1 && fixture.streamEvents == 0, + "same-rate epochs do not retry fallbacks or restart the whole RX stream"); + } + NegotiatingFixture alreadyNative({48000}, 48000); + PcmProducer producer; + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(alreadyNative.engine, producer, tone(48000, 480)); + alreadyNative.dispatch(); + check(alreadyNative.requestedRates.isEmpty() && alreadyNative.deviceRate == 48000, + "an already-native speaker is retained without a needless reopen"); +} + +void negotiationOpenFailure() +{ + for (bool recoverable : {true, false}) { + NegotiatingFixture fixture(recoverable ? QList{24000} : QList{}); + fixture.failedOpens = recoverable ? 1 : 0; + PcmProducer producer; + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(48000, 480)); + const qsizetype queued = AudioEngineRatesTestAccess::rawBytes(fixture.engine); + fixture.dispatch(); + check(fixture.requestedRates == QList({48000, 24000}), + "failed speaker renegotiation retries the previous policy exactly once"); + check(AudioEngineRatesTestAccess::hasSink(fixture.engine) == recoverable + && AudioEngineRatesTestAccess::producerRate(fixture.engine) == 48000 + && AudioEngineRatesTestAccess::rawBytes(fixture.engine) == queued + && fixture.engine.clientEqRx()->sampleRate() == 48000 + && fixture.streamEvents == 0, + "speaker failure recovery preserves producer PCM, DSP and stream lifecycle"); + if (recoverable) { + fixture.tick(); + for (int tick = 1; tick < 100; ++tick) { + feed(fixture.engine, producer, tone(48000, 480, tick * 480)); + fixture.tick(); + } + fixture.finish(); + check(finite(fixture.output.data()) + && amplitude(fixture.output.data(), 24000, 0, 1000) > 0.27 + && amplitude(fixture.output.data(), 24000, 1, 2300) > 0.15, + "restored speaker renders the current producer at the actual device rate"); + } + // Neither a working fallback nor a completely unavailable endpoint + // may turn each new source epoch into another reopen attempt. + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(48000, 480)); + fixture.dispatch(); + check(fixture.requestedRates.size() == 2, + "failed negotiation has no repeated retry on a same-rate source epoch"); + } +} + +void negotiationLifetimeAndCoalescing() +{ + for (int staleCase = 0; staleCase < 4; ++staleCase) { + NegotiatingFixture fixture({24000, 48000}); + PcmProducer producer; + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(48000, 480)); + if (staleCase == 0) { + producer.invalidate(); + } else if (staleCase == 1) { + producer.setFormat({24000, PcmLayout::Stereo}); + } else if (staleCase == 2) { + AudioEngineRatesTestAccess::replaceSinkGeneration(fixture.engine); + } else { + AudioEngineRatesTestAccess::closeSink(fixture.engine); + } + fixture.dispatch(); + check(fixture.requestedRates.isEmpty(), + "revoked lease, changed format, replaced sink or stop cancels queued negotiation"); + } + NegotiatingFixture fixture({24000, 48000}); + PcmProducer producer; + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(48000, 480)); + producer.setFormat({24000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(24000, 240)); + producer.setFormat({48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(48000, 480)); + fixture.dispatch(); + check(fixture.requestedRates == QList{48000} && fixture.deviceRate == 48000, + "format churn coalesces to one current native48 request"); + + const qsizetype nativeAttempts = fixture.requestedRates.size(); + producer.invalidate(); + fixture.tick(); // actual retirement can precede the replacement by an event turn + fixture.dispatch(); + fixture.engine.feedAudioData({}); + fixture.dispatch(); + check(fixture.deviceRate == 48000 && fixture.requestedRates.size() == nativeAttempts, + "retirement and empty ingress do not bounce a native48 speaker to24 during a source gap"); + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(48000, 480)); + fixture.dispatch(); + check(fixture.deviceRate == 48000 && fixture.requestedRates.size() == nativeAttempts, + "replacement native48 epoch after a drained gap reuses the same speaker sink"); + + producer.invalidate(); + fixture.engine.feedAudioData(bytes(tone(24000, 240))); + fixture.dispatch(); + const auto legacy = AudioFormatNegotiator::buildLadder( + AudioFormatNegotiator::hostTargetOs(), AudioFormatNegotiator::Direction::Output, + {}, AudioFormatNegotiator::ResamplerPolicy::PreservePan).first().rate; + check(AudioEngineRatesTestAccess::producerRate(fixture.engine) == 24000 + && fixture.deviceRate == legacy, + "retired native48 route returns to the unchanged legacy24 speaker policy"); + + AudioEngineRatesTestAccess::closeSink(fixture.engine); + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + const qsizetype attempts = fixture.requestedRates.size(); + feed(fixture.engine, producer, tone(48000, 480)); + fixture.dispatch(); + check(fixture.requestedRates.size() == attempts, + "PCM arrival cannot reopen an explicitly stopped speaker"); + check(AudioEngineRatesTestAccess::openSink(fixture.engine, 48000) + && fixture.deviceRate == 48000, + "an explicit fresh sink open can retry the accepted producer domain"); +} + +void auxiliaryAcrossNegotiation() +{ + NegotiatingFixture fixture({24000, 48000}); + fixture.engine.setKiwiSdrAudioSourceEnabled(kKiwiId, true); + check(AudioEngineRatesTestAccess::prepareKiwi(fixture.engine), + "auxiliary processing initializes before speaker transition"); + PcmProducer auxiliary; + auxiliary.start(PcmPurpose::Auxiliary); + quint64 offset = 0; + for (int packet = 0; packet < 40; ++packet) { + fixture.engine.feedKiwiPcmFrame(kKiwiId, + *auxiliary.produce(tone(24000, 240, offset, 700, 3200))); + offset += 240; + } + const qsizetype queued = AudioEngineRatesTestAccess::kiwiBytes(fixture.engine, kKiwiId); + PcmProducer main; + main.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, main, QVector(960, 0.0f)); + fixture.dispatch(); + check(queued > 0 && AudioEngineRatesTestAccess::kiwiBytes(fixture.engine, kKiwiId) == queued, + "speaker rate change preserves admitted auxiliary24 producer samples"); + fixture.capture(); + for (int tick = 0; tick < 100; ++tick) { + feed(fixture.engine, main, tone(48000, 480, tick * 480, 15000, 1000)); + fixture.engine.feedKiwiPcmFrame(kKiwiId, + *auxiliary.produce(tone(24000, 240, offset, 700, 3200))); + offset += 240; + fixture.tick(); + } + const QByteArray aux = fixture.captured(QStringLiteral("post"), QStringLiteral("kiwi"), kKiwiId); + check(finite(aux) && amplitude(aux, 48000, 0, 700) > 0.27 + && amplitude(aux, 48000, 1, 3200) > 0.15 + && amplitude(aux, 48000, 1, 700) < 0.002, + "admitted auxiliary24 retains stereo layout and frequencies at renegotiated48"); + check(AudioEngineRatesTestAccess::recordedCwRate(fixture.engine) == 24000 + && fixture.streamEvents == 0, + "speaker-only reopen leaves fixed CW recording and whole-stream lifecycle unchanged"); +} + void transitionsAndRejection() { Fixture fixture(48000); @@ -423,15 +690,17 @@ void queueBudgetsAndDeviceTransitions() check(fixture.engine.receivePresentationAudioQueues().flexRawBufferMs >= 100, "presentation delay retains the configured producer duration"); + const qsizetype producerBytes = AudioEngineRatesTestAccess::rawBytes(fixture.engine); AudioEngineRatesTestAccess::deviceRate(fixture.engine, 24000); fixture.deviceRate = 24000; - check(AudioEngineRatesTestAccess::rawBytes(fixture.engine) == 0 + check(AudioEngineRatesTestAccess::rawBytes(fixture.engine) == producerBytes && AudioEngineRatesTestAccess::outputBytes(fixture.engine) == 0, - "device format transition discards queued old-rate samples"); + "device format transition preserves producer bytes and discards old device bytes"); check(AudioEngineRatesTestAccess::producerRate(fixture.engine) == producerRate && AudioEngineRatesTestAccess::outputRate(fixture.engine) == 24000, "device renegotiation retains the producer rate"); fixture.engine.setReceivePresentationDelays(0, 0); + fixture.finish(); // consume the producer-domain samples preserved above const qsizetype before = fixture.output.data().size(); for (int tick = 0; tick < 100; ++tick) { feed(fixture.engine, producer, tone(producerRate, producerRate / 100, @@ -831,6 +1100,10 @@ int main(int argc, char** argv) check(settings.isValid(), "isolated settings profile"); rateMatrix(); bandwidthAndMono(); + negotiatedSpeakerOutput(); + negotiationOpenFailure(); + negotiationLifetimeAndCoalescing(); + auxiliaryAcrossNegotiation(); transitionsAndRejection(); queueBudgetsAndDeviceTransitions(); concurrentSourcesAndEffects(); diff --git a/tests/audio_format_negotiation_test.cpp b/tests/audio_format_negotiation_test.cpp index 882a5295e6..e75cad2cf6 100644 --- a/tests/audio_format_negotiation_test.cpp +++ b/tests/audio_format_negotiation_test.cpp @@ -83,6 +83,83 @@ DeviceCaps dev(QList rates, return c; } +// The RX speaker caller can supply a native 48 kHz producer. Device preference +// is not proof that an open succeeds: retain a 24 kHz fallback even when the +// device advertises 48 kHz but accepts only 24 kHz. All inputs remain injected. +void native48OutputPolicy() +{ + const auto floatRates = [](TargetOs os, Direction direction, + const DeviceCaps& caps, int internalRate, + ResamplerPolicy policy = ResamplerPolicy::PreservePan) { + QList rates; + for (const FormatCandidate& candidate : buildLadder( + os, direction, caps, policy, internalRate)) { + if (candidate.fmt == SampleFmt::Float32) { + rates.append(candidate.rate); + } + } + return rates; + }; + for (TargetOs os : {TargetOs::Windows, TargetOs::MacOS, TargetOs::Linux}) { + const std::string prefix = std::string("native48 output / ") + toString(os); + DeviceCaps caps = dev({48000, 44100, 24000}); + caps.preferredRate = 48000; + report(prefix + " Float open order retains 48 -> 44.1 -> 24 without retrying preferred48", + floatRates(os, Direction::Output, caps, 48000) + == QList{48000, 44100, 24000}); + + // Exercise reliable capability checks and the try-at-open policy used + // by WASAPI. Each row removes the previous successful rate while the + // advertised preferred format remains 48 kHz throughout. + for (bool reliable : {true, false}) { + caps.isFormatSupportedReliable = reliable; + for (int workingRate : {48000, 44100, 24000}) { + caps.supportedRates = workingRate == 48000 + ? QList{24000, 44100, 48000} + : workingRate == 44100 ? QList{24000, 44100} + : QList{24000}; + const NegotiatedFormat selected = negotiate( + os, Direction::Output, caps, ResamplerPolicy::PreservePan, 48000); + const ResamplerKind expectedResampler = workingRate == 48000 + ? ResamplerKind::None : ResamplerKind::PreservePan; + report(prefix + (reliable ? " reliable" : " try-at-open") + + " chooses " + std::to_string(workingRate) + + " with advertised preferred48", + selected.ok && selected.rate == workingRate + && selected.fmt == SampleFmt::Float32 && selected.channels == 2 + && selected.resampler == expectedResampler + && selected.fellBack == (workingRate != 48000), + fmtOf(selected)); + } + } + + // Freeze the existing orders outside this narrow output case. In + // particular, passing internalRate=48000 must not give input capture + // the new output fallback sequence. + const QList legacyOutput = os == TargetOs::Linux + ? QList{24000, 48000, 44100} : QList{48000, 24000, 44100}; + report(prefix + " preserves default24 output order", + floatRates(os, Direction::Output, caps, kInternalRate) == legacyOutput); + for (ResamplerPolicy policy : {ResamplerPolicy::RegenerateAtRate, + ResamplerPolicy::MonoCollapse}) { + const std::string policyName = policy == ResamplerPolicy::RegenerateAtRate + ? "RegenerateAtRate" : "MonoCollapse"; + report(prefix + " preserves native48 " + policyName + " output order", + floatRates(os, Direction::Output, caps, 48000, policy) + == QList{48000, 44100}); + } + for (int internalRate : {24000, 48000}) { + const QList inputOrder = os == TargetOs::Linux + ? (internalRate == 24000 ? QList{48000, 24000, 44100} + : QList{48000, 44100}) + : (internalRate == 24000 ? QList{48000, 44100, 24000, 16000} + : QList{48000, 44100, 16000}); + report(prefix + " preserves input" + std::to_string(internalRate) + " order", + floatRates(os, Direction::Input, caps, internalRate) == inputOrder); + } + } +} + } // namespace int main() @@ -98,6 +175,8 @@ int main() const auto Mac = TargetOs::MacOS; const auto Lin = TargetOs::Linux; + native48OutputPolicy(); + // ── Standard 48k-only DAC, RX speaker (PreservePan) ─────────────────────── runRow({"std 48k DAC / Win / RX", Win, Out, Pan, dev({48000}), true, 48000, F, ResamplerKind::PreservePan, 2, false}); diff --git a/tests/container_widget_test.cpp b/tests/container_widget_test.cpp index 69eb081238..ba152e40d1 100644 --- a/tests/container_widget_test.cpp +++ b/tests/container_widget_test.cpp @@ -1,8 +1,8 @@ // Phase 1 smoke tests for the container system — exercises the // ContainerWidget <-> FloatingContainerWindow float/dock cycle. // -// Headless-ish: uses QApplication so the widgets can instantiate and -// process signals, but never calls show() (no X11 / display needed). +// Uses QApplication and the offscreen platform to exercise real show/hide +// and reparent behavior without a radio, sound device or visible desktop. // Run: ./build/container_widget_test #include "TestSettingsProfile.h" @@ -91,6 +91,134 @@ void testVisibilitySignal() spy.count() == 0); } +void testTransientPresentation() +{ + QWidget panel; + auto* layout = new QVBoxLayout(&panel); + ContainerWidget c("conditional", "Conditional"); + c.setContent(new QLabel("payload")); + layout->addWidget(&c); + QSignalSpy visibility(&c, &ContainerWidget::visibilityChanged); + + // makeEntry's default-open call is a logical no-op on a fresh container. + // The first availability transition must still retain that open intent. + c.setContainerVisible(true); + c.setPresentationAvailable(false); + panel.show(); + c.setPresentationAvailable(true); + report("default logical open survives the first construction gate", c.isVisible()); + c.setPresentationAvailable(false); + QWidget* generic = &c; + generic->show(); + report("direct show cannot expose a suppressed docked container", + c.isContainerVisible() && c.isHidden() && !c.isVisible()); + panel.hide(); + panel.show(); + report("ancestor show cannot bypass presentation suppression", c.isHidden()); + c.setPresentationAvailable(true); + report("availability restores the requested docked presentation", c.isVisible()); + panel.hide(); + c.setPresentationAvailable(false); + c.setPresentationAvailable(true); + panel.show(); + report("ancestor hiding does not become an explicit child hide", c.isVisible()); + report("availability emits no logical close or open", visibility.isEmpty()); + + c.setPresentationAvailable(false); + generic->hide(); + c.setPresentationAvailable(true); + report("explicit direct hide remains hidden after availability returns", + c.isHidden() && c.isContainerVisible()); + c.setContainerVisible(false); + c.setPresentationAvailable(false); + c.setContainerVisible(true); + report("logical reopen under suppression stays physically hidden", + c.isContainerVisible() && c.isHidden()); + c.setPresentationAvailable(true); + report("logical reopen restores when available", c.isVisible()); + c.setContainerVisible(false); + c.setPresentationAvailable(false); + c.setPresentationAvailable(true); + report("logical close remains closed across availability changes", + !c.isContainerVisible() && c.isHidden()); +} + +void testFloatingPresentation() +{ + ContainerWidget c("conditional-float", "Conditional float"); + c.setContent(new QLabel("payload")); + QSignalSpy visibility(&c, &ContainerWidget::visibilityChanged); + FloatingContainerWindow win; + c.setPresentationAvailable(false); + win.takeContainer(&c); + QWidget* generic = &win; + generic->show(); + report("suppressed float hides its outer window but keeps content shown", + win.isHidden() && !c.isHidden() && c.isContainerVisible()); + c.setPresentationAvailable(true); + report("float availability returns without blank content", + win.isVisible() && c.isVisible() && c.content()->isVisible()); + c.setPresentationAvailable(false); + win.setAlwaysOnTop(true); + win.setFramelessMode(false); + report("window flag changes cannot reveal a suppressed float", win.isHidden()); + c.setPresentationAvailable(true); + report("window flag recreation retains the suppressed show request", + win.isVisible() && c.content()->isVisible()); + c.setPresentationAvailable(false); + generic->hide(); + c.setPresentationAvailable(true); + report("explicitly hidden float is not reopened by availability", win.isHidden()); + generic->show(); + c.setPresentationAvailable(false); + c.setContainerVisible(false); + c.setPresentationAvailable(true); + report("logical close during suppressed float cannot restore a blank window", + win.isHidden() && !c.isContainerVisible()); + generic->show(); + report("unconditional restore show cannot override a managed logical close", win.isHidden()); + c.setContainerVisible(true); + report("logical reopen restores both managed window and content", + win.isVisible() && c.isVisible() && c.content()->isVisible()); + c.setContainerVisible(false); + report("logical close also suppresses an available managed float", win.isHidden()); + c.setPresentationAvailable(false); + c.setContainerVisible(true); + report("logical reopen while unavailable remains suppressed", win.isHidden()); + c.setPresentationAvailable(true); + report("availability then restores the new logical reopen", win.isVisible()); + visibility.clear(); + c.setPresentationAvailable(false); + win.releaseContainer(); + generic->hide(); + c.setPresentationAvailable(true); + report("released container cannot toggle its former window", win.isHidden()); + c.setPresentationAvailable(false); + QWidget panel; + auto* layout = new QVBoxLayout(&panel); + layout->addWidget(&c); + c.show(); + panel.show(); + report("docking a suppressed float reapplies the container gate", + c.isPanelDocked() && c.isHidden() && c.isContainerVisible()); + c.setPresentationAvailable(true); + report("docked content recovers without losing its body", c.content()->isVisible()); + report("floating availability never emits a persisted visibility change", + visibility.isEmpty()); + c.setParent(nullptr); // stack-owned; panel must not destroy it first + + ContainerWidget replacement("replacement", "Replacement"); + replacement.setContent(new QLabel("new payload")); + win.takeContainer(&c); + generic->show(); + win.takeContainer(&replacement); + c.setPresentationAvailable(false); + report("replaced container cannot suppress the new window owner", win.isVisible()); + replacement.setPresentationAvailable(false); + report("only the current hosted container gates its window", win.isHidden()); + win.releaseContainer(); +} + void testFloatDockCycle() { ContainerWidget c("id", "T"); @@ -116,6 +244,12 @@ void testFloatDockCycle() report("dockMode change signal fired", modeSpy.count() >= 1); + win.show(); + c.setContainerVisible(false); + report("unmanaged float retains its existing outer-window behavior", + win.isVisible() && !c.isPresentationManaged()); + c.setContainerVisible(true); + // Release → back to docked state. ContainerWidget* released = win.releaseContainer(); report("releaseContainer returns the same container", @@ -222,6 +356,8 @@ int main(int argc, char** argv) testContainerBasics(); testSetContent(); testVisibilitySignal(); + testTransientPresentation(); + testFloatingPresentation(); testFloatDockCycle(); testFloatingWidthPolicy(); testDefaultFloatingSize(); diff --git a/tests/mode_filter_presets_test.cpp b/tests/mode_filter_presets_test.cpp index 02edfd6f78..b978eae6b4 100644 --- a/tests/mode_filter_presets_test.cpp +++ b/tests/mode_filter_presets_test.cpp @@ -24,6 +24,7 @@ private slots: void unknownModesGetTheSsbLadder(); void fmLaddersRequireDeclaredControl(); void fmPresetsRespectDeclaredEdges(); + void broadcastFmPresetsRespectDeclaredEdges(); }; void ModeFilterPresetsTest::ssbPinsItsLowCutAndDerivesTheHigh() @@ -158,5 +159,25 @@ void ModeFilterPresetsTest::fmPresetsRespectDeclaredEdges() QVERIFY(acceptsFmEdges(QStringLiteral("FMN"), &control, {-4000, 6000})); } +void ModeFilterPresetsTest::broadcastFmPresetsRespectDeclaredEdges() +{ + const AetherSDR::ReceiveFilterControl control{ + AetherSDR::SliceFrequencyControl::Authority::Engine, + {{QStringLiteral("WFM"), -90000, -15000, 15000, 80000, 120000, 180000}}}; + const QVector expected{120000, 140000, 160000}; + for (const char* spelling : {"WFM", "WBFM"}) { + const QString mode = QString::fromLatin1(spelling); + QVERIFY(widthsForMode(mode).isEmpty()); + QVERIFY(widthsForMode(mode, nullptr).isEmpty()); + QCOMPARE(widthsForMode(mode, &control), expected); + for (int width : expected) { + QVERIFY(acceptsFmEdges(mode, &control, edgesForWidth(mode, width, {}))); + } + QVERIFY(acceptsFmEdges(mode, &control, {-90000, 80000})); + QVERIFY(!acceptsFmEdges(mode, &control, {-90000, 90000})); + } + QVERIFY(widthsForMode(QStringLiteral("FM"), &control).isEmpty()); +} + QTEST_MAIN(ModeFilterPresetsTest) #include "mode_filter_presets_test.moc" diff --git a/tests/qso_recorder_rates_test.cpp b/tests/qso_recorder_rates_test.cpp index 73d1a39096..1873bc20ba 100644 --- a/tests/qso_recorder_rates_test.cpp +++ b/tests/qso_recorder_rates_test.cpp @@ -12,10 +12,12 @@ #include #include +#include #include #include #include #include +#include #include #include @@ -127,6 +129,23 @@ qint16 sampleAt(const QByteArray& wav, int frame, int channel) return qFromLittleEndian(wav.constData() + offset); } +double wavAmplitude(const QByteArray& wav, int rate, int channel, double frequency) +{ + const int frames = static_cast((wav.size() - 44) / 4); + if (frames <= 0) { + return 0.0; + } + double real = 0.0; + double imaginary = 0.0; + for (int frame = 0; frame < frames; ++frame) { + const double angle = 2.0 * std::numbers::pi * frequency * frame / rate; + const double sample = sampleAt(wav, frame, channel) / 32767.0; + real += sample * std::cos(angle); + imaginary += sample * std::sin(angle); + } + return 2.0 * std::hypot(real, imaginary) / frames; +} + void verifyIsolatedSegment(const QByteArray& wav, int firstFrame, int frames, int activeChannel, const char* message) { @@ -204,6 +223,43 @@ void knownProducerRateAndDuration() } } +void writtenWideStereoSpectrum() +{ + // Inspect actual production WAV samples: a 48 kHz header alone cannot + // reveal a hidden 24 kHz bottleneck or a collapsed stereo recording path. + constexpr int kRate = 48000; + QTemporaryDir directory; + check(directory.isValid(), "create wide-stereo recording directory"); + QsoRecorder recorder; + configure(recorder, directory.path()); + PcmProducer producer; + check(producer.start(PcmPurpose::Speaker, -1, {kRate, PcmLayout::Stereo}), + "start current 48 kHz stereo recording source"); + recorder.feedRxFrame(produce(producer, stereo(1, 0.0f, 0.0f))); + recorder.startRecording(); + check(recorder.isRecording(), "wide-stereo recording starts after current metadata"); + const QString path = recorder.recordingFilePath(); + QVector samples(kRate * 2); + for (int frame = 0; frame < kRate; ++frame) { + const double angle = 2.0 * std::numbers::pi * frame / kRate; + samples[2 * frame] = static_cast(0.3 * std::sin(15000.0 * angle)); + samples[2 * frame + 1] = static_cast(0.17 * std::sin(1700.0 * angle)); + } + recorder.feedRxFrame(produce(producer, std::move(samples))); + check(recorder.stopRecording() == 1, "written wide-stereo duration is one second"); + const QByteArray wav = readFile(path); + verifyWav(wav, kRate, kRate); + check(std::abs(wavAmplitude(wav, kRate, 0, 15000) - 0.3) < 0.001, + "actual 48 kHz WAV retains the left 15 kHz tone amplitude"); + check(std::abs(wavAmplitude(wav, kRate, 1, 1700) - 0.17) < 0.001, + "actual 48 kHz WAV retains the independent right tone amplitude"); + check(wavAmplitude(wav, kRate, 0, 1700) < 0.001 + && wavAmplitude(wav, kRate, 1, 15000) < 0.001, + "actual WAV preserves stereo without cross-channel tone leakage"); + check(wavAmplitude(wav, kRate, 0, 9000) < 0.001, + "actual WAV does not contain a 9 kHz alias from a hidden 24 kHz path"); +} + void legacyAndMonoQuantization() { QTemporaryDir directory; @@ -660,6 +716,7 @@ int main(int argc, char** argv) AppSettings::instance().save(); finalizedStopDuration(); knownProducerRateAndDuration(); + writtenWideStereoSpectrum(); legacyAndMonoQuantization(); earlyAndTxFirstRateSelection(); replacementReconnectAndReplay(); diff --git a/tests/rtl_audio_mixer_test.cpp b/tests/rtl_audio_mixer_test.cpp index cbd8650582..8aa9d1954a 100644 --- a/tests/rtl_audio_mixer_test.cpp +++ b/tests/rtl_audio_mixer_test.cpp @@ -14,6 +14,16 @@ struct Sink final : Mixer::Sink { std::vector samples; std::uint64_t next = 0; bool valid = true; + struct Missing { + Mixer::Input input; + std::uint64_t first; + std::uint64_t clock; + Mixer::MissingMask mask; + }; + std::vector missing; + void missingFrames(const Mixer::Input& input, std::uint64_t first, + std::uint64_t clock, const Mixer::MissingMask& mask) noexcept override + { missing.push_back({input, first, clock, mask}); } void speakerBlock(std::uint64_t first, std::span input, bool discontinuity) noexcept override { valid &= first == next && discontinuity == samples.empty() && input.size() == Mixer::kQuantum * 2; @@ -26,7 +36,7 @@ int main() std::array inputs{{{0, 1, 1}, {3, 2, 1}}}; std::array left, right; left.fill(0.8f); right.fill(-0.8f); - Sink sink; sink.samples.reserve(40000); + Sink sink; sink.samples.reserve(40000); sink.missing.reserve(64); check(mixer->configure(1, 1, inputs, 0), "sparse independent inputs configured"); check(mixer->push(0, 1, 1, 0, left, right), "first slice queues capture positions"); mixer->drain(128, sink); @@ -42,6 +52,13 @@ int main() mixer->drain(256 + Mixer::kDeadlineFrames, sink); check(sink.next == 256 && sink.samples[256] == 0.4f && sink.samples[257] == -0.4f, "deadline substitutes silence without stalling or shifting healthy audio"); + check(sink.missing.size() == 1 && sink.missing.front().input.slot == 3 + && sink.missing.front().input.instance == 2 && sink.missing.front().input.epoch == 1 + && sink.missing.front().first == 128 + && sink.missing.front().clock == 256 + Mixer::kDeadlineFrames + && sink.missing.front().mask[0] == std::numeric_limits::max() + && sink.missing.front().mask[1] == std::numeric_limits::max(), + "diagnostics distinguish the expired sparse receiver and exact 128-frame hole"); check(mixer->lateFrames() == 128 && !mixer->push(3, 2, 1, 128, left, right), "expired receiver audio cannot reappear at current time"); inputs[0].gain = 0.5f; inputs[0].pan = 0; inputs[1].mute = true; @@ -70,6 +87,20 @@ int main() check(mixer->configure(2, 1, inputs, 0) && mixer->nextSample() == 0, "reconnect starts new source clock"); const auto rejected = mixer->rejectedBlocks(); check(rejected >= 7, "queue refusals are observable"); + // Two isolated absent contributions are not a contiguous two-frame gap. + auto sparseMixer = std::make_unique(); + const std::array single{{{4, 99, 7}}}; + Sink sparse; sparse.samples.reserve(256); sparse.missing.reserve(1); + check(sparseMixer->configure(7, 9, single, 0), "sparse diagnostic input configured"); + left.fill(.2f); right.fill(-.2f); + check(sparseMixer->push(4, 99, 7, 1, std::span(left).first(63), std::span(right).first(63)), + "queue audio after first missing frame"); + check(sparseMixer->push(4, 99, 7, 65, std::span(left).first(63), std::span(right).first(63)), + "queue audio after separated missing frame"); + sparseMixer->drain(128 + Mixer::kDeadlineFrames, sparse); + check(sparseMixer->lateFrames() == 2 && sparse.missing.size() == 1 + && sparse.missing.front().mask[0] == 1 && sparse.missing.front().mask[1] == 1, + "missing masks retain exact separated sample positions across a 64-bit boundary"); std::fprintf(stderr, "rtl_audio_mixer_test: %d failures\n", failures); return failures ? 1 : 0; } diff --git a/tests/rtl_capture_worker_test.cpp b/tests/rtl_capture_worker_test.cpp index 511aad3c39..ed329f234b 100644 --- a/tests/rtl_capture_worker_test.cpp +++ b/tests/rtl_capture_worker_test.cpp @@ -27,7 +27,8 @@ struct RtlCaptureBackendTestAccess { static void start(RtlSdrBackend& backend, std::unique_ptr device, int capacity = 1) { backend.m_requested.hardware = {100'000'000, 2'400'000, 0, 0, 0, 240}; - backend.m_requested.receivers = {{{0, 100'000'000, -100'000, 100'000, 0, 0, 0}, T::Mode::Wfm}}; + const double wfmGuard = RtlReceivePipeline::kQualifiedWfmEnabled ? 3000 : 0; + backend.m_requested.receivers = {{{0, 100'000'000, -100'000, 100'000, 0, wfmGuard, wfmGuard}, T::Mode::Wfm}}; if (capacity > 1) { backend.m_receiverCapacity = capacity; backend.m_capture = RtlCaptureTransaction({8, static_cast(capacity)}); @@ -112,12 +113,25 @@ static void sustainedPanProgress(bool narrow) const int beforeFrames = frames; const int beforeWrites = device->writes; const int beforeCallbacks = device->callbacks; - for (int block = 0; block < 7; ++block) { device->block(); } + // Each injected block represents real sample time (3.4 ms here). + // Bursting all seven drains native WFM's nonblocking WDSP output ring + // and correctly requests receiver repair, superseding this pan. Pace + // the fixture so it tests held-pan ordering through the native graph. + const unsigned long callbackMicroseconds = static_cast(std::ceil( + 1.0e6 * (device->callbackBytes.load() / 2.0) / accepted.hardware.sampleRateHz)); + const auto clockBlock = [&] { + const int completed = device->callbacks + 1; + device->block(); + check(waitFor([&] { return device->callbacks >= completed; }), + "paced capture callback completes"); + QThread::usleep(callbackMicroseconds); + }; + for (int block = 0; block < 7; ++block) { clockBlock(); } check(waitFor([&] { return device->callbacks >= beforeCallbacks + 7; }), "fresh capture receives seven partial FFT blocks"); check(frames == beforeFrames && device->writes == beforeWrites, "next pan waits for a whole fresh observation, not a partial window"); - device->block(); + clockBlock(); check(waitFor([&] { return frames > beforeFrames; }), "fresh RF frame escapes while continuous pointer motion is pending"); check(std::abs(framedCenter - accepted.hardware.centerHz) < 1, @@ -332,7 +346,7 @@ int main(int argc, char** argv) const int beforeFilter = changes; backend.setSliceFilter(0, -8000, 8000); check(changes == beforeFilter && !rtl::RtlCaptureBackendTestAccess::busy(backend), - "unimplemented WFM filter request neither publishes nor queues work"); + "invalid narrow WFM filter request neither publishes nor queues work"); const int beforeMode = changes; backend.setSliceMode(0, QStringLiteral("FM")); check(waitFor([&] { state->block(); QThread::msleep(5); return changes > beforeMode; }), "mode adopted at callback boundary"); diff --git a/tests/rtl_model_acceptance_test.cpp b/tests/rtl_model_acceptance_test.cpp index 297545927d..49e66f8b11 100644 --- a/tests/rtl_model_acceptance_test.cpp +++ b/tests/rtl_model_acceptance_test.cpp @@ -12,9 +12,12 @@ #include #include #include +#include #include #include +#include #include +#include using namespace AetherSDR; using T = rtl::RtlCaptureTransaction; @@ -38,15 +41,44 @@ struct RtlCaptureBackendTestAccess { { backend.m_receiverCapacity = capacity; backend.m_capture = T({8, static_cast(capacity)}); - backend.m_requested.hardware = {100'000'000, 2'400'000, 0, 0, 0, 240}; - backend.m_requested.receivers = {{{0, 100'000'000, -100'000, 100'000, 0, 0, 0}, T::Mode::Wfm}}; + backend.m_requested = {}; + backend.m_requested.hardware = {static_cast(backend.m_panCenterHz), + backend.m_sampleRateHz, 0, 0, backend.m_ppmCorrection, 240}; + backend.m_requested.dcSuppression = backend.m_dcSuppression; + // Keep the production bootstrap selected by handRestoredStateToBackend; + // replace only the USB entry point. In particular, do not inject a + // different WFM graph or discard the provisional monitor mute here. + backend.m_requested.receivers = {backend.initialReceiver()}; if (capacity > 1) { + // The separate membership fixture deliberately admits several FM + // receivers; it has no saved receiver state or production capacity. + backend.m_requested.hardware = {100'000'000, 2'400'000, 0, 0, 0, 240}; backend.m_requested.receivers = {{{0, 100'000'000, -8000, 8000, 0, 3000, 3000}, T::Mode::Fm}}; } backend.startCapture(std::make_unique(std::move(device), nullptr, capacity)); } static bool idle(const RtlSdrBackend& backend) { return !backend.m_capture.busy() && !backend.m_pendingDrag; } static T::State state(const RtlSdrBackend& backend) { return *backend.m_capture.confirmed(); } + static void pilot(RtlSdrBackend& backend, int id, T::Token token, bool stereo, + std::uint32_t sequence = 2, double magnitude = 0.1) + { + RtlReceivePipeline::Packet packet; + const auto& receivers = backend.m_lastPublished->receivers; + const auto receiver = std::ranges::find_if(receivers, + [id](const auto& value) { return value.passband.stableId == id; }); + packet.slot = id; packet.token = token; + packet.wfmStereoDetected = stereo && !receiver->wfmForceMono; + packet.wfmReception = WfmReceptionDiagnostics{true, magnitude, stereo, + stereo ? 5000U : 0U, 0, 0, 6000, 5000, 0.06, 0.03, 100, 0, 93, 187, sequence}; + backend.observeWfm(packet); + } + static void expirePilot(RtlSdrBackend& backend, int id) + { + backend.m_wfmObservationAge[id] = QElapsedTimer(); + backend.m_wfmObservationAge[id].start(); + QThread::msleep(510); + backend.expireWfmObservations(); + } static void spectrum(RtlSdrBackend& backend, const QByteArray& frame, T::Token token) { emit backend.m_worker->spectrumFrameReady(token.session, token.revision, 0, frame); } }; @@ -64,6 +96,31 @@ template static bool waitFor(F predicate) } return predicate(); } +// This fixture's capture remains 2.4 MS/s with 8192 complex frames per +// callback. Queue at most one callback at a time and no faster than real time. +// Accumulating callbacks during off-thread planning would replay a burst into +// nonblocking WDSP and test artificial output underruns instead of adoption. +class CaptureClock { +public: + explicit CaptureClock(std::shared_ptr device) + : m_device(std::move(device)) + { m_clock.start(); } + void advance() + { + const qint64 now = m_clock.elapsed(); + if (m_device->starts.load() == 0 || m_device->callbacks.load() < m_queued + || now < m_nextBlockMs) { return; } + m_device->block(); + ++m_queued; + m_nextBlockMs = now + 4; + } +private: + std::shared_ptr m_device; + QElapsedTimer m_clock; + int m_queued = 0; + qint64 m_nextBlockMs = 0; +}; + int main(int argc, char** argv) { TestSettingsProfile profile(QStringLiteral("rtl-model-acceptance")); @@ -83,10 +140,11 @@ int main(int argc, char** argv) RadioModelSliceLifecycleTestAccess::restore(model, "model-accepted"); auto& backend = *static_cast(model.backend()); auto device = std::make_shared(); + CaptureClock captureClock(device); rtl::RtlCaptureBackendTestAccess::start(backend, std::make_unique(device)); device->releaseReadback(); check(waitFor([&] { - device->block(); + captureClock.advance(); return model.slice(3) && !model.slice(0) && rtl::RtlCaptureBackendTestAccess::idle(backend); }), "capacity-one restore materializes stable ID 3 instead of zero"); QPointer slice = model.slice(3); @@ -131,7 +189,7 @@ int main(int argc, char** argv) RadioModelSliceLifecycleTestAccess::flush(model); check(scope.featureExact("RtlSlices") == initial, "forced pending flush preserves accepted document"); check(waitFor([&] { - device->block(); + captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend) && slice->frequency() == 100.1 && slice->filterLow() == -7000 && slice->audioGain() == 37 && slice->audioMute() && slice->audioPan() == 73 && slice->squelchLevel() == 61; @@ -151,14 +209,14 @@ int main(int argc, char** argv) check(scope.featureExact("RtlSlices") == accepted, "refused edits never persist"); // A return to the already observed value must still supersede pending intent. slice->setFrequency(100.2); slice->setFrequency(100.1); - check(waitFor([&] { device->block(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }), + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }), "observed-value request cancels a pending different value"); check(slice->frequency() == 100.1 && !frequencies.contains(100.2), "superseded receiver-only value never becomes observable"); const int modeBefore = observed; slice->setMode("FMN"); check(slice->mode() == "FM" && observed == modeBefore, "mode waits for adopted DSP"); - check(waitFor([&] { device->block(); return slice->mode() == "FMN" && rtl::RtlCaptureBackendTestAccess::idle(backend); }), + check(waitFor([&] { captureClock.advance(); return slice->mode() == "FMN" && rtl::RtlCaptureBackendTestAccess::idle(backend); }), "accepted mode reaches model through backend status"); // Fail the center write, then hold the verified compensation readback. { @@ -183,7 +241,7 @@ int main(int argc, char** argv) slice->setFrequency(107.0); check(slice->frequency() == 100.1, "both hardware requests remain invisible while pending"); device->releaseReadback(); - check(waitFor([&] { device->block(); return rtl::RtlCaptureBackendTestAccess::idle(backend) && slice->frequency() == 107.0; }), + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend) && slice->frequency() == 107.0; }), "latest hardware state publishes after compensation"); check(!frequencies.contains(106.0), "superseded hardware state never publishes"); // Display geometry must never become a hardware/sample-rate/slice command. @@ -213,14 +271,14 @@ int main(int argc, char** argv) slice->setFrequency(107.05); model.requestPanCenter(pan->panId(), 107.05); check(slice->frequency() == 107.0, "typed frequency waits for receiver adoption"); - check(waitFor([&] { device->block(); return slice->frequency() == 107.05 + check(waitFor([&] { captureClock.advance(); return slice->frequency() == 107.05 && rtl::RtlCaptureBackendTestAccess::idle(backend); }), "typed in-window tune adopted"); check(std::abs(pan->centerMhz() - 107.05) < 0.002 && device->writes == beforeWrites, "typed centering moves only the view when capture already fits"); check(model.requestConfirmedReceiveTune(3, 107.08, IRadioBackend::ReceiveTuneView::Center), "confirmed GUI tune admits receiver and view as one intent"); check(slice->frequency() == 107.05, "joint typed intent remains unobserved until adoption"); - check(waitFor([&] { device->block(); return slice->frequency() == 107.08 + check(waitFor([&] { captureClock.advance(); return slice->frequency() == 107.08 && rtl::RtlCaptureBackendTestAccess::idle(backend); }), "joint typed intent adopted"); check(std::abs(pan->centerMhz() - 107.08) < 0.002 && device->writes == beforeWrites, "joint in-window typed intent centers display without USB writes"); @@ -228,7 +286,7 @@ int main(int argc, char** argv) check(model.requestConfirmedReceiveTune(3, 107.2, IRadioBackend::ReceiveTuneView::Center) && model.requestConfirmedReceiveTune(3, 107.08, IRadioBackend::ReceiveTuneView::Preserve), "newer preserve-view tune supersedes an unadopted centering intent"); - check(waitFor([&] { device->block(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }) + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }) && slice->frequency() == 107.08 && pan->centerMhz() == acceptedView, "superseded frequency and centering never become observations"); check(!model.requestConfirmedReceiveTune(3, 3000.0, IRadioBackend::ReceiveTuneView::Center) @@ -252,12 +310,12 @@ int main(int argc, char** argv) const auto captured = rtl::RtlCaptureBackendTestAccess::state(backend); check(model.requestConfirmedReceiveTune(3, captured.capture.centerHz / 1e6, IRadioBackend::ReceiveTuneView::Center), "operator can tune onto DC without an implicit capture retune"); - check(waitFor([&] { device->block(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }), + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }), "on-DC receiver tune adopted"); check(!backend.healthSnapshot().values.value("rtlCaptureDcClear").toBool(), "DC overlap is operator-visible rather than hidden by a removed bin"); check(model.requestReceiveCaptureRecenter(pan->panId()), "explicit DC placement admitted"); - check(waitFor([&] { device->block(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }), + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }), "explicit DC placement adopted"); const auto displaced = rtl::RtlCaptureBackendTestAccess::state(backend); check(displaced.hardware.centerHz != captured.hardware.centerHz @@ -322,7 +380,7 @@ int main(int argc, char** argv) QObject::disconnect(frameConnection); QObject::disconnect(waterfallConnection); slice->setFrequency(107.0); - check(waitFor([&] { device->block(); return slice->frequency() == 107.0 + check(waitFor([&] { captureClock.advance(); return slice->frequency() == 107.0 && rtl::RtlCaptureBackendTestAccess::idle(backend); }), "test receiver returned after viewport checks"); } // A staged object cannot control an otherwise-connected backend. @@ -339,33 +397,102 @@ int main(int argc, char** argv) if (value == 107.1) { ++reentrantEdits; slice->setAudioGain(41); } }); backend.setSliceFrequency(3, 107'100'000); - check(waitFor([&] { device->block(); return model.slice(3) == slice && slice->frequency() == 107.1 + check(waitFor([&] { captureClock.advance(); return model.slice(3) == slice && slice->frequency() == 107.1 && slice->audioGain() == 41 && rtl::RtlCaptureBackendTestAccess::idle(backend); }), "accepted state reclaims the staged object and adopts a reentrant edit"); check(reentrantEdits == 1, "reclaimed binding publishes the accepted tune once"); QObject::disconnect(reentrant); slice->setMode("WFM"); - check(waitFor([&] { device->block(); return slice->mode() == "WFM" + check(waitFor([&] { captureClock.advance(); return slice->mode() == "WFM" && rtl::RtlCaptureBackendTestAccess::idle(backend); }), - "legacy WFM mode adopted before unsupported filter request"); - const int wfmLow = slice->filterLow(); - const int wfmHigh = slice->filterHigh(); - const int wfmObserved = observed; - RadioModelSliceLifecycleTestAccess::flush(model); - const auto wfmSettings = scope.featureExact("RtlSlices"); - slice->setFilterWidth(-90'000, 90'000); - slice->setSquelch(true, 75); - check(rtl::RtlCaptureBackendTestAccess::idle(backend) - && slice->filterLow() == wfmLow && slice->filterHigh() == wfmHigh - && observed == wfmObserved && !slice->squelchOn(), - "unsupported WFM filter neither queues work nor changes observed state"); - RadioModelSliceLifecycleTestAccess::flush(model); - check(scope.featureExact("RtlSlices") == wfmSettings, - "unsupported WFM filter does not persist a cosmetic passband"); + "WFM mode adopted before control checks"); + if constexpr (rtl::RtlReceivePipeline::kQualifiedWfmEnabled) { + check(slice->filterLow() == -100000 && slice->filterHigh() == 100000, + "entering native WFM selects the established broadcast RF width"); + RadioModelSliceLifecycleTestAccess::flush(model); + const auto acceptedWfmSettings = scope.featureExact("RtlSlices"); + slice->setFilterWidth(-90000, 90000); + slice->setWfmDeemphasis(50); + slice->setWfmForceMono(true); + check(!slice->wfmForceMono(), "forced Mono request is not optimistic accepted state"); + check(slice->filterLow() == -100000 && slice->wfmDeemphasisUs() == 75 + && scope.featureExact("RtlSlices") == acceptedWfmSettings, + "WFM filter and deemphasis requests remain intent until adopted"); + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend) + && slice->filterLow() == -90000 && slice->filterHigh() == 90000 + && slice->wfmDeemphasisUs() == 50 && slice->wfmForceMono(); }), + "WFM filter, deemphasis and forced Mono adopt together"); + slice->setSquelch(true, 75); + slice->setWfmDeemphasis(60); + check(rtl::RtlCaptureBackendTestAccess::idle(backend) && !slice->squelchOn() + && slice->wfmDeemphasisUs() == 50, + "manual WFM squelch and unsupported deemphasis remain refused"); + RadioModelSliceLifecycleTestAccess::flush(model); + check(RtlSliceSettings(scope).load().document.slices.value(3).wfmDeemphasisUs == 50, + "only accepted WFM deemphasis reaches persisted receiver state"); + check(RtlSliceSettings(scope).load().document.slices.value(3).wfmForceMono, + "only adopted forced Mono reaches persisted settings"); + const auto token = rtl::RtlCaptureBackendTestAccess::state(backend).token; + rtl::RtlCaptureBackendTestAccess::pilot(backend, 3, token, true); + check(slice->wfmStereoStatus() == WfmStereoStatus::Mono, + "current forced Mono output is reported independently of acquired pilot"); + check(slice->wfmForceMono() && slice->wfmReceptionDiagnostics().valid + && slice->wfmReceptionDiagnostics().pilotLocked, + "actual pilot observation is distinct from selected forced Mono"); + rtl::RtlCaptureBackendTestAccess::pilot(backend, 3, {token.session, token.revision - 1}, false); + rtl::RtlCaptureBackendTestAccess::pilot(backend, 3, {token.session + 1, token.revision}, false); + check(slice->wfmStereoStatus() == WfmStereoStatus::Mono + && slice->wfmReceptionDiagnostics().pilotLocked, + "old revision and foreign session cannot change WFM observation"); + rtl::RtlCaptureBackendTestAccess::expirePilot(backend, 3); + check(slice->wfmStereoStatus() == WfmStereoStatus::Acquiring + && !slice->wfmReceptionDiagnostics().valid, + "stopped decoder observations expire rather than retaining stereo"); + rtl::RtlCaptureBackendTestAccess::pilot(backend, 3, token, true); + check(!slice->wfmReceptionDiagnostics().valid, + "cached decoder sequence cannot refresh expired reception"); + rtl::RtlCaptureBackendTestAccess::pilot(backend, 3, token, true, 4, + std::numeric_limits::quiet_NaN()); + check(!slice->wfmReceptionDiagnostics().valid, "invalid magnitude cannot refresh reception"); + rtl::RtlCaptureBackendTestAccess::pilot(backend, 3, token, false, 4); + check(slice->wfmStereoStatus() == WfmStereoStatus::Mono, + "current no-pilot observation reports mono"); + slice->setWfmForceMono(false); + check(slice->wfmForceMono(), "Auto Stereo remains intent until decoder adoption"); + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend) + && !slice->wfmForceMono(); }), "Auto Stereo adoption confirms the selection"); + check(!slice->wfmReceptionDiagnostics().valid + || slice->wfmReceptionDiagnostics().observationDurationMs < 6000, + "new decoder revision cannot retain the old reception history"); + RadioModelSliceLifecycleTestAccess::flush(model); + check(!RtlSliceSettings(scope).load().document.slices.value(3).wfmForceMono, + "accepted Auto Stereo replaces only the chosen persistent policy"); + } else { + const int wfmLow = slice->filterLow(); + const int wfmHigh = slice->filterHigh(); + const int wfmObserved = observed; + RadioModelSliceLifecycleTestAccess::flush(model); + const auto wfmSettings = scope.featureExact("RtlSlices"); + slice->setFilterWidth(-90'000, 90'000); + slice->setSquelch(true, 75); + check(rtl::RtlCaptureBackendTestAccess::idle(backend) + && slice->filterLow() == wfmLow && slice->filterHigh() == wfmHigh + && observed == wfmObserved && !slice->squelchOn(), + "unsupported WFM filter neither queues work nor changes observed state"); + RadioModelSliceLifecycleTestAccess::flush(model); + check(scope.featureExact("RtlSlices") == wfmSettings, + "unsupported WFM filter does not persist a cosmetic passband"); + } slice->setMode("FMN"); - check(waitFor([&] { device->block(); return slice->mode() == "FMN" + check(waitFor([&] { captureClock.advance(); return slice->mode() == "FMN" && rtl::RtlCaptureBackendTestAccess::idle(backend); }), - "FMN restored after legacy WFM refusal check"); + "FMN restored after WFM control checks"); + check(slice->wfmStereoStatus() == WfmStereoStatus::Unavailable, + "leaving WFM invalidates the pilot observation"); + if constexpr (rtl::RtlReceivePipeline::kQualifiedWfmEnabled) { + check(slice->wfmDeemphasisUs() == 50, + "mode changes retain the selected broadcast deemphasis"); + } RadioModelSliceLifecycleTestAccess::flush(model); const auto beforeDisconnect = scope.featureExact("RtlSlices"); slice->setFrequency(107.2); slice->setAudioGain(80); diff --git a/tests/rtl_receive_pipeline_test.cpp b/tests/rtl_receive_pipeline_test.cpp index 3b4877635f..36ae3bca6c 100644 --- a/tests/rtl_receive_pipeline_test.cpp +++ b/tests/rtl_receive_pipeline_test.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #ifdef AETHER_BACKEND_RTL #include "core/backends/rtl/RtlSdrDdc.h" @@ -21,6 +22,194 @@ using T = AetherSDR::rtl::RtlCaptureTransaction; using namespace std::chrono_literals; namespace AetherSDR::rtl { struct RtlReceivePipelineTestAccess { + // Observation seam only: the receiver supplies a fixed failure; this does + // not claim to reproduce the underlying WDSP underrun. + static bool traceObservation(RtlReceivePipeline& pipeline) + { + struct FailingReceiver final : RtlReceiverRegistry::Receiver { + RtlReceiverRegistry::ProcessingFailure failure; + mutable unsigned observations = 0; + WdspChannel::ProcessResult processIq(std::span, std::span) noexcept override + { return WdspChannel::ProcessResult::Ok; } + std::span left() const noexcept override { return {}; } + std::span right() const noexcept override { return {}; } + bool processCapture(const RtlReceiverRegistry::SampleBlock&, RtlReceiverRegistry::AudioSink&) noexcept override + { return false; } + std::optional processingFailure() const noexcept override + { ++observations; return failure; } + } receiver; + receiver.failure.reason = RtlReceiverRegistry::ProcessingFailureReason::DspProcess; + receiver.failure.processResult = WdspChannel::ProcessResult::Underrun; + receiver.failure.captureFirst = receiver.failure.expectedCaptureFirst = 5000; + receiver.failure.captureFrames = 6400; + receiver.failure.iqFirst = 800; receiver.failure.iqFrames = 2048; + receiver.failure.hasExpectedCaptureFirst = receiver.failure.hasIqFirst = true; + receiver.failure.extraction.emplace(); + receiver.failure.extraction->reason = RtlRfExtractor::FailureReason::SinkRejected; + receiver.failure.extraction->iqFirst = 800; + receiver.failure.extraction->iqFrames = 2048; + const auto originalFailure = receiver.failure; + pipeline.m_legacy = false; pipeline.m_token = {42, 19}; pipeline.m_captureEpoch = 91; + pipeline.m_capture = {88, 37, 100000000, 2400000, 1080000, 1080000}; + RtlReceiverRegistry::ReceiverSpec spec; + spec.handle = {999, 0xabcdef, 3}; spec.epoch = 77; spec.passband.stableId = 6; + const std::array views{{{&spec, &receiver}}}; + std::array, 6400> iq{}; + RtlReceiverRegistry::SampleBlock block{999, pipeline.m_capture, 5000, false, iq}; + pipeline.process(block, views); + if (!pipeline.needsRepair() || receiver.observations != 1) { return false; } + // The first queued event must own its failure, not borrow this value. + receiver.failure.processResult = WdspChannel::ProcessResult::Busy; + receiver.failure.captureFirst = 999999; + std::array present; present.fill(0.25f); + if (!pipeline.m_mixer.push(3, spec.handle.instance, spec.epoch, 101, present, present)) { return false; } + block.firstSample = 107400; // end clock 2276: first quantum's exact deadline + pipeline.process(block, views); + if (receiver.observations != 1) { return false; } // one report per faulted bank + RtlReceivePipeline::TraceEvent event; + if (!pipeline.takeTraceEvent(event) + || event.kind != RtlReceivePipeline::TraceEvent::Kind::ReceiverFailure + || event.token != pipeline.m_token || event.hardwareGeneration != 37 || event.captureEpoch != 91 + || event.slot != 3 || event.stableId != 6 || event.instance != 0xabcdef || event.receiverEpoch != 77 + || event.captureFirst != 5000 || event.captureFrames != 6400 || event.captureClock != 228 + || event.failure != originalFailure) { return false; } + if (!pipeline.takeTraceEvent(event) + || event.kind != RtlReceivePipeline::TraceEvent::Kind::MixerMissing + || event.token != pipeline.m_token || event.hardwareGeneration != 37 || event.captureEpoch != 91 + || event.slot != 3 || event.stableId != 6 || event.instance != 0xabcdef || event.receiverEpoch != 77 + || event.captureFirst != 107400 || event.captureFrames != 6400 || event.captureClock != 2276 + || event.quantumFirst != 100 || event.missingMask != RtlAudioMixer::MissingMask{1, std::uint64_t{1} << 63} + || event.failure || pipeline.takeTraceEvent(event)) { return false; } + RtlReceivePipeline::Packet packet; + if (!pipeline.takePacket(packet) || packet.slot != -1 || packet.firstSample != 100 || packet.frames != 128 + || packet.samples[0] != 0 || packet.samples[2] != 0.25f || packet.samples[254] != 0 + || pipeline.takePacket(packet) || pipeline.diagnostics().mixerLateFrames != 2) { return false; } + + // Fill the independent trace ring, then partially drain and wrap it. + // Every accepted event must survive in FIFO order; only new ones drop. + constexpr unsigned capacity = RtlReceivePipeline::kTraceEvents - 1; + constexpr unsigned offered = RtlReceivePipeline::kTraceEvents + 4; + constexpr unsigned drained = 17; + event.failure = originalFailure; + for (unsigned n = 0; n < offered; ++n) { + event.captureFirst = n; + pipeline.enqueueTrace(event); + } + if (pipeline.diagnostics().droppedTraceEvents != offered - capacity) { return false; } + for (unsigned n = 0; n < drained; ++n) { + if (!pipeline.takeTraceEvent(event) || event.captureFirst != n || event.failure != originalFailure) { return false; } + } + for (unsigned n = 0; n < drained; ++n) { + event.captureFirst = offered + n; + pipeline.enqueueTrace(event); + } + pipeline.enqueueTrace(event); // full again; cannot overwrite oldest + if (pipeline.diagnostics().droppedTraceEvents != offered - capacity + 1) { return false; } + std::array audio; audio.fill(0.5f); + pipeline.speakerBlock(333, audio, false); + if (!pipeline.takePacket(packet) || packet.firstSample != 333 || packet.frames != 128 + || packet.token != pipeline.m_token || packet.samples[0] != 0.5f || packet.samples[255] != 0.5f + || pipeline.droppedPackets() != 0) { return false; } + for (unsigned n = drained; n < capacity; ++n) { + if (!pipeline.takeTraceEvent(event) || event.captureFirst != n || event.failure != originalFailure) { return false; } + } + for (unsigned n = 0; n < drained; ++n) { + if (!pipeline.takeTraceEvent(event) || event.captureFirst != offered + n || event.failure != originalFailure) { return false; } + } + if (pipeline.takeTraceEvent(event)) { return false; } + event.captureFirst = 123456; + pipeline.enqueueTrace(event); + return pipeline.takeTraceEvent(event) && event.captureFirst == 123456 + && !pipeline.takeTraceEvent(event) && !pipeline.takePacket(packet) + && pipeline.diagnostics().droppedTraceEvents == offered - capacity + 1; + } + // Exercise the production extractor, pipeline publication, mixer and + // queues. Fixed PCM replaces only demodulation: no async WDSP scheduling + // is involved in this sample-coordinate regression. + static bool commonLatticeOrigin(RtlReceivePipeline& pipeline, std::uint64_t rate, + std::uint64_t expectedOrigin) + { + struct ExtractedPcm final : RtlReceiverRegistry::Receiver, RtlRfExtractor::Sink { + RtlReceiverRegistry::ReceiverSpec spec; + RtlRfExtractor extractor; + RtlReceiverRegistry::AudioSink* sink = nullptr; + std::array pcm{}; + bool first = true; + explicit ExtractedPcm(RtlReceiverRegistry::ReceiverSpec value) + : spec(value), extractor({value.capture, value.passband, 384000, 2048, 48000}) + { pcm.fill(0.25f); } + WdspChannel::ProcessResult processIq(std::span, std::span) noexcept override + { return WdspChannel::ProcessResult::Ok; } + std::span left() const noexcept override { return pcm; } + std::span right() const noexcept override { return pcm; } + bool processCapture(const RtlReceiverRegistry::SampleBlock& block, + RtlReceiverRegistry::AudioSink& target) noexcept override + { + sink = ⌖ + const bool accepted = extractor.process(block.capture, block.firstSample, block.samples, *this); + sink = nullptr; + return accepted; + } + bool iqBlock(std::span i, std::span q, std::uint64_t position) noexcept override + { + if (i.size() != 2048 || q.size() != 2048 || position % 8) { return false; } + sink->audioBlock(spec, position / 8, pcm, pcm, first); + first = false; + return true; + } + }; + pipeline.m_legacy = false; pipeline.m_token = {47, 1}; pipeline.m_captureEpoch = 17; + pipeline.m_capture = {47, 1, 100000000, double(rate), rate * 0.45, rate * 0.45}; + RtlReceiverRegistry::ReceiverSpec spec; + spec.handle = {47, 93, 3}; spec.epoch = 11; spec.capture = pipeline.m_capture; + spec.passband = {3, 100000000, -90000, 90000, 0, 1000, 1000}; + spec.dsp.inputSampleRate = 384000; spec.dsp.inputBlockSize = 2048; + spec.dsp.dspSampleRate = 192000; spec.dsp.dspBlockSize = 1024; + spec.dsp.outputSampleRate = 48000; + spec.dsp.wbfmReceive.emplace(); // preserve native fixed PCM on the observation path + ExtractedPcm receiver(spec); + if (!receiver.extractor.valid()) { return false; } + const std::array views{{{&receiver.spec, &receiver}}}; + std::array, 257> input{}; + RtlReceivePipeline::Packet packet; + std::array seen{}; + std::array first{}, next{}; + bool continuous = true; + // Every tested origin is an exact known lattice point. Feed through + // the coprime wait, then far enough past the existing 2048-frame deadline. + const std::uint64_t end = expectedOrigin * rate / 48000 + rate / 12; + for (std::uint64_t position = 25; position < end;) { + const std::size_t count = std::min(input.size(), end - position); + const RtlReceiverRegistry::SampleBlock block{47, pipeline.m_capture, position, false, + std::span(input).first(count)}; + inCallback = true; + pipeline.process(block, views); + inCallback = false; + position += count; + while (pipeline.takePacket(packet)) { + const int stream = packet.slot < 0 ? 1 : 0; + if (!seen[stream]) { first[stream] = packet.firstSample; seen[stream] = true; } + else { continuous &= packet.firstSample == next[stream]; } + next[stream] = packet.firstSample + packet.frames; + } + RtlReceivePipeline::TraceEvent event; + while (pipeline.takeTraceEvent(event)) {} // retain production counters, avoid observation overflow + } + const auto diagnostic = pipeline.diagnostics(); + std::printf("LATTICE rate=%llu tap=%llu speaker=%llu late=%llu\n", + static_cast(rate), static_cast(first[0]), + static_cast(first[1]), static_cast(diagnostic.mixerLateFrames)); + // Same epoch configure is allowed to update inputs, never to relocate + // already queued healthy audio or its next speaker position. + const std::array mixerInputs{{{3, 93, 11}}}; + const std::uint64_t preserved = pipeline.m_mixer.nextSample(); + const bool retained = pipeline.m_mixer.configure(47, 17, mixerInputs, preserved + 10000) + && pipeline.m_mixer.nextSample() == preserved; + return retained && seen[0] && seen[1] && continuous && first[0] == expectedOrigin + && first[1] == first[0] && diagnostic.mixerLateFrames == 0 + && diagnostic.mixerRejectedBlocks == 0 && diagnostic.droppedPackets == 0 + && diagnostic.mixerConfigurationFailures == 0 && !pipeline.needsRepair(); + } static void exhaustEpoch(RtlReceivePipeline& pipeline) { pipeline.m_nextEpoch = std::numeric_limits::max(); } static std::uint64_t requested(RtlReceivePipeline& pipeline) @@ -250,6 +439,20 @@ static void measureParkAndResume() int main(int argc, char** argv) { QCoreApplication app(argc, argv); + { + auto traced = std::make_unique(); + inCallback = true; + const bool observed = AetherSDR::rtl::RtlReceivePipelineTestAccess::traceObservation(*traced); + inCallback = false; + check(observed, "first-failure identity/copy, exact missing mask, trace FIFO/full/wrap and independent audio delivery"); + check(callbackAllocations == 0, "trace capture, overflow and delivery perform no callback allocation"); + } + for (const auto [rate, origin] : std::array, 3>{{ + {2400000, 1}, {1843200, 5}, {225001, 48000}}}) { + auto aligned = std::make_unique(); + check(AetherSDR::rtl::RtlReceivePipelineTestAccess::commonLatticeOrigin(*aligned, rate, origin), + "new speaker epoch starts at the actual extractor origin without premature missing contributions"); + } measureParkAndResume(); measureSingleFmReceiver(T::Mode::Fm); measureSingleFmReceiver(T::Mode::Fmn); diff --git a/tests/rtl_receiver_registry_test.cpp b/tests/rtl_receiver_registry_test.cpp index 4015572656..722ea58b2b 100644 --- a/tests/rtl_receiver_registry_test.cpp +++ b/tests/rtl_receiver_registry_test.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include using Registry = AetherSDR::rtl::RtlReceiverRegistry; @@ -691,6 +692,58 @@ void retainedRfHistoryAndAdoption() check(stats->callbackDestruction == 0, "reused receiver never destructs on callback"); } +void actualExtractionFailure() +{ + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_trivially_copyable_v>); + Registry registry({1, 1, 2}); + const std::uint64_t session = registry.beginSession(capture()); + const auto handle = registry.reserveSlot(); + if (!handle) { check(false, "extraction failure handle"); return; } + Registry::ReceiverSpec desired = spec(*handle); + desired.extractRf = true; + auto reader = registry.attachReader(); + check(registry.submit(capture(), std::span(&desired, 1)) == Registry::Result::Accepted, + "real extraction failure fixture admitted"); + check(ready(registry, 1), "real extraction failure fixture prepared"); + struct FailureProbe final : Registry::BlockProcessor, Registry::AudioSink { + bool valid = false; + int audioCalls = 0; + void audioBlock(const Registry::ReceiverSpec&, std::uint64_t, std::span, + std::span, bool) noexcept override { ++audioCalls; } + void process(const Registry::SampleBlock& value, std::span views) noexcept override + { + if (views.size() != 1) { return; } + Registry::Receiver& receiver = *views[0].receiver; + if (receiver.processingFailure() || !receiver.processCapture(value, *this)) { return; } + Registry::SampleBlock gap = value; + gap.firstSample = 90; + gap.discontinuity = false; + if (receiver.processCapture(gap, *this)) { return; } + const auto failure = receiver.processingFailure(); + if (!failure || failure->reason != Registry::ProcessingFailureReason::Extraction + || !failure->extraction + || failure->extraction->reason != AetherSDR::rtl::RtlRfExtractor::FailureReason::CapturePositionMismatch + || !failure->hasExpectedCaptureFirst || failure->expectedCaptureFirst != 89 + || failure->captureFirst != 90 || failure->captureFrames != 64 + || !failure->hasIqFirst || failure->iqFirst != 1 || failure->iqFrames != 0) { return; } + gap.firstSample = 89; + if (receiver.processCapture(gap, *this)) { return; } + // A different later failure must not replace the original cause. + if (receiver.processIq({}, {}) != WdspChannel::ProcessResult::InvalidBuffer) { return; } + valid = receiver.processingFailure() == failure && audioCalls == 0; + } + } probe; + Registry::SampleBlock delivery = block(capture(), 25); + delivery.session = session; + check(countedProcessBlock(reader, probe, delivery) && probe.valid, + "production receiver retains exact extraction failure across withdrawal and a different later error"); + check(totalCallbackAllocations == 0, "failure observation is allocation-free on acquisition"); + reader.stop(); + check(until(registry, [](const auto& status) { return status.residentReceivers == 0; }), + "failed extraction receiver retires off acquisition"); +} + void actualPreparedDsp() { Registry registry({1, 1, 2}); // production factory, real reserved WDSP channel @@ -707,11 +760,17 @@ void actualPreparedDsp() { std::array silence {}; if (views.size() != 1) { return; } + if (views[0].receiver->processingFailure()) { return; } if (views[0].receiver->processIq({}, silence) != WdspChannel::ProcessResult::InvalidBuffer) { return; } + const auto failure = views[0].receiver->processingFailure(); + if (!failure || failure->reason != Registry::ProcessingFailureReason::DspProcess + || failure->processResult != WdspChannel::ProcessResult::InvalidBuffer + || failure->hasIqFirst || failure->hasExpectedCaptureFirst || failure->extraction) { return; } const WdspChannel::ProcessResult result = views[0].receiver->processIq(silence, silence); valid = (result == WdspChannel::ProcessResult::Ok || result == WdspChannel::ProcessResult::Underrun) && - views[0].receiver->left().size() == 1024 && views[0].receiver->right().size() == 1024; + views[0].receiver->left().size() == 1024 && views[0].receiver->right().size() == 1024 + && views[0].receiver->processingFailure() == failure; } } probe; Registry::SampleBlock delivery = block(); @@ -747,6 +806,7 @@ int main(int argc, char** argv) reconnectAndRegistryBound(); drain(); retainedRfHistoryAndAdoption(); drain(); actualPreparedDsp(); drain(); + actualExtractionFailure(); drain(); check(totalCallbackAllocations == 0, "all acquisition callback paths remained free of ordinary C++ allocations"); std::cout << "RTL receiver registry: " << checks << " checks, " << failures << " failures\n"; return failures == 0 ? 0 : 1; diff --git a/tests/rtl_rf_extractor_test.cpp b/tests/rtl_rf_extractor_test.cpp index c689dfd1db..986c4d35f3 100644 --- a/tests/rtl_rf_extractor_test.cpp +++ b/tests/rtl_rf_extractor_test.cpp @@ -1,11 +1,14 @@ #include "core/backends/rtl/RtlRfExtractor.h" #include "CallbackAllocationProbe.h" +#include "CDSPResampler.h" +#include #include #include #include #include #include +#include #include using Extractor = AetherSDR::rtl::RtlRfExtractor; @@ -58,8 +61,163 @@ static double toneMagnitude(std::span samples, double hz) } return std::abs(sum) / samples.size(); } +static void wfmUsbCallbackBursts() +{ + constexpr std::size_t kUsbIqFrames = 8192; // 16384 interleaved RTL U8 bytes. + constexpr std::size_t kWfmIqBlock = 2048; + constexpr int kWfmIqRate = 384000; + constexpr std::array rates{ + 225001, 250000, 300000, 1000000, 1536000, 1843200, 2000000, 2400000, 3000000}; + static_assert(kUsbIqFrames % Extractor::kInputChunk == 0); + struct CountingSink final : Extractor::Sink { + std::size_t callbackBlocks = 0; + std::uint64_t next = 0; + bool continuous = true; + bool iqBlock(std::span i, std::span q, + std::uint64_t first) noexcept override + { + if (i.size() != kWfmIqBlock || q.size() != i.size() || first != next || first % 8 != 0) { + continuous = false; + return false; + } + ++callbackBlocks; + next += i.size(); + return true; + } + }; + std::array, kUsbIqFrames> input; + input.fill({0.25f, -0.125f}); + for (const int rate : rates) { + const Extractor::Config cfg{{1, 1, 100e6, double(rate), 0.45 * rate, 0.45 * rate}, + {0, 100e6, -90000, 90000, 0, 1000, 1000}, kWfmIqRate, kWfmIqBlock, 48000}; + Extractor extractor(cfg); + check(extractor.valid(), "WFM callback observation uses an admitted full passband"); + if (!extractor.valid()) { continue; } + + // Same converter recipe as the production extractor/Resampler. r8brain + // composes each stage's getMaxOutLen() into this prepared bound; it is + // independent of startup state and of the observations below. A full + // USB callback performs 32 conversions, even with a partial input + // chunk already staged, and can begin with 2047 staged output frames. + r8b::CDSPResampler24 converterBound(rate, kWfmIqRate, Extractor::kInputChunk, 10.0); + const int maxChunkOutput = converterBound.getMaxOutLen(Extractor::kInputChunk); + check(maxChunkOutput > 0 && maxChunkOutput <= 16384, "converter output bound fits extractor storage"); + if (maxChunkOutput <= 0 || maxChunkOutput > 16384) { continue; } + const std::size_t boundBlocks = (kWfmIqBlock - 1 + + (kUsbIqFrames / Extractor::kInputChunk) * std::size_t(maxChunkOutput)) / kWfmIqBlock; + CountingSink sink; + std::size_t callbacks = 0; + std::size_t maxBlocks = 0; + std::uint64_t maxCaptureFirst = 0; + std::uint64_t captureFrames = 0; + bool accepted = true; + // Two seconds rounded up to whole production USB callbacks. This is + // sample time, with no sleeps, elapsed-time assertions, WDSP or radio. + while (captureFrames < 2 * std::uint64_t(rate)) { + sink.callbackBlocks = 0; + inCallback = true; + accepted = extractor.process(cfg.capture, captureFrames, input, sink); + inCallback = false; + if (!accepted) { break; } + if (sink.callbackBlocks > maxBlocks) { + maxBlocks = sink.callbackBlocks; + maxCaptureFirst = captureFrames; + } + ++callbacks; + captureFrames += input.size(); + } + check(accepted && sink.continuous && !extractor.withdrawn(), + "WFM USB callbacks preserve exact fixed-block IQ and final-audio positions"); + check(sink.next > 0 && sink.next == extractor.outputFrames(), "WFM burst observation emitted real blocks"); + check(maxBlocks <= boundBlocks, "observed callback burst respects composed converter bound"); + // This bound covers ONE callback, not consecutive queued callbacks + // without WDSP progress, and is not a realtime/latency qualification. + std::printf("WFM_EXTRACTOR_BURST rate=%d callbacks=%zu captureFrames=%llu iqFrames=%llu " + "maxBlocks=%zu maxCaptureFirst=%llu chunkOutputBound=%d callbackBlockBound=%zu " + "maxAudioFrames=%zu audioFrameBound=%zu continuous=%d\n", + rate, callbacks, static_cast(captureFrames), + static_cast(sink.next), maxBlocks, + static_cast(maxCaptureFirst), maxChunkOutput, boundBlocks, + maxBlocks * 256, boundBlocks * 256, accepted && sink.continuous); + } +} +static void firstFailureObservation() +{ + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_trivially_copyable_v>); + struct RejectingSink final : Extractor::Sink { + int calls = 0; + bool iqBlock(std::span, std::span, std::uint64_t) noexcept override + { ++calls; return false; } + } sink; + const auto process = [&sink](Extractor& extractor, std::uint64_t first, + std::span> input, bool discontinuity = false) { + inCallback = true; + const bool result = extractor.process(config().capture, first, input, sink, discontinuity); + inCallback = false; + return result; + }; + std::array, 64> shortInput{}; + Extractor gap(config()); + check(!gap.failure(), "new extractor has no failure observation"); + check(process(gap, 25, shortInput), "nonzero origin starts without a complete DSP block"); + check(!process(gap, 90, shortInput) && gap.withdrawn(), "one missing capture sample still withdraws"); + const auto gapFailure = gap.failure(); + check(gapFailure && gapFailure->reason == Extractor::FailureReason::CapturePositionMismatch + && gapFailure->hasExpectedCaptureFirst && gapFailure->expectedCaptureFirst == 89 + && gapFailure->captureFirst == 90 && gapFailure->captureFrames == 64 + && gapFailure->hasIqFirst && gapFailure->iqFirst == 1 && gapFailure->iqFrames == 0, + "gap observation names exact expected/actual capture and next IQ positions"); + check(!process(gap, 89, shortInput) && gap.failure() == gapFailure, + "withdrawn retry cannot erase or replace the first gap"); + + Extractor discontinuous(config()); + check(process(discontinuous, 25, shortInput), "explicit-discontinuity fixture starts"); + check(!process(discontinuous, 89, shortInput, true), "explicit discontinuity remains a refusal"); + check(discontinuous.failure() + && discontinuous.failure()->reason == Extractor::FailureReason::Discontinuity + && discontinuous.failure()->captureFirst == discontinuous.failure()->expectedCaptureFirst, + "explicit discontinuity differs from a positional gap"); + + Extractor invalid(config()); + shortInput[3] = {std::numeric_limits::quiet_NaN(), 0}; + check(!process(invalid, 50, shortInput), "nonfinite input remains refused"); + const auto invalidFailure = invalid.failure(); + check(invalidFailure && invalidFailure->reason == Extractor::FailureReason::NonFiniteInput + && invalidFailure->captureFirst == 50 && invalidFailure->captureFrames == 64 + && !invalidFailure->hasExpectedCaptureFirst && !invalidFailure->hasIqFirst, + "pre-conversion failure does not invent an IQ or previous capture position"); + shortInput[3] = {}; + check(!process(invalid, 50, shortInput) && invalid.failure() == invalidFailure, + "later finite input retains the nonfinite first cause"); + + Extractor overflow(config()); + check(!process(overflow, std::numeric_limits::max() - 31, shortInput), + "capture end overflow remains refused"); + check(overflow.failure() && overflow.failure()->reason == Extractor::FailureReason::CapturePositionOverflow, + "capture overflow has its own precise reason"); + + Extractor empty(config()); + check(!process(empty, 10, {}) && empty.failure() + && empty.failure()->reason == Extractor::FailureReason::EmptyInput, + "empty input has a distinct first-failure reason"); + + Extractor rejected(config()); + std::vector> input(65536); + check(!process(rejected, 0, input) && rejected.withdrawn(), "sink rejection still withdraws immediately"); + const auto rejectedFailure = rejected.failure(); + check(rejectedFailure && rejectedFailure->reason == Extractor::FailureReason::SinkRejected + && rejectedFailure->captureFirst == 0 && rejectedFailure->captureFrames == input.size() + && rejectedFailure->hasIqFirst && rejectedFailure->iqFirst == 0 && rejectedFailure->iqFrames == 1024 + && rejected.outputFrames() == 0 && sink.calls == 1, + "sink failure names the attempted full IQ block without counting it as emitted"); + check(!process(rejected, input.size(), shortInput) && rejected.failure() == rejectedFailure && sink.calls == 1, + "withdrawal preserves sink cause and never calls it again"); +} int main() { + firstFailureObservation(); + wfmUsbCallbackBursts(); Extractor extractor(config()); check(extractor.valid(), "valid readback and full passband prepare extraction"); std::vector> input(65536, {0.5f, 0}); @@ -118,7 +276,11 @@ int main() auto stale = config().capture; ++stale.generation; check(!changed.process(stale, 0, input, output) && !changed.withdrawn(), "stale capture generation cannot consume current history"); + const auto mismatch = changed.failure(); + check(mismatch && mismatch->reason == Extractor::FailureReason::CaptureMismatch, + "non-withdrawing metadata rejection is observable"); check(changed.process(config().capture, 0, input, output), "matching generation still works"); + check(changed.failure() == mismatch, "success does not reset the first observation"); check(!changed.process(config().capture, input.size() + 1, input, output) && changed.withdrawn(), "gap withdraws history without realtime reset"); check(!changed.process(config().capture, input.size(), input, output), "withdrawn extractor cannot resume stale history"); diff --git a/tests/rtl_runtime_settings_test.cpp b/tests/rtl_runtime_settings_test.cpp index 1ab8a4d527..e9aea2d84a 100644 --- a/tests/rtl_runtime_settings_test.cpp +++ b/tests/rtl_runtime_settings_test.cpp @@ -8,6 +8,9 @@ #include "core/backends/rtl/RtlSdrBackend.h" #include #include +#include +#include +#include #include using namespace AetherSDR; namespace AetherSDR { @@ -49,14 +52,33 @@ struct RtlCaptureBackendTestAccess { backend.m_receiverCapacity = 4; backend.m_capture = RtlCaptureTransaction({8, 4}); backend.m_requested.hardware = {100000000, 2400000, 0, 0, 0, 240}; - backend.m_requested.receivers = {{{0, 100000000, -100000, 100000, 0, 0, 0}, RtlCaptureTransaction::Mode::Wfm}}; + const double wfmGuard = RtlReceivePipeline::kQualifiedWfmEnabled ? 3000 : 0; + backend.m_requested.receivers = {{{0, 100000000, -100000, 100000, 0, wfmGuard, wfmGuard}, RtlCaptureTransaction::Mode::Wfm}}; backend.startCapture(std::make_unique(std::move(device), nullptr, 4)); } + static void startRestored(RtlSdrBackend& backend, std::unique_ptr device) + { + // Keep applyRestoredState's real bootstrap choice. Replace only the USB + // entry, then use the production transaction, worker and restore path. + backend.m_requested = {}; + backend.m_requested.hardware = {static_cast(backend.m_panCenterHz), + backend.m_sampleRateHz, 0, 0, backend.m_ppmCorrection, 240}; + backend.m_requested.dcSuppression = backend.m_dcSuppression; + backend.m_requested.receivers = {backend.initialReceiver()}; + backend.startCapture(std::make_unique(std::move(device))); + } + static bool bootstrapMuted(const RtlSdrBackend& backend) + { return backend.m_requested.receivers.front().audioMute; } static bool restored(const RtlSdrBackend& backend) { return backend.m_settingsActive && backend.m_restoreAttempted && !backend.m_restoreToken.revision && backend.m_lastPublished && backend.m_lastPublished->receivers.front().passband.stableId == 1; } + static bool restoreFinished(const RtlSdrBackend& backend) + { + return backend.m_settingsActive && backend.m_restoreAttempted && !backend.m_restoreToken.revision + && backend.m_lastPublished && !backend.m_capture.busy(); + } static std::optional state(const RtlSdrBackend& backend) { return backend.m_lastPublished; } static bool busy(const RtlSdrBackend& backend) { return backend.m_capture.busy(); } @@ -73,6 +95,180 @@ static QJsonObject legacy() {"filterHighHz", 8000}, {"sampleRateHz", 2400000}, {"ext", QJsonObject{{"rfGain", QJsonObject{{"gainDb", 24}}}}}, {"future", "preserve"}}; } +static void reconnectAtCaptureLimits() +{ + struct SavedCapture { + std::uint32_t centerHz; + std::uint32_t rateHz; + const char* mode; + int filterHz; + }; + // The first two need a smaller provisional receiver at the lowest legal + // rate. The others straddle the WFM RF+transition guard's lower RF edge. + constexpr std::array cases{{ + {100'000'000, 225'001, "WFM", 90'000}, + {100'000'000, 225'001, "FM", 7'000}, + {100'000, 2'400'000, "WFM", 90'000}, + {102'999, 2'400'000, "FM", 7'000}, + {103'000, 2'400'000, "WFM", 90'000} + }}; + for (const SavedCapture& capture : cases) { + const QString serial = QStringLiteral("bootstrap-%1-%2-%3") + .arg(capture.centerHz).arg(capture.rateHz).arg(QLatin1String(capture.mode)); + const RadioSettingsScope scope("rtl", serial); + RtlSliceSettings::Slice saved; + saved.id = 1; saved.frequencyHz = capture.centerHz; + saved.mode = QLatin1String(capture.mode); + saved.filterLowHz = -capture.filterHz; saved.filterHighHz = capture.filterHz; + saved.wfmDeemphasisUs = 50; + saved.wfmForceMono = true; + check(RtlSliceSettings(scope).patch(capture.centerHz, capture.rateHz, {saved}), + "capture-limit reconnect fixture preserves a valid saved receiver"); + const QJsonObject original = scope.featureExact("RtlSlices"); + rtl::RtlSdrBackend receiver; + for (int attempt = 0; attempt < 2; ++attempt) { + std::fprintf(stderr, "RTL_RECONNECT center=%u rate=%u mode=%s attempt=%d\n", + capture.centerHz, capture.rateHz, capture.mode, attempt); + receiver.configureSettingsScope(scope, {serial, false}); + receiver.applyRestoredState({}); + check(receiver.currentOperatingState().sampleRateHz == int(capture.rateHz), + "real bootstrap retains the saved capture rate before USB startup"); + auto device = std::make_shared(); + rtl::RtlCaptureBackendTestAccess::startRestored(receiver, + std::make_unique(device)); + check(rtl::RtlCaptureBackendTestAccess::bootstrapMuted(receiver), + "saved restoration keeps the provisional speaker muted until adoption"); + device->releaseReadback(); + QElapsedTimer deadline; deadline.start(); + int queued = 0; + qint64 nextBlockMs = 0; + const int callbackMs = static_cast(std::ceil(8192.0 * 1000 / capture.rateHz)); + while (deadline.elapsed() < 8000 && !device->destroyed + && !(rtl::RtlCaptureBackendTestAccess::restored(receiver) + && !rtl::RtlCaptureBackendTestAccess::busy(receiver))) { + // Never burst queued callbacks while WDSP is still preparing. + if (device->starts > 0 && device->callbacks >= queued + && deadline.elapsed() >= nextBlockMs) { + device->block(); ++queued; + nextBlockMs = deadline.elapsed() + callbackMs; + } + QCoreApplication::processEvents(); QThread::msleep(1); + } + const auto accepted = rtl::RtlCaptureBackendTestAccess::state(receiver); + const bool restored = receiver.isConnected() + && rtl::RtlCaptureBackendTestAccess::restored(receiver) + && !rtl::RtlCaptureBackendTestAccess::busy(receiver); + check(restored && accepted && accepted->hardware.centerHz == capture.centerHz + && accepted->hardware.sampleRateHz == capture.rateHz + && accepted->receivers.size() == 1 && accepted->receivingIds == std::vector{1}, + "low-rate and RF-boundary reconnect preserves capture and restores the receiving sparse slice"); + if (restored && accepted) { + const auto& actual = accepted->receivers.front(); + const auto expectedMode = saved.mode == QLatin1String("WFM") + ? rtl::RtlCaptureTransaction::Mode::Wfm : rtl::RtlCaptureTransaction::Mode::Fm; + check(actual.mode == expectedMode && actual.passband.carrierHz == saved.frequencyHz + && actual.passband.filterLowHz == saved.filterLowHz + && actual.passband.filterHighHz == saved.filterHighHz + && actual.wfmDeemphasisUs == 50 && actual.wfmForceMono + && actual.audioMute == saved.audioMute, + "provisional bootstrap does not resize or replace saved receiver choices"); + check(device->starts == 1 && device->writes == 6 && device->cancels == 0, + "accepted saved receivers restore without a second hardware capture transaction"); + check(receiver.storeOperatingState(scope, receiver.currentOperatingState()).value_or(false), + "accepted restored receiver remains owned by the RTL settings writer"); + } + check(scope.featureExact("RtlSlices") == original, + "reconnect and accepted persistence preserve the complete saved slice document"); + receiver.disconnectRadio(); + } + } +} +static void unsupportedStoredWfmRemainsIntact() +{ + if (!rtl::RtlReceivePipeline::kQualifiedWfmEnabled) { return; } + const QString serial = QStringLiteral("legacy-wfm-narrow-filter"); + const RadioSettingsScope scope("rtl", serial); + RtlSliceSettings::Slice saved; + // Old WFM ignored these stored RF edges. They must not acquire new RF + // meaning or be silently migrated when the native decoder is enabled. + saved.id = 0; saved.frequencyHz = 100'100'000; saved.mode = QStringLiteral("WFM"); + saved.filterLowHz = -4000; saved.filterHighHz = 4000; + saved.audioMute = false; saved.audioGain = 23; saved.audioPan = 71; + saved.agcMode = QStringLiteral("slow"); saved.agcThreshold = 17; + saved.squelchEnabled = true; saved.squelchLevel = 42; + check(RtlSliceSettings(scope).patch(100'000'000, 2'400'000, {saved}), + "seed schema-valid legacy WFM with unsupported native RF edges"); + QJsonObject original = scope.featureExact("RtlSlices"); + original.insert(QStringLiteral("futureDocument"), QJsonObject{{"keep", "document extension"}}); + QJsonObject slices = original.value(QStringLiteral("slices")).toObject(); + QJsonObject entry = slices.value(QStringLiteral("0")).toObject(); + entry.remove(QStringLiteral("wfmDeemphasisUs")); // Genuine old schema-one entry. + entry.insert(QStringLiteral("futureSlice"), QJsonObject{{"keep", "slice extension"}}); + QJsonObject squelch = entry.value(QStringLiteral("squelch")).toObject(); + squelch.insert(QStringLiteral("futureSquelch"), 91); + entry.insert(QStringLiteral("squelch"), squelch); + slices.insert(QStringLiteral("0"), entry); + original.insert(QStringLiteral("slices"), slices); + check(scope.setFeature("RtlSlices", RtlSliceSettings::kSchemaVersion, original), + "retain old optional-field absence and unknown members in the legacy document"); + + RadioModel model; + check(model.rebuildBackendForTest("rtl"), "legacy WFM restore uses the real model settings owner"); + auto& receiver = *static_cast(model.backend()); + QStringList warnings; + QObject::connect(&receiver, &IRadioBackend::configurationWarning, &model, + [&](const QString& warning) { warnings.append(warning); }); + RadioModelSliceLifecycleTestAccess::restore(model, serial); + check(receiver.currentOperatingState().mode == QLatin1String("AM"), + "real restored-state application chooses the provisional AM bootstrap"); + auto device = std::make_shared(); + rtl::RtlCaptureBackendTestAccess::startRestored(receiver, + std::make_unique(device)); + check(rtl::RtlCaptureBackendTestAccess::bootstrapMuted(receiver), + "unsupported saved WFM never starts audible provisional AM"); + device->releaseReadback(); + QElapsedTimer deadline; deadline.start(); + int queued = 0; + qint64 nextBlockMs = 0; + while (deadline.elapsed() < 8000 && !device->destroyed + && !(device->starts > 0 && rtl::RtlCaptureBackendTestAccess::restoreFinished(receiver))) { + if (device->starts > 0 && device->callbacks >= queued && deadline.elapsed() >= nextBlockMs) { + device->block(); ++queued; + nextBlockMs = deadline.elapsed() + 4; // 8192 / 2.4 MS/s, rounded up. + } + QCoreApplication::processEvents(); QThread::msleep(1); + } + const auto accepted = rtl::RtlCaptureBackendTestAccess::state(receiver); + check(receiver.isConnected() && rtl::RtlCaptureBackendTestAccess::restoreFinished(receiver) + && accepted && accepted->hardware.centerHz == 100'000'000 + && accepted->hardware.sampleRateHz == 2'400'000 + && accepted->receivers.size() == 1 + && accepted->receivers.front().passband.stableId == 0 + && accepted->receivers.front().mode == rtl::RtlCaptureTransaction::Mode::Am + && accepted->receivers.front().audioMute, + "unsupported WFM remains unadopted while the confirmed provisional AM receiver stays muted"); + bool explained = false; + for (const QString& warning : warnings) { + const QString text = warning.toLower(); + explained |= text.contains(QStringLiteral("wfm")) && text.contains(QStringLiteral("filter")) + && (text.contains(QStringLiteral("saved")) || text.contains(QStringLiteral("stored"))) + && (text.contains(QStringLiteral("unsupported")) || text.contains(QStringLiteral("not supported"))); + } + check(explained, "typed configuration warning explains the unsupported stored WFM filter"); + check(device->starts == 1 && device->writes == 6 && device->cancels == 0, + "unsupported stored WFM does not trigger a replacement hardware capture"); + check(scope.featureExact("RtlSlices") == original, + "bootstrap and refused WFM restore preserve the complete saved document"); + check(receiver.storeOperatingState(scope, receiver.currentOperatingState()).value_or(false), + "RTL settings owner handles the accepted provisional-state store"); + RadioModelSliceLifecycleTestAccess::forcePendingFlush(model); + check(scope.featureExact("RtlSlices") == original, + "explicit store and model flush cannot overwrite or resize the omitted legacy WFM entry"); + RadioModelSliceLifecycleTestAccess::queueDisconnectFlush(model); + receiver.disconnectRadio(); + check(!receiver.isConnected() && scope.featureExact("RtlSlices") == original, + "disconnect flush preserves all legacy WFM choices and unknown document members"); +} int main(int argc, char** argv) { TestSettingsProfile profile(QStringLiteral("rtl-runtime-settings")); @@ -260,6 +456,8 @@ int main(int argc, char** argv) "drag persists the new capture center without changing saved slice RF or monitor settings"); live.disconnectRadio(); } + reconnectAtCaptureLimits(); + unsupportedStoredWfmRemainsIntact(); std::fprintf(stderr, "rtl_runtime_settings_test: %d failures\n", failures); return failures ? 1 : 0; } diff --git a/tests/rtl_slice_settings_test.cpp b/tests/rtl_slice_settings_test.cpp index 92bbc2df63..5c51c7fd27 100644 --- a/tests/rtl_slice_settings_test.cpp +++ b/tests/rtl_slice_settings_test.cpp @@ -188,6 +188,63 @@ int main(int argc, char** argv) check(!RtlSliceSettings::decode(bad, decoded, reason) && decoded.captureCenterHz == 42, "malformed field rejects entire document without partial output"); } + { + const RadioSettingsScope wfmScope("rtl", "wfm-deemphasis"); + RtlSliceSettings wfm(wfmScope); + auto selected = slice(3); + selected.mode = QStringLiteral("WFM"); + selected.filterLowHz = -90000; selected.filterHighHz = 90000; + selected.wfmDeemphasisUs = 50; + selected.wfmForceMono = true; + check(wfm.patch(100'000'000, 2'400'000, {selected}) + && wfm.load().document.slices.value(3).wfmDeemphasisUs == 50, + "accepted 50 us deemphasis persists per stable slice"); + check(wfm.load().document.slices.value(3).wfmForceMono, + "accepted forced Mono persists under the existing per-slice owner"); + selected.mode = QStringLiteral("FM"); + check(wfm.patch(100'000'000, 2'400'000, {selected}) + && wfm.load().document.slices.value(3).wfmDeemphasisUs == 50, + "leaving WFM retains the accepted broadcast deemphasis choice"); + selected.mode = QStringLiteral("WFM"); selected.wfmDeemphasisUs = 75; + check(wfm.patch(100'000'000, 2'400'000, {selected}) + && wfm.load().document.slices.value(3).wfmDeemphasisUs == 75, + "accepted 75 us selection replaces only its slice setting"); + QJsonObject old = wfmScope.featureExact(feature); + QJsonObject oldEntries = old.value("slices").toObject(); + QJsonObject oldEntry = oldEntries.value("3").toObject(); + oldEntry.remove("wfmDeemphasisUs"); oldEntry.remove("wfmForceMono"); oldEntries.insert("3", oldEntry); + old.insert("slices", oldEntries); + RtlSliceSettings::Document restored; + check(RtlSliceSettings::decode(old, restored, reason) + && restored.slices.value(3).wfmDeemphasisUs == 75, + "existing schema-one documents retain the legacy 75 us response"); + check(!restored.slices.value(3).wfmForceMono, "old documents retain Auto Stereo"); + for (const QJsonValue& invalid : {QJsonValue(0), QJsonValue(1), QJsonValue("false"), + QJsonValue(QJsonValue::Null), QJsonValue(QJsonObject{})}) { + QJsonObject malformed = old; + QJsonObject items = oldEntries, item = oldEntry; + item.insert("wfmForceMono", invalid); items.insert("3", item); + malformed.insert("slices", items); restored.captureCenterHz = 42; + check(!RtlSliceSettings::decode(malformed, restored, reason) && restored.captureCenterHz == 42, + "malformed Mono selection refuses the whole document without partial adoption"); + } + for (const QJsonValue& invalid : {QJsonValue(0), QJsonValue(60), QJsonValue(75.5), + QJsonValue("50"), QJsonValue(QJsonValue::Null)}) { + QJsonObject malformed = old; + QJsonObject items = oldEntries, item = oldEntry; + item.insert("wfmDeemphasisUs", invalid); items.insert("3", item); + malformed.insert("slices", items); + restored.captureCenterHz = 42; + check(!RtlSliceSettings::decode(malformed, restored, reason) + && restored.captureCenterHz == 42, + "invalid deemphasis refuses the complete document atomically"); + } + const auto beforeInvalid = wfmScope.featureExact(feature); + selected.wfmDeemphasisUs = 60; + check(!wfm.patch(100'000'000, 2'400'000, {selected}) + && wfmScope.featureExact(feature) == beforeInvalid, + "invalid deemphasis cannot overwrite accepted settings"); + } QJsonObject bad = valid; entries = bad.value("slices").toObject(); entries.insert("00", entries.take("0")); diff --git a/tests/rtl_usb_controls_test.cpp b/tests/rtl_usb_controls_test.cpp index 0e560e31e0..8da51b3d30 100644 --- a/tests/rtl_usb_controls_test.cpp +++ b/tests/rtl_usb_controls_test.cpp @@ -79,7 +79,8 @@ int main() T owner({8, 1}); owner.beginSession(); T::Desired desired; desired.hardware = device.hardware; - desired.receivers = {{{0, 100'000'000, -100'000, 100'000, 0, 0, 0}, T::Mode::Wfm}}; + const double wfmGuard = AetherSDR::rtl::RtlReceivePipeline::kQualifiedWfmEnabled ? 3000 : 0; + desired.receivers = {{{0, 100'000'000, -100'000, 100'000, 0, wfmGuard, wfmGuard}, T::Mode::Wfm}}; check(bool(owner.submit(desired)), "initial request admitted"); auto work = owner.takeWork(); check(work && owner.complete(T::execute(*work, usb)) == T::Completion::Published, diff --git a/tests/rtl_wfm_pipeline_test.cpp b/tests/rtl_wfm_pipeline_test.cpp new file mode 100644 index 0000000000..4dcc7b2bac --- /dev/null +++ b/tests/rtl_wfm_pipeline_test.cpp @@ -0,0 +1,638 @@ +#include "core/backends/rtl/RtlReceivePipeline.h" +#include "core/backends/rtl/RtlRfExtractor.h" +#include "core/backends/rtl/RtlSdrDdc.h" +#include "core/dsp/WdspChannel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using Pipeline = AetherSDR::rtl::RtlReceivePipeline; +using Extractor = AetherSDR::rtl::RtlRfExtractor; +using Transaction = AetherSDR::rtl::RtlCaptureTransaction; +using namespace std::chrono_literals; +namespace { +constexpr double kTau = 2 * std::numbers::pi; +int failures = 0; +void check(bool condition, const char* description) +{ + if (!condition) { ++failures; std::fprintf(stderr, "FAIL: %s\n", description); } +} +bool ready(Pipeline& pipeline) +{ + const auto deadline = std::chrono::steady_clock::now() + 20s; + while (std::chrono::steady_clock::now() < deadline) { + const auto result = pipeline.service(); + if (result == Pipeline::Preparation::Ready) { return true; } + if (result == Pipeline::Preparation::Failed) { return false; } + std::this_thread::sleep_for(1ms); + } + return false; +} +Transaction::State state() +{ + Transaction::State result; + result.token = {47, 1}; + result.hardware.centerHz = 100'000'000; + result.capture = {47, 1, 100'000'000, 2'400'000, 1'080'000, 1'080'000}; + result.receivers = {{{3, 100'000'000, -100'000, 100'000, 0, 3000, 3000}, Transaction::Mode::Wfm}}; + result.receivingIds = {3}; + return result; +} +class ClockSink final : public Extractor::Sink { +public: + bool iqBlock(std::span i, std::span q, + std::uint64_t position) noexcept override + { + check(i.size() == 2048 && q.size() == i.size(), "WFM extracted IQ uses complete paired blocks"); + check(position % 8 == 0, "384 kHz IQ starts on the final 48 kHz sample lattice"); + if (blocks == 0) { first = position; } + else { check(position == next, "extracted IQ positions advance without partition-dependent rounding"); } + next = position + i.size(); + ++blocks; + return true; + } + std::uint64_t first = 0; + std::uint64_t next = 0; + unsigned blocks = 0; +}; +void outputClockAlignment() +{ + // At 2.4 MS/s, capture sample 25 is on the 384 kHz lattice but halfway + // between final 48 kHz frames. Coprime rates also exercise the bounded wait. + for (std::uint64_t rate : {225001u, 1843200u, 2400000u, 2400001u}) { + const std::uint64_t first = 25; + const std::uint64_t period = rate / std::gcd(rate, std::uint64_t{48000}); + const std::uint64_t start = first + (period - first % period) % period; + Extractor::Config config{{1, 1, 100'000'000, double(rate), rate * 0.45, rate * 0.45}, + {3, 100'000'000, -100'000, 100'000, 0, 1000, 1000}, 384000, 2048, 48000}; + Extractor extractor(config); + check(extractor.valid(), "WFM extraction accepts achieved unusual rates and bounded interpolation"); + if (!extractor.valid()) { continue; } + ClockSink sink; + std::array, 257> input; + input.fill({0.3f, 0}); + const std::uint64_t end = start + rate / 4; + for (std::uint64_t position = first; position < end;) { + const std::size_t count = std::min(input.size(), end - position); + check(extractor.process(config.capture, position, std::span(input).first(count), sink), + "partitioned input preserves the capture sample clock"); + position += count; + } + check(sink.blocks > 4, "clock fixture observes actual paired resampler output"); + check(sink.first == start / period * (384000 / std::gcd(rate, std::uint64_t{48000})), + "first IQ position is the exact capture-derived origin"); + check(sink.first / 8 == start / period * (48000 / std::gcd(rate, std::uint64_t{48000})), + "audio origin converts exactly to the shared 48 kHz timeline"); + check(!extractor.process(config.capture, end + 1, input, sink), + "a capture gap withdraws the extractor instead of relabeling old history"); + } +} + +struct Audio { + std::vector left; + std::vector right; + std::uint64_t next = 0; + std::uint64_t instance = 0; + std::uint64_t receiverEpoch = 0; + std::uint64_t captureEpoch = 0; + bool seen = false; + bool stereo = false; + bool pilot = false; + bool discontinuity = false; + double peak = 0; + std::size_t clipped = 0; + void accept(const Pipeline::Packet& packet) + { + check(packet.sampleRateHz == 48000 && packet.channelCount == 2, + "native audio declares 48 kHz paired stereo"); + check(packet.frames == (packet.slot < 0 ? 128u : 256u), + "speaker and slice packet frame counts describe their actual output blocks"); + if (seen) { check(packet.firstSample == next, "native output positions are contiguous at 48 kHz"); } + else { + instance = packet.instance; + receiverEpoch = packet.receiverEpoch; + captureEpoch = packet.captureEpoch; + discontinuity = packet.discontinuity; + } + seen = true; + next = packet.firstSample + packet.frames; + stereo |= packet.wfmStereoDetected.value_or(false); + pilot |= packet.wfmReception && packet.wfmReception->pilotLocked; + for (std::size_t i = 0; i < packet.frames; ++i) { + const float l = packet.samples[2 * i]; + const float r = packet.samples[2 * i + 1]; + check(std::isfinite(l) && std::isfinite(r), "native WFM samples remain finite"); + left.push_back(l); right.push_back(r); + peak = std::max({peak, std::abs(double(l)), std::abs(double(r))}); + clipped += std::abs(l) >= 0.999999f || std::abs(r) >= 0.999999f; + } + } +}; +// Integrate the published broadcast multiplex analytically. No firmware peer: +// L=.35*sin(1k), R=.20*sin(2k), pilot=.1*sin(19k), difference at sin(38k). +// Pilot/subcarrier phase follows the same public broadcast reference as the +// low-level WDSP fixture; changing only one to cosine would test quadrature. +std::complex referenceIq(std::uint64_t position, double rate) +{ + const double time = position / rate; + const auto sineIntegral = [time](double hz) { return -std::cos(kTau * hz * time) / hz; }; + const auto cosineIntegral = [time](double hz) { return std::sin(kTau * hz * time) / hz; }; + const double phase = 75000 * (0.1575 * sineIntegral(1000) + 0.09 * sineIntegral(2000) + + 0.07875 * (cosineIntegral(37000) - cosineIntegral(39000)) + - 0.045 * (cosineIntegral(36000) - cosineIntegral(40000)) + 0.1 * sineIntegral(19000)); + return {float(0.3 * std::cos(phase)), float(0.3 * std::sin(phase))}; +} +std::array run(Pipeline& pipeline, const Transaction::State& current, + std::uint64_t& position, double seconds) +{ + std::array result; + for (Audio& audio : result) { + audio.left.reserve(std::size_t(seconds * 48000) + 1024); + audio.right.reserve(std::size_t(seconds * 48000) + 1024); + } + const auto started = std::chrono::steady_clock::now(); + const std::uint64_t origin = position; + const double rate = current.capture.achievedSampleRateHz; + const std::uint64_t end = position + std::uint64_t(seconds * rate); + std::array, 8192> iq; + while (position < end) { + const std::size_t count = std::min(iq.size(), end - position); + for (std::size_t i = 0; i < count; ++i) { iq[i] = referenceIq(position + i, rate); } + if (!pipeline.process(position, std::span(iq).first(count))) { + check(false, "WFM acquisition accepts continuous generated IQ"); break; + } + position += count; + Pipeline::Packet packet; + while (pipeline.takePacket(packet)) { + check(packet.token == current.token, "PCM and decoder status retain the accepted capture token"); + check(packet.slot == -1 || packet.slot == 3, "native routing preserves sparse slice identity"); + if (packet.slot < 0) { + check(!packet.wfmStereoDetected && !packet.wfmReception, + "speaker mix does not claim an independent receiver observation"); + } else { + // Prefilled D8 PCM can precede the first completed decoder + // observation. Absence is truthful; status must not be invented. + check(packet.wfmStereoDetected.has_value() == packet.wfmReception.has_value(), + "slice output status is published only with a decoder observation"); + if (packet.wfmReception) { + check(packet.wfmReception->valid && packet.wfmStereoDetected + == (packet.wfmReception->pilotLocked && !current.receivers[0].wfmForceMono), + "observed output status honors the accepted mono selection and independent pilot"); + } + } + result[packet.slot < 0 ? 1 : 0].accept(packet); + } + std::this_thread::sleep_until(started + std::chrono::microseconds( + std::uint64_t((position - origin) * 1'000'000.0 / rate))); + } + check(!pipeline.needsRepair(), "real WDSP processing does not withdraw the receiver"); + return result; +} +double amplitude(const std::vector& samples, double hz) +{ + constexpr std::size_t window = 48000; + if (samples.size() < window) { return 0; } + std::complex sum{}; + const std::size_t first = samples.size() - window; + for (std::size_t i = 0; i < window; ++i) { + const double phase = -kTau * hz * i / 48000; + sum += double(samples[first + i]) * std::complex(std::cos(phase), std::sin(phase)); + } + return 2 * std::abs(sum) / window; +} +void nativeRouting() +{ + auto pipeline = std::make_unique(1, true); + Transaction::State current = state(); + check(pipeline->prepare(current, true) && ready(*pipeline) && pipeline->adopt(), + "qualified WFM prepares and adopts the production native graph"); + if (pipeline->legacy()) { pipeline->stop(); return; } + std::uint64_t position = 25; + const auto output = run(*pipeline, current, position, 4.0); + for (const Audio& audio : output) { + const double left = amplitude(audio.left, 1000); + const double leftLeak = amplitude(audio.left, 2000); + const double right = amplitude(audio.right, 2000); + const double rightLeak = amplitude(audio.right, 1000); + std::printf("WFM_PIPELINE samples=%zu peak=%.6f L1k=%.6f L2k=%.6f R2k=%.6f R1k=%.6f clipped=%zu\n", + audio.left.size(), audio.peak, left, leftLeak, right, rightLeak, audio.clipped); + check(audio.left.size() > 3 * 48000 && audio.left.size() <= 4 * 48000, + "native duration follows the 48 kHz output clock"); + check(audio.discontinuity, "a newly installed WFM graph starts discontinuously"); + check(left > 0.001 && right > 0.001, "both reference stereo channels reach native output"); + check(left > 100 * leftLeak && right > 100 * rightLeak, + "native WFM preserves correct L/R with at least 40 dB separation"); + check(audio.peak < 1 && audio.clipped == 0, "normalization precedes the independent tap and final speaker limiter"); + } + check(output[0].stereo, "actual WDSP pilot acquisition reaches slice metadata"); + + current.token.revision++; + current.receivers[0].audioMute = true; + check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "monitor mute adopts without replacing the receiver"); + const auto muted = run(*pipeline, current, position, 0.5); + check(muted[0].peak > 0.001 && muted[1].peak == 0, "monitor mute silences speakers while the independent tap remains live"); + check(muted[0].receiverEpoch == output[0].receiverEpoch, "monitor edits retain the decoder epoch"); + + current.token.revision++; + current.receivers[0].audioMute = false; + current.receivers[0].wfmDeemphasisUs = 50; + check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "50 us deemphasis prepares a fresh immutable decoder recipe"); + const auto changed = run(*pipeline, current, position, 1.0); + check(changed[0].seen && changed[1].seen && changed[0].discontinuity && changed[1].discontinuity, + "deemphasis changes mark both slice and speaker discontinuities"); + check(changed[0].receiverEpoch != output[0].receiverEpoch && changed[1].captureEpoch != output[1].captureEpoch, + "deemphasis replaces both decoder and speaker epochs"); + + current.token.revision++; + current.receivers[0].wfmForceMono = true; + check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "Force Mono prepares an accepted decoder recipe"); + const auto mono = run(*pipeline, current, position, 4.0); + for (const Audio& audio : mono) { + check(audio.seen && audio.peak > 0.001 && audio.left == audio.right, + "Force Mono is identical nonzero L/R at independent tap and speaker"); + check(audio.discontinuity, "Force Mono retires both audio lifetimes"); + } + check(!mono[0].stereo && mono[0].pilot && mono[0].receiverEpoch != changed[0].receiverEpoch + && mono[1].captureEpoch != changed[1].captureEpoch, + "Force Mono reports mono output with real pilot detection while replacing decoder and speaker epochs"); + current.token.revision++; + current.receivers[0].wfmForceMono = false; + check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "Auto Stereo readopts"); + const auto automatic = run(*pipeline, current, position, 4.0); + check(amplitude(automatic[0].left, 1000) > 100 * amplitude(automatic[0].right, 1000) + && amplitude(automatic[1].right, 2000) > 100 * amplitude(automatic[1].left, 2000), + "Auto Stereo restores separated native tap and speaker audio"); + current.token.revision++; + current.receivingIds.clear(); + check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "parking admits an empty active bank"); + const auto parked = run(*pipeline, current, position, 0.05); + check(!parked[0].seen && !parked[1].seen, "parked WFM emits neither stale audio nor pilot observations"); + current.token.revision++; + current.receivingIds = {3}; + Pipeline::Submission resume = Pipeline::Submission::RetryRetiringSlot; + const auto deadline = std::chrono::steady_clock::now() + 20s; + while ((resume == Pipeline::Submission::RetryRetiringSlot + || resume == Pipeline::Submission::RetryPlannerBusy) + && std::chrono::steady_clock::now() < deadline) { + resume = pipeline->prepareDetailed(current); + if (resume != Pipeline::Submission::Accepted) { + pipeline->service(); + std::this_thread::sleep_for(1ms); + } + } + check(resume == Pipeline::Submission::Accepted && ready(*pipeline) && pipeline->adopt(), + "resuming waits for safe retirement before constructing a fresh WFM receiver"); + const auto resumed = run(*pipeline, current, position, 1.5); + check(resumed[0].seen && resumed[0].instance != changed[0].instance + && resumed[0].discontinuity && resumed[1].discontinuity, + "resumed WFM cannot inherit the retired receiver's PCM or pilot lifetime"); + // A gap withdraws the immutable receiver. Repair has the same recipe, + // but a fresh decoder/filter history must also retire the speaker history. + std::array, 257> gapIq; + ++position; + for (std::size_t i = 0; i < gapIq.size(); ++i) { + gapIq[i] = referenceIq(position + i, current.capture.achievedSampleRateHz); + } + check(pipeline->process(position, gapIq) && pipeline->needsRepair(), + "capture gaps withdraw WFM and request the existing owner repair path"); + position += gapIq.size(); + Pipeline::Packet stale; + while (pipeline->takePacket(stale)) {} // consume only the old accepted revision + current.token.revision++; + check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), + "an unchanged WFM recipe can repair its withdrawn decoder"); + const auto repaired = run(*pipeline, current, position, 1.0); + check(repaired[0].seen && repaired[1].seen + && repaired[0].receiverEpoch != resumed[0].receiverEpoch + && repaired[1].captureEpoch != resumed[1].captureEpoch + && repaired[0].discontinuity && repaired[1].discontinuity, + "WFM repair retires both decoder and speaker histories"); + const auto diagnostics = pipeline->diagnostics(); + check(diagnostics.droppedPackets == 0 && diagnostics.mixerRejectedBlocks == 0 + && diagnostics.mixerConfigurationFailures == 0, + "paced WFM integration keeps bounded queues and valid mixer positions"); + pipeline->stop(); +} +void workerUnderrunTrace() +{ + using Registry = AetherSDR::rtl::RtlReceiverRegistry; + auto pipeline = std::make_unique(1, true); + Transaction::State current = state(); + const bool installed = pipeline->prepare(current, true) && ready(*pipeline) && pipeline->adopt(); + check(installed && !pipeline->legacy(), "underrun fixture prepares the actual nonblocking WFM receiver"); + if (!installed || pipeline->legacy()) { pipeline->stop(); return; } + + // The first worker to reach the existing handoff point is the sole WFM + // worker in this fixture. Destruction order releases it before the pipeline + // can join/retire WDSP, including every failed-assertion/early-return path. + struct HandoffHold { + HandoffHold() { WdspChannel::setWorkerHandoffHoldForTest(true); } + ~HandoffHold() { release(); } + void release() { WdspChannel::setWorkerHandoffHoldForTest(false); } + } hold; + std::uint64_t position = 0; + std::uint64_t failedCaptureFirst = 0; + std::size_t slicePackets = 0; + Pipeline::Packet firstSlice; + std::array, 256> iq; + const auto feed = [&] { + for (std::size_t i = 0; i < iq.size(); ++i) { + iq[i] = referenceIq(position + i, current.capture.achievedSampleRateHz); + } + check(pipeline->process(position, iq), "continuous capture remains admitted while WDSP is held"); + if (pipeline->needsRepair()) { failedCaptureFirst = position; } + position += iq.size(); + Pipeline::Packet packet; + while (pipeline->takePacket(packet)) { + if (packet.slot == 3) { + if (slicePackets == 0) { firstSlice = packet; } + check(packet.firstSample == slicePackets * 256, + "successful pre-fault slice PCM retains its exact 48 kHz positions"); + ++slicePackets; + } + } + }; + // Small capture partitions produce at most one WDSP block per call. Stop + // after the first output, then wait for an explicit worker acknowledgement; + // no elapsed sleep is assumed to imply that the worker has been scheduled. + for (unsigned chunk = 0; chunk < 512 && slicePackets == 0 && !pipeline->needsRepair(); ++chunk) { feed(); } + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (!WdspChannel::workerHandoffHeldForTest() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(1ms); + } + const bool held = WdspChannel::workerHandoffHeldForTest(); + check(held && slicePackets == 1 && !pipeline->needsRepair(), + "first real worker handoff is acknowledged before the next exchange"); + if (!held || slicePackets != 1 || pipeline->needsRepair()) { hold.release(); pipeline->stop(); return; } + + // An 8192-sample USB callback can produce seven WFM IQ blocks at the + // slowest admitted capture rates. Prepared headroom must accept that finite + // burst without needing the held worker to run. This fails on the old D2 + // exchange, independently of host speed or an arbitrary sleep duration. + for (unsigned chunk = 0; chunk < 512 && slicePackets < 7 && !pipeline->needsRepair(); ++chunk) { feed(); } + check(slicePackets == 7 && !pipeline->needsRepair(), + "nonblocking WFM accepts the bounded seven-block USB burst while its worker is held"); + // D8 has seven prefilled output blocks plus the first acknowledged worker + // handoff. Exhaustion therefore occurs on the ninth IQ block. It remains a + // real fexchange2 -2; treating -2 as Ok must still fail this withdrawal. + for (unsigned chunk = 0; chunk < 512 && !pipeline->needsRepair(); ++chunk) { feed(); } + check(pipeline->needsRepair() && slicePackets == 8, + "real nonblocking WFM exchange withdraws beyond its bounded eight-block credit"); + std::printf("WFM_HELD_BURST successful_blocks=%zu repair=%d\n", slicePackets, pipeline->needsRepair()); + const std::uint64_t faultCaptureFirst = failedCaptureFirst; + // Subsequent continuous RF cannot publish stale decoder output or a second + // first-fault record from the withdrawn receiver. + for (unsigned chunk = 0; chunk < 8; ++chunk) { feed(); } + check(slicePackets == 8, "withdrawn WFM receiver emits no stale independent PCM"); + unsigned faultEvents = 0; + Pipeline::TraceEvent event; + while (pipeline->takeTraceEvent(event)) { + if (event.kind != Pipeline::TraceEvent::Kind::ReceiverFailure) { continue; } + ++faultEvents; + check(event.token == current.token && event.hardwareGeneration == current.capture.generation + && event.captureEpoch == firstSlice.captureEpoch && event.slot == 3 && event.stableId == 3 + && event.instance == firstSlice.instance && event.receiverEpoch == firstSlice.receiverEpoch, + "real underrun trace retains the accepted receiver and capture identity"); + check(event.captureFirst == faultCaptureFirst && event.captureFrames == 256, + "real underrun trace names the exact failing capture callback"); + check(event.failure.has_value(), "real underrun trace carries a typed first failure"); + if (event.failure) { + const auto& failure = *event.failure; + check(failure.reason == Registry::ProcessingFailureReason::DspProcess + && failure.processResult == WdspChannel::ProcessResult::Underrun, + "first fault reports actual WDSP Underrun rather than a capture gap"); + check(failure.hasExpectedCaptureFirst && failure.expectedCaptureFirst == faultCaptureFirst + && failure.captureFirst == faultCaptureFirst && failure.captureFrames == 256 + && failure.hasIqFirst && failure.iqFirst == 16384 && failure.iqFrames == 2048, + "first fault proves continuous capture and identifies the ninth exact IQ block"); + check(failure.extraction && failure.extraction->reason == Extractor::FailureReason::SinkRejected, + "RF extraction reports downstream rejection of the actual failing WDSP block"); + } + } + check(faultEvents == 1 && pipeline->diagnostics().droppedTraceEvents == 0, + "one retained first-fault event survives later RF without diagnostic loss"); + hold.release(); // mandatory before control-side repair can retire the held worker + current.token.revision++; + const bool repaired = pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(); + check(repaired, "unchanged WFM recipe repairs after the real worker is released"); + if (repaired) { + const auto audio = run(*pipeline, current, position, 2.0); + check(audio[0].seen && audio[1].seen && audio[0].receiverEpoch != firstSlice.receiverEpoch + && audio[1].captureEpoch != firstSlice.captureEpoch + && audio[0].discontinuity && audio[1].discontinuity, + "underrun repair replaces decoder and speaker histories"); + check(audio[0].stereo && amplitude(audio[0].left, 1000) > 0.001 + && amplitude(audio[0].right, 2000) > 0.001 + && amplitude(audio[1].left, 1000) > 0.001 && amplitude(audio[1].right, 2000) > 0.001, + "released-worker repair delivers actual paired reference audio and pilot acquisition"); + } + pipeline->stop(); +} +void boundedBenchmark() +{ + // Opt-in pipeline plus production DDC/spectrum throughput qualification. + // USB byte conversion, backend timers, AudioEngine and GUI are absent. + // The 65536-bin display FFT at 25 RF frames/second, squelch detector, + // extraction, WDSP worker, independent tap, mixer and packet drain run + // sequentially on the same capture IQ. No synthetic firmware peer. + constexpr std::uint64_t kCaptureRate = 2'400'000; + constexpr std::uint64_t kRfSeconds = 4; + constexpr std::size_t kChunk = 8192; + constexpr double kFeedSpeed = 2.1; + constexpr int kSpectrumFps = 25; + constexpr std::uint64_t kNominalFrames = kRfSeconds * 48000; + const Transaction::State current = state(); + std::vector> iq(kRfSeconds * kCaptureRate); + // All fixture frequencies are integer kHz, so this analytically generated + // one-millisecond period repeats exactly. Generation is outside the timer. + std::array, kCaptureRate / 1000> period; + for (std::size_t i = 0; i < period.size(); ++i) { + period[i] = referenceIq(i, kCaptureRate); + } + for (std::size_t offset = 0; offset < iq.size(); offset += period.size()) { + std::copy(period.begin(), period.end(), iq.begin() + offset); + } + + // Determine the exact finite-run frame count, including resampler startup + // and the incomplete final IQ block, without involving WDSP or timing. + // This prevents an apparently fast run from qualifying by dropping audio. + const Extractor::Config extractorConfig{current.capture, + current.receivers.front().passband, 384000, 2048, 48000}; + Extractor oracle(extractorConfig); + check(oracle.valid(), "benchmark frame-count extractor prepares"); + if (!oracle.valid()) { return; } + ClockSink expected; + for (std::size_t offset = 0; offset < iq.size(); offset += kChunk) { + const std::size_t count = std::min(kChunk, iq.size() - offset); + if (!oracle.process(current.capture, offset, std::span(iq).subspan(offset, count), expected)) { + check(false, "benchmark frame-count extraction accepts the complete RF vector"); + return; + } + } + const std::uint64_t expectedFrames = std::uint64_t(expected.blocks) * 256; + check(expectedFrames <= kNominalFrames && expectedFrames > kNominalFrames - 1024, + "finite benchmark output approaches four seconds at the declared 48 kHz rate"); + using Ddc = AetherSDR::rtl::RtlSdrDdc; + Ddc ddc; // FFT plans, window preparation and signal wiring are untimed. + ddc.applyCapture(kCaptureRate, current.capture.centerHz, + current.receivers.front().passband.carrierHz, Transaction::Mode::Wfm, -100000, 100000, + current.capture.usableLeftHz, current.capture.usableRightHz); + ddc.setSpectrumRateFps(kSpectrumFps); + std::uint64_t spectrumFrames = 0; + std::uint64_t detectorFrames = 0; + unsigned legacyAudioPackets = 0; + QObject::connect(&ddc, &Ddc::spectrumFrameReady, &ddc, + [&](int panId, const QByteArray& frame) { + ++spectrumFrames; + check(panId == 0 && frame.size() == Ddc::kSpectrumBinCount * qsizetype(sizeof(float)), + "benchmark computes complete production display FFT frames"); + bool finite = true; + float peak = -std::numeric_limits::infinity(); + for (qsizetype offset = 0; offset + qsizetype(sizeof(float)) <= frame.size(); offset += sizeof(float)) { + float value; + std::memcpy(&value, frame.constData() + offset, sizeof(value)); + finite &= std::isfinite(value); + peak = std::max(peak, value); + } + check(finite && peak > -100, + "benchmark display FFT contains finite measured RF energy"); + }); + QObject::connect(&ddc, &Ddc::audioFrameReady, &ddc, + [&](const QByteArray&, const QByteArray&) { ++legacyAudioPackets; }); + const std::uint64_t expectedSpectrumFrames = 1 + + (iq.size() - Ddc::kSpectrumBinCount) / (kCaptureRate / kSpectrumFps); + QVector> captureBlock(kChunk); + auto pipeline = std::make_unique(1, true); + const bool installed = pipeline->prepare(current, true) && ready(*pipeline) && pipeline->adopt(); + check(installed && !pipeline->legacy(), "benchmark prepares the real native WFM graph before timing"); + if (!installed || pipeline->legacy()) { pipeline->stop(); return; } + std::array output; + for (Audio& audio : output) { + audio.left.reserve(kNominalFrames); + audio.right.reserve(kNominalFrames); + } + std::uint64_t position = 0; + const auto started = std::chrono::steady_clock::now(); + while (position < iq.size()) { + const std::size_t count = std::min(kChunk, iq.size() - position); + captureBlock.resize(count); // Retains the preallocated chunk capacity. + std::copy_n(iq.data() + position, count, captureBlock.data()); + // Match the worker's detector/display/audio ordering with DC suppression + // off, and disable legacy DDC audio while native WFM owns reception. + ddc.processSquelchSpectrum(captureBlock); + ddc.processIqData(captureBlock, false, false); + const auto spectrum = ddc.takeSquelchSpectrum(); + if (!spectrum.empty()) { + ++detectorFrames; + check(spectrum.size() == 2048, "benchmark retains the real squelch detector workload"); + pipeline->observeSpectrum(spectrum, position); + } + if (!pipeline->process(position, std::span(captureBlock.constData(), captureBlock.size())) + || pipeline->needsRepair()) { + check(false, "benchmark sustains real nonblocking WDSP processing without withdrawal"); + break; + } + position += count; + Pipeline::Packet packet; + while (pipeline->takePacket(packet)) { + check(packet.token == current.token && (packet.slot == -1 || packet.slot == 3), + "benchmark output preserves current token and sparse receiver identity"); + output[packet.slot < 0 ? 1 : 0].accept(packet); + } + // An unlimited burst can outrun the nonblocking WDSP exchange worker. + // Exercise a sustained >2x RF clock instead, and measure ALL feed and + // drain work plus scheduling waits. The measured speed must still pass; + // this cap is a throughput lower bound, not a maximum-speed claim. + std::this_thread::sleep_until(started + std::chrono::duration( + double(position) / (kCaptureRate * kFeedSpeed))); + if (std::chrono::steady_clock::now() - started > 5s) { + check(false, "benchmark completes within its bounded feed interval"); + break; + } + } + const double elapsed = std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + const double rfSeconds = double(position) / kCaptureRate; + const double speed = rfSeconds / elapsed; + const auto diagnostics = pipeline->diagnostics(); + std::printf("WFM_BENCHMARK scope=pipeline_ddc_fft25 capture_rate=%llu rf_seconds=%.6f elapsed_seconds=%.6f " + "speed=%.3fx feed_cap=%.1fx expected_frames=%llu slice_frames=%zu speaker_frames=%zu " + "repair=%d drops=%llu late_frames=%llu mixer_rejections=%llu mixer_config_failures=%llu " + "fft_fps=%d expected_fft_frames=%llu fft_frames=%llu detector_frames=%llu " + "ddc_legacy_audio_packets=%u\n", + static_cast(kCaptureRate), rfSeconds, elapsed, speed, kFeedSpeed, + static_cast(expectedFrames), output[0].left.size(), output[1].left.size(), + pipeline->needsRepair(), static_cast(diagnostics.droppedPackets), + static_cast(diagnostics.mixerLateFrames), + static_cast(diagnostics.mixerRejectedBlocks), + static_cast(diagnostics.mixerConfigurationFailures), kSpectrumFps, + static_cast(expectedSpectrumFrames), static_cast(spectrumFrames), + static_cast(detectorFrames), + legacyAudioPackets); + check(spectrumFrames == expectedSpectrumFrames + && detectorFrames > 0 && legacyAudioPackets == 0, + "benchmark includes every scheduled 25 FPS display FFT and excludes duplicate legacy audio"); + check(position == iq.size() && rfSeconds >= 4.0 && speed >= 2.0, + "benchmark completes at least four RF seconds at measured >=2x real time"); + check(diagnostics.observed && !pipeline->needsRepair() && diagnostics.droppedPackets == 0 + && diagnostics.mixerLateFrames == 0 && diagnostics.mixerRejectedBlocks == 0 + && diagnostics.mixerConfigurationFailures == 0, + "benchmark has no receiver faults, packet-mailbox loss or mixer failure"); + for (const Audio& audio : output) { + check(audio.left.size() == expectedFrames && audio.right.size() == expectedFrames + && audio.next == expectedFrames, + "benchmark delivers every expected contiguous 48 kHz frame to slice and speaker"); + check(amplitude(audio.left, 1000) > 0.001 && amplitude(audio.right, 2000) > 0.001 + && audio.peak < 1 && audio.clipped == 0, + "benchmark output contains finite unclipped reference audio rather than empty packets"); + } + check(output[0].stereo, "benchmark preserves actual WFM pilot acquisition under the faster RF clock"); + pipeline->stop(); +} +void admission() +{ + auto legacy = std::make_unique(); + const Transaction::State current = state(); + check(legacy->prepare(current, true) && ready(*legacy) && legacy->adopt() + && legacy->legacy() == !Pipeline::kQualifiedWfmEnabled, + "default pipeline follows the explicit qualification gate"); + legacy->stop(); + auto native = std::make_unique(1, true); + Transaction::State invalid = current; + invalid.receivers[0].wfmDeemphasisUs = 51; + check(!native->prepare(invalid), "unsupported deemphasis is refused before preparation"); + invalid = current; + invalid.receivers[0].squelchEnabled = true; + check(!native->prepare(invalid), "manual narrow-FM squelch cannot masquerade as WFM internal squelch"); + invalid = current; + invalid.receivers.push_back(invalid.receivers[0]); + invalid.receivers.back().passband.stableId = 4; + invalid.receivingIds.push_back(4); + check(!native->prepare(invalid), "WFM qualification does not increase production receiver admission"); + native->stop(); +} +} +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + outputClockAlignment(); + admission(); + nativeRouting(); + workerUnderrunTrace(); + if (qEnvironmentVariableIntValue("AETHER_WFM_BENCHMARK") == 1) { boundedBenchmark(); } + QThreadPool::globalInstance()->waitForDone(); + std::printf("rtl_wfm_pipeline_test: %d failures\n", failures); + return failures == 0 ? 0 : 1; +} diff --git a/tests/tests.cmake b/tests/tests.cmake index 424e13384f..157f3bd362 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -1623,6 +1623,12 @@ add_executable(wdsp_allocation_scope_test tests/wdsp_allocation_scope_test.cpp) target_link_libraries(wdsp_allocation_scope_test PRIVATE aether_wdsp Threads::Threads) add_test(NAME wdsp_allocation_scope_test COMMAND wdsp_allocation_scope_test) +add_executable(rtl_wfm_pipeline_test tests/rtl_wfm_pipeline_test.cpp) +target_include_directories(rtl_wfm_pipeline_test PRIVATE src) +target_link_libraries(rtl_wfm_pipeline_test PRIVATE aethercore aether_wdsp Qt6::Core) +add_test(NAME rtl_wfm_pipeline_test COMMAND rtl_wfm_pipeline_test) +set_tests_properties(rtl_wfm_pipeline_test PROPERTIES TIMEOUT 120) + add_executable(rtl_receive_pipeline_test tests/rtl_receive_pipeline_test.cpp) target_include_directories(rtl_receive_pipeline_test PRIVATE src) target_link_libraries(rtl_receive_pipeline_test PRIVATE aethercore aether_wdsp Qt6::Core) @@ -7400,6 +7406,36 @@ add_test(NAME fm_filter_controls_test set_tests_properties(fm_filter_controls_test PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +add_executable(wfm_controls_test + tests/wfm_controls_test.cpp + src/gui/WfmApplet.cpp + src/gui/WfmLockScope.cpp + src/gui/RxApplet.cpp + src/gui/ControlAvailabilityRegistry.cpp + src/gui/VfoWidget.cpp + src/gui/ModeFilterPresets.cpp + src/gui/VfoDisplayDefaults.cpp + src/gui/FrequencyEntryParser.cpp + src/gui/DragValuePopup.cpp + src/gui/FilterPassbandWidget.cpp + src/gui/SliceColorManager.cpp + src/gui/SliceLabel.cpp + src/gui/PhaseKnob.cpp + src/gui/SmartMtrWidget.cpp + src/gui/SmartMtrConfig.cpp + src/gui/MeterViewController.cpp + src/gui/AdaptiveFilterControls.cpp + src/gui/GuardedSlider.h +) +target_include_directories(wfm_controls_test PRIVATE src) +target_link_libraries(wfm_controls_test PRIVATE + aethercore Qt6::Widgets Qt6::Test +) +add_test(NAME wfm_controls_test + COMMAND wfm_controls_test) +set_tests_properties(wfm_controls_test PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + # The VFO flag's AetherRX / AetherTX launchers stay the same width on every # mode's DSP grid. Same socket-free build as the squelch test above. add_executable(vfo_dsp_launcher_width_test @@ -7693,6 +7729,7 @@ set(AETHER_SETTINGS_CONSUMERS gui_nested_lifetime_test rx_applet_squelch_reconciliation_test fm_filter_controls_test + wfm_controls_test spectrum_confirmed_geometry_test flex_slice_mode_intent_test rtl_slice_settings_test diff --git a/tests/wdsp_wbfm_test.cpp b/tests/wdsp_wbfm_test.cpp index 06d6557db2..584bb24090 100644 --- a/tests/wdsp_wbfm_test.cpp +++ b/tests/wdsp_wbfm_test.cpp @@ -1,7 +1,9 @@ #include "core/dsp/WdspChannel.h" +#include "../third_party/wdsp/include/aether_wdsp.h" #include #include +#include #include #include #include @@ -11,6 +13,7 @@ #include #include #include +#include #include namespace { @@ -266,6 +269,17 @@ bool stereoAndPilot() ok = require(stereo.finite && leftSeparation >= 40 && rightSeparation >= 40, "reference stereo separates correctly oriented L/R >=40dB") && ok; ok = require(channel->wbfmStereoDetected() == true, "real pilot acquires stereo indication") && ok; + const auto acquired = channel->wbfmReceptionDiagnostics(); + ok = require(acquired && acquired->valid && acquired->pilotLocked + && acquired->pilotMagnitude > acquired->pilotEngageThreshold + && acquired->pilotEngageThreshold == 0.06 && acquired->pilotReleaseThreshold == 0.03 + && acquired->engageBlocks == 93 && acquired->releaseBlocks == 187 + && acquired->consecutiveHighBlocks >= acquired->engageBlocks + && acquired->consecutiveLowBlocks == 0 + && acquired->lockLossCount == 0 && acquired->reacquisitionCount == 0 + && acquired->observationDurationMs >= 5900 && acquired->lockDurationMs >= 5000 + && acquired->stableDurationMs == 5000, + "completed decoder snapshot reports real pilot thresholds, hysteresis and sample-clock duration") && ok; ok = require(stereo.peak < 0.95 && stereo.clipped == 0, "stereo reference preserves unclipped headroom") && ok; for (const Vector vector : {Vector::StereoLowAudio, Vector::StereoHighAudio, Vector::StereoHighModulation}) { @@ -289,6 +303,13 @@ bool stereoAndPilot() ok = require(channel->wbfmStereoDetected() == false && lost.finite && std::sqrt(lost.differenceEnergy / lost.frames) < 1.0e-6, "pilot loss returns truthful mono with identical L/R") && ok; + const auto loss = channel->wbfmReceptionDiagnostics(); + ok = require(loss && loss->valid && !loss->pilotLocked && loss->lockDurationMs == 0 + && loss->lockLossCount == 1 && loss->reacquisitionCount == 0 + && acquired && loss->observationSequence != acquired->observationSequence + && loss->pilotMagnitude < loss->pilotReleaseThreshold + && loss->consecutiveLowBlocks >= loss->releaseBlocks, + "pilot loss is counted in DSP even between UI snapshots") && ok; // RF amplitude changes must not change valid FM audio gain or stereo // orientation; these clean vectors are not a weak-noisy-station claim. const Measurement weak = run(*channel, Vector::Stereo, 5.0, 2.0, 0.0, 0.001); @@ -296,6 +317,10 @@ bool stereoAndPilot() && std::abs(weak.left(0) / stereo.left(0) - 1.0) < 0.01 && std::abs(weak.right(1) / stereo.right(1) - 1.0) < 0.01, "clean40dB lower RF amplitude retains FM level and stereo") && ok; + const auto reacquired = channel->wbfmReceptionDiagnostics(); + ok = require(reacquired && reacquired->pilotLocked && reacquired->lockLossCount == 1 + && reacquired->reacquisitionCount == 1, + "reacquisition excludes first acquisition and retains measured loss") && ok; const Measurement noise = run(*channel, Vector::Noise, 4.0, 2.0); std::cout << "No-signal noise peak=" << noise.peak << " pilot=" << channel->wbfmStereoDetected().value_or(true) << '\n'; @@ -304,11 +329,41 @@ bool stereoAndPilot() "internal automatic squelch suppresses no-signal noise without a false pilot") && ok; ok = require(channel->setRunning(false) && channel->wbfmStereoDetected() == false, "stop immediately invalidates previous stereo indication") && ok; + ok = require(!channel->wbfmReceptionDiagnostics(), "stop invalidates completed-block diagnostics") && ok; + // A stop only arms WDSP's down-slew. An immediate restart cancels it, + // retaining decoder history because no flush occurred. Clock its 480-frame + // fade and 256-frame zero tail through three blocking 256-frame exchanges; + // setRunning(true) then waits for the completed fade's real flush worker. + std::array drainInput{}; + std::vector drainLeft(channel->outputBlockSize()), drainRight(channel->outputBlockSize()); + for (int block = 0; block < 3; ++block) { + if (!require(channel->processIq(drainInput, drainInput, drainLeft, drainRight) + == WdspChannel::ProcessResult::Ok, + "stop clocks the real down-slew before reception-history flush")) { return false; } + ok = require(!channel->wbfmReceptionDiagnostics(), + "clocking the stopped channel does not republish reception") && ok; + } if (!require(channel->setRunning(true), "WFM restarts")) { return false; } const Measurement resumed = run(*channel, Vector::Mono, 3.0, 2.0); ok = require(resumed.finite && channel->wbfmStereoDetected() == false && resumed.peak < 0.95 && resumed.clipped == 0, "restart on mono never retains old station's stereo flag") && ok; + const auto fresh = channel->wbfmReceptionDiagnostics(); + ok = require(fresh && !fresh->pilotLocked && fresh->lockLossCount == 0 + && fresh->reacquisitionCount == 0 && fresh->observationDurationMs < 3100, + "flush starts new reception counters and sample-clock history") && ok; + auto forced = config(); forced.wbfmReceive->forceMono = true; + if (!require(channel->reconfigure(forced, &error), error.c_str())) { return false; } + const Measurement mono = run(*channel, Vector::Stereo, 4.0); + const auto monoReception = channel->wbfmReceptionDiagnostics(); + ok = require(mono.finite && mono.peak > 0.001 && mono.differenceEnergy == 0.0 + && channel->wbfmStereoDetected() == false && monoReception && monoReception->pilotLocked, + "Force Mono produces identical nonzero L/R while truthfully observing the actual pilot") && ok; + if (!require(channel->reconfigure(config(), &error), error.c_str())) { return false; } + const Measurement automatic = run(*channel, Vector::Stereo, 4.0); + ok = require(automatic.finite && automatic.left(0) > 100 * automatic.right(0) + && automatic.right(1) > 100 * automatic.left(1), + "return to Auto Stereo restores the decoder matrix") && ok; return ok; } @@ -359,6 +414,247 @@ bool clockPilotAndNoise() return ok; } +// These barriers are test-thread observations, never acquisition policy. A +// deadline only detects a stuck worker; accepted exchange count is independent +// of CPU timing. Holds are released before any return can destroy the channel. +template +bool awaitWorker(Predicate&& predicate) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!predicate()) { + if (std::chrono::steady_clock::now() >= deadline) { return false; } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return true; +} + +struct WorkerHold { + explicit WorkerHold(bool beforeCopy = false) : copy(beforeCopy) + { + if (copy) { WdspChannel::setWorkerOutputCopyHoldForTest(true); } + else { WdspChannel::setWorkerHandoffHoldForTest(true); } + } + ~WorkerHold() { release(); } + void release() + { + if (copy) { WdspChannel::setWorkerOutputCopyHoldForTest(false); } + else { WdspChannel::setWorkerHandoffHoldForTest(false); } + } + bool entered() const + { + return copy ? WdspChannel::workerOutputCopyHeldForTest() + : WdspChannel::workerHandoffHeldForTest(); + } + bool copy; +}; + +struct ExchangeVector { + static constexpr std::size_t kBlocks = 480; + static constexpr std::size_t kAudioBlock = 256; + std::vector i = std::vector(kBlocks * kInputBlock); + std::vector q = std::vector(kBlocks * kInputBlock); + ExchangeVector() + { + for (std::size_t frame = 0; frame < i.size(); ++frame) { + const double time = static_cast(frame) / kInputRate; + const auto sineIntegral = [time](double hz) { + return -std::cos(kTwoPi * hz * time) / hz; + }; + const auto stereoIntegral = [time](double hz) { + return 0.5 * (std::sin(kTwoPi * (38000.0 - hz) * time) / (38000.0 - hz) + - std::sin(kTwoPi * (38000.0 + hz) * time) / (38000.0 + hz)); + }; + // Non-harmonic-to-block L/R tones distinguish temporal order as + // well as channel order. Analytically integrated continuous FM. + const double phase = 75000.0 * (0.225 * (sineIntegral(731.0) + + sineIntegral(1279.0) + stereoIntegral(731.0) - stereoIntegral(1279.0)) + + 0.1 * sineIntegral(19000.0)); + i[frame] = static_cast(0.1 * std::cos(phase)); + q[frame] = static_cast(0.1 * std::sin(phase)); + } + } + WdspChannel::ProcessResult process(WdspChannel& channel, std::size_t block, + std::span left, std::span right) const + { + return channel.processIq(std::span(i).subspan(block * kInputBlock, kInputBlock), + std::span(q).subspan(block * kInputBlock, kInputBlock), left, right); + } +}; + +bool exchangeOrder() +{ + const ExchangeVector signal; + constexpr std::size_t audioBlock = ExchangeVector::kAudioBlock; + constexpr std::size_t extraBlocks = 6; + constexpr std::size_t heldBlock = 384; // >2 seconds of real pilot acquisition. + constexpr int readySamples = 7 * audioBlock; + std::string error; + std::unique_ptr channel = WdspChannel::create(config(), &error); + if (!require(channel != nullptr, error.c_str())) { return false; } + bool ok = require(channel->outputSamplesReadyForTest() == audioBlock, + "blocking WFM retains depth2 startup geometry"); + std::vector referenceLeft(signal.kBlocks * audioBlock); + std::vector referenceRight(referenceLeft.size()); + for (std::size_t block = 0; block < signal.kBlocks; ++block) { + if (!require(signal.process(*channel, block, + std::span(referenceLeft).subspan(block * audioBlock, audioBlock), + std::span(referenceRight).subspan(block * audioBlock, audioBlock)) + == WdspChannel::ProcessResult::Ok, "blocking ordered-audio reference succeeds")) { + return false; + } + } + WdspChannel::Config native = config(); + native.blockForOutput = false; + if (!require(channel->reconfigure(native, &error), error.c_str())) { return false; } + std::array left{}, right{}; + // Reuse the same owner/slot twice; each preparation must restore the + // exact prefix, and all wraps and queued input after release are compared. + for (int preparation = 0; preparation < 2; ++preparation) { + if (preparation != 0 && !require(channel->reconfigure(native, &error), error.c_str())) { + return false; + } + if (!require(channel->outputSamplesReadyForTest() == readySamples, + "nonblocking WFM prepares seven credits on every rebuild")) { return false; } + double maximumError = 0.0; + bool finite = true; + double burstEnergy = 0.0; + double stereoEnergy = 0.0; + const std::uint64_t allocations = WdspChannel::allocationSequenceForTest(); + for (std::size_t block = 0; block < signal.kBlocks; ++block) { + static_assert(heldBlock + 8 < ExchangeVector::kBlocks); + std::optional hold; + if (block == heldBlock) { + // Ready count alone precedes the previous handoff's hook. Do + // not let the new hold accidentally claim that previous block. + channel->synchronizeWorkerForTest(); + hold.emplace(); + } + const std::size_t count = hold ? 8 : 1; + for (std::size_t part = 0; part < count; ++part) { + const std::size_t current = block + part; + if (!require(signal.process(*channel, current, left, right) + == WdspChannel::ProcessResult::Ok, + "prepared nonblocking WFM accepts ordered bounded burst")) { return false; } + if (block == heldBlock && part == 0 + && !require(awaitWorker([] { return WdspChannel::workerHandoffHeldForTest(); }), + "ordered burst starts after acknowledged worker handoff")) { return false; } + finite = finite && std::ranges::all_of(left, [](float sample) { return std::isfinite(sample); }) + && std::ranges::all_of(right, [](float sample) { return std::isfinite(sample); }); + if (current >= extraBlocks) { + for (std::size_t frame = 0; frame < audioBlock; ++frame) { + const std::size_t expected = (current - extraBlocks) * audioBlock + frame; + maximumError = std::max({maximumError, + std::abs(static_cast(left[frame]) - referenceLeft[expected]), + std::abs(static_cast(right[frame]) - referenceRight[expected])}); + if (block == heldBlock) { + burstEnergy += left[frame] * left[frame] + right[frame] * right[frame]; + stereoEnergy += (left[frame] - right[frame]) * (left[frame] - right[frame]); + } + } + } + } + if (hold) { hold->release(); } + if (!require(awaitWorker([&] { return channel->outputSamplesReadyForTest() == readySamples; }), + "worker publishes all completed burst output without extra input")) { return false; } + block += count - 1; + } + std::cout << "WFM_EXCHANGE_ORDER preparation=" << preparation << " extra_frames=" + << extraBlocks * audioBlock << " max_error=" << maximumError + << " burst_energy=" << burstEnergy << " stereo_energy=" << stereoEnergy << '\n'; + ok = require(finite && maximumError < 2.0e-5 && burstEnergy > 1.0 && stereoEnergy > 0.5, + "nonzero paired output matches blocking reference with exactly1536 added frames") && ok; + ok = require(WdspChannel::allocationSequenceForTest() == allocations, + "prepared bursts allocate no WDSP memory") && ok; + } + return ok; +} + +// A held copy must not grant credit for bytes it has not written. This also +// tests the cold zero prefix after a genuine clocked stop/flush. A faulted +// channel is never fed again: release, then reprepare off the acquisition path. +bool unpublishedOutput(WdspChannel& channel, const ExchangeVector& signal) +{ + constexpr int audioBlock = ExchangeVector::kAudioBlock; + std::array left{}, right{}; + channel.synchronizeWorkerForTest(); + WorkerHold hold(true); + bool ok = true; + for (std::size_t block = 0; block < 7; ++block) { + if (!require(signal.process(channel, block, left, right) == WdspChannel::ProcessResult::Ok, + "seven seeded output blocks remain available while next copy is held")) { return false; } + if (block == 0) { + if (!require(awaitWorker([&] { return hold.entered(); }), + "worker acknowledged before actual output memcpy")) { return false; } + ok = require(channel.outputSamplesReadyForTest() == 6 * audioBlock, + "unpublished output never grants a premature credit") && ok; + } + ok = require(std::ranges::all_of(left, [](float sample) { return sample == 0.0f; }) + && std::ranges::all_of(right, [](float sample) { return sample == 0.0f; }), + "prepared or flushed prefix contains actual paired silence") && ok; + } + ok = require(signal.process(channel, 7, left, right) == WdspChannel::ProcessResult::Underrun, + "eighth exchange underruns while output memcpy is unpublished") && ok; + return ok; +} + +bool exchangePreparation() +{ + const ExchangeVector signal; + WdspChannel::Config native = config(); native.blockForOutput = false; + std::string error; + std::unique_ptr channel = WdspChannel::create(native, &error); + if (!require(channel != nullptr, error.c_str())) { return false; } + bool ok = true; + const std::uint64_t allocations = WdspChannel::allocationSequenceForTest(); + for (const int depth : {-1, 0, 1, 9, std::numeric_limits::max()}) { + ok = require(OpenChannelWithExchangeDepth(channel->channelId(), 2048, 1024, + 384000, 192000, 48000, 0, 0, 0.01, 0.025, 0.0, 0.01, 0, depth) == 0, + "invalid prepared depth refuses before changing a live channel") && ok; + } + for (const int id : {-1, 32}) { + ok = require(OpenChannelWithExchangeDepth(id, 2048, 1024, + 384000, 192000, 48000, 0, 0, 0.01, 0.025, 0.0, 0.01, 0, 8) == 0, + "prepared creation rejects invalid channel identity") && ok; + } + ok = require(WdspChannel::allocationSequenceForTest() == allocations + && channel->outputSamplesReadyForTest() == 7 * 256, + "refused preparation leaves existing ring and allocations intact") && ok; + ok = unpublishedOutput(*channel, signal) && ok; + for (int round = 0; round < 2; ++round) { + if (!require(channel->reconfigure(native, &error), error.c_str())) { return false; } + std::array left{}, right{}; + for (std::size_t block = 0; block < 64; ++block) { + if (!require(signal.process(*channel, block, left, right) == WdspChannel::ProcessResult::Ok + && awaitWorker([&] { return channel->outputSamplesReadyForTest() == 7 * 256; }), + "flush fixture warms actual worker with bounded exchanges")) { return false; } + } + if (!require(channel->setRunning(false), "clocked flush stop accepted")) { return false; } + // Config's 480-frame down-slew plus the WDSP 256-frame zero tail + // completes within three 256-frame exchanges. Each handoff is awaited; + // setRunning(true) then waits for that completed fade's flush worker. + for (std::size_t block = 0; block < 3; ++block) { + if (!require(signal.process(*channel, block, left, right) == WdspChannel::ProcessResult::Ok + && awaitWorker([&] { return channel->outputSamplesReadyForTest() == 7 * 256; }), + "stop clocks real fade and flush without starving worker")) { return false; } + } + if (!require(channel->setRunning(true), "flushed WFM restarts without rebuilding")) { return false; } + ok = require(channel->outputSamplesReadyForTest() == 7 * 256, + "flush retains prepared depth8 credits") && ok; + ok = unpublishedOutput(*channel, signal) && ok; + } + // The same slot must not retain its former depth when a legacy owner opens + // it. Blocking WFM, legacy WBFM, ordinary receive and transmit stay at2. + std::array legacy{config(), config(), WdspChannel::Config{}, WdspChannel::Config{}}; + legacy[1].wbfmReceive.reset(); legacy[1].blockForOutput = false; + legacy[3].direction = WdspChannel::Direction::Transmit; + for (const WdspChannel::Config& candidate : legacy) { + if (!require(channel->reconfigure(candidate, &error), error.c_str())) { return false; } + ok = require(channel->outputSamplesReadyForTest() == static_cast(channel->outputBlockSize()), + "legacy and blocking owners reset reused slot to depth2") && ok; + } + return ok; +} + bool invalidConfiguration() { using Deemphasis = WdspChannel::WbfmReceive::Deemphasis; @@ -397,6 +693,8 @@ bool invalidConfiguration() int main() { const std::uint64_t allocations = WdspChannel::outstandingAllocationsForTest(); + const bool order = exchangeOrder(); + const bool preparation = exchangePreparation(); const bool filter = filterAndHeadroom(); const bool response = deemphasis(); const bool envelope = rfEnvelope(); @@ -405,5 +703,7 @@ int main() const bool validation = invalidConfiguration(); const bool leaks = require(WdspChannel::outstandingAllocationsForTest() == allocations, "WFM channel teardown frees tracked allocations"); - return filter && response && envelope && stereo && conditions && validation && leaks ? 0 : 1; + const bool ok = order && preparation && filter && response && envelope && stereo && conditions && validation && leaks; + std::cout << "WDSP_WBFM_TEST completed success=" << ok << '\n'; + return ok ? 0 : 1; } diff --git a/tests/wfm_controls_test.cpp b/tests/wfm_controls_test.cpp new file mode 100644 index 0000000000..24ed2a607f --- /dev/null +++ b/tests/wfm_controls_test.cpp @@ -0,0 +1,832 @@ +// Socket-free model and widget validation. The injected backend captures +// typed intents; no receiver, firmware peer, sound device or RF is opened. +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" +#include "core/backends/SliceDelta.h" +#include "gui/ControlAvailabilityRegistry.h" +#include "gui/FilterPassbandWidget.h" +#include "gui/RxApplet.h" +#include "gui/VfoWidget.h" +#include "gui/WfmApplet.h" +#include "gui/WfmLockScope.h" +#include "core/ThemeManager.h" +#include "models/RadioModel.h" +#include "models/SliceModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace AetherSDR; + +namespace { +class WfmBackend final : public IRadioBackend { +public: + RadioCapabilities caps; + bool connected = true; + bool refuseWfm = false; + QVector> requests; + QVector> monoRequests; + RadioCapabilities capabilities() const override { return caps; } + bool isConnected() const override { return connected; } + ReceiveControlPolicy receiveControlPolicy() const override { return ReceiveControlPolicy::Confirmed; } + void connectRadio(const RadioConnectRequest&) override {} + void disconnectRadio() override { connected = false; } + void setSliceFrequency(int, double) override {} + void setSliceMode(int, const QString&) override {} + void setSliceFilter(int, int, int) override {} + void setSliceAgc(int, const QString&, int) override {} + ReceiveDispatch requestSliceWfm(int id, const SliceWfmRequest& request) override + { + if (refuseWfm || !request.valid()) { return ReceiveDispatch::Unsupported; } + if (request.field == SliceWfmRequest::Field::ForceMono) { + setSliceWfmForceMono(id, request.value != 0); + } else { + setSliceWfmDeemphasis(id, request.value); + } + return ReceiveDispatch::Dispatched; + } + void setSliceWfmDeemphasis(int id, int microseconds) override + { requests.append({id, microseconds}); } + void setSliceWfmForceMono(int id, bool mono) override + { monoRequests.append({id, mono}); } + void setPanCenter(const QString&, double, PanCenterIntent) override {} + void setKeying(bool, const TxCoordinator::Operation&, + const TxCoordinator::Completion&) override {} + void invokeExtension(const QString&, const QString&, quint64, const QVariant&) override {} +}; + +WfmReceptionDiagnostics measuredPilot(bool locked = false) +{ + WfmReceptionDiagnostics value; + value.valid = true; + value.pilotMagnitude = locked ? 0.04 : 0.001; + value.pilotEngageThreshold = 0.01; + value.pilotReleaseThreshold = 0.005; + value.engageBlocks = 3; + value.releaseBlocks = 5; + value.pilotLocked = locked; + value.observationDurationMs = 6000; + value.stableDurationMs = 5000; + value.lockDurationMs = locked ? 6000 : 0; + return value; +} + +SliceDelta initialWfm() +{ + SliceDelta delta; + delta.mode = QStringLiteral("WFM"); + delta.frequency = 100.3; + delta.filterLow = -90000; + delta.filterHigh = 90000; + delta.wfmDeemphasisUs = 75; + delta.wfmStereoStatus = WfmStereoStatus::Acquiring; + delta.wfmForceMono = false; + delta.wfmReceptionDiagnostics = measuredPilot(); + return delta; +} + +WfmBackend* attachBackend(RadioModel& model) +{ + auto backend = std::make_unique(); + backend->caps.broadcastFmReceive = BroadcastFmReceive{{50, 75}, true, true}; + backend->caps.receiveFilterControl = ReceiveFilterControl{ + SliceFrequencyControl::Authority::Engine, + {{QStringLiteral("WFM"), -100000, -15000, 15000, 100000, 30000, 200000}}}; + WfmBackend* source = backend.get(); + model.setBackendForTest(std::move(backend), QStringLiteral("test")); + emit source->sliceChanged(3, initialWfm()); + return source; +} + +QPushButton* button(QWidget& widget, const QString& text) +{ + for (QPushButton* candidate : widget.findChildren()) { + if (candidate->text().compare(text, Qt::CaseInsensitive) == 0) { + return candidate; + } + } + return nullptr; +} + +// Mirrors the panel's ownership of complete tile visibility. The child +// must remain showable when the parent tile reconnects or floats. +class WfmHarness : public QWidget { +public: + QWidget* tile; + WfmApplet* applet; + WfmHarness() + { + auto* outer = new QVBoxLayout(this); + tile = new QWidget(this); + auto* inner = new QVBoxLayout(tile); + applet = new WfmApplet(tile); + inner->addWidget(applet); + outer->addWidget(tile); + tile->hide(); + connect(applet, &WfmApplet::availabilityChanged, tile, &QWidget::setVisible); + } +}; + +void checkBroadcastFmVisibility(WfmApplet& applet, bool visible) +{ + QComboBox* combo = applet.findChild(QStringLiteral("wfmDeemphasis")); + QLabel* status = applet.findChild(QStringLiteral("wfmStereoStatus")); + QVERIFY(combo && status); + QWidget* tile = applet.parentWidget(); + QVERIFY(tile && tile->parentWidget()); + QCOMPARE(applet.isAvailable(), visible); + QCOMPARE(tile->isHidden(), !visible); + QVERIFY(!applet.isHidden()); + QCOMPARE(combo->isVisibleTo(tile->parentWidget()), visible); + QCOMPARE(status->isVisibleTo(tile->parentWidget()), visible); + QLayout* layout = tile->parentWidget()->layout(); + QVERIFY(layout); + const int index = layout->indexOf(tile); + QVERIFY(index >= 0); + QCOMPARE(layout->itemAt(index)->isEmpty(), !visible); +} +} // namespace + +class WfmControlsTest : public QObject { + Q_OBJECT +private slots: + void observationsNeverFollowIntentOptimistically() + { + SliceModel slice(3); + QCOMPARE(slice.wfmDeemphasisUs(), 0); + QCOMPARE(slice.wfmStereoStatus(), WfmStereoStatus::Unavailable); + QSignalSpy requested(&slice, &SliceModel::wfmDeemphasisRequested); + QSignalSpy adopted(&slice, &SliceModel::wfmDeemphasisChanged); + QSignalSpy status(&slice, &SliceModel::wfmStereoStatusChanged); + QSignalSpy wire(&slice, &SliceModel::commandReady); + slice.setWfmDeemphasis(50); + QCOMPARE(requested.count(), 1); + QCOMPARE(slice.wfmDeemphasisUs(), 0); + QVERIFY(adopted.isEmpty()); + QVERIFY(wire.isEmpty()); + for (int invalid : {-1, 0, 51, 100}) { slice.setWfmDeemphasis(invalid); } + QCOMPARE(requested.count(), 1); + slice.applyChanges(initialWfm()); + QCOMPARE(slice.wfmDeemphasisUs(), 75); + QCOMPARE(slice.wfmStereoStatus(), WfmStereoStatus::Acquiring); + QCOMPARE(adopted.count(), 1); + QCOMPARE(status.count(), 1); + slice.applyChanges(initialWfm()); + QCOMPARE(adopted.count(), 1); + QCOMPARE(status.count(), 1); + SliceDelta invalid; + invalid.wfmDeemphasisUs = 99; + invalid.wfmStereoStatus = static_cast(99); + slice.applyChanges(invalid); + QCOMPARE(slice.wfmDeemphasisUs(), 75); + QCOMPARE(slice.wfmStereoStatus(), WfmStereoStatus::Acquiring); + for (WfmStereoStatus value : {WfmStereoStatus::Stereo, WfmStereoStatus::Mono, + WfmStereoStatus::Unavailable}) { + SliceDelta delta; + delta.wfmStereoStatus = value; + slice.applyChanges(delta); + QCOMPARE(slice.wfmStereoStatus(), value); + } + QCOMPARE(status.count(), 4); + } + + void intentsRequireLiveOwnedWfmAndDeclaredValue() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + SliceModel* slice = model.slice(3); + QVERIFY(slice); + slice->setWfmDeemphasis(50); + QCOMPARE(source->requests.size(), 1); + QCOMPARE(source->requests.last().first, 3); + QCOMPARE(source->requests.last().second, 50); + QCOMPARE(slice->wfmDeemphasisUs(), 75); + source->caps.broadcastFmReceive.reset(); + slice->setWfmDeemphasis(50); + source->caps.broadcastFmReceive = BroadcastFmReceive{{75}}; + slice->setWfmDeemphasis(50); + source->caps.broadcastFmReceive = BroadcastFmReceive{{50, 75}}; + source->connected = false; + slice->setWfmDeemphasis(50); + source->connected = true; + SliceDelta delta; + delta.mode = QStringLiteral("FM"); + slice->applyChanges(delta); + slice->setWfmDeemphasis(50); + delta.mode = QStringLiteral("WFM"); + slice->applyChanges(delta); + slice->setExternalReceiveAudioReplacementMute(true); + slice->setWfmDeemphasis(50); + slice->setExternalReceiveAudioReplacementMute(false); + std::thread foreignThread([slice] { slice->setWfmDeemphasis(50); }); + foreignThread.join(); + QCOMPARE(source->requests.size(), 1); + emit source->sliceRemoved(3); + QVERIFY(!model.slice(3)); + // The retired object remains alive until deleteLater is delivered. + slice->setWfmDeemphasis(50); + QCOMPARE(source->requests.size(), 1); + } + + void backendRefusalReportsOperatorIntentWithoutChangingObservation() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + SliceModel* slice = model.slice(3); + QVERIFY(slice); + QSignalSpy refused(&model, &RadioModel::commandDropped); + source->refuseWfm = true; + slice->setWfmDeemphasis(50); + slice->setWfmForceMono(true); + QCOMPARE(refused.count(), 2); + QVERIFY(source->requests.isEmpty()); + QVERIFY(source->monoRequests.isEmpty()); + QCOMPARE(slice->wfmDeemphasisUs(), 75); + QVERIFY(!slice->wfmForceMono()); + source->refuseWfm = false; + slice->setWfmDeemphasis(50); + QCOMPARE(source->requests.size(), 1); + QCOMPARE(refused.count(), 2); + } + + void registryKeepsLegacyOfflinePolicyWhileLiveControlsDim() + { + RadioModel model; + QWidget parent; + QWidget legacy(&parent); + QWidget live(&parent); + ControlAvailabilityRegistry registry(model); + const auto never = [](bool, const RadioCapabilities&) { return false; }; + registry.registerWidget(&legacy, QStringLiteral("Unsupported"), never); + registry.registerWidget(&live, QStringLiteral("Connect a receiver"), never, {}, false); + QVERIFY(legacy.isEnabled()); + QVERIFY(!live.isEnabled()); + QVERIFY(!live.isHidden()); + QCOMPARE(live.accessibleDescription(), QStringLiteral("Connect a receiver")); + } + + void keyboardSelectionWaitsForAdoptedStateAndStatusIsObserved() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + SliceModel* slice = model.slice(3); + QVERIFY(slice); + WfmHarness host; + WfmApplet& rx = *host.applet; + QComboBox* combo = rx.findChild(QStringLiteral("wfmDeemphasis")); + QLabel* status = rx.findChild(QStringLiteral("wfmStereoStatus")); + QVERIFY(combo && status); + checkBroadcastFmVisibility(rx, false); + QVERIFY(!combo->isEnabled()); + QVERIFY(!combo->accessibleDescription().isEmpty()); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + rx.setSlice(slice); + checkBroadcastFmVisibility(rx, false); + rx.setRadioModel(&model); + checkBroadcastFmVisibility(rx, true); + QVERIFY(combo->isEnabled()); + QCOMPARE(combo->focusPolicy(), Qt::StrongFocus); + QCOMPARE(combo->currentData().toInt(), 75); + QCOMPARE(status->text(), QStringLiteral("Acquiring")); + QVERIFY(source->requests.isEmpty()); + QTest::keyClick(combo, Qt::Key_Up); + QCOMPARE(source->requests.size(), 1); + QCOMPARE(source->requests.last().second, 50); + QCOMPARE(combo->currentData().toInt(), 75); + SliceDelta delta; + delta.wfmDeemphasisUs = 50; + delta.wfmStereoStatus = WfmStereoStatus::Stereo; + delta.wfmReceptionDiagnostics = measuredPilot(true); + emit source->sliceChanged(3, delta); + QCOMPARE(combo->currentData().toInt(), 50); + QCOMPARE(status->text(), QStringLiteral("Stereo")); + QVERIFY(status->accessibleName().contains(QStringLiteral("Stereo"))); + delta.wfmStereoStatus = WfmStereoStatus::Mono; + delta.wfmReceptionDiagnostics = measuredPilot(false); + emit source->sliceChanged(3, delta); + QCOMPARE(status->text(), QStringLiteral("Mono fallback")); + delta.mode = QStringLiteral("FM"); + emit source->sliceChanged(3, delta); + checkBroadcastFmVisibility(rx, false); + QVERIFY(!combo->isEnabled()); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + delta.mode = QStringLiteral("WFM"); + emit source->sliceChanged(3, delta); + checkBroadcastFmVisibility(rx, true); + QVERIFY(combo->isEnabled()); + // Replacement receive changes the individual controls' availability, + // not whether this supported WFM cluster belongs in the layout. + slice->setExternalReceiveAudioReplacementMute(true); + checkBroadcastFmVisibility(rx, true); + QVERIFY(!combo->isEnabled()); + slice->setExternalReceiveAudioReplacementMute(false); + checkBroadcastFmVisibility(rx, true); + QVERIFY(combo->isEnabled()); + delta.inCapture = false; + delta.wfmStereoStatus = WfmStereoStatus::Unavailable; + emit source->sliceChanged(3, delta); + checkBroadcastFmVisibility(rx, true); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + delta.inCapture = true; + delta.wfmStereoStatus = WfmStereoStatus::Acquiring; + emit source->sliceChanged(3, delta); + checkBroadcastFmVisibility(rx, true); + + QLayout* layout = host.layout(); + QVERIFY(layout); + layout->activate(); + const int visibleHeight = layout->minimumSize().height(); + source->caps.broadcastFmReceive.reset(); + emit model.capabilitiesChanged(true, source->caps); + checkBroadcastFmVisibility(rx, false); + QVERIFY(!combo->isEnabled()); + layout->activate(); + QVERIFY(layout->minimumSize().height() < visibleHeight); + source->caps.broadcastFmReceive = BroadcastFmReceive{{50, 75}, true, true}; + emit model.capabilitiesChanged(true, source->caps); + checkBroadcastFmVisibility(rx, true); + QVERIFY(combo->isEnabled()); + layout->activate(); + QCOMPARE(layout->minimumSize().height(), visibleHeight); + source->connected = false; + emit model.connectionStateChanged(false); + checkBroadcastFmVisibility(rx, false); + QVERIFY(!combo->isEnabled()); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + QVERIFY(!combo->accessibleDescription().isEmpty()); + source->connected = true; + emit model.connectionStateChanged(true); + checkBroadcastFmVisibility(rx, true); + QVERIFY(combo->isEnabled()); + rx.setSlice(nullptr); + checkBroadcastFmVisibility(rx, false); + QVERIFY(!combo->isEnabled()); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + QCOMPARE(source->requests.size(), 1); + } + + void clusterVisibilityWaitsForAcceptedMode() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + SliceModel* slice = model.slice(3); + QVERIFY(slice); + WfmHarness host; + WfmApplet& rx = *host.applet; + rx.setRadioModel(&model); + rx.setSlice(slice); + QSignalSpy requested(slice, &SliceModel::modeChangeRequested); + checkBroadcastFmVisibility(rx, true); + + slice->setMode(QStringLiteral("AM")); + QCOMPARE(requested.count(), 1); + QCOMPARE(slice->mode(), QStringLiteral("WFM")); + checkBroadcastFmVisibility(rx, true); + SliceDelta accepted; + accepted.mode = QStringLiteral("AM"); + emit source->sliceChanged(3, accepted); + checkBroadcastFmVisibility(rx, false); + + slice->setMode(QStringLiteral("WFM")); + QCOMPARE(requested.count(), 2); + QCOMPARE(slice->mode(), QStringLiteral("AM")); + checkBroadcastFmVisibility(rx, false); + accepted.mode = QStringLiteral("WFM"); + emit source->sliceChanged(3, accepted); + checkBroadcastFmVisibility(rx, true); + QVERIFY(source->requests.isEmpty()); + } + + void clusterFollowsSelectedOwnedSliceAndModelBinding() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + SliceDelta otherState = initialWfm(); + otherState.mode = QStringLiteral("FM"); + otherState.filterLow = -8000; + otherState.filterHigh = 8000; + otherState.wfmStereoStatus = WfmStereoStatus::Unavailable; + emit source->sliceChanged(4, otherState); + SliceModel* first = model.slice(3); + SliceModel* second = model.slice(4); + QVERIFY(first && second); + SliceModel foreign(3); + foreign.applyChanges(initialWfm()); + WfmHarness host; + WfmApplet& rx = *host.applet; + rx.setRadioModel(&model); + checkBroadcastFmVisibility(rx, false); + rx.setSlice(first); + checkBroadcastFmVisibility(rx, true); + rx.setSlice(second); + checkBroadcastFmVisibility(rx, false); + + SliceDelta oldSliceUpdate; + oldSliceUpdate.wfmStereoStatus = WfmStereoStatus::Stereo; + emit source->sliceChanged(3, oldSliceUpdate); + checkBroadcastFmVisibility(rx, false); + otherState = initialWfm(); + emit source->sliceChanged(4, otherState); + checkBroadcastFmVisibility(rx, true); + oldSliceUpdate.mode = QStringLiteral("AM"); + emit source->sliceChanged(3, oldSliceUpdate); + checkBroadcastFmVisibility(rx, true); + rx.setSlice(first); + checkBroadcastFmVisibility(rx, false); + emit source->sliceChanged(3, initialWfm()); + checkBroadcastFmVisibility(rx, true); + + // A matching numeric ID does not make a slice owned by this model. + rx.setSlice(&foreign); + checkBroadcastFmVisibility(rx, false); + rx.setSlice(first); + checkBroadcastFmVisibility(rx, true); + rx.setRadioModel(nullptr); + checkBroadcastFmVisibility(rx, false); + rx.setRadioModel(&model); + checkBroadcastFmVisibility(rx, true); + rx.setSlice(nullptr); + checkBroadcastFmVisibility(rx, false); + emit source->sliceChanged(3, oldSliceUpdate); + checkBroadcastFmVisibility(rx, false); + rx.setSlice(second); + checkBroadcastFmVisibility(rx, true); + QVERIFY(source->requests.isEmpty()); + } + + void rxNoLongerContainsBroadcastControls() + { + RxApplet rx; + QVERIFY(!rx.findChild(QStringLiteral("wfmDeemphasis"))); + QVERIFY(!rx.findChild(QStringLiteral("wfmStereoStatus"))); + QVERIFY(!rx.findChild(QStringLiteral("broadcastFmControls"))); + } + + void audioCycleWaitsForAcceptanceAndPreservesObservedPilot() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + WfmHarness host; + WfmApplet& applet = *host.applet; + applet.setRadioModel(&model); + applet.setSlice(model.slice(3)); + QPushButton* cycle = applet.findChild(QStringLiteral("wfmAudioMode")); + QLabel* status = applet.findChild(QStringLiteral("wfmStereoStatus")); + QVERIFY(cycle && status && cycle->isEnabled()); + QCOMPARE(cycle->text(), QStringLiteral("Auto Stereo")); + SliceDelta accepted; + accepted.wfmStereoStatus = WfmStereoStatus::Stereo; + accepted.wfmReceptionDiagnostics = measuredPilot(true); + emit source->sliceChanged(3, accepted); + QCOMPARE(status->text(), QStringLiteral("Stereo")); + const QString stereoStyle = status->styleSheet(); + QTest::keyClick(cycle, Qt::Key_Space); + QCOMPARE(source->monoRequests.size(), 1); + QCOMPARE(source->monoRequests.last().first, 3); + QVERIFY(source->monoRequests.last().second); + QVERIFY(!model.slice(3)->wfmForceMono()); + QCOMPARE(cycle->text(), QStringLiteral("Auto Stereo")); + QCOMPARE(status->styleSheet(), stereoStyle); + accepted.wfmForceMono = true; + accepted.wfmStereoStatus = WfmStereoStatus::Mono; + emit source->sliceChanged(3, accepted); + QCOMPARE(cycle->text(), QStringLiteral("Mono")); + QCOMPARE(status->text(), QStringLiteral("Pilot detected")); + QVERIFY(status->styleSheet() != stereoStyle); + QVERIFY(cycle->accessibleName().contains(QStringLiteral("Mono"))); + cycle->click(); + QCOMPARE(source->monoRequests.size(), 2); + QVERIFY(!source->monoRequests.last().second); + QCOMPARE(cycle->text(), QStringLiteral("Mono")); + accepted.wfmForceMono = false; + accepted.wfmStereoStatus = WfmStereoStatus::Stereo; + emit source->sliceChanged(3, accepted); + QCOMPARE(cycle->text(), QStringLiteral("Auto Stereo")); + QCOMPARE(status->styleSheet(), stereoStyle); + source->caps.broadcastFmReceive->forceMonoControl = false; + emit model.capabilitiesChanged(true, source->caps); + QVERIFY(!cycle->isEnabled()); + QVERIFY(!cycle->accessibleDescription().isEmpty()); + checkBroadcastFmVisibility(applet, true); + cycle->click(); + QCOMPARE(source->monoRequests.size(), 2); + } + + void bandwidthUsesAcceptedEdgesAndCapabilityBounds() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + WfmHarness host; + WfmApplet& applet = *host.applet; + applet.setRadioModel(&model); + applet.setSlice(model.slice(3)); + QComboBox* bandwidth = applet.findChild(QStringLiteral("wfmBandwidth")); + QVERIFY(bandwidth && bandwidth->isEnabled()); + QCOMPARE(bandwidth->currentData().toInt(), 180000); + QSignalSpy requests(model.slice(3), &SliceModel::filterCommandIssued); + QTest::keyClick(bandwidth, Qt::Key_Down); + QCOMPARE(requests.count(), 1); + QCOMPARE(requests.last().at(0).toInt(), -100000); + QCOMPARE(requests.last().at(1).toInt(), 100000); + QCOMPARE(bandwidth->currentData().toInt(), 180000); + SliceDelta accepted; + accepted.filterLow = -70000; + accepted.filterHigh = 80000; + emit source->sliceChanged(3, accepted); + QCOMPARE(bandwidth->currentText(), QStringLiteral("150 kHz (custom)")); + QCOMPARE(requests.count(), 1); + source->caps.receiveFilterControl.reset(); + emit model.capabilitiesChanged(true, source->caps); + QVERIFY(!bandwidth->isEnabled()); + QCOMPARE(model.slice(3)->filterLow(), -70000); + QCOMPARE(model.slice(3)->filterHigh(), 80000); + checkBroadcastFmVisibility(applet, true); + QCOMPARE(requests.count(), 1); + } + + void diagnosticsExpireAndMeasuredTransitionsWarn() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + WfmApplet applet; + applet.setRadioModel(&model); + applet.setSlice(model.slice(3)); + QLabel* status = applet.findChild(QStringLiteral("wfmStereoStatus")); + QLabel* details = applet.findChild(QStringLiteral("wfmDiagnostics")); + QVERIFY(status && details); + SliceDelta observation; + observation.wfmStereoStatus = WfmStereoStatus::Stereo; + observation.wfmReceptionDiagnostics = measuredPilot(true); + emit source->sliceChanged(3, observation); + const QString healthyStyle = status->styleSheet(); + QCOMPARE(status->text(), QStringLiteral("Stereo")); + observation.wfmReceptionDiagnostics->lockLossCount = 1; + observation.wfmReceptionDiagnostics->reacquisitionCount = 1; + observation.wfmReceptionDiagnostics->stableDurationMs = 100; + emit source->sliceChanged(3, observation); + QVERIFY(status->text().contains(QStringLiteral("Unstable"))); + QVERIFY(status->styleSheet() != healthyStyle); + QVERIFY(details->text().contains(QStringLiteral("Lock losses: 1"))); + QVERIFY(details->text().contains(QStringLiteral("Acquire / release: 0.01 / 0.005 relative"))); + QVERIFY(details->text().contains(QStringLiteral("High blocks: 0 / 3"))); + QVERIFY(details->text().contains(QStringLiteral("Pilot state unchanged for 0.1 s (5 s window)"))); + observation.wfmReceptionDiagnostics->stableDurationMs = 5000; + emit source->sliceChanged(3, observation); + QCOMPARE(status->text(), QStringLiteral("Stereo")); + QCOMPARE(status->styleSheet(), healthyStyle); + // Explicit evidence export only: no files or extra waits in the + // default test run. The image names and visible banner identify the + // source as a socket-free fixture, never a live-radio qualification. + const QString screenshotDirectory = qEnvironmentVariable("AETHER_WFM_APPLET_SCREENSHOT_DIR"); + if (!screenshotDirectory.isEmpty()) { + QDir output(screenshotDirectory); + QVERIFY(output.mkpath(QStringLiteral("."))); + QCheckBox* showScope = applet.findChild(QStringLiteral("wfmShowLockScope")); + QCheckBox* showDiagnostics = applet.findChild(QStringLiteral("wfmShowDiagnostics")); + QPushButton* settings = applet.findChild(QStringLiteral("wfmSettingsToggle")); + QVERIFY(showScope && showDiagnostics && settings); + const bool previousScope = showScope->isChecked(); + const bool previousDiagnostics = showDiagnostics->isChecked(); + QLabel banner(QStringLiteral("TEST FIXTURE · synthetic pilot data"), &applet); + ThemeManager::instance().applyStyleSheet(&banner, + "QLabel { color: {{color.accent.warning}}; font-size: 10px; }"); + auto* layout = qobject_cast(applet.layout()); + QVERIFY(layout); + layout->insertWidget(0, &banner); + showScope->setChecked(true); + showDiagnostics->setChecked(true); + settings->setChecked(true); + applet.resize(300, applet.minimumSizeHint().height()); + applet.show(); + // Populate a short, real-time display history at the same 4 Hz + // presentation cadence used by the backend. No receiver is open. + for (int i = 0; i < 16; ++i) { + SliceDelta fixture = observation; + fixture.wfmReceptionDiagnostics->pilotMagnitude = 0.025 + 0.003 * (i % 5); + fixture.wfmReceptionDiagnostics->observationDurationMs += 250 * (i + 1); + fixture.wfmReceptionDiagnostics->observationSequence = i + 1; + emit source->sliceChanged(3, fixture); + QTest::qWait(250); + } + QVERIFY(applet.grab().save(output.filePath(QStringLiteral("wfm-test-fixture-expanded.png")))); + showScope->setChecked(false); + showDiagnostics->setChecked(false); + settings->setChecked(false); + applet.resize(260, applet.minimumSizeHint().height()); + QApplication::processEvents(); + QVERIFY(applet.grab().save(output.filePath(QStringLiteral("wfm-test-fixture-controls.png")))); + showScope->setChecked(previousScope); + showDiagnostics->setChecked(previousDiagnostics); + applet.hide(); + } + // Invalidation clears a previously green status even if a producer + // has not delivered a separate stereo-status change yet. + observation.wfmReceptionDiagnostics = WfmReceptionDiagnostics{}; + emit source->sliceChanged(3, observation); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + QVERIFY(status->styleSheet() != healthyStyle); + QVERIFY(details->text().startsWith(QStringLiteral("No current receive measurements"))); + observation.wfmStereoStatus = WfmStereoStatus::Mono; + observation.wfmReceptionDiagnostics = measuredPilot(false); + emit source->sliceChanged(3, observation); + QCOMPARE(status->text(), QStringLiteral("Mono fallback")); + QVERIFY(!status->text().contains(QStringLiteral("Unstable"))); + } + + void settingsPersistIndependentDisplaysAndPreserveSiblingValues() + { + AppSettings::instance().setValue(QStringLiteral("WfmApplet"), + QStringLiteral(R"({"other":17,"ui":{"retained":"yes","showLockScope":true,"showDiagnostics":false}})")); + RadioModel model; + attachBackend(model); + { + WfmApplet applet; + applet.setRadioModel(&model); + applet.setSlice(model.slice(3)); + QCheckBox* scope = applet.findChild(QStringLiteral("wfmShowLockScope")); + QCheckBox* diagnostics = applet.findChild(QStringLiteral("wfmShowDiagnostics")); + QLabel* status = applet.findChild(QStringLiteral("wfmStereoStatus")); + QVERIFY(scope && diagnostics && status); + scope->setChecked(false); + QVERIFY(!status->isHidden()); + diagnostics->setChecked(true); + QVERIFY(applet.findChild()->isHidden()); + QVERIFY(!applet.findChild(QStringLiteral("wfmDiagnostics"))->isHidden()); + } + WfmApplet restored; + QVERIFY(!restored.findChild(QStringLiteral("wfmShowLockScope"))->isChecked()); + QVERIFY(restored.findChild(QStringLiteral("wfmShowDiagnostics"))->isChecked()); + const QJsonObject document = QJsonDocument::fromJson(AppSettings::instance() + .value(QStringLiteral("WfmApplet")).toString().toUtf8()).object(); + QCOMPARE(document.value(QStringLiteral("other")).toInt(), 17); + QCOMPARE(document.value(QStringLiteral("ui")).toObject().value(QStringLiteral("retained")).toString(), QStringLiteral("yes")); + AppSettings::instance().remove(QStringLiteral("WfmApplet")); + } + + void scopeIsBoundedAndStopsWhileHidden() + { + QWidget parent; + WfmLockScope scope(&parent); + scope.appendSample(0.04, 0.01, 0.005, WfmStereoStatus::Stereo, false); + QCOMPARE(scope.sampleCount(), 0); + parent.show(); + scope.show(); + for (int i = 0; i < 200; ++i) { + scope.appendSample(0.04, 0.01, 0.005, WfmStereoStatus::Stereo, false); + } + QCOMPARE(scope.sampleCount(), 160); + QAccessibleInterface* accessible = QAccessible::queryAccessibleInterface(&scope); + QVERIFY(accessible); + QVERIFY(accessible->text(QAccessible::Value).contains(QStringLiteral("0.04 relative"))); + QVERIFY(accessible->text(QAccessible::Value).contains(QStringLiteral("Stereo"))); + scope.hide(); + QCOMPARE(scope.sampleCount(), 0); + scope.appendSample(0.04, 0.01, 0.005, WfmStereoStatus::Stereo, false); + QCOMPARE(scope.sampleCount(), 0); + scope.show(); + QCOMPARE(scope.sampleCount(), 0); + scope.appendSample(0.04, 0.01, 0.005, WfmStereoStatus::Stereo, false); + QCOMPARE(scope.sampleCount(), 1); + scope.appendSample(std::numeric_limits::quiet_NaN(), 0.01, 0.005, + WfmStereoStatus::Stereo, false); + QCOMPARE(scope.sampleCount(), 0); + scope.appendSample(0.04, 0.01, 0.005, WfmStereoStatus::Stereo, false); + scope.clear(); + QCOMPARE(scope.sampleCount(), 0); + QVERIFY(accessible->text(QAccessible::Value).contains(QStringLiteral("Awaiting"))); + scope.appendSample(0.04, 0.005, 0.01, WfmStereoStatus::Stereo, false); + QCOMPARE(scope.sampleCount(), 0); + scope.appendSample(0.04, 0.01, 0.005, WfmStereoStatus::Unavailable, false); + QCOMPARE(scope.sampleCount(), 0); + } + + void scopeHistoryClearsAcrossTuneSelectionAndDisconnect() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + WfmHarness host; + WfmApplet& applet = *host.applet; + applet.setRadioModel(&model); + applet.setSlice(model.slice(3)); + host.show(); + WfmLockScope* scope = applet.findChild(); + QVERIFY(scope && scope->isVisible()); + SliceDelta observation; + observation.wfmStereoStatus = WfmStereoStatus::Stereo; + observation.wfmReceptionDiagnostics = measuredPilot(true); + emit source->sliceChanged(3, observation); + QCOMPARE(scope->sampleCount(), 1); + SliceDelta tune; + tune.frequency = 100.5; + emit source->sliceChanged(3, tune); + QCOMPARE(scope->sampleCount(), 0); + observation.wfmReceptionDiagnostics->observationDurationMs += 250; + emit source->sliceChanged(3, observation); + QCOMPARE(scope->sampleCount(), 1); + applet.setSlice(nullptr); + QCOMPARE(scope->sampleCount(), 0); + applet.setSlice(model.slice(3)); + QCOMPARE(scope->sampleCount(), 0); + observation.wfmReceptionDiagnostics->observationDurationMs += 250; + emit source->sliceChanged(3, observation); + QCOMPARE(scope->sampleCount(), 1); + source->connected = false; + emit model.connectionStateChanged(false); + QCOMPARE(scope->sampleCount(), 0); + source->connected = true; + emit model.connectionStateChanged(true); + QCOMPARE(scope->sampleCount(), 0); + observation.wfmReceptionDiagnostics = WfmReceptionDiagnostics{}; + emit source->sliceChanged(3, observation); + QCOMPARE(scope->sampleCount(), 0); + } + + void retiredSliceAndDestroyedModelReleaseBinding() + { + WfmHarness host; + auto model = std::make_unique(); + WfmBackend* source = attachBackend(*model); + host.applet->setRadioModel(model.get()); + host.applet->setSlice(model->slice(3)); + checkBroadcastFmVisibility(*host.applet, true); + emit source->sliceRemoved(3); + checkBroadcastFmVisibility(*host.applet, false); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + emit source->sliceChanged(3, initialWfm()); + // Reusing an ID must not bind the new receiver implicitly. + checkBroadcastFmVisibility(*host.applet, false); + host.applet->setSlice(model->slice(3)); + checkBroadcastFmVisibility(*host.applet, true); + model.reset(); + checkBroadcastFmVisibility(*host.applet, false); + } + + void broadcastFilterPresetsPreserveSavedEdgesInBothWidgets() + { + RadioModel model; + attachBackend(model); + SliceModel* slice = model.slice(3); + QVERIFY(slice); + RxApplet rx; + VfoWidget vfo; + rx.setRadioModel(&model); + vfo.setRadioModel(&model); + rx.setSlice(slice); + vfo.setSlice(slice); + FilterPassbandWidget* passband = rx.findChild(); + QVERIFY(passband && passband->isEnabled()); + QSignalSpy intents(slice, &SliceModel::filterCommandIssued); + for (QWidget* surface : {static_cast(&rx), static_cast(&vfo)}) { + QPushButton* preset = button(*surface, QStringLiteral("180K")); + QVERIFY(preset && preset->isEnabled()); + const int before = intents.count(); + preset->click(); + QCOMPARE(intents.count(), before + 1); + QCOMPARE(intents.last().at(0).toInt(), -90000); + QCOMPARE(intents.last().at(1).toInt(), 90000); + } + AppSettings::instance().setValue(QStringLiteral("FilterPresets_WFM"), + QStringLiteral("-70000:80000")); + rx.setSlice(slice); + vfo.setSlice(slice); + for (QWidget* surface : {static_cast(&rx), static_cast(&vfo)}) { + QPushButton* saved = button(*surface, QStringLiteral("150K")); + QVERIFY(saved && saved->isEnabled()); + const int before = intents.count(); + saved->click(); + QCOMPARE(intents.count(), before + 1); + QCOMPARE(intents.last().at(0).toInt(), -70000); + QCOMPARE(intents.last().at(1).toInt(), 80000); + } + QCOMPARE(AppSettings::instance().value(QStringLiteral("FilterPresets_WFM")).toString(), + QStringLiteral("-70000:80000")); + AppSettings::instance().remove(QStringLiteral("FilterPresets_WFM")); + } +}; + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("wfm-controls")); + if (!profile.isValid()) { return 1; } + QApplication app(argc, argv); + AppSettings::instance().load(); + qRegisterMetaType(); + WfmControlsTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "wfm_controls_test.moc" diff --git a/tests/workspace_controller_test.cpp b/tests/workspace_controller_test.cpp index ecd3347c25..450cc10975 100644 --- a/tests/workspace_controller_test.cpp +++ b/tests/workspace_controller_test.cpp @@ -803,6 +803,54 @@ int main(int argc, char** argv) report("...both ways", inner->isContainerVisible()); } + // A capability/mode-specific applet can suppress its presentation + // without closing the workspace item. Recall still owns logical open + // state, including a workspace that deliberately closed the applet. + { + ctl.switchWorkspace(wsA); + rx->setContainerVisible(true); + if (!rx->isOnCanvas()) { ctl.sendAppletToCanvas("RX"); } + const QString openWorkspace = ctl.createWorkspace( + WorkspaceController::NewWorkspaceSource::Current, + QStringLiteral("PresentationOpen")); + const QString closedWorkspace = ctl.createWorkspace( + WorkspaceController::NewWorkspaceSource::Current, + QStringLiteral("PresentationClosed")); + rx->setContainerVisible(false); + ctl.switchWorkspace(openWorkspace); + const QString beforeGate = storedDocument(); + const QVariant beforePreference = AppSettings::instance().value("Applet_RX"); + const NormRect beforeRect = canvas.itemRect("applet:RX"); + rx->setPresentationAvailable(false); + report("presentation suppression preserves workspace bytes and preference", + storedDocument() == beforeGate + && AppSettings::instance().value("Applet_RX") == beforePreference + && rx->isContainerVisible() && rx->isOnCanvas() + && canvas.itemRect("applet:RX") == beforeRect && rx->isHidden()); + ctl.switchWorkspace(closedWorkspace); + ctl.switchWorkspace(openWorkspace); + report("recall retains logical membership without exposing suppressed content", + rx->isContainerVisible() && rx->isOnCanvas() + && canvas.contains("applet:RX") && rx->isHidden() + && canvas.itemRect("applet:RX") == beforeRect); + const QString beforeResume = storedDocument(); + rx->setPresentationAvailable(true); + report("resuming presentation preserves recalled workspace intent", + rx->isVisible() && storedDocument() == beforeResume + && AppSettings::instance().value("Applet_RX") == beforePreference); + rx->setPresentationAvailable(false); + ctl.switchWorkspace(closedWorkspace); + const QString beforeClosedResume = storedDocument(); + rx->setPresentationAvailable(true); + report("availability cannot reopen a workspace's deliberately closed applet", + !rx->isContainerVisible() && rx->isHidden() + && !canvas.contains("applet:RX") + && storedDocument() == beforeClosedResume); + ctl.switchWorkspace(wsA); + ctl.deleteWorkspace(openWorkspace); + ctl.deleteWorkspace(closedWorkspace); + } + // Red-team B2: recall-driven bulk visibility runs inside the // recall guard, so the panel can suppress preference writes. { diff --git a/third_party/wdsp/AETHERSDR-PATCHES.md b/third_party/wdsp/AETHERSDR-PATCHES.md index ca67db487c..7d27a52f92 100644 --- a/third_party/wdsp/AETHERSDR-PATCHES.md +++ b/third_party/wdsp/AETHERSDR-PATCHES.md @@ -1106,3 +1106,135 @@ mutex to the acquisition-side getter. Keep the discriminator/DC changes explicitly opt-in unless a separately reviewed upstream equivalent preserves both continuous-waveform response and low-frequency stereo phase. Regenerate the FIR from its polynomial and rerun analytic fixtures if its geometry changes. + + +## Patch 20 — prepared WFM exchange headroom and output publication (RFC #5468) + +`upstream/channel.{h,c}` and `upstream/iobuffs.c` add an immutable prepared +`exchangeDepth` per channel. `OpenChannelWithExchangeDepth()` rejects channel +indices outside the table and depths outside 2..8 before modifying any state, +then shares the existing construction body. Its other arguments retain the +ordinary `OpenChannel()` contract. Every legacy `OpenChannel()` explicitly +selects `DSP_MULT` (still 2), including a reused former WFM slot. Existing +size/rate rebuilds retain the prepared field. Both creation and flush seed the +same output prefix from `r2_active_buffsize - r2_size`. + +The facade selects 8 only for its opt-in nonblocking `WbfmReceive` recipe: +384 kHz / 2048 IQ input, 192 kHz / 1024 DSP, 48 kHz / 256 paired output. +Blocking WFM, legacy WBFM, other RX, and TX retain depth 2. Measured continuous +8192-sample extraction callbacks realize a maximum of seven complete IQ +exchanges at the lowest approved capture rates (225001 and 250000 Hz). The +composed converter bound is seven; no wall-clock scheduler assumption is used +to derive it. The tests-only RED against `2e44eb04` accepted two exchanges with +the worker held after its first handoff, then reported the actual `Underrun`. +Live first-fault telemetry had independently observed `Underrun` with continuous +capture, so a missing capture packet is not needed to produce this failure. + +At this geometry the two rings occupy 288 KiB per depth-8 channel, versus +72 KiB at depth 2: 216 KiB additional prepared memory. Seven seeded output +blocks plus the existing previous-chain-output worker stage delay audio by +2048 frames / 42.667 ms, exactly six blocks / 1536 frames / 32 ms more than +blocking depth 2. These are exchange-stage costs only; RF filtering, rate +conversion, decoder, mixer waiting and device buffering are additional. One +callback's bound does not promise survival of arbitrary host starvation or +multiple queued callbacks without worker progress. Depth 8 is not a relaxed +mixer deadline and does not extend the existing 2048-frame hole-wait policy. + +For equal exchange/DSP transfers, S successful host calls and P worker +handoffs satisfy S-P <= D-1. The worker has copied each input before publishing +its corresponding credit. The next host write therefore cannot overwrite an +unread input before the first failure. The failing call can occupy the final +slot, so no further call is safe: every non-OK still withdraws that receiver, +and repair prepares a new epoch off acquisition. No retries or substitute silence on underrun, +callback allocations, new waits or new threads are introduced. + +A separate existing publication race incremented `r2_havesamps` before copying +the output bytes. `dexchange()` now copies the output and advances its private +write index first, then publishes credit under the existing count lock. The +copy remains outside that lock. This shared correctness repair is not claimed +as the established cause of the observed underrun. + +The default-inactive port rendezvous immediately before `copy_output_to_ring` +copies bytes and the test/control-only locked output-count readback provide +scheduler-independent regression barriers. A test-only `csDSP` rendezvous, +after count readiness and before arming a hold, also lets the previous block's +post-publication hook finish, so it cannot claim the next block's hold. The +count query takes `csEXCH` then the count lock to cover asynchronous flush. +Production calls none of these readiness helpers. Fixtures serialize the global +holds, keep other workers idle, and release before teardown/reprepare. +`wdsp_wbfm_test` compares nonzero paired output against the unchanged blocking +reference through a warm eight-exchange held burst, wraparound, and release, +with the exact six-block displacement removed. It checks repeated preparation, +clocked flush, legacy slot reuse, bounded API refusal, and genuinely unpublished +output: seven seeded calls succeed, the eighth must underrun while the copy is +held. Moving publication before the helper must fail that assertion. +`rtl_wfm_pipeline_test` acknowledges the first completed handoff, accepts eight +calls total, then requires the ninth's actual underrun, one typed first fault, +receiver withdrawal, no stale PCM, and a fresh nonzero stereo PCM repair epoch. +These are socket-free deterministic DSP tests, not hardware latency proof. + +On upstream refresh, retain the explicit legacy depth reset, prepared field on +all rebuilds, matching create/flush prefix, and bytes-before-credit edge unless +upstream supplies equivalent semantics. Do not increase global `DSP_MULT`, +weaken non-OK withdrawal, or move the test readiness query onto acquisition. + + +## Patch 21 — selected WFM mono and bounded pilot reception snapshot + +Local changes to `upstream/wbfm.c`, `upstream/wbfm.h`, the public WDSP header, +and the narrow `include/aether_wdsp.h` / `aether_wbfm_observation.h` facade, +against the same pinned upstream 2.10 revision. This is an AetherSDR extension, +not an upstream PLL or a calibrated signal-quality measurement. + +`SetRXAWBFMForceMono` selects the existing L+R-only matrix branch while leaving +INDY and automatic squelch running. The default remains Auto Stereo. RTL sets +this only while preparing an immutable receiver recipe; accepted state and +persistence follow the matching receiver revision, including failure/rollback. +A changed recipe retires both slice and speaker PCM epochs. Other WDSP owners +retain the false default, and rebuilding a configured receiver reapplies its +selection. Mono is real identical-channel audio, not merely a changed badge. Packet/model +stereo status describes the actual selected output: forced mono remains Mono +even when the independent reception diagnostics report an acquired pilot. + +`GetRXAWBFMReception` publishes one coherent latest-completed-block snapshot. +The worker publishes a fixed sequence plus 17 interlocked 32-bit payload words; +each exact double is copied into two words, never read nonatomically across +threads. A reader makes at most four attempts and returns unavailable on a +collision. No DSP mutex, wait, allocation, wall clock or logging enters the +acquisition path. Both publication and reading use the port's existing +sequentially consistent interlocked operations. The sequence advances only on +completed blocks or an explicitly invalidating reset, allowing backend freshness +to reject repeated cached observations while exchange credits are consumed. + +The amplitude is INDY's actual `sqrt(filtIstable^2 + filtQstable^2)` after the +19 kHz filter and before pilot AGC, in FM-discriminator units normalized to +75 kHz peak deviation. It is not RF power, dBm, dBFS, SNR, a quality percentage, +or PLL phase/frequency/lock. Exact low/high thresholds and consecutive-low/high +block counters plus their configured off/on block counts are included. At +192 kHz / 1,024 frames those thresholds are 0.03/0.06 and the integer hysteresis +counts are 187/93; readback uses the actual fields rather than duplicated UI +constants. Absent pilot is ordinary mono fallback. + +Lock duration, observation duration and time since either indicator transition +are computed from processed DSP samples. The recent stable duration is capped +at 5,000 ms. Loss and reacquisition counts cover every completed block between +UI updates; the first acquisition is excluded from reacquisitions. Durations +and counts saturate at `INT32_MAX`. Construction, flush, rate/size rebuild and +new receiver lifetimes invalidate/reset the measurements. These durations do +not include audio-ring/device latency or assert wall-clock continuity. + +The RTL owner publishes diagnostics at most four times per second plus actual +indicator transitions/invalidation, under the accepted PCM session/revision. +Only a new valid decoder sequence refreshes its 500 ms freshness clock. Retune, +park, mode/session changes and expiry clear the model's reception claim; raw +measurements are never persisted. Processing health remains a separate surface. + +Generated `wdsp_wbfm_test` vectors cover exact nonzero paired Mono output with +an honestly retained pilot, Auto Stereo restoration, measured hysteresis, +loss/reacquisition and flush reset. `rtl_wfm_pipeline_test` checks both the +independent tap and speaker with new epochs. Settings/model fixtures cover +request-versus-adoption, strict boolean decoding, old-document defaults, replay +freshness and retired observations. These tests are added validation obligations; +their results must come from a subsequent exact-source Nobara run. On upstream +refresh retain this extension unless equivalent typed, bounded, race-free +semantics replace it, and do not label INDY as a PLL. diff --git a/third_party/wdsp/README.md b/third_party/wdsp/README.md index b7dea38e86..3865c00377 100644 --- a/third_party/wdsp/README.md +++ b/third_party/wdsp/README.md @@ -24,6 +24,9 @@ therefore retains its upstream fallback behavior. recorded in `AETHERSDR-PATCHES.md`. The WFM integration adds patch 14: live paired deemphasis, lock-free stereo observation, complete decoder reset, and opt-in phase-difference/DC corrections that preserve stereo separation. +Patch 15 prepares depth-8 exchange rings only for nonblocking opt-in WFM, +retains legacy depth 2, and publishes copied output before its ring credit. +Its fixed memory/latency costs and deterministic tests are in the patch registry. Existing WDSP overlap-save RF filtering is exposed through the narrow C facade without changing the vendor filter. All portability changes live outside it: diff --git a/third_party/wdsp/include/aether_wbfm_observation.h b/third_party/wdsp/include/aether_wbfm_observation.h new file mode 100644 index 0000000000..9c2854d44e --- /dev/null +++ b/third_party/wdsp/include/aether_wbfm_observation.h @@ -0,0 +1,17 @@ +#pragma once +#include + +// C-only data at the vendor boundary; latest completed decoder block, not the +// delayed output ring's exact audio timestamp. Caller owns channel lifetime. +// Pilot magnitude/thresholds are exact normalized-discriminator values before +// pilot AGC. Durations count DSP samples, milliseconds saturate at INT32_MAX. +// Stereo means the hysteretic INDY result, independently of forced-mono output. +typedef struct AetherWdspWbfmObservation { + uint32_t observationSequence; + int valid; + int pilotLocked; + uint32_t lockDurationMs, lockLossCount, reacquisitionCount; + uint32_t observationDurationMs, stableDurationMs; + uint32_t consecutiveHighBlocks, consecutiveLowBlocks, engageBlocks, releaseBlocks; + double pilotMagnitude, pilotReleaseThreshold, pilotEngageThreshold; +} AetherWdspWbfmObservation; diff --git a/third_party/wdsp/include/aether_wdsp.h b/third_party/wdsp/include/aether_wdsp.h index 5797c941cc..3e3c52c400 100644 --- a/third_party/wdsp/include/aether_wdsp.h +++ b/third_party/wdsp/include/aether_wdsp.h @@ -1,6 +1,7 @@ #pragma once #include +#include "aether_wbfm_observation.h" #ifdef __cplusplus extern "C" { @@ -27,6 +28,13 @@ void OpenChannel(int channel, int inputSize, int dspSize, int inputSampleRate, int dspSampleRate, int outputSampleRate, int type, int state, double delayUp, double slewUp, double delayDown, double slewDown, int blockForOutput); +// Prepared ring depth, fixed for this channel lifetime and its rebuilds. +// Returns 0 before touching the channel for an invalid channel/depth; otherwise +// the ordinary OpenChannel parameter/lifetime contract applies. Depth is 2..8. +int OpenChannelWithExchangeDepth(int channel, int inputSize, int dspSize, + int inputSampleRate, int dspSampleRate, int outputSampleRate, int type, + int state, double delayUp, double slewUp, double delayDown, double slewDown, + int blockForOutput, int exchangeDepth); void CloseChannel(int channel); // Channel run state. state 1 = running, 0 = stopped. dmode 1 makes a stop // BLOCK until the channel has flushed (bounded by WDSP's own 100 ms timeout), @@ -52,6 +60,10 @@ void SetRXAMode(int channel, int mode); void SetRXAWBFMDiscriminatorCompensation(int channel, int enabled); void SetRXAWBFMdmph(int channel, int run, int continent); int GetRXAWBFMStereoIndicator(int channel); +// Construction/control only; actual matrix output becomes paired L+R in mono. +void SetRXAWBFMForceMono(int channel, int forceMono); +// Fixed four-attempt atomic snapshot; 0 means retry later, never wait on DSP. +int GetRXAWBFMReception(int channel, AetherWdspWbfmObservation* observation); // Host-owned WFM RF prefilter. WBFM intentionally disables RXA's internal // RF filters, so this existing overlap-save stage runs at the input rate @@ -451,6 +463,20 @@ uint64_t wdspPortOutstandingAllocations(void); // which is the window in which #5734's input overwrite happened. 0 (the // default) is a single relaxed load and no sleep. Process-global. void wdspPortSetHandoffPauseForTest(unsigned microseconds); +// TEST ONLY: arm the next worker at that same handoff; only the claiming worker +// waits. Poll the acknowledgement outside acquisition. Serialize fixtures and +// release (enabled=0) before retiring/closing that worker or arming another. +void wdspPortSetHandoffHoldForTest(int enabled); +int wdspPortHandoffHeldForTest(void); +// Same lifetime/serialization contract, but immediately BEFORE output memcpy. +void wdspPortSetOutputCopyHoldForTest(int enabled); +int wdspPortOutputCopyHeldForTest(void); +// Locks existing exchange then ring-count sections. Test/control only; caller owns +// an open channel and excludes destruction. Never an acquisition readiness API. +int GetChannelOutputSamplesForTest(int channel); +// After count readiness, wait for the previous handoff hook/DSP to finish. +// Test/control only; never call with either worker hold armed or entered. +void SynchronizeChannelWorkerForTest(int channel); #ifdef __cplusplus } diff --git a/third_party/wdsp/port/wdsp_port.c b/third_party/wdsp/port/wdsp_port.c index 82e3a35a50..8f77f0748d 100644 --- a/third_party/wdsp/port/wdsp_port.c +++ b/third_party/wdsp/port/wdsp_port.c @@ -6,6 +6,10 @@ * shutdown through its run flags and semaphores. */ +// Default-inactive, one-worker test rendezvous at the existing handoff hook. +// Inactive hooks only read their state; no shipping scheduling change. +enum WdspHandoffHoldState { WDSP_HOLD_OFF, WDSP_HOLD_ARMED, WDSP_HOLD_ENTERED }; + #ifndef _WIN32 #include "wdsp_port.h" @@ -42,6 +46,8 @@ static _Atomic uint64_t g_allocationSequence = 0; static _Atomic uint64_t g_outstandingAllocations = 0; // AetherSDR patch 13: see wdspPortHandoffPauseForTest() below. static _Atomic unsigned g_handoffPauseMicroseconds = 0; +static _Atomic int g_handoffHoldState = WDSP_HOLD_OFF; +static _Atomic int g_outputCopyHoldState = WDSP_HOLD_OFF; static uint64_t monotonicMilliseconds(void) { @@ -422,8 +428,58 @@ void wdspPortSetHandoffPauseForTest(unsigned microseconds) atomic_store_explicit(&g_handoffPauseMicroseconds, microseconds, memory_order_relaxed); } +void wdspPortSetOutputCopyHoldForTest(int enabled) +{ + atomic_store_explicit(&g_outputCopyHoldState, + enabled ? WDSP_HOLD_ARMED : WDSP_HOLD_OFF, memory_order_release); +} + +int wdspPortOutputCopyHeldForTest(void) +{ + return atomic_load_explicit(&g_outputCopyHoldState, memory_order_acquire) == WDSP_HOLD_ENTERED; +} + +void wdspPortOutputCopyHoldForTest(void) +{ + if (atomic_load_explicit(&g_outputCopyHoldState, memory_order_relaxed) == WDSP_HOLD_ARMED) + { + int expected = WDSP_HOLD_ARMED; + if (atomic_compare_exchange_strong_explicit(&g_outputCopyHoldState, &expected, + WDSP_HOLD_ENTERED, memory_order_acq_rel, memory_order_relaxed)) + { + while (atomic_load_explicit(&g_outputCopyHoldState, memory_order_acquire) == WDSP_HOLD_ENTERED) + { + Sleep(1); + } + } + } +} + +void wdspPortSetHandoffHoldForTest(int enabled) +{ + atomic_store_explicit(&g_handoffHoldState, + enabled ? WDSP_HOLD_ARMED : WDSP_HOLD_OFF, memory_order_release); +} + +int wdspPortHandoffHeldForTest(void) +{ + return atomic_load_explicit(&g_handoffHoldState, memory_order_acquire) == WDSP_HOLD_ENTERED; +} + void wdspPortHandoffPauseForTest(void) { + if (atomic_load_explicit(&g_handoffHoldState, memory_order_relaxed) == WDSP_HOLD_ARMED) + { + int expected = WDSP_HOLD_ARMED; + if (atomic_compare_exchange_strong_explicit(&g_handoffHoldState, &expected, + WDSP_HOLD_ENTERED, memory_order_acq_rel, memory_order_relaxed)) + { + while (atomic_load_explicit(&g_handoffHoldState, memory_order_acquire) == WDSP_HOLD_ENTERED) + { + Sleep(1); + } + } + } const unsigned microseconds = atomic_load_explicit(&g_handoffPauseMicroseconds, memory_order_relaxed); if (microseconds != 0) @@ -534,14 +590,62 @@ uint64_t wdspPortOutstandingAllocations(void) // millisecond resolution, so a non-zero request rounds UP to whole milliseconds // -- a longer window, never a shorter one. static volatile LONG g_handoffPauseMicroseconds = 0; +static volatile LONG g_handoffHoldState = WDSP_HOLD_OFF; +static volatile LONG g_outputCopyHoldState = WDSP_HOLD_OFF; void wdspPortSetHandoffPauseForTest(unsigned microseconds) { InterlockedExchange(&g_handoffPauseMicroseconds, (LONG)microseconds); } +void wdspPortSetOutputCopyHoldForTest(int enabled) +{ + InterlockedExchange(&g_outputCopyHoldState, enabled ? WDSP_HOLD_ARMED : WDSP_HOLD_OFF); +} + +int wdspPortOutputCopyHeldForTest(void) +{ + return InterlockedCompareExchange(&g_outputCopyHoldState, 0, 0) == WDSP_HOLD_ENTERED; +} + +void wdspPortOutputCopyHoldForTest(void) +{ + // Aligned LONG reads are atomic on Windows. Only an armed test performs a + // modifying interlocked operation, so inactive workers do not contend. + if (g_outputCopyHoldState == WDSP_HOLD_ARMED && + InterlockedCompareExchange(&g_outputCopyHoldState, WDSP_HOLD_ENTERED, + WDSP_HOLD_ARMED) == WDSP_HOLD_ARMED) + { + while (InterlockedCompareExchange(&g_outputCopyHoldState, 0, 0) == WDSP_HOLD_ENTERED) + { + Sleep(1); + } + } +} + +void wdspPortSetHandoffHoldForTest(int enabled) +{ + InterlockedExchange(&g_handoffHoldState, enabled ? WDSP_HOLD_ARMED : WDSP_HOLD_OFF); +} + +int wdspPortHandoffHeldForTest(void) +{ + return InterlockedCompareExchange(&g_handoffHoldState, 0, 0) == WDSP_HOLD_ENTERED; +} + void wdspPortHandoffPauseForTest(void) { + // Aligned LONG reads are atomic on Windows. Only an armed test performs a + // modifying interlocked operation, so inactive workers do not contend. + if (g_handoffHoldState == WDSP_HOLD_ARMED && + InterlockedCompareExchange(&g_handoffHoldState, WDSP_HOLD_ENTERED, + WDSP_HOLD_ARMED) == WDSP_HOLD_ARMED) + { + while (InterlockedCompareExchange(&g_handoffHoldState, 0, 0) == WDSP_HOLD_ENTERED) + { + Sleep(1); + } + } const LONG microseconds = InterlockedCompareExchange(&g_handoffPauseMicroseconds, 0, 0); if (microseconds != 0) { diff --git a/third_party/wdsp/upstream/channel.c b/third_party/wdsp/upstream/channel.c index bf9f9bc83d..c4b973cf7f 100644 --- a/third_party/wdsp/upstream/channel.c +++ b/third_party/wdsp/upstream/channel.c @@ -81,10 +81,12 @@ void build_channel (int channel) post_main_build (channel); } -PORT -void OpenChannel (int channel, int in_size, int dsp_size, int input_samplerate, int dsp_rate, int output_samplerate, - int type, int state, double tdelayup, double tslewup, double tdelaydown, double tslewdown, int bfo) +// AetherSDR patch 15: one construction path. The depth is prepared before +// allocating either ring and survives the existing size/rate rebuilds. +static void open_channel (int channel, int in_size, int dsp_size, int input_samplerate, int dsp_rate, int output_samplerate, + int type, int state, double tdelayup, double tslewup, double tdelaydown, double tslewdown, int bfo, int exchangeDepth) { + ch[channel].exchangeDepth = exchangeDepth; ch[channel].in_size = in_size; ch[channel].dsp_size = dsp_size; ch[channel].in_rate = input_samplerate; @@ -109,6 +111,28 @@ void OpenChannel (int channel, int in_size, int dsp_size, int input_samplerate, _MM_SET_FLUSH_ZERO_MODE (_MM_FLUSH_ZERO_ON); } +PORT +void OpenChannel (int channel, int in_size, int dsp_size, int input_samplerate, int dsp_rate, int output_samplerate, + int type, int state, double tdelayup, double tslewup, double tdelaydown, double tslewdown, int bfo) +{ + // Explicit on EVERY legacy open, including a slot previously used at depth 8. + open_channel (channel, in_size, dsp_size, input_samplerate, dsp_rate, output_samplerate, + type, state, tdelayup, tslewup, tdelaydown, tslewdown, bfo, DSP_MULT); +} + +PORT +int OpenChannelWithExchangeDepth (int channel, int in_size, int dsp_size, int input_samplerate, int dsp_rate, int output_samplerate, + int type, int state, double tdelayup, double tslewup, double tdelaydown, double tslewdown, int bfo, int exchangeDepth) +{ + if (channel < 0 || channel >= MAX_CHANNELS || exchangeDepth < 2 || exchangeDepth > 8) + { + return 0; + } + open_channel (channel, in_size, dsp_size, input_samplerate, dsp_rate, output_samplerate, + type, state, tdelayup, tslewup, tdelaydown, tslewdown, bfo, exchangeDepth); + return 1; +} + void pre_main_destroy (int channel) { IOB a = ch[channel].iob.pc; diff --git a/third_party/wdsp/upstream/channel.h b/third_party/wdsp/upstream/channel.h index 88da7e6aae..0d94a4a142 100644 --- a/third_party/wdsp/upstream/channel.h +++ b/third_party/wdsp/upstream/channel.h @@ -50,6 +50,7 @@ struct _ch double tslewup; double tdelaydown; double tslewdown; + int exchangeDepth; // AetherSDR patch 15: prepared ring slots; legacy Open resets to DSP_MULT int bfo; // 'block_for_output', block fexchange until output is available volatile long flushflag; struct //io buffers @@ -63,6 +64,8 @@ extern struct _ch ch[]; PORT void OpenChannel (int channel, int in_size, int dsp_size, int input_samplerate, int dsp_rate, int output_samplerate, int type, int state, double tdelayup, double tslewup, double tdelaydown, double tslewdown, int bfo); +PORT int OpenChannelWithExchangeDepth (int channel, int in_size, int dsp_size, int input_samplerate, int dsp_rate, int output_samplerate, int type, int state, double tdelayup, double tslewup, double tdelaydown, double tslewdown, int bfo, int exchangeDepth); + PORT void CloseChannel (int channel); extern void flushChannel (void* p); diff --git a/third_party/wdsp/upstream/iobuffs.c b/third_party/wdsp/upstream/iobuffs.c index 1cde690ae6..6722451f97 100644 --- a/third_party/wdsp/upstream/iobuffs.c +++ b/third_party/wdsp/upstream/iobuffs.c @@ -30,6 +30,7 @@ warren@wpratt.com // Declared here rather than in a port header so the same line serves the POSIX // port and the native Windows build alike. void wdspPortHandoffPauseForTest (void); +void wdspPortOutputCopyHoldForTest (void); /******************************************************************************************************** * * @@ -404,16 +405,16 @@ void create_iobuffs (int channel) a->r2_size = a->out_size; else a->r2_size = a->r2_insize; - a->r1_active_buffsize = DSP_MULT * a->r1_size; - a->r2_active_buffsize = DSP_MULT * a->r2_size; + a->r1_active_buffsize = ch[channel].exchangeDepth * a->r1_size; + a->r2_active_buffsize = ch[channel].exchangeDepth * a->r2_size; a->r1_baseptr = (double*) malloc0 (a->r1_active_buffsize * sizeof (complex)); a->r2_baseptr = (double*) malloc0 (a->r2_active_buffsize * sizeof (complex)); a->r1_inidx = 0; a->r1_outidx = 0; a->r1_unqueuedsamps = 0; - a->r2_inidx = (DSP_MULT - 1) * a->r2_size; + a->r2_inidx = (a->r2_active_buffsize - a->r2_size); a->r2_outidx = 0; - a->r2_havesamps = (DSP_MULT - 1) * a->r2_size; + a->r2_havesamps = (a->r2_active_buffsize - a->r2_size); n = a->r2_havesamps / a->out_size; a->r2_unqueuedsamps = a->r2_havesamps - n * a->out_size; InitializeCriticalSectionAndSpinCount(&a->r2_ControlSection, 2500); @@ -504,9 +505,9 @@ void flush_iobuffs (int channel) a->r1_inidx = 0; a->r1_outidx = 0; a->r1_unqueuedsamps = 0; - a->r2_inidx = (DSP_MULT - 1) * a->r2_size; + a->r2_inidx = (a->r2_active_buffsize - a->r2_size); a->r2_outidx = 0; - a->r2_havesamps = (DSP_MULT - 1) * a->r2_size; + a->r2_havesamps = (a->r2_active_buffsize - a->r2_size); while (!WaitForSingleObject (a->Sem_BuffReady, 1)); n = a->r2_havesamps / a->out_size; a->r2_unqueuedsamps = a->r2_havesamps - n * a->out_size; @@ -650,6 +651,42 @@ void fexchange2 (int channel, INREAL *Iin, INREAL *Qin, OUTREAL *Iout, OUTREAL * } } +// Test/control-thread observation only. Caller excludes channel teardown. +// Production exchange does not call this or poll for worker readiness. +PORT +int GetChannelOutputSamplesForTest (int channel) +{ + IOB a = ch[channel].iob.pc; + int samples; + // flush_iobuffs resets the count under csEXCH, outside the count lock. + // Test-only observation takes the same order as fexchange, covering both. + EnterCriticalSection (&ch[channel].csEXCH); + EnterCriticalSection (&a->r2_ControlSection); + samples = a->r2_havesamps; + LeaveCriticalSection (&a->r2_ControlSection); + LeaveCriticalSection (&ch[channel].csEXCH); + return samples; +} + +// After the output-count barrier proves all submitted handoffs were published, +// this control/test-only rendezvous also waits for the last hook and DSP body. +// Never call while a test hold is armed/entered; production does not call it. +PORT +void SynchronizeChannelWorkerForTest (int channel) +{ + EnterCriticalSection (&ch[channel].csDSP); + LeaveCriticalSection (&ch[channel].csDSP); +} + +static void copy_output_to_ring (IOB a, const double* in) +{ + // Default-inactive fixture rendezvous BEFORE the actual bytes are copied. + wdspPortOutputCopyHoldForTest (); + memcpy (a->r2_baseptr + 2 * a->r2_inidx, in, a->r2_insize * sizeof (complex)); + if ((a->r2_inidx += a->r2_insize) == a->r2_active_buffsize) + a->r2_inidx = 0; +} + int dexchange (int channel, double* in, double* out) { int n; @@ -696,13 +733,9 @@ int dexchange (int channel, double* in, double* out) if ((a->r1_outidx += a->r1_outsize) == a->r1_active_buffsize) a->r1_outidx = 0; - // AetherSDR patch 15: PUBLISH AFTER THE COPY. Upstream counts the block - // into r2_havesamps before it is in r2, so a host that exchanges the moment - // GetChannelOutputReady() says 1 could read a slot still being written. - // See AETHERSDR-PATCHES.md patch 15. - memcpy (a->r2_baseptr + 2 * a->r2_inidx, in, a->r2_insize * sizeof (complex)); - if ((a->r2_inidx += a->r2_insize) == a->r2_active_buffsize) - a->r2_inidx = 0; + // AetherSDR patches 15/17: publish only bytes already copied. The memcpy stays + // OUTSIDE the existing short credit lock; no longer acquisition lock span. + copy_output_to_ring (a, in); EnterCriticalSection (&a->r2_ControlSection); a->r2_havesamps += a->r2_insize; LeaveCriticalSection (&a->r2_ControlSection); diff --git a/third_party/wdsp/upstream/wbfm.c b/third_party/wdsp/upstream/wbfm.c index 3a8660edf6..74d7cb5fb0 100644 --- a/third_party/wdsp/upstream/wbfm.c +++ b/third_party/wdsp/upstream/wbfm.c @@ -25,6 +25,7 @@ warren@pratt.one #define _CRT_SECURE_NO_WARNINGS #include "comm.h" +#include "../include/aether_wbfm_observation.h" /******************************************************************************************************** * * @@ -744,6 +745,72 @@ static void decalc_wbfm (WBFM a) _aligned_free(a->disc_out); } +// Counters measure decoder work, independent of GUI sampling and audio-ring +// latency. Publishing all words atomically avoids a C data race during retry. +static uint32_t reception_ms(uint64_t samples, double rate) +{ + double value = 1000.0 * (double)samples / rate; + return value >= INT_MAX ? INT_MAX : (uint32_t)value; +} + +static void publish_reception(WBFM a, int valid) +{ + uint32_t words[17] = {0}; + double values[3] = {a->mag19, a->pIndy->low_thresh, a->pIndy->high_thresh}; + words[0] = valid; + words[1] = a->stereo; + words[2] = reception_ms(a->lockedSamples, a->rate); + words[3] = a->lockLossCount; + words[4] = a->reacquisitionCount; + words[5] = reception_ms(a->receptionSamples, a->rate); + words[6] = min(5000U, reception_ms(a->stableSamples, a->rate)); + words[7] = a->pIndy->consecutive_high_frames; + words[8] = a->pIndy->consecutive_low_frames; + words[9] = a->pIndy->on_delay_frames; + words[10] = a->pIndy->off_delay_frames; + memcpy(&words[11], values, sizeof(values)); + // Stay within signed 32-bit long on Windows; wrap only the sequence. + long sequence = InterlockedAnd(&a->receptionPublished[0], ~0L); + long next = (long)(((unsigned long)sequence + 2UL) & 0x7ffffffeUL); + InterlockedExchange(&a->receptionPublished[0], sequence + 1); + for (int i = 0; i < 17; ++i) { + InterlockedExchange(&a->receptionPublished[i + 1], (long)words[i]); + } + InterlockedExchange(&a->receptionPublished[0], next); +} + +static void reset_reception(WBFM a) +{ + a->stereo = 0; a->mag19 = 0.0; + InterlockedExchange(&a->stereoPublished, 0); + a->receptionSamples = a->lockedSamples = a->stableSamples = 0; + a->lockLossCount = a->reacquisitionCount = 0; + a->receptionPreviousStereo = a->receptionHadPilot = 0; + publish_reception(a, 0); +} + +static void observe_reception(WBFM a) +{ + // Saturation in samples prevents all long-session arithmetic overflow. + const uint64_t maximum = (uint64_t)INT_MAX * (uint64_t)a->rate / 1000; + a->receptionSamples = min(maximum, a->receptionSamples + (uint64_t)a->size); + if (a->stereo != a->receptionPreviousStereo) { + a->stableSamples = 0; + a->lockedSamples = 0; + if (a->stereo) { + if (a->receptionHadPilot && a->reacquisitionCount < INT_MAX) { ++a->reacquisitionCount; } + a->receptionHadPilot = 1; + } else if (a->lockLossCount < INT_MAX) { ++a->lockLossCount; } + } else { + a->stableSamples = min(maximum, a->stableSamples + (uint64_t)a->size); + } + if (a->stereo && a->receptionPreviousStereo) { + a->lockedSamples = min(maximum, a->lockedSamples + (uint64_t)a->size); + } else { a->lockedSamples = 0; } + a->receptionPreviousStereo = a->stereo; + publish_reception(a, 1); +} + WBFM create_wbfm (int run, int size, double* in, double* out, double rate) { WBFM a = (WBFM)malloc0(sizeof(wbfm)); @@ -759,6 +826,7 @@ WBFM create_wbfm (int run, int size, double* in, double* out, double rate) a->dmph = 1; a->dmph_type = 0; calc_wbfm(a); + reset_reception(a); return a; } @@ -808,6 +876,7 @@ void flush_wbfm(WBFM a) a->psql->npwr.bqsec[section].y_z2 = 0.0; } InterlockedExchange(&a->stereoPublished, 0); + reset_reception(a); } void xwbfm(WBFM a) @@ -825,12 +894,13 @@ void xwbfm(WBFM a) xsdelay(a->del15); xsdelay(a->del53); dsb_demod(a->size, a->fil19_out, a->fil53_out, a->LmR); - mx(a->size, a->stereo, a->LpR, a->LmR, a->L, a->R, a->sqgain); + mx(a->size, a->stereo && !a->force_mono, a->LpR, a->LmR, a->L, a->R, a->sqgain); if (a->disc_compensate) audio_dc_block(a); xdmph(a->dmphL); xdmph(a->dmphR); combine(a->size, a->L, a->R, a->out); InterlockedExchange(&a->stereoPublished, a->stereo); + observe_reception(a); } else if (a->in != a->out) memcpy(a->out, a->in, a->size * sizeof(complex)); @@ -847,6 +917,7 @@ void setSamplerate_wbfm(WBFM a, int rate) decalc_wbfm(a); a->rate = rate; calc_wbfm(a); + reset_reception(a); } void setSize_wbfm(WBFM a, int size) @@ -854,6 +925,7 @@ void setSize_wbfm(WBFM a, int size) decalc_wbfm(a); a->size = size; calc_wbfm(a); + reset_reception(a); } @@ -896,6 +968,44 @@ void SetRXAWBFMdmph(int channel, int dmph_run, int dmph_continent) LeaveCriticalSection(&ch[channel].csDSP); } +PORT +void SetRXAWBFMForceMono(int channel, int forceMono) +{ + EnterCriticalSection(&ch[channel].csDSP); + rxa[channel].wbfm.p->force_mono = forceMono != 0; + LeaveCriticalSection(&ch[channel].csDSP); +} + +PORT +int GetRXAWBFMReception(int channel, AetherWdspWbfmObservation* observation) +{ + if (!observation) { return 0; } + WBFM a = rxa[channel].wbfm.p; + for (int attempt = 0; attempt < 4; ++attempt) { + long sequence = InterlockedAnd(&a->receptionPublished[0], ~0L); + if (sequence & 1) { continue; } + uint32_t words[17]; + for (int i = 0; i < 17; ++i) { + words[i] = (uint32_t)InterlockedAnd(&a->receptionPublished[i + 1], ~0L); + } + if (sequence != InterlockedAnd(&a->receptionPublished[0], ~0L)) { continue; } + AetherWdspWbfmObservation result = {0}; + result.observationSequence = (uint32_t)sequence; + result.valid = words[0]; result.pilotLocked = words[1]; + result.lockDurationMs = words[2]; result.lockLossCount = words[3]; + result.reacquisitionCount = words[4]; result.observationDurationMs = words[5]; + result.stableDurationMs = words[6]; result.consecutiveHighBlocks = words[7]; + result.consecutiveLowBlocks = words[8]; result.engageBlocks = words[9]; + result.releaseBlocks = words[10]; + double values[3]; memcpy(values, &words[11], sizeof(values)); + result.pilotMagnitude = values[0]; result.pilotReleaseThreshold = values[1]; + result.pilotEngageThreshold = values[2]; + *observation = result; + return 1; + } + return 0; +} + PORT int GetRXAWBFMStereoIndicator(int channel) { diff --git a/third_party/wdsp/upstream/wbfm.h b/third_party/wdsp/upstream/wbfm.h index 168935c272..15d49f60b6 100644 --- a/third_party/wdsp/upstream/wbfm.h +++ b/third_party/wdsp/upstream/wbfm.h @@ -27,6 +27,7 @@ warren@pratt.one #define _wbfm_h #include "firmin.h" +#include #include "wcpAGC.h" //Forward declarations: @@ -110,6 +111,13 @@ typedef struct _wbfm int stereo; // AetherSDR: lock-free publication of the last completed decoder block. long stereoPublished; + // AetherSDR: all publication words use interlocked accesses. Word zero is + // a sequence, the remaining fixed words form one coherent bounded snapshot. + long receptionPublished[18]; + uint64_t receptionSamples, lockedSamples, stableSamples; + uint32_t lockLossCount, reacquisitionCount; + int receptionPreviousStereo, receptionHadPilot; + int force_mono; double sqgain; double mag19; int dmph; diff --git a/third_party/wdsp/upstream/wdsp.h b/third_party/wdsp/upstream/wdsp.h index e1afaccefb..a15086c64b 100644 --- a/third_party/wdsp/upstream/wdsp.h +++ b/third_party/wdsp/upstream/wdsp.h @@ -786,7 +786,10 @@ WDSP_API int GetWDSPVersion (void); /* ---- wbfm.c ------------------------------------------------------------ */ +#include "../include/aether_wbfm_observation.h" WDSP_API int GetRXAWBFMStereoIndicator (int channel); +WDSP_API void SetRXAWBFMForceMono (int channel, int forceMono); +WDSP_API int GetRXAWBFMReception (int channel, AetherWdspWbfmObservation* observation); WDSP_API void SetRXAWBFMdmph (int channel, int dmph_run, int dmph_continent); /* ---- wcpAGC.c ---------------------------------------------------------- */