From 9df9135d197aadba5f1fefb20abdff4a71ac0241 Mon Sep 17 00:00:00 2001 From: Ozy311 Date: Sat, 17 Oct 2026 11:03:50 -0700 Subject: [PATCH] Add RTL SSB/CW and AM/SAM reception with accepted controls. Principle II. Rebase the accepted RTL analog receive increment onto main 9dfe8f666, preserving accepted-state pitch controls, current CW decoder integration, transmit acknowledgement routing and settings isolation. Keep CW/CWR vocabulary in the existing model-layer owner. Preserve all production-pipeline, model, UI and stale-audio regression coverage. Co-authored-by: jensenpat --- docs/DEVELOPER-GUIDE.md | 2 + docs/architecture/aetherd-touchpoints.md | 8 +- docs/architecture/radio-capabilities-map.md | 38 ++ docs/automation-bridge.md | 2 +- docs/rtl-am-sam.md | 65 ++ docs/rtl-m1-runtime.md | 23 +- docs/rtl-multirx-analog.md | 9 +- docs/rtl-ssb-cw.md | 80 +++ docs/user/docs/copy-assist.md | 9 +- docs/user/docs/cw-decoder.md | 7 +- docs/user/docs/rtl-sdr.md | 34 +- docs/user/docs/tnf-tracking-notch-filters.md | 18 +- .../src/components/LogAnalyzer/rules.json | 28 +- src/asr/AsrEngine.cpp | 58 +- src/asr/AsrEngine.h | 28 +- src/core/AetherDspModePolicy.cpp | 4 +- src/core/AetherDspModePolicy.h | 1 + src/core/AutomationServer.cpp | 31 +- src/core/PcmFrame.h | 1 + src/core/RtlSliceSettings.cpp | 119 +++- src/core/RtlSliceSettings.h | 30 +- src/core/backends/RadioCapabilities.h | 26 + src/core/backends/ReceiveCommand.h | 6 +- .../backends/rtl/RtlCaptureTransaction.cpp | 16 +- src/core/backends/rtl/RtlCaptureTransaction.h | 24 + src/core/backends/rtl/RtlReceivePipeline.cpp | 42 +- src/core/backends/rtl/RtlReceiveRecipe.h | 124 ++++ src/core/backends/rtl/RtlReceiverRegistry.cpp | 10 + src/core/backends/rtl/RtlReceiverRegistry.h | 3 + src/core/backends/rtl/RtlSdrBackend.cpp | 512 ++++++++++++--- src/core/backends/rtl/RtlSdrBackend.h | 15 +- src/core/backends/rtl/RtlSdrWorker.cpp | 1 + src/core/backends/rtl/RtlTrackingNotches.h | 35 ++ src/core/dsp/WdspChannel.cpp | 97 ++- src/core/dsp/WdspChannel.h | 32 + src/gui/AsrAudioTap.cpp | 59 +- src/gui/AsrAudioTap.h | 9 + src/gui/AudioButtonIcon.h | 78 +++ src/gui/ControlAvailabilityRegistry.cpp | 57 ++ src/gui/ControlAvailabilityRegistry.h | 13 + src/gui/CopyAssistController.cpp | 73 ++- src/gui/CopyAssistController.h | 13 + src/gui/CopyAssistSettingsDialog.cpp | 15 + src/gui/CopyAssistSettingsDialog.h | 3 + src/gui/MainWindow.cpp | 26 +- src/gui/MainWindow_DspApplets.cpp | 7 + src/gui/MainWindow_Menus.cpp | 1 + src/gui/MainWindow_Wiring.cpp | 10 +- src/gui/ModeFilterPresets.cpp | 32 +- src/gui/PhoneCwApplet.cpp | 38 +- src/gui/PhoneCwApplet.h | 2 + src/gui/RxApplet.cpp | 80 ++- src/gui/SpectrumOverlayMenu.cpp | 25 +- src/gui/SpectrumWidget.cpp | 20 +- src/gui/TitleBar.cpp | 80 +-- src/gui/TitleBar.h | 1 + src/gui/VfoWidget.cpp | 133 +++- src/gui/VfoWidget.h | 1 + src/models/DecoderAudioModel.cpp | 11 +- src/models/DecoderAudioModel.h | 2 +- src/models/ModeVocabulary.h | 20 +- src/models/RadioModel.cpp | 79 ++- src/models/SliceModel.cpp | 178 ++++++ src/models/SliceModel.h | 1 + src/models/TnfModel.cpp | 12 + src/models/TnfModel.h | 6 +- src/models/TransmitModel.cpp | 5 + src/models/TransmitModel.h | 4 + tests/aether_dsp_mode_policy_test.cpp | 12 +- tests/asr_audio_tap_test.cpp | 13 + tests/asr_engine_test.cpp | 58 ++ tests/asr_native_receiver_test.cpp | 155 +++++ .../automation_dsp_backend_readback_test.cpp | 105 +++- tests/control_availability_registry_test.cpp | 85 +++ tests/cw_mode_gate_test.cpp | 27 + tests/fm_filter_controls_test.cpp | 61 ++ tests/mode_filter_presets_test.cpp | 35 +- tests/phone_cw_applet_apf_test.cpp | 35 ++ tests/radiomodel_tnf_removal_status_test.cpp | 46 ++ tests/receive_intent_routing_test.cpp | 173 +++++- tests/rtl_am_sam_pipeline_test.cpp | 446 +++++++++++++ tests/rtl_capture_transaction_test.cpp | 32 +- tests/rtl_model_acceptance_test.cpp | 471 +++++++++++++- tests/rtl_receive_pipeline_test.cpp | 56 ++ tests/rtl_runtime_settings_test.cpp | 27 +- tests/rtl_slice_settings_test.cpp | 109 ++++ tests/rtl_ssb_cw_pipeline_test.cpp | 588 ++++++++++++++++++ tests/rtl_wfm_pipeline_test.cpp | 30 +- tests/tests.cmake | 40 ++ tests/unified_title_bar_test.cpp | 40 ++ third_party/wdsp/include/aether_wdsp.h | 7 + 91 files changed, 4898 insertions(+), 385 deletions(-) create mode 100644 docs/rtl-am-sam.md create mode 100644 docs/rtl-ssb-cw.md create mode 100644 src/core/backends/rtl/RtlReceiveRecipe.h create mode 100644 src/core/backends/rtl/RtlTrackingNotches.h create mode 100644 src/gui/AudioButtonIcon.h create mode 100644 tests/asr_native_receiver_test.cpp create mode 100644 tests/cw_mode_gate_test.cpp create mode 100644 tests/rtl_am_sam_pipeline_test.cpp create mode 100644 tests/rtl_ssb_cw_pipeline_test.cpp diff --git a/docs/DEVELOPER-GUIDE.md b/docs/DEVELOPER-GUIDE.md index 1484336378..b22962a9e3 100644 --- a/docs/DEVELOPER-GUIDE.md +++ b/docs/DEVELOPER-GUIDE.md @@ -253,3 +253,5 @@ If your AI agent hits an issue requiring maintainer coordination, open a GitHub issue with: your analysis, relevant log output, code references, and proposed fix. The maintainer's Claude instance monitors issues and will respond. + +RTL receiver signal contracts: [SSB/CW](rtl-ssb-cw.md) and [AM/SAM](rtl-am-sam.md) describe native RF extraction, accepted controls and offline validation. diff --git a/docs/architecture/aetherd-touchpoints.md b/docs/architecture/aetherd-touchpoints.md index e115f18341..1757482693 100644 --- a/docs/architecture/aetherd-touchpoints.md +++ b/docs/architecture/aetherd-touchpoints.md @@ -167,7 +167,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `core/TailnetShimTokenStore.h` | 1 | peripheral(tailnet-shim) — OS-keychain storage of the tailnet shim's admin token, keyed by the Flex chassis serial (RFC #6271). Not SmartSDR wire: AetherSDR's own HTTP API to a separate service (the shim) that happens to run in the radio's container runtime, so a direct-transport peripheral, NOT behind the IRadioBackend radio seam (same shape as core/PgxlConnection.h). | 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` | 179 | 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` | 180 | 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` | 16 | ui-support — QWidget property marker guarding TX-keying controls from the automation bridge; GUI-shell plumbing, no radio state. | unconverted | @@ -242,7 +242,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `models/DStarModel.h` | 1 | universal — D-STAR configuration and session state (callsigns, RPT1/RPT2 routing, modem/serial paths). Digital-voice state any backend carrying DV would present. | unconverted | | `models/DaxIqModel.h` | 1 | vendor(flex) — Flex DAX IQ streams: dax_iq stream create/rate cmds, 4-ch DAX model, pipes to SDR apps — DAX is Flex-only | unconverted | | `models/DaxReceiveModel.h` | 1 | universal — Owner-thread adapter from typed native receive PCM and stable TCI receiver bindings to the existing 24 kHz virtual audio bridges; no radio wire protocol. | unconverted | -| `models/DecoderAudioModel.h` | 1 | mixed(flex) — Selected-receiver fixed24 decoder PCM and bounded delivery, independent of speaker controls. Native ingress uses normalized typed slice frames; the compatibility DAX lane uses RadioModel's existing stream and holder registry. | unconverted | +| `models/DecoderAudioModel.h` | 3 | mixed(flex) — Selected-receiver fixed24 decoder PCM and bounded delivery, independent of speaker controls. Native ingress uses normalized typed slice frames; the compatibility DAX lane uses RadioModel's existing stream and holder registry. | unconverted | | `models/DigitalVoiceWaveformHistory.h` | 1 | mixed(flex) — Rolling health history for the DV waveform path (sample rate, turnaround, deficits). Generic rate/latency telemetry fused with Flex VITA sequence-gap counters. | unconverted | | `models/EqualizerModel.h` | 3 | universal — 8-band TX/RX audio EQ state (enable + band gains) — core capability; SmartSDR wire parsing/cmds move to flex adapter | unconverted | | `models/FirmwareInstallModel.h` | 1 | peripheral(ctr2) — CTR2-Max firmware installer model behind the CTR2 window: the signed release catalog, which controller is connected and in what mode, and actions on it (EspFirmwareJob in ROM download mode, or Ctr2ProxyModel's updater over the USB link), each checked against the device the operator confirmed. Controller firmware tooling, no radio state; a peripheral, NOT behind the radio seam. | unconverted | @@ -256,13 +256,13 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `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` | 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` | 53 | mixed(flex) — Central radio aggregate: core slice/pan/TX/meter/memory state fused with Flex protocol, DAX, SmartLink, Multi-Flex | unconverted | +| `models/RadioModel.h` | 54 | 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` | 36 | mixed(flex) — Slice state (freq/mode/filter/DSP) is core-profile; DAX, index_letter, SmartSDR status KVs are flex ext | unconverted | +| `models/SliceModel.h` | 37 | 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 c19711514b..328f56d9bb 100644 --- a/docs/architecture/radio-capabilities-map.md +++ b/docs/architecture/radio-capabilities-map.md @@ -996,3 +996,41 @@ that already holds it untouched; Flex keeps its wire route. The wider per-backend declaration sweep lived in `tests/radio_capability_gating_test.cpp` until #5452 removed it for an ASan-flaky IC-9700 block; #5443 tracks recovering the rest of that coverage. + + +## Confirmed host receive controls + +`receiveCwPitchControl` declares a session-wide receive BFO pitch range and step. +It uses the existing `setCwPitch` verb, with accepted pitch reported through +`TransmitDelta::cwPitch`. The record grants no transmit or microphone path; +`hostModulates` remains independent. A backend using confirmed receive controls +publishes pitch only after DSP adoption, and the shared model forwards operator +intent without optimistically changing the observed pitch or sending a raw Flex +command. An accepted pitch report is never routed back as another pitch request. +The backend owns restoration; connecting does not push the preceding session's +pitch into an engine declaring this record. + +`receiveDspControl` declares the qualified modes and individually supported +signal controls (AGC, NB, NR, ANF, manual notch, APF and RIT). This can expose host +DSP without claiming `hasRadioSideDsp`. An absent record retains existing family +capability behavior; a present record makes unlisted features/modes unavailable. +The optional entries in `featureModes` restrict an individual feature to a +subset of those modes: RTL declares APF only for CW/CWR while its other receive +controls cover AM/SAM/USB/LSB/CW/CWR. A missing entry preserves the common mode +list; an empty entry makes the feature unavailable. A restriction cannot add a +mode or feature absent from the common lists. +The VFO and RX applet retain accepted state while a confirmed engine prepares a +request. AGC/DSP requests identify the changed field: a confirmed backend merges +that field into its pending recipe rather than overwriting independent pending +edits with companion fields from the last observation. + +These desktop records do not themselves grant an aetherd control verb. Flex's +radio-authoritative command/status and persistence contracts remain unchanged. + +Tracking-notch engines with `ReceiveControlPolicy::Confirmed` use the existing +`createNotch`, `setNotch`, `removeNotch` and `setNotchesEnabled` verbs. `TnfModel` +keeps moves, widths, removals and global bypass at accepted state until +`notchChanged`, `notchRemoved` or `RadioDelta::tnfEnabled` arrives. Legacy +optimistic sessions retain their existing behavior. Notch capability limits +remain the existing `maxNotchFilters`, width bounds and `notchHasDepth` fields; +a single relative MN control does not imply a tracking-notch bank. diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index c634075ceb..90efc18320 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -1893,7 +1893,7 @@ re-poll `get slices`. | `filter` | ` ` e.g. `-3000 -150` | set the active slice passband through `SliceModel::setFilterWidth`, which emits a typed `receiveFilterRequested` with Operator origin and reaches `IRadioBackend::requestSliceFilter`. Mode normalization emits a separately tagged request: host DSP applies it, while Flex preserves its radio-owned mode-filter memory. Assert the passband before measuring the audio path. Returns requested edges and post-normalization `filterLow`/`filterHigh`; desktop model readback alone does not prove hardware application. Use `-4000 4000` for a carrier-straddling AM passband | | `filterpreset` | `` | select a stable radio-owned RX filter slot without conflating it with a passband-width edit. Returns the requested slot; re-poll `get slice active filterPreset` and the filter edges for radio-authoritative readback | | `agc` | ` [threshold 0..100]` | set receive AGC through `SliceModel` operator setters and typed `receiveAgcRequested` requests. Applies threshold before mode; each changed field dispatches independently. Flex writes only that field; the default backend adapter passes the current mode/threshold pair to host DSP for either edit. HL2 maps this to WDSP RXA AGC mode and ceiling in dB. This is not an atomic paired command. Use `off` with a low threshold for a linear measurement path | -| `dsp` | ` [level] [fill]` | drive the receive DSP controls an operator drives — noise blanker, noise reduction, auto-notch, and squelch (with an optional 0..100 level). `nb2` selects WDSP's second impulse blanker and takes an optional fill mode 0..4; at most one blanker runs, so `nb2 on` replaces `nb`. `slice dsp squelch` is the squelch path; there is deliberately no separate squelch verb (#5102) | +| `dsp` | ` [level] [fill]` | drive the receive DSP controls an operator drives — noise blanker, noise reduction, auto-notch, and squelch (with an optional 0..100 level). `nb2` selects WDSP's second impulse blanker and takes an optional fill mode 0..4; at most one blanker runs, so `nb2 on` replaces `nb`. An explicit ANF level is forwarded like the NR level. Mode-dependent receive DSP rejects unsupported `on` requests but still accepts `off` intents for cleanup; command success is not a confirmed DSP readback. `slice dsp squelch` is the squelch path; there is deliberately no separate squelch verb (#5102) | | `tone` | ` [freq]` | set the FM CTCSS encode mode and tone. The value is applied before the mode, so enabling CTCSS never keys on the previous tone for a round trip. The mode pair is what a FlexRadio slice carries | | `offset` | ` [mhz]` | set repeater duplex. The magnitude is unsigned (0..100 MHz — the GUI spinboxes' own bound); the direction carries the sign. Writes all three radio fields — `repeater_offset_dir`, `fm_repeater_offset_freq` **and** the signed `tx_offset_freq` that actually moves the transmitter — then reports `txOffsetFreq` so the applied split can be asserted rather than assumed | | `diversity` | ` ` | enable or disable diversity through the slice model; re-poll `get slices` for parent/child state | diff --git a/docs/rtl-am-sam.md b/docs/rtl-am-sam.md new file mode 100644 index 0000000000..7af3f349de --- /dev/null +++ b/docs/rtl-am-sam.md @@ -0,0 +1,65 @@ +# RTL AM and synchronous AM reception + +AM and SAM use the existing shared-capture extractor and prepared WDSP +receiver bank at 48 kHz. AM invokes the envelope detector; SAM invokes the +vendored `amd.c` carrier PLL with dual-sideband detection and fade leveling. +The upstream PLL frequency bound is ±2 kHz. An abrupt carrier jump after a +dropout can require retuning to reset acquisition. No worker, decoder library, +transmit path or receiver-count default changes. + +## RF and audio contract + +RF cuts straddle the dial carrier with at least 100 Hz on each side, bounded +at ±12 kHz. Asymmetric cuts are supported. RTL analytic IQ is conjugated to +WDSP's receive convention, preserving the same signed RF meaning as USB/LSB. +RIT translates the extractor without moving the dial. The RF NBP receives +the signed cuts; the post-detector bandpass receives 50 Hz to the larger +absolute cut with 4096 taps, removing the demodulated carrier before the AGC and PCM taps. +This `WdspChannel::AmReceive` recipe is opt-in, so other WDSP owners retain +their existing AM behavior. The analytic post-filter outputs are averaged to cancel opposite Hilbert +components, yielding the original real audio waveform in both mono channels +before independent PCM delivery. SAM mode selection is real coherent detection, +not an envelope alias. There is no asserted lock indicator. + +The existing transaction placement treats AM/SAM as converter-DC-sensitive. +Explicit follow tuning and recentering prefer a legal clear capture; free +browsing does not drag stations into capture or move them to compensate. +Converter DC suppression at the wanted RF carrier remains destructive and +is distinct from audio DC removal. A muted USB bootstrap preserves the saved +capture center and reserves the first restorable saved ID, including sparse +singleton IDs and invalid earlier records. + +## Receiver controls and state + +AGC mode/threshold/off gain, NB/NB2/fill, NR, ANF, MN, tracking notches and RIT +use the existing accepted recipe. MN maps the audio position from 300 Hz to +200 Hz below the wider RF cut (at least 300 Hz), with 400 Hz paired RF +notches; it does not place a notch on the carrier. Absolute tracking notches +keep their RF meaning, including a notch deliberately placed on a carrier. +APF remains CW-only. AM/SAM squelch is not exposed. Advanced radio-specific +algorithms remain unavailable. + +Each slice remembers AM and SAM filter cuts separately, like its other modes. +A first entry uses the mode default; later entries recall only accepted cuts, +including after reconnecting. Graph changes, +parking, receiver recreation and mode transitions invalidate the matching +receiver/presentation epochs. Speaker monitor controls remain downstream of +independent TCI/DAX, selected CW-decoder and selected native-ASR routes. +Normal launches admit one receiver; 2/4/8 receivers remain process-only +evaluation with no sustained-capacity qualification implied. + +## Validation + +`rtl_am_sam_pipeline_test` drives analytic modulated IQ through production +RF extraction, WDSP and receiver PCM. Assertions cover actual modulation +level, harmonic distortion, DC suppression, carrier offsets, quadrature +rejection that distinguishes SAM from envelope AM, asymmetric RF cuts, RIT, +dropout/fade recovery, retune/park/recreate epochs and eight mixed +AM/SAM/USB/LSB/CW/CWR/FM receivers. Receiver gain/noise/notch controls are +measured on audio, not inferred from a successful setter. + +`rtl_model_acceptance_test` drives public SliceModel controls and injected +USB through the production worker to verify adoption, refusal, sparse +restore, persistence and parking. Native ASR regressions include AM and SAM. +These are offline checks; a candidate still requires operator listening and +known RF stimulus before making a hardware intelligibility claim. diff --git a/docs/rtl-m1-runtime.md b/docs/rtl-m1-runtime.md index 7768778f5b..d71c1e5192 100644 --- a/docs/rtl-m1-runtime.md +++ b/docs/rtl-m1-runtime.md @@ -96,9 +96,13 @@ 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. +muted native bootstrap which reserves the first restorable saved receiver ID +before adopting its saved recipe and monitor state. -The other non-FM modes retain their exclusive legacy DDC and 24 kHz format. +USB, LSB, CW and CWR use native independent 48 kHz receiver recipes, with +carrier-relative filtering and CW BFO translation described in +[SSB/CW reception](rtl-ssb-cw.md). AM and SAM also use independent 48 kHz +receiver recipes; see [AM/SAM reception](rtl-am-sam.md). 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 @@ -127,29 +131,28 @@ closed until measured and closes after 100 ms without detector input. The detector runs at 30 Hz independently of display throttling, sharing the FFT and a fixed buffer without allocating a detector frame. Threshold changes travel in the existing capture transaction and preserve the receiver's DSP -epoch/history. Capture/receiver changes reset detector history. Legacy modes +epoch/history. Capture/receiver changes reset detector history. AM, SAM, USB, LSB, CW and CWR remain unsquelched; changing into one confirms squelch Off. No RF test or calibration is implied by these offline semantics. The scale, presets and FM recipe remain subject to maintainer review under RFC #5468. ## Capture placement and display geometry -FM/FM-N establishment and necessary capture recentering prefer a hardware -center one quarter of the capture rate above the selected FM carrier. The existing +AM/SAM, FM/FM-N and CW/CWR establishment and necessary capture recentering prefer a hardware +center one quarter of the capture rate above the selected carrier. The existing receiver NCO translates the unchanged absolute RF to baseband. The requested viewport is fitted to the confirmed usable capture. For each slice that will be DSP-active, its complete passband and guards must fit; a configured slice that cannot fit remains parked at its original RF. The center preference -keeps converter DC at least 48 kHz from the selected FM carrier (or outside +keeps converter DC at least 48 kHz from the selected DC-sensitive carrier (or outside its guarded passband, whichever is wider) where a legal placement allows it. -Other receiving FM slices can overlap DC; Radio Health reports that condition. +Other DC-sensitive receivers can overlap DC; Radio Health reports that condition. Legal integer-Hz tuner bounds and the automatic 24 MHz direct-sampling boundary still constrain capture movement. DC overlap is reported truthfully rather than silently moving a slice or refusing browsing solely to keep another slice active. This does not enable librtlsdr offset tuning, alter the R82xx guard, erase FFT bins, subtract a signal mean, or notch -demodulated audio. This FM-specific DC preference does not alter WFM or other -legacy demodulators. Free-pan membership and parking apply to every configured +demodulated audio. This DC preference does not alter WFM, USB or LSB. Free-pan membership and parking apply to every configured RTL slice. Ordinary in-window frequency changes keep the existing legal capture, including @@ -206,7 +209,7 @@ USB writes. A distant Center follows its selected slice with a capture retune; other configured slices may park. The transaction fits the selected slice's complete guarded passband and requires its RF to center in the real 65536-bin view within half a bin plus integer-Hz tolerance. At full width this view requirement -can leave converter DC on the selected FM carrier when no DC-clear position also +can leave converter DC on the selected DC-sensitive carrier when no DC-clear position also fits. Radio Health reports the accepted overlap. If no legal capture can satisfy both the passband and Center view, the request refuses. Exact behavior at the tuner RF limits has no dedicated regression yet. Pan requests beyond capture diff --git a/docs/rtl-multirx-analog.md b/docs/rtl-multirx-analog.md index 160101ad3a..244319110b 100644 --- a/docs/rtl-multirx-analog.md +++ b/docs/rtl-multirx-analog.md @@ -17,10 +17,11 @@ platform retains one. The automation bridge retains its independent receive authorization and TX exclusion. Use a separate `AETHER_SETTINGS_DIR` and preserve the normal launcher/profile before hardware testing. -FM, FM-N and native analog WFM can coexist. Each receiver owns its exact RF -filter, Mono/Stereo choice, deemphasis and monitor gain/pan/mute. AM/SAM/SSB/CW remain singleton legacy configurations, including when a -configured sibling is parked. A refusal reports the unsupported combination -without adopting a partial recipe. Digital FM is outside this branch. +AM, SAM, FM, FM-N, native analog WFM, USB, LSB, CW and CWR can coexist. Each receiver owns its exact RF +filter and applicable demodulator controls, plus monitor gain/pan/mute. CW pitch is session-wide. +See [SSB/CW reception](rtl-ssb-cw.md) for the signed filter, BFO and consumer contracts. +See [AM/SAM reception](rtl-am-sam.md) for carrier tracking and asymmetric +RF cuts. A refused recipe never adopts partial state. Digital FM is outside this branch. ## Selection and presentation diff --git a/docs/rtl-ssb-cw.md b/docs/rtl-ssb-cw.md new file mode 100644 index 0000000000..b94c32d514 --- /dev/null +++ b/docs/rtl-ssb-cw.md @@ -0,0 +1,80 @@ +# RTL SSB and CW reception + +USB, LSB, CW and CWR use the existing shared-capture extractor, prepared +receiver registry and WDSP channels. Their independent slice PCM and speaker +mix are 48 kHz. There is no new worker, decoder engine or dependency. The +normal one-receiver admission and process-only 2/4/8 evaluation limits are +unchanged; mode support is not sustained-capacity qualification. + +## Frequency and filter contract + +USB filters are positive relative to the displayed carrier; LSB filters are +negative. CW/CWR filters straddle the displayed carrier. The dial and persisted +filter describe RF, not an audio-shifted station frequency. CW translates the +carrier to positive pitch and CWR reverses the detuning sense. The extractor +translation and WDSP filter shift cancel at the RF boundary, including RIT. +CW pitch is session-wide, while RIT and receive DSP belong to each receiver. + +RTL analytic IQ is conjugated at the native sideband receiver boundary to +match WDSP's receive convention. FM/FM-N/WFM keep their existing orientation +and demodulator recipes. AM/SAM use the same native bank; their carrier and +audio-filter contract is described in [AM/SAM reception](rtl-am-sam.md). + +All affected receiver controls are prepared and adopted before readback or +persistence. Invalid controls or failed preparation retain the prior accepted +state. Parked receivers retain their settings and supply no PCM. Follow-tuning +CW/CWR prefers a legal DC-clear capture placement; free browsing does not drag +parked stations back into capture. + +## Control ownership + +`ReceiveDspControl` declares the supported feature and mode combinations; +absence preserves other radios' existing capability semantics. +`ReceiveCwPitchControl` enables the existing pitch verb and confirmed pitch +readback without enabling transmission or host microphone modulation. A +control the receiver does not support is dimmed with a reason. + +AGC, blanking, filtering and peaking run inside the individual receiver. Client +AetherDSP processing remains on the presentation stream. Similar control +names do not imply identical proprietary-radio DSP algorithms or calibrated +RF levels. + +## Receiver notches and noise processing + +NR and ANF use the existing WDSP adaptive filters; MN is a 400 Hz local +notch positioned across the signed receive passband. The shared tracking-notch +bank holds up to 32 RF-frequency notches, each 200–12,000 Hz wide. Its +frequency conversion includes receiver carrier, RIT and signed CW pitch. +Only overlapping notches reach each prepared receiver filter. Global TNF +bypass and manual-notch enable are independent. The bank is persisted with +the RTL settings and adopted transactionally, including parked receivers. +There is no radio-stored permanence or selectable Flex notch-depth algorithm. + +## Decoder and transcription source + +The existing selected-receiver decoder route resamples native 48 kHz PCM to +the CW decoder's 24 kHz contract. CW and CWR both enable the decoder and use +the appropriate zero-beat direction. Speaker mute and gain do not affect the +decoder feed. Selection, retuning, parking and retired PCM leases invalidate +queued audio from the old source. + +Copy Assist uses the selected native receiver before speaker controls and +client DSP. Its existing presentation pre/post-DSP preference is preserved +for other radios. The settings control states when that choice is unavailable. +ASR carries a source lease and generation through queued input, inference and +transcript delivery, so text from a retired source cannot enter a new session. +Flex retains its existing presentation route. + +## Validation + +`rtl_ssb_cw_pipeline_test` feeds analytic generated IQ through the production +extractor and WDSP channels. It checks sideband rejection, passband cuts, +CW/CWR pitch and RIT sense, gain and peaking, and keyed RF through the actual +CW decoder. Its optional external-IQ fixture exports demodulated speech for +`asr_whisper_backend_test` with an explicitly supplied local speech model. + +Model tests cover accepted state, refused requests, persisted restore, +parking and typed intents. Native ASR tests cover selected-source routing and +retired input/results. These are offline proofs. A testable build still needs +operator listening tests, a known RF stimulus and representative multi-receiver +continuity measurements; finite PCM alone does not establish intelligibility. diff --git a/docs/user/docs/copy-assist.md b/docs/user/docs/copy-assist.md index d4173168f7..1c664cdce0 100644 --- a/docs/user/docs/copy-assist.md +++ b/docs/user/docs/copy-assist.md @@ -86,6 +86,12 @@ Either one shows or hides the panel, which docks under the waterfall of the acti - A band-stack recall or a disconnect does **not** close an open panel, so transcription survives a band change. - Whenever the panel is open, the ASR toggle is lit and clickable, so you can always close it by hand. +### Which receiver is transcribed + +On RTL-SDR, Copy Assist uses the selected slice's independent audio before speaker volume, mute and client DSP. Other audible slices do not enter that feed. **Transcribe from unprocessed audio (bypasses NR and RX effects)** is unavailable for this route and explains why; its saved preference is retained for radios that use the presentation stream. Retuning, switching slices or parking the receiver starts a fresh transcription context. A parked receiver supplies no audio until it returns to capture. + +Other radios retain their existing presentation-audio route and pre/post-DSP choice. Selecting a slice controls mode availability; it does not isolate that slice from a shared presentation mix. + ### Transcribing 1. Open the panel and click **Disabled** so it reads **Enabled**. @@ -101,7 +107,8 @@ Text is **colored by recognition confidence**, the same way as the [CW Decoder]( | Orange | Questionable | | Red | Low | -By default Copy Assist hears the audio after AetherSDR's noise reduction and RX effects, as you hear it, so a suitable [DSP Noise Mitigation](./dsp-noise-mitigation.md) setting can improve the transcript as well as your own listening. Noise-reduction artifacts can also confuse the speech model more than the noise does; to transcribe the audio before them, turn on **Transcribe from unprocessed audio (bypasses NR and RX effects)** in Copy Assist Settings. What you hear is unchanged. +On radios using the presentation stream, the default processed-audio setting hears client-side [DSP Noise Mitigation](./dsp-noise-mitigation.md), which can affect transcription as well as listening. The unprocessed setting and RTL's independent receiver feed bypass those client effects. +Noise-reduction artifacts can confuse the speech model more than noise does. On presentation-stream radios, turn on **Transcribe from unprocessed audio (bypasses NR and RX effects)** in Copy Assist Settings to bypass those effects for transcription. What you hear is unchanged. ### Context diff --git a/docs/user/docs/cw-decoder.md b/docs/user/docs/cw-decoder.md index ba96148dda..a69efeec99 100644 --- a/docs/user/docs/cw-decoder.md +++ b/docs/user/docs/cw-decoder.md @@ -16,11 +16,11 @@ AetherSDR includes a built-in CW (Morse code) decoder powered by [ggmorse](https On Apple Silicon Macs, Linux and Windows you can also choose one of two neural decoders, **DeepFist** or **DeepCW**, which can copy weak or irregular signals that ggmorse misses. See [Choosing a decoder](#choosing-a-decoder). -The decoder works on every radio family that has a CW mode: FlexRadio, the [Hermes-Lite 2](./hermes-lite-2.md), and [Networked Icom](./networked-icom.md) radios (it opens when an Icom slice is in CW). +The decoder works on every radio family that has a CW mode: FlexRadio, the [Hermes-Lite 2](./hermes-lite-2.md), [Networked Icom](./networked-icom.md), and experimental [RTL-SDR](./rtl-sdr.md) reception. ## Requirements -- The selected slice is in **CW** or **CWL**. +- The selected slice is in a supported CW mode: **CW**, **CWU**, **CWL** or **CWR** (reverse CW on RTL-SDR). - On a FlexRadio, a **DAX RX channel** (1–8) assigned to the selected slice is recommended, so the decoder hears that slice alone. See [Which audio is decoded](#which-audio-is-decoded). ## Setup @@ -40,7 +40,7 @@ Both appear in the same panel. Your own sending is shown in **cyan**, so it stan ## Using the CW decoder -When the selected slice is in **CW** or **CWL**, the decode panel appears below the waterfall, and it hides again when you switch to another mode. +When the selected slice is in **CW**, **CWU**, **CWL** or **CWR**, the decode panel appears below the waterfall, and it hides again when you switch to another mode. Drag the grip on the panel's edge to resize it; the height is remembered. Right-click the text for font size and Clear. @@ -87,6 +87,7 @@ Retuning resets the decoder state; pitch and speed locks survive a retune. The decoder follows the **selected slice**, independently of speaker volume or mute: +- **On RTL-SDR**, CW and CWR use the selected slice's independent audio. Speaker gain and mute do not affect decoding. Moving that receiver outside capture suspends decoding until it can receive again. - **On a FlexRadio, assign a DAX RX channel to the selected slice** (1–8). The decoder then reads that slice's audio alone, regardless of speaker gain, mute, or other audible slices. - Without a DAX channel, it uses the shared receive audio, which mixes every audible slice and follows speaker gain and mute. The panel shows **RX: shared audio**; hover it for the explanation. diff --git a/docs/user/docs/rtl-sdr.md b/docs/user/docs/rtl-sdr.md index d4e1cece92..c612139578 100644 --- a/docs/user/docs/rtl-sdr.md +++ b/docs/user/docs/rtl-sdr.md @@ -26,9 +26,11 @@ Building from source: the `ENABLE_RTL` option is on by default and takes effect 2. Open **File → Connect to Radio...** → **On This Network**. Supported dongles on USB are listed with the network radios. 3. Select the dongle and click **Connect Selected Radio**. +The radio tab shows **Connected** with a green status dot once the USB connection is established. Amber indicates discovery; a USB receiver does not need a network heartbeat to show its connected state. + ## Using an RTL-SDR -- **Modes:** AM, SAM, FM, FM-N, WFM, USB, LSB, CW and CW-R. +- **Modes:** AM, SAM, FM, FM-N, WFM, USB, LSB, CW and CWR. - **Tuning range:** 24 kHz to 1.766 GHz. Below 24 MHz the dongle uses direct sampling; HF reception on particular dongles (for example ones with an upconverter or the RTL-SDR Blog V4) has not been qualified. - **Sample rates** up to 3.0 MS/s; 2.4 MS/s is the default. - **Browsing the capture:** pan around the captured bandwidth and your slice keeps its frequency and settings. A slice that falls outside the usable capture is parked (silent) and resumes when it fits again. @@ -40,6 +42,34 @@ Building from source: the `ENABLE_RTL` option is on by default and takes effect Normal launches admit **one receiver**, even though the capture is wide. +The **RX ANT** controls show the dongle's fixed **RX** input. They do not offer FlexRadio antenna ports. The RX applet and VFO antenna button are dimmed unless a configured virtual receiver is available. **TX ANT** shows **None** and is dimmed because RTL-SDR cannot transmit. + +### SSB and CW + +Select **USB** or **LSB** for single-sideband reception, or **CW** / **CWR** (reverse CW) for Morse. These modes use independent receive DSP and 48 kHz audio. Filter presets and draggable edges change the actual receive passband. The CW marker remains on the station carrier; **Pitch** changes the audio tone without moving the dial frequency. Pitch is shared by the RTL session's CW receivers. + +**AGC** offers Off, Slow, Med and Fast. **AGC-T** sets the local DSP's maximum gain; with AGC Off, **Off Level** controls fixed gain. These are local receiver controls, not a calibrated match for a FlexRadio's AGC-T scale. **NB** operates on receiver IQ; **APF** narrows CW audio around the selected pitch. **RIT** offsets reception while keeping the displayed dial frequency. These settings belong to each slice, except the shared CW pitch, and survive parking and reconnecting. + +The speaker icon opens a slice's audio controls; right-click it to toggle mute. A crossed speaker marks muted audio. The RX Controls speaker button also follows the selected slice's mute state. + +The [CW decoder](./cw-decoder.md) follows the selected CW or CWR slice. [Copy Assist](./copy-assist.md) follows the selected voice slice's independent audio before speaker volume, mute and client audio effects. A muted slice can still decode; a parked slice cannot. Selecting another slice or parking it discards queued audio from the old source. Copy Assist needs a downloaded speech model. + +Each slice remembers its last accepted filter separately for each mode. Switching from USB to FM and back recalls that slice’s USB passband, including after reconnecting. A mode you have not used on that slice starts with its normal default filter. Pending or refused filter changes do not replace the remembered passband. + +**NR**, **ANF** and **MN** run independently inside each SSB/CW receiver. NR reduces noise, ANF suppresses sustained tones, and MN places a fixed-width notch within the passband. Their settings use the local WDSP algorithms, so the values are not equivalent to a FlexRadio's settings. [Tracking notch filters](./tnf-tracking-notch-filters.md) stay at an RF frequency as receivers tune past them. + +Client **AetherDSP** voice processing applies to the speaker stream; it is not independent per-slice processing and is not included in the native Copy Assist feed. + +### AM and synchronous AM + +**AM** uses envelope detection. **SAM** follows the station carrier with a phase-locked synchronous detector; it combines both received sidebands. Both modes have independent 48 kHz audio and the same **AGC**, **AGC-T**, **Off Level**, **NB**, **NR**, **ANF** and **RIT** controls described above. **APF** remains a CW control, and squelch remains available only in FM and FM-N. + +Filter edges describe RF relative to the displayed carrier. Each edge can be moved independently, from −12,000 to −100 Hz below the carrier and +100 to +12,000 Hz above it. Narrow the interfered side while keeping the other side wide. Switching between AM and SAM preserves these cuts. SAM needs a received carrier within its tracking range (up to ±2 kHz); tune close to the station for acquisition. If the carrier jumps after a dropout and does not recover, retune to restart acquisition. It is not a suppressed-carrier SSB decoder. + +**MN** places a 400 Hz notch in both RF sidebands at the selected audio frequency, leaving the carrier intact. Tracking notches keep their absolute RF positions; placing one on the station carrier can prevent SAM acquisition. The receiver removes demodulated carrier/DC from its audio, independently of **Suppress IQ DC**. Copy Assist, TCI and DAX receive the independent audio before speaker mute and volume. + +Tuning or using **Move capture away from DC** places the carrier away from the converter's centre where possible. Free panning preserves station frequencies. If you bring an AM/SAM carrier onto the capture centre while **Suppress IQ DC** is enabled, move the capture away or disable that correction: removing the wanted carrier distorts AM and can break SAM tracking. + ### Broadcast FM Select **WFM** to use the **Broadcast FM** applet. It shows the selected slice letter and confirmed frequency. **Mono / Auto Stereo**, **De-emphasis** (50 or 75 µs) and **Bandwidth** belong to that receiver. **Auto Stereo** falls back to mono without a detected stereo pilot. The observed pilot status is separate from your selected audio mode; parking clears observations while retaining the controls. @@ -58,7 +88,7 @@ Use the [TCI Server](./tci-server.md) on Linux, macOS or Windows. On Linux and m The process-only evaluation setting admits 2, 4 or 8 receivers on Linux x86-64, Windows x64 and macOS arm64. It requires both `AETHER_AUTOMATION=1` and `AETHER_RTL_EVALUATION_RECEIVERS=2`, `4` or `8` at launch; it is not saved and cannot resize a running session. Use an isolated settings profile for evaluation. It does **not** change the one-receiver production limit or qualify a platform for sustained multi-receiver use. -FM, FM-N and analog WFM can share the capture in this evaluation. Each receiver retains its frequency, filter, squelch and audio controls when parked. Selecting another slice does not retune the capture or change its audio settings. AM, SAM, USB, LSB, CW and CW-R remain singleton configurations, even if another configured receiver is parked. See the [evaluation procedure](https://github.com/aethersdr/AetherSDR/blob/main/docs/rtl-multirx-analog.md) for the validation boundaries. +AM, SAM, FM, FM-N, analog WFM, USB, LSB, CW and CWR can share the capture in this evaluation. Each receiver retains its frequency, filter, squelch and audio controls when parked. Selecting another slice does not retune the capture or change its audio settings. See the [evaluation procedure](https://github.com/aethersdr/AetherSDR/blob/main/docs/rtl-multirx-analog.md) for the validation boundaries. ## Reference diff --git a/docs/user/docs/tnf-tracking-notch-filters.md b/docs/user/docs/tnf-tracking-notch-filters.md index f75d9a8057..bb9f716ff2 100644 --- a/docs/user/docs/tnf-tracking-notch-filters.md +++ b/docs/user/docs/tnf-tracking-notch-filters.md @@ -3,12 +3,12 @@ title: "TNF (Tracking Notch Filters)" slug: "/tnf-tracking-notch-filters" description: "Tracking Notch Filters remove persistent interfering carriers from the audio passband." status: "Supported" -applies_to: ["FlexRadio", "Hermes-Lite 2 (experimental)"] +applies_to: ["FlexRadio", "Hermes-Lite 2 (experimental)", "RTL-SDR (experimental)"] --- :::info[Status] -**Status:** Supported · **Applies to:** FlexRadio, Hermes-Lite 2 (experimental) +**Status:** Supported · **Applies to:** FlexRadio, Hermes-Lite 2 (experimental), RTL-SDR (experimental) ::: @@ -69,6 +69,12 @@ Permanent** entries are not offered. A notch (or a very narrow CW filter) lengthens the receive filter slightly, which adds a little audio delay. See [Hermes-Lite 2](./hermes-lite-2.md). +### RTL-SDR + +RTL-SDR supports up to 32 shared tracking notches in USB, LSB, CW and CWR. Each notch stays at its RF frequency and affects only receivers whose passbands overlap it. RIT and CW pitch changes preserve that RF position. Notches do not affect FM, FM-N or WFM. + +Add, move, resize and remove notches using the same gestures. The minimum width is 200 Hz. Changes appear after the receiver accepts them. The computer saves the notch bank with the RTL radio settings, including the global enabled state; **Depth** and **Make Permanent** are not offered. A parked receiver retains its notches and applies them when reception resumes. + ## Reference ### Permanent vs temporary @@ -78,8 +84,10 @@ lengthens the receive filter slightly, which adds a little audio delay. See | Temporary | Yellow | No | Yes | | Permanent | Green | Yes | No | -TNF removal and permanent-state changes are applied straight away in the +FlexRadio TNF removal and permanent-state changes are applied straight away in the display; the radio does not send a status confirmation for these commands. +RTL-SDR waits for accepted state before changing its markers. The temporary/permanent +distinction above applies to radio-stored FlexRadio notches, not the locally saved RTL bank. ## Troubleshooting @@ -91,11 +99,11 @@ supports. 1. Remove a notch you no longer need (right-click it → **Remove TNF**), then try again. 2. If the entry never appears, your radio doesn't offer notches. TNFs work on - FlexRadio and Hermes-Lite 2. + FlexRadio, Hermes-Lite 2 and RTL-SDR. ### My notches disappeared after the radio restarted -They were temporary (yellow). Temporary notches don't survive a power cycle. +On FlexRadio, temporary (yellow) notches do not survive a power cycle. RTL-SDR instead restores its locally saved notch bank. 1. Add the notch again. 2. Right-click it and choose **Make Permanent**. It turns green. diff --git a/docs/user/src/components/LogAnalyzer/rules.json b/docs/user/src/components/LogAnalyzer/rules.json index b1b8337ed9..5041235018 100644 --- a/docs/user/src/components/LogAnalyzer/rules.json +++ b/docs/user/src/components/LogAnalyzer/rules.json @@ -9,7 +9,7 @@ "source": [ { "file": "src/models/RadioModel.cpp", - "line": 7683, + "line": 7718, "text": "else if (key == \"model\")" } ] @@ -21,7 +21,7 @@ "source": [ { "file": "src/models/RadioModel.cpp", - "line": 7692, + "line": 7727, "text": "else if (key == \"software_ver\")" } ] @@ -94,7 +94,7 @@ "source": [ { "file": "src/models/RadioModel.cpp", - "line": 7415, + "line": 7450, "text": "\"RadioModel: terminal GUI client registration failure\"" } ] @@ -111,12 +111,12 @@ "source": [ { "file": "src/models/RadioModel.cpp", - "line": 7345, + "line": 7380, "text": "RadioModel: this GUI client was force-disconnected by another client" }, { "file": "src/models/RadioModel.cpp", - "line": 7389, + "line": 7424, "text": "RadioModel: radio disconnected this session because another GUI client used the same ID" } ] @@ -133,7 +133,7 @@ "source": [ { "file": "src/models/RadioModel.cpp", - "line": 7255, + "line": 7290, "text": "RadioModel: multiFLEX disabled, other clients present" } ] @@ -167,7 +167,7 @@ "source": [ { "file": "src/models/RadioModel.cpp", - "line": 8396, + "line": 8431, "text": "consecutive pings unanswered — forcing disconnect" } ] @@ -193,7 +193,7 @@ }, { "file": "src/models/RadioModel.cpp", - "line": 7692, + "line": 7727, "text": "else if (key == \"software_ver\")" } ] @@ -210,7 +210,7 @@ "source": [ { "file": "src/models/RadioModel.cpp", - "line": 7580, + "line": 7615, "text": "RadioModel: no VITA-49 UDP data received after 10s" } ] @@ -227,7 +227,7 @@ "source": [ { "file": "src/models/RadioModel.cpp", - "line": 7558, + "line": 7593, "text": "RadioModel: UDP stream setup failed" }, { @@ -237,7 +237,7 @@ }, { "file": "src/models/RadioModel.cpp", - "line": 7633, + "line": 7668, "text": "RadioModel: client udpport collision" } ] @@ -452,7 +452,7 @@ "source": [ { "file": "src/models/RadioModel.cpp", - "line": 1369, + "line": 1351, "text": "RadioModel: dax_rx stream create for channel" }, { @@ -474,7 +474,7 @@ "source": [ { "file": "src/models/RadioModel.cpp", - "line": 12735, + "line": 12770, "text": "RadioModel: DAX TX create failed" } ] @@ -547,7 +547,7 @@ "source": [ { "file": "src/gui/SpectrumWidget.cpp", - "line": 13181, + "line": 13187, "text": "\"SpectrumWidget: QRhi failure:\"" }, { diff --git a/src/asr/AsrEngine.cpp b/src/asr/AsrEngine.cpp index b664fc2ad9..bb53a642ba 100644 --- a/src/asr/AsrEngine.cpp +++ b/src/asr/AsrEngine.cpp @@ -274,7 +274,14 @@ std::vector AsrWorker::toSixteenK(const QVector& monoSamples, int return out; } -void AsrWorker::processAudio(const QVector& monoSamples, int sampleRate) +bool AsrWorker::inputCurrent() const +{ + return m_generation == m_requestedGeneration.load() + && (m_source.stream().source == 0 || m_source.current()); +} + +void AsrWorker::processAudio(const QVector& monoSamples, int sampleRate, + quint64 generation, const PcmEpochLease& source) { // Duration of this input chunk; reported as "processed" once the worker has // handled it (including any blocking transcription), so the engine can show a @@ -284,7 +291,9 @@ void AsrWorker::processAudio(const QVector& monoSamples, int sampleRate) const double chunkMs = 1000.0 * static_cast(monoSamples.size()) / rate; [&] { - if (m_cancelPending.load(std::memory_order_relaxed)) { + if (m_cancelPending.load(std::memory_order_relaxed) + || generation != m_requestedGeneration.load() + || (source.stream().source != 0 && !source.current())) { // Shutting down or ASR was disabled: drop this queued chunk instead of // segmenting/blocking-transcribing it. Still falls through to // emit processedMs(chunkMs) below so the backlog meter's accounting @@ -295,6 +304,7 @@ void AsrWorker::processAudio(const QVector& monoSamples, int sampleRate) return; } + m_source = source; const std::vector pcm16k = toSixteenK(monoSamples, sampleRate); if (pcm16k.empty()) { return; @@ -382,8 +392,10 @@ void AsrWorker::clearContext() void AsrWorker::decodeSegments(std::vector& segments) { for (AsrSegmenter::ClosedSegment& seg : segments) { + if (!inputCurrent()) { return; } QString error; const AsrTranscript result = m_backend->transcribe(seg.samples, &error); + if (!inputCurrent()) { return; } if (!error.isEmpty()) { emit errorOccurred(error); // A failed decode yields no tail — same as an empty one below: drop @@ -425,7 +437,8 @@ void AsrWorker::decodeSegments(std::vector& segment speaker = m_clusterer.assign( m_embedder->embed(seg.samples.data(), static_cast(seg.samples.size()))); } - emit segmentText(emitText, result.confidence, speaker); + if (!inputCurrent()) { return; } + emit segmentText(emitText, result.confidence, speaker, m_generation, m_source); } } @@ -447,8 +460,10 @@ void AsrWorker::markDiscontinuity() } } -void AsrWorker::reset() +void AsrWorker::reset(quint64 generation) { + m_generation = generation; + m_source = {}; m_segmenter.reset(); // A retune/clear is a new context — don't prime the next decode with the // previous QSO's carried prompt (retune is the one boundary where the @@ -462,8 +477,9 @@ void AsrWorker::reset() m_resamplerSrcRate = 0; } -void AsrWorker::resumeAfterCancel() +void AsrWorker::resumeAfterCancel(quint64 generation) { + if (generation != m_requestedGeneration.load()) { return; } // Runs on the worker thread, behind everything restartSession() meant to // discard, so clearing here cannot let an old-chain block through. m_cancelPending.store(false, std::memory_order_relaxed); @@ -492,6 +508,7 @@ void AsrEngine::startThread(AsrBackendFactory factory, const AsrSegmenter::Confi // Info thread table and in external profilers (#2554); Qt propagates this // to the OS thread name when the thread starts. m_thread->setObjectName(QStringLiteral("AsrWorker")); + qRegisterMetaType(); m_worker = new AsrWorker(std::move(factory), segConfig, std::move(speakerEmbedderFactory)); m_worker->moveToThread(m_thread); @@ -537,7 +554,10 @@ void AsrEngine::startThread(AsrBackendFactory factory, const AsrSegmenter::Confi connect(m_worker, &AsrWorker::speakerModelLoaded, this, &AsrEngine::speakerModelLoaded); connect(m_worker, &AsrWorker::segmentText, this, - [this](const QString& text, float confidence, int speaker) { + [this](const QString& text, float confidence, int speaker, quint64 generation, + const PcmEpochLease& source) { + if (!m_enabled || generation != m_generation + || (source.stream().source != 0 && !source.current())) { return; } // A queued worker toggle can be behind an in-flight decode. Mask // its late result on the main side so the latest UI intent never // appends a stale [A]/[B] label. @@ -629,6 +649,24 @@ void AsrEngine::setSpeakerLabelingEnabled(bool on) } void AsrEngine::pushAudio(const QVector& monoSamples, int sampleRate) +{ + pushAudioInput(monoSamples, sampleRate, {}); +} + +void AsrEngine::pushNativePcm(const PcmFrame& frame) +{ + if (!frame.current() || frame.stream().purpose != PcmPurpose::Slice) { return; } + const int channels = frame.stream().format.channels(); + QVector mono(frame.frameCount()); + for (qsizetype i = 0; i < frame.frameCount(); ++i) { + mono[i] = frame.samples()[i * channels]; + if (channels == 2) { mono[i] = mono[i] * 0.5f + frame.samples()[i * channels + 1] * 0.5f; } + mono[i] = std::clamp(mono[i], -1.0f, 1.0f); + } + pushAudioInput(mono, frame.stream().format.sampleRateHz, frame.epochLease()); +} + +void AsrEngine::pushAudioInput(const QVector& monoSamples, int sampleRate, const PcmEpochLease& source) { if (!m_enabled || monoSamples.isEmpty()) { return; @@ -673,7 +711,7 @@ void AsrEngine::pushAudio(const QVector& monoSamples, int sampleRate) m_pushedMs += chunkMs; updateBacklog(); - emit requestProcess(monoSamples, sampleRate); + emit requestProcess(monoSamples, sampleRate, m_generation, source); } void AsrEngine::updateBacklog() @@ -733,6 +771,8 @@ void AsrEngine::setContextCarryEnabled(bool on) void AsrEngine::reset() { + ++m_generation; + if (m_worker) { m_worker->setInputGeneration(m_generation); } // A retune/clear drops buffered work; zero the backlog meter so it doesn't // carry a stale lag across the reset. Whatever is still queued on the worker // will be reported as processed later — remember it so those reports don't @@ -747,7 +787,7 @@ void AsrEngine::reset() m_droppedMs = 0.0; m_droppedAtEntryMs = 0.0; updateDropped(); - emit requestReset(); + emit requestReset(m_generation); } void AsrEngine::restartSession() @@ -760,7 +800,7 @@ void AsrEngine::restartSession() reset(); // queued behind them // Queued behind the reset, so the flag clears only once the old-chain // backlog has been discarded. - emit requestResumeAfterCancel(); + emit requestResumeAfterCancel(m_generation); } } // namespace AetherSDR diff --git a/src/asr/AsrEngine.h b/src/asr/AsrEngine.h index b66db3202e..b9ddf81f0f 100644 --- a/src/asr/AsrEngine.h +++ b/src/asr/AsrEngine.h @@ -3,6 +3,7 @@ #include "asr/AsrSegmenter.h" #include "asr/IAsrBackend.h" #include "asr/SpeakerClusterer.h" +#include "core/PcmFrame.h" #include #include @@ -48,6 +49,7 @@ class AsrWorker : public QObject { // whole backlog. Used so shutdown and ASR-disable don't have to wait for a // backlog of queued segments to finish (each includes a blocking transcribe). void setCancelPending(bool cancel) { m_cancelPending.store(cancel, std::memory_order_relaxed); } + void setInputGeneration(quint64 generation) { m_requestedGeneration.store(generation); } // Thread-safe; set once by ~AsrEngine() before it joins this thread. Distinct // from setCancelPending() above, which is ALSO raised on every ASR-disable @@ -63,7 +65,8 @@ public slots: void setSpeakerLabelingEnabled(bool on); // Mono float samples at sampleRate; resampled to whisper's 16 kHz on this // (worker) thread before segmentation — never on the audio/caller thread. - void processAudio(const QVector& monoSamples, int sampleRate); + void processAudio(const QVector& monoSamples, int sampleRate, quint64 generation, + const AetherSDR::PcmEpochLease& source); void setMaxSegmentMs(int ms); void setSpeechRms(float rms); void setHangoverMs(int ms); @@ -77,18 +80,19 @@ public slots: // text/context so a segment never splices audio from either side of the // gap. Narrower than reset() — speaker labels and the resampler survive. void markDiscontinuity(); - void reset(); + void reset(quint64 generation); // Clear the cancel flag that restartSession() raised. A separate slot, not // part of reset(), so this only ever runs for a tap-point switch: the // ASR-disable and shutdown paths keep the flag latched as they always did. - void resumeAfterCancel(); + void resumeAfterCancel(quint64 generation); signals: void loaded(); void loadFailed(const QString& error); void speakerModelLoaded(const QString& modelPath, bool loaded); // speaker: 0-based cluster index (A/B/C…), or -1 when labeling is off. - void segmentText(const QString& text, float confidence, int speaker); + void segmentText(const QString& text, float confidence, int speaker, quint64 generation, + const AetherSDR::PcmEpochLease& source); void processedMs(double ms); // this chunk fully handled (for the backlog meter) void errorOccurred(const QString& error); @@ -115,6 +119,10 @@ public slots: std::unique_ptr m_resampler; int m_resamplerSrcRate = 0; bool m_warnedNoModel = false; + quint64 m_generation = 0; + std::atomic m_requestedGeneration{0}; + PcmEpochLease m_source; + bool inputCurrent() const; std::atomic m_cancelPending{false}; // see setCancelPending() std::atomic m_shuttingDown{false}; // see requestShutdown() }; @@ -159,6 +167,7 @@ class AsrEngine : public QObject { // (#5730): past asrBacklogHighWaterMs() chunks are dropped (droppedAudioChanged) // until the backlog drains to half, then the worker starts a fresh segment. void pushAudio(const QVector& monoSamples, int sampleRate); + void pushNativePcm(const AetherSDR::PcmFrame& frame); // Segmentation tuning (applied on the worker thread): // - decode buffer: max audio (ms) before a decode is forced without silence @@ -213,7 +222,8 @@ class AsrEngine : public QObject { void requestLoad(const QString& modelPath); void requestLoadSpeakerModel(const QString& modelPath); void requestSetSpeakerLabelingEnabled(bool on); - void requestProcess(const QVector& monoSamples, int sampleRate); + void requestProcess(const QVector& monoSamples, int sampleRate, quint64 generation, + const AetherSDR::PcmEpochLease& source); void requestSetMaxSegmentMs(int ms); void requestSetSpeechRms(float rms); void requestSetHangoverMs(int ms); @@ -222,13 +232,17 @@ class AsrEngine : public QObject { void requestSetContextCarryEnabled(bool on); void requestClearContext(); void requestMarkDiscontinuity(); - void requestReset(); - void requestResumeAfterCancel(); + void requestReset(quint64 generation); + void requestResumeAfterCancel(quint64 generation); private: + friend struct AsrEngineTestAccess; void startThread(AsrBackendFactory factory, const AsrSegmenter::Config& segConfig, AsrSpeakerEmbedderFactory speakerEmbedderFactory); + void pushAudioInput(const QVector& monoSamples, int sampleRate, const PcmEpochLease& source); + quint64 m_generation = 0; + void updateBacklog(); // recompute lag = pushed − processed, emit if it moved void updateDropped(); // emit droppedAudioChanged if the total moved diff --git a/src/core/AetherDspModePolicy.cpp b/src/core/AetherDspModePolicy.cpp index 43f93313c3..657205bac5 100644 --- a/src/core/AetherDspModePolicy.cpp +++ b/src/core/AetherDspModePolicy.cpp @@ -14,14 +14,14 @@ bool aetherDspModeRequiresDisable(QStringView mode) // NNR means a measured 28 dB hole where the signal was, since it treats a // steady carrier as noise. return mode == u"DIGU" || mode == u"DIGL" || mode == u"RTTY" - || mode == u"CW" || mode == u"CWU" || mode == u"CWL" + || mode == u"CW" || mode == u"CWU" || mode == u"CWL" || mode == u"CWR" || mode == u"NT"; } bool aetherDspMixRequiresDisable(const QList& slices) { for (const AetherDspSliceAudioState& slice : slices) { - if (!slice.muted && slice.gain > 0.0f + if (slice.receiving && !slice.muted && slice.gain > 0.0f && aetherDspModeRequiresDisable(slice.mode)) { return true; } diff --git a/src/core/AetherDspModePolicy.h b/src/core/AetherDspModePolicy.h index 53d6402508..a5db2219bf 100644 --- a/src/core/AetherDspModePolicy.h +++ b/src/core/AetherDspModePolicy.h @@ -12,6 +12,7 @@ struct AetherDspSliceAudioState { QString mode; bool muted{false}; float gain{0.0f}; + bool receiving{true}; }; bool aetherDspMixRequiresDisable(const QList& slices); diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 56b9e401d0..3c748d56e2 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -7886,16 +7886,26 @@ QJsonObject AutomationServer::doSlice(const QString& action, const QString& arg) if (!s) return err(QStringLiteral("no slice available")); - // LEVEL BEFORE ENABLE, for the reason the AGC branch above gives: the - // enable setter emits an intent carrying both values, so setting the - // level first makes one request reach the backend as a coherent pair. - // A radio with no radio-side NR / ANF (HL2, ANAN) cannot turn them - // on; the setter would only mark the model on. - if (on && which == QLatin1String("nr") - && !radio->radioSideNoiseReductionAvailable()) - return err(QStringLiteral("refused: this radio has no radio-side noise reduction")); - if (on && which == QLatin1String("anf") && !radio->radioSideAutoNotchAvailable()) - return err(QStringLiteral("refused: this radio has no auto notch")); + const auto receiveDsp = radio->backendCapabilities().receiveDspControl; + if (receiveDsp && which != QLatin1String("squelch")) { + using Feature = ReceiveDspControl::Feature; + const Feature feature = which == QLatin1String("nr") ? Feature::Nr + : which == QLatin1String("anf") ? Feature::Anf : Feature::Nb; + if (on && !receiveDsp->supports(feature, s->mode())) { + return err(QStringLiteral("refused: %1 is unavailable in receive mode %2") + .arg(which, s->mode())); + } + } else { + if (on && which == QLatin1String("nr") + && !radio->radioSideNoiseReductionAvailable()) { + return err(QStringLiteral("refused: this radio has no radio-side noise reduction")); + } + if (on && which == QLatin1String("anf") && !radio->radioSideAutoNotchAvailable()) { + return err(QStringLiteral("refused: this radio has no auto notch")); + } + } + // Set the level before enabling. Confirmed receivers merge these + // field-specific requests into their pending recipe independently. if (which == QLatin1String("nr")) { if (level >= 0) s->setNrLevel(level); s->setNr(on); @@ -7908,6 +7918,7 @@ QJsonObject AutomationServer::doSlice(const QString& action, const QString& arg) : advanced ? AetherSDR::NoiseBlankerKind::Advanced : AetherSDR::NoiseBlankerKind::Impulse); } else if (which == QLatin1String("anf")) { + if (level >= 0) { s->setAnfLevel(level); } s->setAnf(on); } else { s->setSquelch(on, level >= 0 ? level : s->squelchLevel()); diff --git a/src/core/PcmFrame.h b/src/core/PcmFrame.h index cf627d7ec2..56ab416ae8 100644 --- a/src/core/PcmFrame.h +++ b/src/core/PcmFrame.h @@ -289,3 +289,4 @@ class PcmFrameGate final { } // namespace AetherSDR Q_DECLARE_METATYPE(AetherSDR::PcmFrame) +Q_DECLARE_METATYPE(AetherSDR::PcmEpochLease) diff --git a/src/core/RtlSliceSettings.cpp b/src/core/RtlSliceSettings.cpp index 939008efd7..52b23ead8b 100644 --- a/src/core/RtlSliceSettings.cpp +++ b/src/core/RtlSliceSettings.cpp @@ -5,6 +5,7 @@ #include #include +#include #include namespace AetherSDR { @@ -44,6 +45,14 @@ QJsonObject encodeSlice(const RtlSliceSettings::Slice& slice, QJsonObject previo previous.insert(QStringLiteral("mode"), slice.mode); previous.insert(QStringLiteral("filterLow"), slice.filterLowHz); previous.insert(QStringLiteral("filterHigh"), slice.filterHighHz); + QJsonObject filters = previous.value(QStringLiteral("modeFilters")).toObject(); + for (auto it = slice.modeFilters.cbegin(); it != slice.modeFilters.cend(); ++it) { + QJsonObject filter = filters.value(it.key()).toObject(); + filter.insert(QStringLiteral("low"), it->lowHz); + filter.insert(QStringLiteral("high"), it->highHz); + filters.insert(it.key(), filter); + } + previous.insert(QStringLiteral("modeFilters"), filters); previous.insert(QStringLiteral("agcMode"), slice.agcMode); previous.insert(QStringLiteral("agcThreshold"), slice.agcThreshold); QJsonObject squelch = previous.value(QStringLiteral("squelch")).toObject(); @@ -57,6 +66,23 @@ QJsonObject encodeSlice(const RtlSliceSettings::Slice& slice, QJsonObject previo previous.insert(QStringLiteral("audioPan"), slice.audioPan); previous.insert(QStringLiteral("wfmDeemphasisUs"), slice.wfmDeemphasisUs); previous.insert(QStringLiteral("wfmForceMono"), slice.wfmForceMono); + QJsonObject receive = previous.value(QStringLiteral("receiveDsp")).toObject(); + receive.insert(QStringLiteral("agcOffLevel"), slice.agcOffLevel); + receive.insert(QStringLiteral("cwPitchHz"), slice.cwPitchHz); + receive.insert(QStringLiteral("ritEnabled"), slice.ritEnabled); + receive.insert(QStringLiteral("ritOffsetHz"), slice.ritOffsetHz); + receive.insert(QStringLiteral("nbKind"), static_cast(slice.nbKind)); + receive.insert(QStringLiteral("nbLevel"), slice.nbLevel); + receive.insert(QStringLiteral("nbFill"), static_cast(slice.nbFill)); + receive.insert(QStringLiteral("apfEnabled"), slice.apfEnabled); + receive.insert(QStringLiteral("apfLevel"), slice.apfLevel); + receive.insert(QStringLiteral("nrEnabled"), slice.nrEnabled); + receive.insert(QStringLiteral("nrLevel"), slice.nrLevel); + receive.insert(QStringLiteral("anfEnabled"), slice.anfEnabled); + receive.insert(QStringLiteral("anfLevel"), slice.anfLevel); + receive.insert(QStringLiteral("mnEnabled"), slice.mnEnabled); + receive.insert(QStringLiteral("mnLevel"), slice.mnLevel); + previous.insert(QStringLiteral("receiveDsp"), receive); return previous; } @@ -116,6 +142,54 @@ bool RtlSliceSettings::decode(const QJsonObject& object, Document& output, QStri || !entry.value(QStringLiteral("squelch")).isObject()) { return false; } + if (entry.contains(QStringLiteral("modeFilters"))) { + if (!entry.value(QStringLiteral("modeFilters")).isObject()) { return false; } + const QJsonObject filters = entry.value(QStringLiteral("modeFilters")).toObject(); + if (filters.size() > modes.size()) { return false; } + for (auto filter = filters.begin(); filter != filters.end(); ++filter) { + Filter value; + if (!modes.contains(filter.key()) || !filter.value().isObject() + || !number(filter.value().toObject(), QStringLiteral("low"), -100000, 100000, value.lowHz, true) + || !number(filter.value().toObject(), QStringLiteral("high"), -100000, 100000, value.highHz, true) + || value.lowHz >= value.highHz) { return false; } + slice.modeFilters.insert(filter.key(), value); + } + } + // Optional schema-1 object: old documents use the existing UI defaults. + if (entry.contains(QStringLiteral("receiveDsp"))) { + if (!entry.value(QStringLiteral("receiveDsp")).isObject()) { return false; } + const QJsonObject receive = entry.value(QStringLiteral("receiveDsp")).toObject(); + double pitch = 0, rit = 0, kind = 0, fill = 0; + if (!percent(receive, QStringLiteral("agcOffLevel"), slice.agcOffLevel) + || !number(receive, QStringLiteral("cwPitchHz"), 100, 6000, pitch, true) + || !number(receive, QStringLiteral("ritOffsetHz"), -99999, 99999, rit, true) + || !number(receive, QStringLiteral("nbKind"), 0, 2, kind, true) + || !number(receive, QStringLiteral("nbFill"), 0, 4, fill, true) + || !percent(receive, QStringLiteral("nbLevel"), slice.nbLevel) + || !percent(receive, QStringLiteral("apfLevel"), slice.apfLevel) + || !receive.value(QStringLiteral("ritEnabled")).isBool() + || !receive.value(QStringLiteral("apfEnabled")).isBool()) { return false; } + slice.cwPitchHz = static_cast(pitch); + slice.ritOffsetHz = static_cast(rit); + slice.nbKind = static_cast(static_cast(kind)); + slice.nbFill = static_cast(static_cast(fill)); + slice.ritEnabled = receive.value(QStringLiteral("ritEnabled")).toBool(); + slice.apfEnabled = receive.value(QStringLiteral("apfEnabled")).toBool(); + const auto optionalToggle = [&](const QString& key, bool& value) { + if (!receive.contains(key)) { return true; } + if (!receive.value(key).isBool()) { return false; } + value = receive.value(key).toBool(); return true; + }; + const auto optionalLevel = [&](const QString& key, int& value) { + return !receive.contains(key) || percent(receive, key, value); + }; + if (!optionalToggle(QStringLiteral("nrEnabled"), slice.nrEnabled) + || !optionalToggle(QStringLiteral("anfEnabled"), slice.anfEnabled) + || !optionalToggle(QStringLiteral("mnEnabled"), slice.mnEnabled) + || !optionalLevel(QStringLiteral("nrLevel"), slice.nrLevel) + || !optionalLevel(QStringLiteral("anfLevel"), slice.anfLevel) + || !optionalLevel(QStringLiteral("mnLevel"), slice.mnLevel)) { return false; } + } if (entry.contains(QStringLiteral("wfmDeemphasisUs"))) { double microseconds = 0; if (!number(entry, QStringLiteral("wfmDeemphasisUs"), 50, 75, microseconds, true) @@ -145,6 +219,28 @@ bool RtlSliceSettings::decode(const QJsonObject& object, Document& output, QStri slice.audioMute = entry.value(QStringLiteral("audioMute")).toBool(); parsed.slices.insert(slice.id, slice); } + if (object.contains(QStringLiteral("trackingNotches"))) { + if (!object.value(QStringLiteral("trackingNotches")).isObject()) { return false; } + const auto notches = object.value(QStringLiteral("trackingNotches")).toObject(); + if (!notches.value(QStringLiteral("enabled")).isBool() + || !notches.value(QStringLiteral("entries")).isObject()) { return false; } + parsed.trackingNotches.enabled = notches.value(QStringLiteral("enabled")).toBool(); + const auto entries = notches.value(QStringLiteral("entries")).toObject(); + for (auto it = entries.begin(); it != entries.end(); ++it) { + bool ok = false; + rtl::RtlTrackingNotch notch; + notch.id = it.key().toInt(&ok); + if (!ok || notch.id < 0 || QString::number(notch.id) != it.key() || !it.value().isObject()) { return false; } + const auto entry = it.value().toObject(); + if (!number(entry, QStringLiteral("centerHz"), 24000, 1766000000, notch.centerHz) + || !number(entry, QStringLiteral("widthHz"), 200, 12000, notch.widthHz) + || !entry.value(QStringLiteral("active")).isBool()) { return false; } + notch.active = entry.value(QStringLiteral("active")).toBool(); + parsed.trackingNotches.entries.push_back(notch); + } + std::ranges::sort(parsed.trackingNotches.entries, {}, &rtl::RtlTrackingNotch::id); + if (!parsed.trackingNotches.valid()) { return false; } + } output = std::move(parsed); // Never expose a partially parsed document. reason.clear(); return true; @@ -180,7 +276,8 @@ RtlSliceSettings::ReadResult RtlSliceSettings::load() const } bool RtlSliceSettings::patch(double captureCenterHz, double sampleRateHz, - const QVector& accepted, const QVector& removed) const + const QVector& accepted, const QVector& removed, + const std::optional& trackingNotches) const { if (m_scope.family() != QLatin1String("rtl")) { return refuse(QStringLiteral("invalid RTL write scope")); @@ -222,6 +319,26 @@ bool RtlSliceSettings::patch(double captureCenterHz, double sampleRateHz, object.insert(QStringLiteral("captureCenterHz"), captureCenterHz); object.insert(QStringLiteral("sampleRateHz"), sampleRateHz); object.insert(QStringLiteral("slices"), slices); + // An absent bank already means enabled with no entries. A reconnect that + // merely observes that default must not rewrite the saved document. + if (trackingNotches && (object.contains(QStringLiteral("trackingNotches")) + || !trackingNotches->enabled || !trackingNotches->entries.empty())) { + if (!trackingNotches->valid()) { return refuse(QStringLiteral("invalid tracking notch bank")); } + auto notches = object.value(QStringLiteral("trackingNotches")).toObject(); + const auto previousEntries = notches.value(QStringLiteral("entries")).toObject(); + QJsonObject entries; + for (const auto& notch : trackingNotches->entries) { + const QString key = QString::number(notch.id); + auto entry = previousEntries.value(key).toObject(); + entry.insert(QStringLiteral("centerHz"), notch.centerHz); + entry.insert(QStringLiteral("widthHz"), notch.widthHz); + entry.insert(QStringLiteral("active"), notch.active); + entries.insert(key, entry); + } + notches.insert(QStringLiteral("enabled"), trackingNotches->enabled); + notches.insert(QStringLiteral("entries"), entries); + object.insert(QStringLiteral("trackingNotches"), notches); + } Document checked; if (!decode(object, checked, reason)) { return refuse(reason); diff --git a/src/core/RtlSliceSettings.h b/src/core/RtlSliceSettings.h index 206e1ee422..b0946e6b65 100644 --- a/src/core/RtlSliceSettings.h +++ b/src/core/RtlSliceSettings.h @@ -3,6 +3,10 @@ #include "RadioSettingsScope.h" #include "RadioSettingsIdentity.h" #include "SharedCapturePolicy.h" +#include "core/backends/NoiseBlankerKind.h" +#include "core/backends/rtl/RtlTrackingNotches.h" + +#include #include #include @@ -17,12 +21,18 @@ class RtlSliceSettings { public: static constexpr int kSchemaVersion = 1; static QString featureName() { return QStringLiteral("RtlSlices"); } + struct Filter { + double lowHz = 0; + double highHz = 0; + bool operator==(const Filter&) const = default; + }; struct Slice { int id = -1; double frequencyHz = 0; QString mode; double filterLowHz = 0; double filterHighHz = 0; + QMap modeFilters; // Existing SliceModel defaults, also used for fields legacy RTL never saved. QString agcMode = QStringLiteral("med"); int agcThreshold = 65; @@ -36,11 +46,28 @@ class RtlSliceSettings { bool wfmForceMono = false; bool automaticSquelch = false; int automaticSquelchMarginDb = 10; + int agcOffLevel = 10; + int cwPitchHz = 600; + bool ritEnabled = false; + int ritOffsetHz = 0; + NoiseBlankerKind nbKind = NoiseBlankerKind::Off; + int nbLevel = 50; + NoiseBlankerFill nbFill = NoiseBlankerFill::Zero; + bool apfEnabled = false; + int apfLevel = 50; + bool nrEnabled = false; + int nrLevel = 50; + bool anfEnabled = false; + int anfLevel = 50; + bool mnEnabled = false; + int mnLevel = 50; + }; struct Document { double captureCenterHz = 0; double sampleRateHz = 0; QMap slices; + rtl::RtlTrackingNotches trackingNotches; }; enum class ReadStatus { Missing, Ready, Refused }; struct ReadResult { @@ -58,7 +85,8 @@ class RtlSliceSettings { // Only accepted runtime entries belong here. Omitted entries survive partial // restore/capacity reduction; explicit removals are separate stable IDs. bool patch(double captureCenterHz, double sampleRateHz, - const QVector& accepted, const QVector& removed = {}) const; + const QVector& accepted, const QVector& removed = {}, + const std::optional& trackingNotches = {}) const; MigrationResult migrateLegacy(const RadioSerialIdentity& identity) const; // Adapter provides mode/BFO/guard metadata for EVERY saved entry. This diff --git a/src/core/backends/RadioCapabilities.h b/src/core/backends/RadioCapabilities.h index d3c85784a5..71eaeef85b 100644 --- a/src/core/backends/RadioCapabilities.h +++ b/src/core/backends/RadioCapabilities.h @@ -61,6 +61,30 @@ struct ReceiveFilterControl { SliceFrequencyControl::Authority authority{SliceFrequencyControl::Authority::Unknown}; QList modes; }; +// Supported host receive signal controls and their qualified mode family. +// An absent record preserves existing radio-side capability semantics. +struct ReceiveDspControl { + enum class Feature { Agc, Nb, Nr, Anf, ManualNotch, Apf, Rit }; + QStringList modes; + QList features; + // An entry narrows a declared feature to a subset of the qualified modes. + // An absent entry uses all modes; an empty entry disables that feature. + QMap featureModes; + bool supports(Feature feature, const QString& mode) const + { + const auto restriction = featureModes.constFind(feature); + return features.contains(feature) && modes.contains(mode) + && (restriction == featureModes.cend() || restriction.value().contains(mode)); + } +}; + +// Session-wide receive BFO pitch, dispatched through setCwPitch and confirmed +// through TransmitDelta::cwPitch. Presence grants no transmit or microphone path. +struct ReceiveCwPitchControl { + int minimumHz = 100; + int maximumHz = 6000; + int stepHz = 10; +}; struct ReceiveAudioControl { SliceFrequencyControl::Authority authority{SliceFrequencyControl::Authority::Unknown}; // Both gain (0..100) and mute must act on the shared slice's RX audio. @@ -410,6 +434,8 @@ struct RadioCapabilities { std::optional receiveModeControl; std::optional receiveFilterControl; std::optional receiveAudioControl; + std::optional receiveCwPitchControl; + std::optional receiveDspControl; std::optional receiveAudioExport; std::optional broadcastFmReceive; std::optional receivePanCenterControl; diff --git a/src/core/backends/ReceiveCommand.h b/src/core/backends/ReceiveCommand.h index b978911cc8..4c7a9e38b3 100644 --- a/src/core/backends/ReceiveCommand.h +++ b/src/core/backends/ReceiveCommand.h @@ -27,7 +27,9 @@ struct SliceFilterRequest { struct SliceAgcRequest { // Preserve which field the operator changed: Flex has three independent - // fields, whereas host DSP needs the mode/threshold pair for either edit. + // fields, whereas optimistic host DSP uses the pair for either edit. + // Confirmed engines apply only the selected field to their pending recipe; + // companion values describe the last observation, not a newer pending edit. enum class Field { Mode, Threshold, OffLevel }; // A recall replays a stored value. A radio with its own per-mode AGC // default must not receive that replay as an operator edit. @@ -50,6 +52,8 @@ struct SliceDspRequest { // remember a half of it (see IRadioBackend::setSliceNoiseBlanker). A change // of blanker is an Enabled request: `enabled` says whether one runs, `kind` // says which, so a backend with one blanker reads `enabled` and is right. + // Confirmed engines merge only `field` into their pending recipe, preserving + // independently queued companion edits until their adoption is observed. enum class Field { Enabled, Level, Fill }; Feature feature{Feature::Nb}; Field field{Field::Enabled}; diff --git a/src/core/backends/rtl/RtlCaptureTransaction.cpp b/src/core/backends/rtl/RtlCaptureTransaction.cpp index 760658e0ce..be59eee40e 100644 --- a/src/core/backends/rtl/RtlCaptureTransaction.cpp +++ b/src/core/backends/rtl/RtlCaptureTransaction.cpp @@ -1,5 +1,6 @@ #include "core/backends/rtl/RtlCaptureTransaction.h" #include "core/backends/rtl/RtlViewport.h" +#include "RtlReceiveRecipe.h" #include #include @@ -44,6 +45,7 @@ Policy::Error validateConfigured(const std::vector& receivers, if (receivers.size() > limits.capacity) { return Policy::Error::CapacityExceeded; } std::array used{}; for (const T::Receiver& receiver : receivers) { + if (!RtlReceiveRecipe::valid(receiver)) { return Policy::Error::InvalidNumber; } const int id = receiver.passband.stableId; if (id < 0 || static_cast(id) >= limits.slotCount) { return Policy::Error::InvalidIdentity; @@ -104,9 +106,9 @@ std::vector receivingPassbands(const T::State& state) return result; } -bool narrowFm(const T::Receiver& receiver) +bool dcSensitive(const T::Receiver& receiver) { - return receiver.mode == T::Mode::Fm || receiver.mode == T::Mode::Fmn; + return RtlReceiveRecipe::dcSensitive(receiver.mode); } Policy::Interval dcExclusion(const T::Receiver& receiver) @@ -249,10 +251,12 @@ RtlCaptureTransaction::Submission RtlCaptureTransaction::submit(const Desired& d return {{}, Policy::Error::InvalidNumber}; } } + if (!desired.trackingNotches.valid()) { return {{}, Policy::Error::InvalidNumber}; } State target; target.token = {m_session, m_revision + 1}; target.hardware = desired.hardware; target.receivers = desired.receivers; + target.trackingNotches = desired.trackingNotches; target.automaticDirectSampling = desired.automaticDirectSampling; target.dcSuppression = desired.dcSuppression; target.capture = {m_session, target.token.revision, double(desired.hardware.centerHz), @@ -273,7 +277,7 @@ RtlCaptureTransaction::Submission RtlCaptureTransaction::submit(const Desired& d const auto found = std::ranges::find_if(target.receivers, [id](const Receiver& receiver) { return receiver.passband.stableId == id; }); - if (found != target.receivers.end() && narrowFm(*found)) { + if (found != target.receivers.end() && dcSensitive(*found)) { followed = &*found; break; } @@ -303,7 +307,7 @@ RtlCaptureTransaction::Submission RtlCaptureTransaction::submit(const Desired& d target.capture = *centered.capture; } const bool recentered = target.capture.centerHz != desired.hardware.centerHz; - if (followed && narrowFm(*followed) + if (followed && dcSensitive(*followed) && (!m_confirmed || recentered || desired.avoidDc) && (!m_confirmed || recentered || !dcClearFor(*followed, target.capture.centerHz))) { // An explicit slice tune may move capture away from converter DC; @@ -362,7 +366,7 @@ bool RtlCaptureTransaction::dcClear(const State& state) return value.passband.stableId == id; }); if (receiver == state.receivers.end()) { return false; } - if (narrowFm(*receiver) && (!Policy::occupiedInterval(receiver->passband).interval + if (dcSensitive(*receiver) && (!Policy::occupiedInterval(receiver->passband).interval || !dcClearFor(*receiver, state.capture.centerHz))) { return false; } } return true; @@ -406,6 +410,7 @@ RtlCaptureTransaction::Completion RtlCaptureTransaction::complete(const Result& || result.actual->capture != work.target.capture || result.actual->receivingIds != work.target.receivingIds || result.actual->receivers != work.target.receivers + || result.actual->trackingNotches != work.target.trackingNotches || result.actual->dcSuppression != work.target.dcSuppression || result.actual->automaticDirectSampling != work.target.automaticDirectSampling || !matches(work.target, result.actual->hardware))) @@ -414,6 +419,7 @@ RtlCaptureTransaction::Completion RtlCaptureTransaction::complete(const Result& || result.actual->capture != work.before->capture || result.actual->receivingIds != work.before->receivingIds || result.actual->receivers != work.before->receivers + || result.actual->trackingNotches != work.before->trackingNotches || result.actual->dcSuppression != work.before->dcSuppression || result.actual->automaticDirectSampling != work.before->automaticDirectSampling || !matches(*work.before, result.actual->hardware)))) { diff --git a/src/core/backends/rtl/RtlCaptureTransaction.h b/src/core/backends/rtl/RtlCaptureTransaction.h index 79d1ccf6f9..d85b17066b 100644 --- a/src/core/backends/rtl/RtlCaptureTransaction.h +++ b/src/core/backends/rtl/RtlCaptureTransaction.h @@ -1,6 +1,8 @@ #pragma once #include "core/SharedCapturePolicy.h" +#include "core/backends/NoiseBlankerKind.h" +#include "RtlTrackingNotches.h" #include #include @@ -35,6 +37,26 @@ class RtlCaptureTransaction final { bool wfmForceMono = false; // selected decoder matrix policy, not observed pilot bool automaticSquelch = false; int automaticSquelchMarginDb = 10; + // Host receive controls travel with the immutable prepared receiver. + int agcMode = 3; // WDSP: off=0, slow=2, medium=3, fast=4 + int agcThreshold = 65; + int agcOffLevel = 10; + int cwPitchHz = 600; + bool ritEnabled = false; + int ritOffsetHz = 0; + NoiseBlankerKind nbKind = NoiseBlankerKind::Off; + int nbLevel = 50; + NoiseBlankerFill nbFill = NoiseBlankerFill::Zero; + bool apfEnabled = false; + int apfLevel = 50; + bool nrEnabled = false; + int nrLevel = 50; + bool anfEnabled = false; + int anfLevel = 50; + bool mnEnabled = false; + int mnLevel = 50; + + bool operator==(const Receiver&) const = default; }; struct Desired { @@ -44,6 +66,7 @@ class RtlCaptureTransaction final { }; Hardware hardware; std::vector receivers; + RtlTrackingNotches trackingNotches; bool automaticDirectSampling = true; bool dcSuppression = false; // One-shot operator/mode-entry intent. Display pan/zoom never sets it. @@ -68,6 +91,7 @@ class RtlCaptureTransaction final { // capture; only these sorted IDs have a live DSP receiver bank. std::vector receivingIds; std::vector receivers; + RtlTrackingNotches trackingNotches; bool automaticDirectSampling = true; bool dcSuppression = false; }; diff --git a/src/core/backends/rtl/RtlReceivePipeline.cpp b/src/core/backends/rtl/RtlReceivePipeline.cpp index 9bb3c63005..83cc1fec3f 100644 --- a/src/core/backends/rtl/RtlReceivePipeline.cpp +++ b/src/core/backends/rtl/RtlReceivePipeline.cpp @@ -1,4 +1,5 @@ #include "RtlReceivePipeline.h" +#include "RtlReceiveRecipe.h" #include #include #include @@ -69,7 +70,7 @@ 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] + if (!RtlReceiveRecipe::valid(receiver) || 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 @@ -81,16 +82,16 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( // Parking changes reception membership, not the configured graph's // admission contract. A legacy recipe cannot hide outside the capture // and then resume alongside a native 48 kHz receiver. - const bool native = receiver.mode == Transaction::Mode::Fm - || receiver.mode == Transaction::Mode::Fmn - || (m_enableWfm && receiver.mode == Transaction::Mode::Wfm); + const bool native = RtlReceiveRecipe::native(receiver.mode, m_enableWfm); if (!native && state.receivers.size() != 1) { return Submission::Failed; } validated[id] = true; } + if (!state.trackingNotches.valid()) { return Submission::Failed; } const unsigned faults = m_faults.load(std::memory_order_acquire); std::array receiving{}; bool legacy = false; bool wfmTransition = false; + bool analogTransition = false; std::uint8_t receivingMask = 0; for (int id : state.receivingIds) { if (id < 0 || id >= 8 || receiving[id] || !validated[id]) { return Submission::Failed; } @@ -99,11 +100,18 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( const auto receiver = std::ranges::find_if(state.receivers, [id](const auto& value) { return value.passband.stableId == id; }); - legacy = legacy || (receiver->mode != Transaction::Mode::Fm - && receiver->mode != Transaction::Mode::Fmn - && !(m_enableWfm && receiver->mode == Transaction::Mode::Wfm)); + legacy = legacy || !RtlReceiveRecipe::native(receiver->mode, m_enableWfm); if (legacy && receiver->squelchEnabled) { return Submission::Failed; } const auto& prior = m_specs[id]; + const bool analog = RtlReceiveRecipe::analogDsp(receiver->mode); + if (analog || prior.conjugateIq) { + WdspChannel::Config dsp; + dsp.mode = dspMode(receiver->mode); + RtlReceiveRecipe::applyDsp(*receiver, dsp, state.trackingNotches); + analogTransition |= !analog || !prior.conjugateIq || (faults & (1u << id)) + || prior.passband != RtlReceiveRecipe::extractionBand(*receiver) + || prior.dsp != dsp; + } const bool wide = m_enableWfm && receiver->mode == Transaction::Mode::Wfm; if (wide || prior.dsp.wbfmReceive) { const auto deemphasis = receiver->wfmDeemphasisUs == 50 @@ -117,12 +125,11 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( } } 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. + // Decoder/filter changes retire the mixed waveform and client-effect + // history even when final PCM stays 48 kHz. A withdrawn receiver has lost + // that graph even if its requested recipe is unchanged. const bool advanceCaptureEpoch = resetCapture || legacy != m_legacy - || receivingMask != m_receivingMask || wfmTransition; + || receivingMask != m_receivingMask || wfmTransition || analogTransition; if (advanceCaptureEpoch && m_nextEpoch == std::numeric_limits::max()) { return Submission::Failed; } auto epochs = m_epochs; auto specs = m_specs; @@ -165,12 +172,17 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( spec.dsp.wbfmReceive->deemphasis = receiver.wfmDeemphasisUs == 50 ? WdspChannel::WbfmReceive::Deemphasis::Us50 : WdspChannel::WbfmReceive::Deemphasis::Us75; - } else { + } else if (receiver.mode == Transaction::Mode::Fm || receiver.mode == Transaction::Mode::Fmn) { 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 (RtlReceiveRecipe::analogDsp(receiver.mode)) { + spec.passband = RtlReceiveRecipe::extractionBand(receiver); + spec.conjugateIq = true; + RtlReceiveRecipe::applyDsp(receiver, spec.dsp, state.trackingNotches); + } if (resetCapture || (faults & (1u << id)) || m_specs[id].handle != spec.handle || m_specs[id].passband != spec.passband || m_specs[id].dsp != spec.dsp) { if (epochs[id] == std::numeric_limits::max()) { return Submission::Failed; } @@ -253,7 +265,7 @@ void RtlReceivePipeline::observeSpectrum(std::span bins, std::uint6 for (int id = 0; id < 8; ++id) { if (!(m_receivingMask & (1u << id))) { continue; } const auto& band = m_meterPassbands[id]; - const double offset = band.carrierHz - m_capture.centerHz; + const double offset = band.carrierHz + band.translationHz - m_capture.centerHz; const int low = std::clamp(int(std::floor((offset + band.filterLowHz) / binHz + 1024)), 0, 2047); const int high = std::clamp(int(std::ceil((offset + band.filterHighHz) / binHz + 1024)), low, 2047); float peak = -120.0f; @@ -410,7 +422,7 @@ void RtlReceivePipeline::audioBlockWithStatus(const RtlReceiverRegistry::Receive for (std::size_t i = 0; i < left.size(); ++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); + const float gain = spec.dsp.fmReceive ? m_squelch[packet.slot].gain(first + i) : 1.0f; gatedLeft[i] = left[i] * gain; gatedRight[i] = right[i] * gain; packet.samples[2 * i] = gatedLeft[i]; packet.samples[2 * i + 1] = gatedRight[i]; } diff --git a/src/core/backends/rtl/RtlReceiveRecipe.h b/src/core/backends/rtl/RtlReceiveRecipe.h new file mode 100644 index 0000000000..e77b710764 --- /dev/null +++ b/src/core/backends/rtl/RtlReceiveRecipe.h @@ -0,0 +1,124 @@ +#pragma once + +#include "RtlCaptureTransaction.h" +#include "core/backends/WdspNoiseBlanker.h" +#include "core/dsp/WdspChannel.h" + +#include +#include +#include + +namespace AetherSDR::rtl { + +// The public passband stays relative to the station's carrier. Only the +// prepared extractor/demodulator recipe translates it to the detector's zero. +struct RtlReceiveRecipe { + using Mode = RtlCaptureTransaction::Mode; + using Receiver = RtlCaptureTransaction::Receiver; + static bool sideband(Mode mode) noexcept + { + return mode == Mode::Usb || mode == Mode::Lsb || cw(mode); + } + static bool cw(Mode mode) noexcept { return mode == Mode::Cw || mode == Mode::Cwr; } + static bool amplitude(Mode mode) noexcept { return mode == Mode::Am || mode == Mode::Sam; } + static bool analogDsp(Mode mode) noexcept { return sideband(mode) || amplitude(mode); } + static bool dcSensitive(Mode mode) noexcept + { return amplitude(mode) || cw(mode) || mode == Mode::Fm || mode == Mode::Fmn; } + static bool native(Mode mode, bool wfm = true) noexcept + { + return analogDsp(mode) || mode == Mode::Fm || mode == Mode::Fmn + || (wfm && mode == Mode::Wfm); + } + static double bfo(const Receiver& receiver) noexcept + { + return receiver.mode == Mode::Cw ? receiver.cwPitchHz + : receiver.mode == Mode::Cwr ? -receiver.cwPitchHz : 0.0; + } + static bool valid(const Receiver& receiver) noexcept + { + const auto& band = receiver.passband; + if ((receiver.agcMode != 0 && receiver.agcMode != 2 && receiver.agcMode != 3 && receiver.agcMode != 4) + || receiver.agcThreshold < 0 || receiver.agcThreshold > 100 + || receiver.agcOffLevel < 0 || receiver.agcOffLevel > 100 + || receiver.cwPitchHz < 100 || receiver.cwPitchHz > 6000 + || receiver.ritOffsetHz < -99999 || receiver.ritOffsetHz > 99999 + || !isValidNoiseBlankerKind(static_cast(receiver.nbKind)) + || !isValidNoiseBlankerFill(static_cast(receiver.nbFill)) + || receiver.nbLevel < 0 || receiver.nbLevel > 100 + || receiver.apfLevel < 0 || receiver.apfLevel > 100 + || receiver.nrLevel < 0 || receiver.nrLevel > 100 + || receiver.anfLevel < 0 || receiver.anfLevel > 100 + || receiver.mnLevel < 0 || receiver.mnLevel > 100) { return false; } + if (!analogDsp(receiver.mode)) { return true; } + if (!std::isfinite(band.filterLowHz) || !std::isfinite(band.filterHighHz) + || band.filterLowHz < -12000 || band.filterHighHz > 12000 + || band.filterHighHz - band.filterLowHz < 2 + || band.translationHz != (receiver.ritEnabled ? receiver.ritOffsetHz : 0)) { return false; } + if (receiver.mode == Mode::Usb && band.filterLowHz < 0) { return false; } + if (receiver.mode == Mode::Lsb && band.filterHighHz > 0) { return false; } + if (amplitude(receiver.mode)) { + // Keep a finite carrier region for envelope detection and the SAM + // PLL; either sideband may be narrowed independently. + return band.filterLowHz <= -100 && band.filterHighHz >= 100; + } + return !cw(receiver.mode) || (band.filterLowHz < 0 && band.filterHighHz > 0); + } + static std::pair defaultFilter(Mode mode) noexcept + { + if (mode == Mode::Usb) { return {100, 2900}; } + if (mode == Mode::Lsb) { return {-2900, -100}; } + if (amplitude(mode)) { return {-4000, 4000}; } + return {-250, 250}; + } + static SharedCapturePolicy::SliceDescriptor extractionBand(const Receiver& receiver) noexcept + { + auto band = receiver.passband; + const double pitch = bfo(receiver); + band.translationHz -= pitch; + band.filterLowHz += pitch; + band.filterHighHz += pitch; + return band; + } + static void applyDsp(const Receiver& receiver, WdspChannel::Config& dsp, + const RtlTrackingNotches& tracking = {}) + { + dsp.filterLowHz = receiver.passband.filterLowHz + bfo(receiver); + dsp.filterHighHz = receiver.passband.filterHighHz + bfo(receiver); + if (amplitude(receiver.mode)) { + dsp.amReceive = WdspChannel::AmReceive{}; + // Resolve the 50 Hz audio high-pass transition far enough from DC + // that the demodulated carrier cannot consume output headroom. + dsp.filterTaps = 4096; + } + dsp.agcMode = receiver.agcMode; + // Same 0..100 host AGC scale as the existing WDSP receiver UI. + dsp.maximumAgcGainDb = 0.6 * receiver.agcThreshold; + dsp.agcFixedGainDb = 10.0 + 0.6 * (receiver.agcOffLevel - 10); + dsp.rxPanelGain = 1.0; + dsp.noiseBlanker = toWdsp(receiver.nbKind); + dsp.noiseBlankerLevel = receiver.nbLevel; + dsp.noiseBlankerFill = toWdsp(receiver.nbFill); + dsp.apfEnabled = cw(receiver.mode) && receiver.apfEnabled; + dsp.apfCenterHz = receiver.cwPitchHz; + dsp.apfBandwidthHz = 200.0 * std::pow(2.0, -receiver.apfLevel / 50.0); + dsp.apfGain = 2.0; + const double notch = amplitude(receiver.mode) + ? 300.0 + receiver.mnLevel / 100.0 + * std::max(0.0, std::max(-dsp.filterLowHz, dsp.filterHighHz) - 500.0) + : dsp.filterLowHz + receiver.mnLevel / 100.0 + * (dsp.filterHighHz - dsp.filterLowHz); + dsp.analogReceiveDsp = WdspChannel::AnalogReceiveDsp{ + {}, receiver.nrEnabled, receiver.nrLevel, receiver.anfEnabled, receiver.anfLevel, + receiver.mnEnabled, notch, 400.0}; + for (const auto& value : tracking.entries) { + const double center = value.centerHz - receiver.passband.carrierHz + - receiver.passband.translationHz + bfo(receiver); + if (center + value.widthHz / 2 < dsp.filterLowHz + || center - value.widthHz / 2 > dsp.filterHighHz) { continue; } + dsp.analogReceiveDsp->trackingNotches.push_back( + {center, value.widthHz, tracking.enabled && value.active}); + } + } +}; + +} // namespace AetherSDR::rtl diff --git a/src/core/backends/rtl/RtlReceiverRegistry.cpp b/src/core/backends/rtl/RtlReceiverRegistry.cpp index 63fa225872..41bcbb60f3 100644 --- a/src/core/backends/rtl/RtlReceiverRegistry.cpp +++ b/src/core/backends/rtl/RtlReceiverRegistry.cpp @@ -39,6 +39,10 @@ bool validCapture(const Registry::Capture& capture) bool boundedDsp(const WdspChannel::Config& config) { + if (config.amReceive && ((config.mode != WdspChannel::Mode::Am + && config.mode != WdspChannel::Mode::Sam) || config.fmReceive || config.wbfmReceive + || config.filterLowHz > -100 || config.filterHighHz < 100 + || config.filterLowHz < -12000 || config.filterHighHz > 12000)) { return false; } if (config.wbfmReceive) { const auto& wide = *config.wbfmReceive; if (config.mode != WdspChannel::Mode::Wbfm || config.fmReceive @@ -110,6 +114,7 @@ class WdspReceiver final : public Registry::Receiver, private RtlRfExtractor::Si : m_channel(std::move(channel)), m_left(m_channel->outputBlockSize()), m_right(m_channel->outputBlockSize()), m_spec(spec) { + if (spec.conjugateIq) { m_conjugatedQ.resize(spec.dsp.inputBlockSize); } if (spec.extractRf) { m_extractor = std::make_unique(RtlRfExtractor::Config{ spec.capture, spec.passband, spec.dsp.inputSampleRate, spec.dsp.inputBlockSize, @@ -153,6 +158,10 @@ class WdspReceiver final : public Registry::Receiver, private RtlRfExtractor::Si WdspChannel::ProcessResult processIq(std::span i, std::span q) noexcept override { + if (m_spec.conjugateIq && q.size() == m_conjugatedQ.size() && q.data()) { + std::transform(q.begin(), q.end(), m_conjugatedQ.begin(), [](float value) { return -value; }); + q = m_conjugatedQ; + } 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) { @@ -168,6 +177,7 @@ class WdspReceiver final : public Registry::Receiver, private RtlRfExtractor::Si std::optional processingFailure() const noexcept override { return m_failure; } private: std::unique_ptr m_channel; + std::vector m_conjugatedQ; std::vector m_left; std::vector m_right; const Registry::ReceiverSpec m_spec; diff --git a/src/core/backends/rtl/RtlReceiverRegistry.h b/src/core/backends/rtl/RtlReceiverRegistry.h index ca227377df..31b0ddacdd 100644 --- a/src/core/backends/rtl/RtlReceiverRegistry.h +++ b/src/core/backends/rtl/RtlReceiverRegistry.h @@ -50,6 +50,9 @@ class RtlReceiverRegistry final // Filled by submit(), never borrowed from the backend during preparation. Capture capture; bool extractRf = false; + // Analytic RTL IQ is opposite WDSP RXA's passband axis. FM recipes + // retain their established orientation; sideband recipes opt in. + bool conjugateIq = false; std::uint64_t epoch = 0; // reprepare on a gap without reusing DSP history bool operator==(const ReceiverSpec&) const = default; }; diff --git a/src/core/backends/rtl/RtlSdrBackend.cpp b/src/core/backends/rtl/RtlSdrBackend.cpp index 0a87ef530e..b031db1acf 100644 --- a/src/core/backends/rtl/RtlSdrBackend.cpp +++ b/src/core/backends/rtl/RtlSdrBackend.cpp @@ -4,6 +4,9 @@ #include "core/backends/rtl/RtlSdrWorker.h" #include "core/backends/rtl/RtlSdrDdc.h" #include "core/backends/RadioDelta.h" +#include "core/backends/TransmitDelta.h" +#include "core/backends/NotchDelta.h" +#include "RtlReceiveRecipe.h" #include "core/backends/SliceDelta.h" #include "core/RadioStateMemory.h" #include "core/LogManager.h" @@ -16,6 +19,7 @@ #include #include #include +#include #include #include @@ -24,23 +28,25 @@ namespace AetherSDR::rtl { namespace { Q_LOGGING_CATEGORY(lcRtlReceive, "aether.rtl.receive", QtWarningMsg) +bool hasAnalogDsp(const std::vector& receivers) +{ + return std::ranges::any_of(receivers, [](const auto& receiver) { + return RtlReceiveRecipe::analogDsp(receiver.mode); + }); +} constexpr double kMinTuneHz = 24'000.0; constexpr double kMaxTuneHz = 1'766'000'000.0; bool supportsAnalogBank(const RtlCaptureTransaction::Receiver& receiver) { - return receiver.mode == RtlCaptureTransaction::Mode::Fm - || receiver.mode == RtlCaptureTransaction::Mode::Fmn - || (RtlReceivePipeline::kQualifiedWfmEnabled - && receiver.mode == RtlCaptureTransaction::Mode::Wfm); + return RtlReceiveRecipe::native(receiver.mode, RtlReceivePipeline::kQualifiedWfmEnabled); } -bool hasReceivingNarrowFm(const RtlCaptureTransaction::State& state) +bool hasReceivingDcSensitive(const RtlCaptureTransaction::State& state) { return std::ranges::any_of(state.receivers, [&state](const auto& receiver) { - return (receiver.mode == RtlCaptureTransaction::Mode::Fm - || receiver.mode == RtlCaptureTransaction::Mode::Fmn) + return RtlReceiveRecipe::dcSensitive(receiver.mode) && std::ranges::find(state.receivingIds, receiver.passband.stableId) != state.receivingIds.end(); }); @@ -84,6 +90,59 @@ RtlCaptureTransaction::Mode captureMode(const QString& mode) if (mode == QLatin1String("CWR")) { return Mode::Cwr; } return Mode::Usb; } +int agcModeValue(const QString& mode) +{ + if (mode == QLatin1String("off")) { return 0; } + if (mode == QLatin1String("slow")) { return 2; } + if (mode == QLatin1String("med")) { return 3; } + if (mode == QLatin1String("fast")) { return 4; } + return -1; +} +RtlCaptureTransaction::Receiver savedReceiver(const RtlSliceSettings::Slice& slice, int restoredPitch) +{ + const bool narrowFm = slice.mode == QLatin1String("FM") || slice.mode == QLatin1String("FMN"); + const bool analog = RtlReceiveRecipe::analogDsp(captureMode(slice.mode)); + const SharedCapturePolicy::SliceDescriptor descriptor{slice.id, slice.frequencyHz, + slice.filterLowHz, slice.filterHighHz, + analog && slice.ritEnabled ? double(slice.ritOffsetHz) : 0, 3000, 3000}; + RtlCaptureTransaction::Receiver receiver{descriptor, captureMode(slice.mode), slice.audioGain, slice.audioPan, + slice.audioMute, narrowFm && slice.squelchEnabled, slice.squelchLevel, slice.wfmDeemphasisUs, slice.wfmForceMono, + narrowFm && slice.automaticSquelch, slice.automaticSquelchMarginDb}; + receiver.agcMode = agcModeValue(slice.agcMode); + receiver.agcThreshold = slice.agcThreshold; + receiver.agcOffLevel = slice.agcOffLevel; + receiver.cwPitchHz = restoredPitch; + receiver.ritEnabled = slice.ritEnabled; + receiver.ritOffsetHz = slice.ritOffsetHz; + receiver.nbKind = slice.nbKind; receiver.nbLevel = slice.nbLevel; receiver.nbFill = slice.nbFill; + receiver.apfEnabled = slice.apfEnabled; receiver.apfLevel = slice.apfLevel; + receiver.nrEnabled = slice.nrEnabled; receiver.nrLevel = slice.nrLevel; + receiver.anfEnabled = slice.anfEnabled; receiver.anfLevel = slice.anfLevel; + receiver.mnEnabled = slice.mnEnabled; receiver.mnLevel = slice.mnLevel; + return receiver; +} +bool restorable(const RtlCaptureTransaction::Receiver& receiver) +{ + const auto& band = receiver.passband; + if (!RtlReceiveRecipe::valid(receiver) + || !SharedCapturePolicy::occupiedInterval(band).interval) { return false; } + if (receiver.mode == RtlCaptureTransaction::Mode::Wfm) { + return band.filterLowHz >= -100000 && band.filterLowHz <= -15000 + && band.filterHighHz >= 15000 && band.filterHighHz <= 100000; + } + if (receiver.mode == RtlCaptureTransaction::Mode::Fm || receiver.mode == RtlCaptureTransaction::Mode::Fmn) { + return band.filterLowHz >= -21600 && band.filterLowHz < 0 + && band.filterHighHz > 0 && band.filterHighHz <= 21600; + } + return true; +} +QString agcModeName(int mode) +{ + if (mode == 0) { return QStringLiteral("off"); } + if (mode == 2) { return QStringLiteral("slow"); } + if (mode == 4) { return QStringLiteral("fast"); } + return QStringLiteral("med"); +} QString modeName(RtlCaptureTransaction::Mode mode) { static const QStringList names{QStringLiteral("AM"), QStringLiteral("SAM"), @@ -204,7 +263,21 @@ RadioCapabilities RtlSdrBackend::capabilities() const c.txMonitorControl = std::nullopt; c.lineoutControl = std::nullopt; c.txWaterfallClientFlag = std::nullopt; // receive only; no owner declared - c.hasAgcThreshold = false; + c.hasAgcThreshold = true; + c.hasHostNoiseBlanker = true; + c.receiveCwPitchControl = ReceiveCwPitchControl{100, 6000, 10}; + c.receiveDspControl = ReceiveDspControl{ + {QStringLiteral("AM"), QStringLiteral("SAM"), QStringLiteral("USB"), QStringLiteral("LSB"), QStringLiteral("CW"), QStringLiteral("CWR")}, + {ReceiveDspControl::Feature::Agc, ReceiveDspControl::Feature::Nb, + ReceiveDspControl::Feature::Apf, ReceiveDspControl::Feature::Rit, + ReceiveDspControl::Feature::Nr, ReceiveDspControl::Feature::Anf, ReceiveDspControl::Feature::ManualNotch}, + {{ReceiveDspControl::Feature::Apf, {QStringLiteral("CW"), QStringLiteral("CWR")}}}}; + if (m_lastPublished && hasAnalogDsp(m_lastPublished->receivers)) { + c.maxNotchFilters = RtlTrackingNotches::kCapacity; + c.notchMinWidthHz = 200.0; + c.notchMaxWidthHz = 12000.0; + c.notchHasDepth = false; + } c.hasModeIndependentSquelch = false; // RtlSquelchGate is the receiver's own squelch, FM/FMN only. Its // detector now uses its own 2048-bin FFT; the display has a different @@ -239,8 +312,14 @@ RadioCapabilities RtlSdrBackend::capabilities() const QStringLiteral("FMN"), QStringLiteral("WFM"), QStringLiteral("USB"), QStringLiteral("LSB"), QStringLiteral("CW"), QStringLiteral("CWR")}}; c.receiveFilterControl = ReceiveFilterControl{SliceFrequencyControl::Authority::Engine, - {{QStringLiteral("FM"), -21600, -1, 1, 21600, 2, 43200}, - {QStringLiteral("FMN"), -21600, -1, 1, 21600, 2, 43200}}}; + {{QStringLiteral("AM"), -12000, -100, 100, 12000, 200, 24000}, + {QStringLiteral("SAM"), -12000, -100, 100, 12000, 200, 24000}, + {QStringLiteral("FM"), -21600, -1, 1, 21600, 2, 43200}, + {QStringLiteral("FMN"), -21600, -1, 1, 21600, 2, 43200}, + {QStringLiteral("USB"), 0, 11998, 2, 12000, 2, 12000}, + {QStringLiteral("LSB"), -12000, -2, -11998, 0, 2, 12000}, + {QStringLiteral("CW"), -12000, -1, 1, 12000, 2, 24000}, + {QStringLiteral("CWR"), -12000, -1, 1, 12000, 2, 24000}}}; c.broadcastFmReceive = std::nullopt; if (RtlReceivePipeline::kQualifiedWfmEnabled) { c.receiveFilterControl->modes.append({QStringLiteral("WFM"), @@ -250,7 +329,7 @@ RadioCapabilities RtlSdrBackend::capabilities() const c.receiveAudioControl = ReceiveAudioControl{SliceFrequencyControl::Authority::Engine}; c.receiveAudioExport = ReceiveAudioExport{{24000, 48000}, m_receiverCapacity}; c.panSpanModel = PanSpanModel{false, false}; - if (m_lastPublished && hasReceivingNarrowFm(*m_lastPublished)) { + if (m_lastPublished && hasReceivingDcSensitive(*m_lastPublished)) { c.receiveCapturePlacement = ReceiveCapturePlacement{ qint64(RtlCaptureTransaction::kDcSeparationHz)}; } @@ -270,7 +349,7 @@ RadioCapabilities RtlSdrBackend::capabilities() const c.persistsMemories = false; c.hasSupplyVoltageTelemetry = false; c.hasMultiClientSessions = false; - c.hasAudioPeakingFilter = false; + c.hasAudioPeakingFilter = true; // Client owns all state (RTL-SDR persists nothing) c.clientSettingsDomains = RadioCapabilities::ClientSettingsDomain::Tuning @@ -393,6 +472,7 @@ void RtlSdrBackend::connectRadio(const RadioConnectRequest& request) // All capture controls are requested together; only the worker touches // them, and only its confirmed result is exposed as connected state. m_requested = {}; + m_nextNotchId = 0; m_requested.hardware.centerHz = static_cast(m_panCenterHz); m_requested.hardware.sampleRateHz = m_sampleRateHz; m_requested.hardware.ppm = m_ppmCorrection; @@ -425,8 +505,8 @@ void RtlSdrBackend::connectRadio(const RadioConnectRequest& request) 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; + const double guard = RtlReceiveRecipe::native(captureMode(m_sliceMode), + RtlReceivePipeline::kQualifiedWfmEnabled) ? 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 @@ -434,11 +514,38 @@ RtlCaptureTransaction::Receiver RtlSdrBackend::initialReceiver() const // 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(); + if (receiver.audioMute) { + // Reserve the first restorable saved ID from the start. A provisional + // ID 0 otherwise consumes singleton capacity while a sparse saved ID + // waits for a reservation; the muted bootstrap is native too. + const auto& slices = m_savedSettings.document.slices; + for (const auto& slice : slices) { + if (restorable(savedReceiver(slice, slices.first().cwPitchHz))) { + receiver.passband.stableId = slice.id; + break; + } + } + } + if (RtlReceiveRecipe::analogDsp(receiver.mode) && !RtlReceiveRecipe::valid(receiver)) { + // Legacy stored cuts were never constrained by the native DSP. + // A muted bootstrap lets the existing migration preserve that record + // and report its refusal, without treating it as accepted DSP state. + receiver.mode = RtlCaptureTransaction::Mode::Usb; + receiver.passband.filterLowHz = 100; receiver.passband.filterHighHz = 2900; + receiver.passband.guardLowHz = 3000; receiver.passband.guardHighHz = 3000; + receiver.audioMute = true; + } return receiver; } void RtlSdrBackend::startCapture(std::unique_ptr worker) { + // The global bank is valid independently of receiver admission. Carry it + // through the muted bootstrap so a refused saved receiver cannot erase + // otherwise valid tracking notches during the next settings flush. + if (m_savedSettings.status == RtlSliceSettings::ReadStatus::Ready) { + m_requested.trackingNotches = m_savedSettings.document.trackingNotches; + } m_worker = std::move(worker); m_worker->setReceiveMetersEnabled(m_receiveMetersEnabled); m_startupTrace = qgetenv("AETHER_AUTOMATION") == "1" && qgetenv("AETHER_RTL_STARTUP_TRACE") == "1" @@ -455,6 +562,7 @@ void RtlSdrBackend::startCapture(std::unique_ptr worker) m_activeSliceId = -1; m_published = {}; m_lastPublished.reset(); + m_acceptedModeFilters.clear(); m_viewport.reset(); m_pendingViewport = {}; m_pendingDrag = false; @@ -711,7 +819,7 @@ IRadioBackend::HealthSnapshot RtlSdrBackend::healthSnapshot() const snapshot.labels.insert(keys[1], tr("Captured IQ sample rate (Hz)")); snapshot.labels.insert(keys[2], tr("Usable capture low edge (Hz)")); 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[4], tr("FM/CW 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")); @@ -720,7 +828,7 @@ IRadioBackend::HealthSnapshot RtlSdrBackend::healthSnapshot() const snapshot.values.insert(keys[1], capture.achievedSampleRateHz); snapshot.values.insert(keys[2], std::max(0.0, capture.centerHz - capture.usableLeftHz)); snapshot.values.insert(keys[3], capture.centerHz + capture.usableRightHz); - snapshot.values.insert(keys[4], hasReceivingNarrowFm(*m_lastPublished) + snapshot.values.insert(keys[4], hasReceivingDcSensitive(*m_lastPublished) ? QVariant(RtlCaptureTransaction::dcClear(*m_lastPublished)) : QVariant(tr("No receiving FM or FM-N slice"))); snapshot.values.insert(keys[5], m_captureStatus); @@ -743,13 +851,14 @@ bool RtlSdrBackend::createSlice(const QString& panId, double frequencyHz) || (panId != QStringLiteral("0xe1000000") && !panId.isEmpty()) || m_requested.receivers.size() >= static_cast(m_receiverCapacity)) { return false; } if (!std::ranges::all_of(m_requested.receivers, supportsAnalogBank)) { - emit configurationWarning(tr("Additional RTL slices support FM, FM-N and analog WFM only. AM/SSB/CW require a single configured receiver.")); + emit configurationWarning(tr("Additional RTL slices require supported native analog receive modes.")); return false; } auto desired = m_requested; for (int id = 0; id < 8; ++id) { if (std::ranges::any_of(desired.receivers, [id](const auto& value) { return value.passband.stableId == id; })) { continue; } desired.receivers.push_back({{id, frequencyHz, -8000, 8000, 0, 3000, 3000}, RtlCaptureTransaction::Mode::Fm}); + desired.receivers.back().cwPitchHz = desired.receivers.front().cwPitchHz; // Creation must fit the accepted capture; adding a receiver cannot // recenter the tuner or displace an existing sibling. const auto& actual = m_capture.confirmed()->capture; @@ -827,6 +936,7 @@ bool RtlSdrBackend::requestReceiveTune(int sliceId, double hz, ReceiveTuneView v const bool centerView = view == ReceiveTuneView::Center || (view == ReceiveTuneView::Reveal && outsideView); receiver->passband.carrierHz = hz; + desired.avoidDc = RtlReceiveRecipe::cw(receiver->mode) || RtlReceiveRecipe::amplitude(receiver->mode); desired.automaticDirectSampling = true; desired.followReceiverId = sliceId; if (centerView) { @@ -851,13 +961,12 @@ bool RtlSdrBackend::recenterReceiveCapture(const QString& panId) if (!m_connected || !m_lastPublished || (!panId.isEmpty() && panId != QLatin1String("0xe1000000"))) { return false; } const auto activeFm = std::ranges::find_if(m_lastPublished->receivers, [this](const auto& receiver) { - return (receiver.mode == RtlCaptureTransaction::Mode::Fm - || receiver.mode == RtlCaptureTransaction::Mode::Fmn) + return RtlReceiveRecipe::dcSensitive(receiver.mode) && std::ranges::find(m_lastPublished->receivingIds, receiver.passband.stableId) != m_lastPublished->receivingIds.end(); }); if (m_capture.busy() || activeFm == m_lastPublished->receivers.end()) { - emit configurationWarning(tr("Capture DC placement requires an idle FM or FM-N receiver.")); + emit configurationWarning(tr("Capture DC placement requires an idle AM, SAM, FM, FM-N, CW or CWR receiver.")); return false; } auto desired = m_requested; @@ -878,31 +987,51 @@ void RtlSdrBackend::setSliceMode(int sliceId, const QString& mode) auto candidate = *receiver; candidate.mode = modeValue; if (desired.receivers.size() != 1 && !supportsAnalogBank(candidate)) { - emit configurationWarning(tr("This RTL mode requires a single configured receiver. Multiple receivers support FM, FM-N and analog WFM.")); + emit configurationWarning(tr("This RTL mode requires a single configured receiver. Multiple receivers support AM, SAM, FM, FM-N, analog WFM, USB, LSB, CW and CWR.")); return; } - if (narrowFm && (receiver->passband.filterLowHz < -21600 || receiver->passband.filterHighHz > 21600 - || receiver->passband.filterLowHz >= 0 || receiver->passband.filterHighHz <= 0)) { - // The existing wide/sideband passband cannot describe the FM graph. - // Mode transition selects the new receiver's documented 16 kHz FM - // 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; + if (receiver->mode != modeValue) { + RtlSliceSettings::Filter filter; + if (RtlReceiveRecipe::analogDsp(modeValue)) { + const auto [low, high] = RtlReceiveRecipe::defaultFilter(modeValue); + filter = {double(low), double(high)}; + } else if (narrowFm) { + filter = {-8000, 8000}; + } else if (modeValue == RtlCaptureTransaction::Mode::Wfm) { + filter = {-100000, 100000}; + } else { + filter = {-3000, 3000}; + } + const auto history = m_acceptedModeFilters.value(sliceId); + const auto saved = history.constFind(canonical); + if (saved != history.cend()) { + auto recalled = candidate; + recalled.passband.filterLowHz = saved->lowHz; + recalled.passband.filterHighHz = saved->highHz; + recalled.passband.translationHz = RtlReceiveRecipe::analogDsp(modeValue) && recalled.ritEnabled + ? recalled.ritOffsetHz : 0; + const bool fmValid = !narrowFm || (saved->lowHz >= -21600 && saved->lowHz < 0 + && saved->highHz > 0 && saved->highHz <= 21600); + const bool wfmValid = modeValue != RtlCaptureTransaction::Mode::Wfm + || (saved->lowHz >= -100000 && saved->lowHz <= -15000 + && saved->highHz >= 15000 && saved->highHz <= 100000); + if (fmValid && wfmValid && RtlReceiveRecipe::valid(recalled)) { + filter = *saved; + } else { + emit configurationWarning(tr("The remembered %1 filter is outside the supported range; using the mode default.").arg(canonical)); + } + } + receiver->passband.filterLowHz = filter.lowHz; + receiver->passband.filterHighHz = filter.highHz; } receiver->mode = modeValue; + receiver->passband.translationHz = RtlReceiveRecipe::analogDsp(modeValue) && receiver->ritEnabled + ? receiver->ritOffsetHz : 0; desired.followReceiverId = sliceId; - desired.avoidDc = narrowFm; + desired.avoidDc = RtlReceiveRecipe::dcSensitive(modeValue); if (!narrowFm) { receiver->squelchEnabled = false; receiver->automaticSquelch = false; } - receiver->passband.guardLowHz = (narrowFm || nativeWfm) ? 3000 : 0; - receiver->passband.guardHighHz = (narrowFm || nativeWfm) ? 3000 : 0; + receiver->passband.guardLowHz = supportsAnalogBank(*receiver) ? 3000 : 0; + receiver->passband.guardHighHz = supportsAnalogBank(*receiver) ? 3000 : 0; requestCapture(desired); } @@ -922,6 +1051,7 @@ void RtlSdrBackend::setSliceFilter(int sliceId, int lowHz, int highHz) && (lowHz < -21600 || highHz > 21600 || lowHz >= 0 || highHz <= 0)) { return; } receiver->passband.filterLowHz = lowHz; receiver->passband.filterHighHz = highHz; + if (!RtlReceiveRecipe::valid(*receiver)) { return; } requestCapture(desired); } @@ -952,11 +1082,148 @@ void RtlSdrBackend::setSliceWfmDeemphasis(int sliceId, int microseconds) void RtlSdrBackend::setSliceAgc(int sliceId, const QString& mode, int thresholdDb) { - Q_UNUSED(sliceId); - Q_UNUSED(mode); - Q_UNUSED(thresholdDb); - // Phase 1: AGC is engine-side DSP, not hardware. - // The DDC will apply AGC in Phase 2. + if (!hasAcceptedSlice(sliceId) || thresholdDb < 0 || thresholdDb > 100) { return; } + const int agc = agcModeValue(mode.trimmed().toLower()); + if (agc < 0) { 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() || !RtlReceiveRecipe::analogDsp(receiver->mode)) { return; } + receiver->agcMode = agc; + receiver->agcThreshold = thresholdDb; + requestCapture(desired); +} + +void RtlSdrBackend::requestSliceAgc(int sliceId, const SliceAgcRequest& request) +{ + if (!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() || !RtlReceiveRecipe::analogDsp(receiver->mode)) { return; } + switch (request.field) { + case SliceAgcRequest::Field::Mode: { + const int mode = agcModeValue(request.mode.trimmed().toLower()); + if (mode < 0) { return; } + receiver->agcMode = mode; + break; + } + case SliceAgcRequest::Field::Threshold: + if (request.threshold < 0 || request.threshold > 100) { return; } + receiver->agcThreshold = request.threshold; + break; + case SliceAgcRequest::Field::OffLevel: + if (request.offLevel < 0 || request.offLevel > 100) { return; } + receiver->agcOffLevel = request.offLevel; + break; + default: return; + } + requestCapture(desired); +} + +ReceiveDispatch RtlSdrBackend::requestSliceDsp(int sliceId, const SliceDspRequest& request) +{ + if (!hasAcceptedSlice(sliceId) || !request.valid()) { return ReceiveDispatch::Unsupported; } + 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() || !RtlReceiveRecipe::analogDsp(receiver->mode)) { + return ReceiveDispatch::Unsupported; + } + if (request.feature == SliceDspRequest::Feature::Nb) { + if (request.field == SliceDspRequest::Field::Enabled) { receiver->nbKind = request.requestedBlanker(); } + if (request.field == SliceDspRequest::Field::Level) { receiver->nbLevel = request.level; } + if (request.field == SliceDspRequest::Field::Fill) { receiver->nbFill = request.fill; } + } else if (request.feature == SliceDspRequest::Feature::Apf) { + if (!RtlReceiveRecipe::cw(receiver->mode)) { return ReceiveDispatch::Unsupported; } + if (request.field == SliceDspRequest::Field::Enabled) { receiver->apfEnabled = request.enabled; } + if (request.field == SliceDspRequest::Field::Level) { receiver->apfLevel = request.level; } + } else if (request.feature == SliceDspRequest::Feature::Nr) { + if (request.field == SliceDspRequest::Field::Enabled) { receiver->nrEnabled = request.enabled; } + if (request.field == SliceDspRequest::Field::Level) { receiver->nrLevel = request.level; } + } else if (request.feature == SliceDspRequest::Feature::Anf) { + if (request.field == SliceDspRequest::Field::Enabled) { receiver->anfEnabled = request.enabled; } + if (request.field == SliceDspRequest::Field::Level) { receiver->anfLevel = request.level; } + } else if (request.feature == SliceDspRequest::Feature::Mn) { + if (request.field == SliceDspRequest::Field::Enabled) { receiver->mnEnabled = request.enabled; } + if (request.field == SliceDspRequest::Field::Level) { receiver->mnLevel = request.level; } + } else { return ReceiveDispatch::Unsupported; } + return requestCapture(desired) ? ReceiveDispatch::Dispatched : ReceiveDispatch::Unsupported; +} + +void RtlSdrBackend::createNotch(double centerHz, double widthHz) +{ + if (!m_connected || !hasAnalogDsp(m_requested.receivers) + || !std::isfinite(centerHz) || !std::isfinite(widthHz) || widthHz <= 0 + || m_nextNotchId == std::numeric_limits::max()) { return; } + auto desired = m_requested; + desired.trackingNotches.entries.push_back({m_nextNotchId, centerHz, std::max(200.0, widthHz), true}); + if (!desired.trackingNotches.valid()) { return; } + if (requestCapture(desired)) { ++m_nextNotchId; } +} +void RtlSdrBackend::setNotch(int notchId, const NotchDelta& delta) +{ + if (!m_connected || !hasAnalogDsp(m_requested.receivers)) { return; } + auto desired = m_requested; + auto found = std::ranges::find_if(desired.trackingNotches.entries, + [notchId](const auto& notch) { return notch.id == notchId; }); + if (found == desired.trackingNotches.entries.end()) { return; } + if (delta.centerHz) { found->centerHz = *delta.centerHz; } + if (delta.widthHz) { + if (!std::isfinite(*delta.widthHz) || *delta.widthHz <= 0) { return; } + found->widthHz = std::max(200.0, *delta.widthHz); + } + if (delta.active) { found->active = *delta.active; } + if (desired.trackingNotches.valid()) { requestCapture(desired); } +} +void RtlSdrBackend::removeNotch(int notchId) +{ + if (!m_connected) { return; } + auto desired = m_requested; + if (!std::erase_if(desired.trackingNotches.entries, + [notchId](const auto& notch) { return notch.id == notchId; })) { return; } + requestCapture(desired); +} +void RtlSdrBackend::setNotchesEnabled(bool on) +{ + if (!m_connected) { return; } + auto desired = m_requested; + desired.trackingNotches.enabled = on; + requestCapture(desired); +} + +void RtlSdrBackend::setCwPitch(int hz) +{ + if (!m_connected || hz < 100 || hz > 6000 || m_requested.receivers.empty()) { return; } + auto desired = m_requested; + // The current pitch control is session-wide. One accepted bank applies + // it atomically to every CW receiver, including parked configurations. + for (auto& receiver : desired.receivers) { receiver.cwPitchHz = hz; } + requestCapture(desired); +} + +void RtlSdrBackend::setSliceRitEnabled(int sliceId, bool on) +{ + if (!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() || !RtlReceiveRecipe::analogDsp(receiver->mode)) { return; } + receiver->ritEnabled = on; + receiver->passband.translationHz = on ? receiver->ritOffsetHz : 0; + requestCapture(desired); +} + +void RtlSdrBackend::setSliceRitOffset(int sliceId, int hz) +{ + if (!hasAcceptedSlice(sliceId) || hz < -99999 || hz > 99999) { 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() || !RtlReceiveRecipe::analogDsp(receiver->mode)) { return; } + receiver->ritOffsetHz = hz; + receiver->passband.translationHz = receiver->ritEnabled ? hz : 0; + requestCapture(desired); } void RtlSdrBackend::setSliceSquelch(int sliceId, bool enabled, int level) @@ -1286,6 +1553,7 @@ void RtlSdrBackend::invokeExtension(const QString& ns, const QString& verb, void RtlSdrBackend::configureSettingsScope(const RadioSettingsScope& scope, const RadioSerialIdentity& identity) { m_settingsScope = scope; m_settingsIdentity = identity; + m_acceptedModeFilters.clear(); m_lastPublished.reset(); m_settingsActive = false; m_restoreAttempted = false; m_restoreToken = {}; m_removedSettings.clear(); m_omittedSettings.clear(); @@ -1309,10 +1577,21 @@ QVector RtlSdrBackend::acceptedSettings() const RtlSliceSettings::Slice slice; slice.id = id; slice.frequencyHz = receiver.passband.carrierHz; slice.mode = modeName(receiver.mode); slice.filterLowHz = receiver.passband.filterLowHz; slice.filterHighHz = receiver.passband.filterHighHz; - if (m_savedSettings.document.slices.contains(id)) { - const auto& saved = m_savedSettings.document.slices[id]; - slice.agcMode = saved.agcMode; slice.agcThreshold = saved.agcThreshold; - } + slice.modeFilters = m_acceptedModeFilters.value(id); + slice.agcMode = agcModeName(receiver.agcMode); + slice.agcThreshold = receiver.agcThreshold; + slice.agcOffLevel = receiver.agcOffLevel; + slice.cwPitchHz = receiver.cwPitchHz; + slice.ritEnabled = receiver.ritEnabled; + slice.ritOffsetHz = receiver.ritOffsetHz; + slice.nbKind = receiver.nbKind; + slice.nbLevel = receiver.nbLevel; + slice.nbFill = receiver.nbFill; + slice.apfEnabled = receiver.apfEnabled; + slice.apfLevel = receiver.apfLevel; + slice.nrEnabled = receiver.nrEnabled; slice.nrLevel = receiver.nrLevel; + slice.anfEnabled = receiver.anfEnabled; slice.anfLevel = receiver.anfLevel; + slice.mnEnabled = receiver.mnEnabled; slice.mnLevel = receiver.mnLevel; 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.automaticSquelch = receiver.automaticSquelch; @@ -1336,7 +1615,8 @@ std::optional RtlSdrBackend::storeOperatingState(const RadioSettingsScope& const bool gain = RadioStateMemory::storeRtlRfGainPreservingLegacy(scope, state); if (m_restoreToken.revision != 0) { return gain; } const bool stored = RtlSliceSettings(scope).patch(m_lastPublished->capture.centerHz, - m_lastPublished->capture.achievedSampleRateHz, acceptedSettings(), m_removedSettings); + m_lastPublished->capture.achievedSampleRateHz, acceptedSettings(), m_removedSettings, + m_lastPublished->trackingNotches); if (stored) { m_removedSettings.clear(); } return gain && stored; } @@ -1363,14 +1643,23 @@ void RtlSdrBackend::restoreAcceptedSlices() QSet seen; auto desired = m_requested; desired.receivers.clear(); + desired.trackingNotches = saved.trackingNotches; + for (const auto& notch : desired.trackingNotches.entries) { + m_nextNotchId = std::max(m_nextNotchId, notch.id == std::numeric_limits::max() + ? notch.id : notch.id + 1); + } bool unsupportedWfmFilter = false; + bool unsupportedAnalogFilter = false; + const int restoredPitch = saved.slices.first().cwPitchHz; 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 bool analog = RtlReceiveRecipe::analogDsp(captureMode(slice.mode)); + const double guard = (!wide || nativeWfm || analog) ? 3000.0 : 0.0; const SharedCapturePolicy::SliceDescriptor descriptor{slice.id, slice.frequencyHz, - slice.filterLowHz, slice.filterHighHz, 0, guard, guard}; + slice.filterLowHz, slice.filterHighHz, + analog && slice.ritEnabled ? double(slice.ritOffsetHz) : 0, guard, guard}; m_omittedSettings.insert(slice.id); if (slice.id < 0 || slice.id >= 8 || seen.contains(slice.id) || !SharedCapturePolicy::occupiedInterval(descriptor).interval @@ -1382,19 +1671,22 @@ void RtlSdrBackend::restoreAcceptedSlices() unsupportedWfmFilter = true; continue; } - const bool bankCompatible = !wide || nativeWfm; + const bool bankCompatible = !wide || nativeWfm || analog; if ((!wide && (slice.filterLowHz < -21600 || slice.filterHighHz > 21600 || slice.filterLowHz >= 0 || slice.filterHighHz <= 0)) || (!desired.receivers.empty() && (!bankCompatible || !std::ranges::all_of(desired.receivers, supportsAnalogBank)))) { continue; } - desired.receivers.push_back({descriptor, captureMode(slice.mode), slice.audioGain, slice.audioPan, - slice.audioMute, !wide && slice.squelchEnabled, slice.squelchLevel, slice.wfmDeemphasisUs, slice.wfmForceMono, - !wide && slice.automaticSquelch, slice.automaticSquelchMarginDb}); + const auto receiver = savedReceiver(slice, restoredPitch); + if (RtlReceiveRecipe::valid(receiver)) { desired.receivers.push_back(receiver); } + else if (analog) { unsupportedAnalogFilter = true; } } // 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. + const QString analogWarning = tr("Saved AM/SAM/SSB/CW receiver settings are outside the supported DSP range. " + "The saved receiver is preserved and has not been activated."); if (desired.receivers.empty()) { + if (unsupportedAnalogFilter) { emit configurationWarning(analogWarning); } 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.")); @@ -1402,6 +1694,7 @@ void RtlSdrBackend::restoreAcceptedSlices() return; } if (requestCapture(desired)) { m_restoreToken = m_capture.requested(); } + if (unsupportedAnalogFilter) { emit configurationWarning(analogWarning); } } void RtlSdrBackend::applyRestoredState(const RestoredRadioState& state) @@ -1463,11 +1756,9 @@ void RtlSdrBackend::applyRestoredState(const RestoredRadioState& state) m_sampleRateHz = static_cast(saved.sampleRateHz); } m_sliceFreqHz = m_panCenterHz; - // 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. + // Start with a muted narrow recipe while the saved bank prepares. + // initialReceiver() shares its reservation with the first saved ID; + // the normal restore transaction adopts the original RF and controls. const bool nativeRestore = RtlReceivePipeline::kQualifiedWfmEnabled && !saved.slices.isEmpty(); const double initialHalfWidth = RtlReceivePipeline::kQualifiedWfmEnabled ? 103000 : 100000; @@ -1478,6 +1769,13 @@ void RtlSdrBackend::applyRestoredState(const RestoredRadioState& state) : QStringLiteral("WFM"); m_sliceFilterLow = narrowInitial ? -8000 : -100000; m_sliceFilterHigh = narrowInitial ? 8000 : 100000; + if (nativeRestore) { + // A muted USB bootstrap has no carrier at converter DC and does + // not invoke AM/FM/CW startup placement. Preserve the saved capture + // center until the original receiver bank is restored. + m_sliceMode = QStringLiteral("USB"); + m_sliceFilterLow = 100; m_sliceFilterHigh = 2900; + } } } @@ -1639,6 +1937,7 @@ void RtlSdrBackend::serviceCapture() const auto& state = *m_capture.confirmed(); m_requested.hardware = state.hardware; m_requested.receivers = state.receivers; + m_requested.trackingNotches = state.trackingNotches; m_requested.automaticDirectSampling = state.automaticDirectSampling; m_requested.dcSuppression = state.dcSuppression; m_requested.followReceiverId.reset(); @@ -1692,10 +1991,7 @@ void RtlSdrBackend::publishLegacyPcm(const QByteArray& pcm, const QByteArray& pr || m_lastPublished->receivingIds.size() != 1 || 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 - || (RtlReceivePipeline::kQualifiedWfmEnabled - && m_lastPublished->receivers.front().mode == RtlCaptureTransaction::Mode::Wfm)) { return; } + || supportsAnalogBank(m_lastPublished->receivers.front())) { return; } const int id = m_lastPublished->receivers.front().passband.stableId; const auto token = m_published; for (int slot : {-1, id}) { @@ -1873,6 +2169,20 @@ void RtlSdrBackend::emitSliceState(const RtlCaptureTransaction::Receiver& receiv delta.wfmReceptionDiagnostics = m_wfmReception[id]; delta.wfmStereoStatus = m_wfmStatus[id]; delta.modeList = capabilities().receiveModeControl->modes; + delta.rxAntenna = QStringLiteral("RX"); + delta.rxAntennaList = QStringList{QStringLiteral("RX")}; + delta.txAntenna = QString(); + delta.txAntennaList = QStringList{}; + delta.agcMode = agcModeName(receiver.agcMode); + delta.agcThreshold = receiver.agcThreshold; + delta.agcOffLevel = receiver.agcOffLevel; + delta.ritOn = receiver.ritEnabled; + delta.ritFreq = receiver.ritOffsetHz; + delta.nbKind = receiver.nbKind; delta.nbLevel = receiver.nbLevel; delta.nbFill = receiver.nbFill; + delta.apf = receiver.apfEnabled; delta.apfLevel = receiver.apfLevel; + delta.nr = receiver.nrEnabled; delta.nrLevel = receiver.nrLevel; + delta.anf = receiver.anfEnabled; delta.anfLevel = receiver.anfLevel; + delta.mn = receiver.mnEnabled; delta.mnLevel = receiver.mnLevel; const QPointer traceWorker(m_worker.get()); markStartup(RtlStartupTrace::SliceBegin, id); emit sliceChanged(id, delta); @@ -1906,15 +2216,41 @@ void RtlSdrBackend::publishCapture() 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 || current->wfmDeemphasisUs != previous.wfmDeemphasisUs - || current->wfmForceMono != previous.wfmForceMono) { m_sliceAudio[id] = {}; } + || current->wfmForceMono != previous.wfmForceMono + || current->agcMode != previous.agcMode || current->agcThreshold != previous.agcThreshold + || current->agcOffLevel != previous.agcOffLevel + || (RtlReceiveRecipe::cw(current->mode) && current->cwPitchHz != previous.cwPitchHz) + || current->nbKind != previous.nbKind || current->nbLevel != previous.nbLevel + || current->nbFill != previous.nbFill || current->apfEnabled != previous.apfEnabled + || current->apfLevel != previous.apfLevel + || current->nrEnabled != previous.nrEnabled || current->nrLevel != previous.nrLevel + || current->anfEnabled != previous.anfEnabled || current->anfLevel != previous.anfLevel + || current->mnEnabled != previous.mnEnabled || current->mnLevel != previous.mnLevel) { + m_sliceAudio[id] = {}; + if (std::ranges::find(state.receivingIds, id) != state.receivingIds.end() + && (RtlReceiveRecipe::analogDsp(previous.mode) + || (current != state.receivers.end() && RtlReceiveRecipe::analogDsp(current->mode)))) { + m_speakerAudio = {}; + } + } + } + if (m_lastPublished->trackingNotches != state.trackingNotches) { + for (const auto& receiver : state.receivers) { + if (!RtlReceiveRecipe::analogDsp(receiver.mode)) { continue; } + WdspChannel::Config before, after; + RtlReceiveRecipe::applyDsp(receiver, before, m_lastPublished->trackingNotches); + RtlReceiveRecipe::applyDsp(receiver, after, state.trackingNotches); + if (before.analogReceiveDsp != after.analogReceiveDsp) { + m_sliceAudio[receiver.passband.stableId] = {}; + if (std::ranges::find(state.receivingIds, receiver.passband.stableId) + != state.receivingIds.end()) { m_speakerAudio = {}; } + } + } } 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 - && !(RtlReceivePipeline::kQualifiedWfmEnabled - && value.mode == RtlCaptureTransaction::Mode::Wfm); + && !supportsAnalogBank(value); })) { retireNativeAudio(); } else { retirePcmStreams(); } } @@ -1926,9 +2262,11 @@ void RtlSdrBackend::publishCapture() } const bool restoring = m_restoreToken == state.token; if (restoring) { + m_acceptedModeFilters.clear(); for (const auto& receiver : state.receivers) { const int id = receiver.passband.stableId; const auto& saved = m_savedSettings.document.slices[id]; + m_acceptedModeFilters[id] = saved.modeFilters; m_monitors[id] = {saved.audioGain, saved.audioPan, saved.audioMute}; updateMonitor(id); m_omittedSettings.remove(id); } @@ -1945,6 +2283,7 @@ void RtlSdrBackend::publishCapture() if (std::ranges::none_of(state.receivers, [id](const auto& value) { return value.passband.stableId == id; })) { if (!m_removedSettings.contains(id)) { m_removedSettings.append(id); } m_savedSettings.document.slices.remove(id); + m_acceptedModeFilters.remove(id); m_omittedSettings.remove(id); } } @@ -1977,6 +2316,10 @@ void RtlSdrBackend::publishCapture() } } m_lastPublished = state; + for (const auto& receiver : state.receivers) { + m_acceptedModeFilters[receiver.passband.stableId].insert(modeName(receiver.mode), + {receiver.passband.filterLowHz, receiver.passband.filterHighHz}); + } publishRfMeterDefinitions(); if (!m_capture.confirmed() || m_capture.confirmed()->token != state.token) { return; } for (const auto& receiver : state.receivers) { @@ -2001,6 +2344,7 @@ void RtlSdrBackend::publishCapture() m_sliceFilterHigh = int(state.receivers[0].passband.filterHighHz); m_requested.hardware = state.hardware; m_requested.receivers = state.receivers; + m_requested.trackingNotches = state.trackingNotches; m_requested.automaticDirectSampling = state.automaticDirectSampling; m_requested.dcSuppression = state.dcSuppression; m_requested.avoidDc = false; @@ -2054,17 +2398,41 @@ void RtlSdrBackend::publishCapture() } } if (current()) { + if (prior) { + for (const auto& notch : prior->trackingNotches.entries) { + if (std::ranges::none_of(state.trackingNotches.entries, + [&](const auto& value) { return value.id == notch.id; })) { + emit notchRemoved(notch.id); + if (!current()) { return; } + } + } + } + for (const auto& notch : state.trackingNotches.entries) { + NotchDelta delta; + delta.centerHz = notch.centerHz; delta.widthHz = notch.widthHz; delta.active = notch.active; + emit notchChanged(notch.id, delta); + if (!current()) { return; } + } + RadioDelta notchState; + notchState.tnfEnabled = state.trackingNotches.enabled; + emit radioChanged(notchState); + if (!current()) { return; } + TransmitDelta receivePitch; + receivePitch.cwPitch = state.receivers.front().cwPitchHz; + emit transmitChanged(receivePitch); + if (!current()) { return; } const bool activated = activateSettings(); if (activated || (prior && (prior->capture != state.capture - || hasReceivingNarrowFm(*prior) != hasReceivingNarrowFm(state)))) { + || hasReceivingDcSensitive(*prior) != hasReceivingDcSensitive(state) + || hasAnalogDsp(prior->receivers) != hasAnalogDsp(state.receivers)))) { emit capabilitiesChanged(); } if (!current()) { return; } restoreAcceptedSlices(); if (current()) { emit operatingStateChanged(); } - if (current() && hasReceivingNarrowFm(state) && !RtlCaptureTransaction::dcClear(state) + if (current() && hasReceivingDcSensitive(state) && !RtlCaptureTransaction::dcClear(state) && (!prior || RtlCaptureTransaction::dcClear(*prior))) { - emit configurationWarning(tr("An FM receiver overlaps converter DC. Its frequency is preserved. " + emit configurationWarning(tr("An FM or CW receiver overlaps converter DC. Its frequency is preserved. " "Use Move capture away from DC in the spectrum menu; Radio Health shows the accepted capture.")); } } diff --git a/src/core/backends/rtl/RtlSdrBackend.h b/src/core/backends/rtl/RtlSdrBackend.h index 4eebdbf7a1..be9894f212 100644 --- a/src/core/backends/rtl/RtlSdrBackend.h +++ b/src/core/backends/rtl/RtlSdrBackend.h @@ -28,8 +28,8 @@ class RtlSdrDdc; // IRadioBackend implementation for RTL-SDR USB dongles. // // Receive-only (Principle VI): capabilities().canTransmit = false, -// hostModulates = false. One capture/panadapter, prepared FM receiver bank; -// the other existing modes retain the exclusive legacy DDC. +// hostModulates = false. One capture/panadapter, prepared analog receiver bank; +// AM/SAM use independent WDSP envelope/synchronous receive channels. // // The worker owns the librtlsdr device handle and runs // rtlsdr_read_async → IQ conversion → DDC. Backend relays are delivered to @@ -70,6 +70,15 @@ class RtlSdrBackend : public IRadioBackend { void setSliceMode(int sliceId, const QString& mode) override; void setSliceFilter(int sliceId, int lowHz, int highHz) override; void setSliceAgc(int sliceId, const QString& mode, int thresholdDb) override; + void requestSliceAgc(int sliceId, const SliceAgcRequest& request) override; + ReceiveDispatch requestSliceDsp(int sliceId, const SliceDspRequest& request) override; + void setCwPitch(int hz) override; + void createNotch(double centerHz, double widthHz) override; + void setNotch(int notchId, const NotchDelta& delta) override; + void removeNotch(int notchId) override; + void setNotchesEnabled(bool on) override; + void setSliceRitEnabled(int sliceId, bool on) override; + void setSliceRitOffset(int sliceId, int hz) override; void setPanCenter(const QString& panId, double hz, PanCenterIntent intent) override; void setPanBandwidth(const QString& panId, double hz) override; @@ -207,9 +216,11 @@ class RtlSdrBackend : public IRadioBackend { int m_receiverCapacity = kQualifiedReceiverCapacity; RtlCaptureTransaction m_capture{{8, kQualifiedReceiverCapacity}}; std::optional m_lastPublished; + QMap> m_acceptedModeFilters; // Focus belongs to a configured stable identity, including parked receivers. // It does not change the capture or demodulator bank. int m_activeSliceId = -1; + int m_nextNotchId = 0; struct Monitor { int gain = 100; int pan = 50; bool mute = false; }; std::array m_monitors; struct NativeAudio { diff --git a/src/core/backends/rtl/RtlSdrWorker.cpp b/src/core/backends/rtl/RtlSdrWorker.cpp index 79c13901ff..9bd2149819 100644 --- a/src/core/backends/rtl/RtlSdrWorker.cpp +++ b/src/core/backends/rtl/RtlSdrWorker.cpp @@ -19,6 +19,7 @@ bool onlyMonitorDiffers(const T::State& before, const T::State& after) || before.capture != after.capture || before.dcSuppression != after.dcSuppression || before.automaticDirectSampling != after.automaticDirectSampling || before.receivingIds != after.receivingIds + || before.trackingNotches != after.trackingNotches || before.receivers.size() != after.receivers.size()) { return false; } for (std::size_t i = 0; i < before.receivers.size(); ++i) { T::Receiver previous = before.receivers[i]; diff --git a/src/core/backends/rtl/RtlTrackingNotches.h b/src/core/backends/rtl/RtlTrackingNotches.h new file mode 100644 index 0000000000..0199c1fce5 --- /dev/null +++ b/src/core/backends/rtl/RtlTrackingNotches.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +namespace AetherSDR::rtl { +struct RtlTrackingNotch { + int id = -1; + double centerHz = 0; + double widthHz = 200; + bool active = true; + bool operator==(const RtlTrackingNotch&) const = default; +}; +struct RtlTrackingNotches { + static constexpr int kCapacity = 32; + bool enabled = true; + std::vector entries; + bool operator==(const RtlTrackingNotches&) const = default; + bool valid() const noexcept + { + if (entries.size() > kCapacity) { return false; } + for (std::size_t index = 0; index < entries.size(); ++index) { + const auto& notch = entries[index]; + if (notch.id < 0 || !std::isfinite(notch.centerHz) + || notch.centerHz < 24000 || notch.centerHz > 1766000000 + || !std::isfinite(notch.widthHz) || notch.widthHz < 200 || notch.widthHz > 12000) { return false; } + for (std::size_t earlier = 0; earlier < index; ++earlier) { + if (entries[earlier].id == notch.id) { return false; } + } + } + return true; + } +}; +} // namespace AetherSDR::rtl diff --git a/src/core/dsp/WdspChannel.cpp b/src/core/dsp/WdspChannel.cpp index 6ed5c9de0a..658d4bc035 100644 --- a/src/core/dsp/WdspChannel.cpp +++ b/src/core/dsp/WdspChannel.cpp @@ -552,6 +552,16 @@ WdspChannel::ProcessResult WdspChannel::processIq(std::span inputI, const_cast(channelI), const_cast(channelQ), outputLeft.data(), outputRight.data(), &wdspError); + if (m_config.amReceive && wdspError == 0) { + // AMD duplicates real audio into I/Q. The positive analytic audio + // filter leaves opposite Hilbert terms; their mean restores the real + // waveform and level, with equal mono channels for every receiver tap. + for (std::size_t sample = 0; sample < outputLeft.size(); ++sample) { + const float mono = 0.5f * (outputLeft[sample] + outputRight[sample]); + outputLeft[sample] = mono; + outputRight[sample] = mono; + } + } if (m_wbfm) { if (wdspError == 0) { for (std::size_t sample = 0; sample < outputLeft.size(); ++sample) { @@ -664,9 +674,10 @@ bool WdspChannel::reconfigure(const Config& config, std::string* error) noexcept bool WdspChannel::setMode(Mode mode) noexcept { - // An opt-in FM channel changes family through a complete reconfiguration; - // otherwise its panel normalization would leak into another demodulator. - if ((m_config.fmReceive && mode != Mode::Fm) + // An opt-in analog recipe changes family through full reconfiguration; + // otherwise its filtering or normalization would leak into another mode. + if ((m_config.amReceive && mode != Mode::Am && mode != Mode::Sam) + || (m_config.fmReceive && mode != Mode::Fm) || (m_config.wbfmReceive && mode != Mode::Wbfm)) { return false; } if (!beginControlOperation()) { return false; @@ -691,6 +702,8 @@ bool WdspChannel::setMode(Mode mode) noexcept bool WdspChannel::setFilter(double lowHz, double highHz) noexcept { if (!std::isfinite(lowHz) || !std::isfinite(highHz) || lowHz >= highHz || + (m_config.amReceive && (lowHz > -100 || highHz < 100 + || lowHz < -12000 || highHz > 12000)) || (m_config.wbfmReceive && (lowHz < -0.45 * m_config.inputSampleRate || highHz > 0.45 * m_config.inputSampleRate)) || !beginControlOperation()) { return false; @@ -705,7 +718,8 @@ bool WdspChannel::setFilter(double lowHz, double highHz) noexcept // bandpass and leaves the NBP stage — the filter actually in // circuit — untouched, so nothing selects a sideband. Both // reference clients use the composite call. - SetRXABandpassFreqs(m_channelId, lowHz, highHz); + SetRXABandpassFreqs(m_channelId, m_config.amReceive ? 50.0 : lowHz, + m_config.amReceive ? std::max(-lowHz, highHz) : highHz); RXANBPSetFreqs(m_channelId, lowHz, highHz); } else { SetTXABandpassFreqs(m_channelId, lowHz, highHz); @@ -1315,6 +1329,14 @@ bool WdspChannel::validateConfig(const Config& config, std::string* error) noexc setError(error, "WDSP filter edges are invalid"); return false; } + if (config.amReceive && (config.direction != Direction::Receive + || (config.mode != Mode::Am && config.mode != Mode::Sam) + || config.fmReceive || config.wbfmReceive + || config.filterLowHz > -100 || config.filterHighHz < 100 + || config.filterLowHz < -12000 || config.filterHighHz > 12000)) { + setError(error, "WDSP AM receive recipe requires a carrier-spanning RF passband"); + return false; + } if (config.fmReceive && (config.direction != Direction::Receive || config.mode != Mode::Fm || !std::isfinite(config.fmDeviationHz) || config.fmDeviationHz <= 0.0 || config.fmDeviationHz >= config.dspSampleRate / 2.0)) { @@ -1396,6 +1418,28 @@ bool WdspChannel::validateConfig(const Config& config, std::string* error) noexc setError(error, "WDSP AGC fixed gain must be finite"); return false; } + if (config.analogReceiveDsp) { + const auto& receive = *config.analogReceiveDsp; + if (receive.trackingNotches.size() > 32 + || std::ranges::any_of(receive.trackingNotches, [&](const ReceiveNotch& notch) { + return !std::isfinite(notch.centerHz) || std::abs(notch.centerHz) > config.dspSampleRate + || !std::isfinite(notch.widthHz) || notch.widthHz < 200 || notch.widthHz > 12000; + })) { + setError(error, "WDSP tracking notch bank is outside its valid range"); + return false; + } + if (config.direction != Direction::Receive + || receive.nrLevel < 0 || receive.nrLevel > 100 + || receive.anfLevel < 0 || receive.anfLevel > 100 + || !std::isfinite(receive.manualNotchCenterHz) + || std::abs(receive.manualNotchCenterHz) >= config.dspSampleRate / 2.0 + || !std::isfinite(receive.manualNotchWidthHz) + || receive.manualNotchWidthHz <= 0 + || receive.manualNotchWidthHz >= config.dspSampleRate / 2.0) { + setError(error, "WDSP analog receive controls are outside their valid range"); + return false; + } + } if (config.rxPanelGain && (config.direction != Direction::Receive || !std::isfinite(*config.rxPanelGain) || *config.rxPanelGain < 0.0)) { setError(error, "WDSP RX panel gain must be finite, non-negative and receive-only"); @@ -1562,9 +1606,18 @@ bool WdspChannel::open() noexcept m_wbfmStereo.store(0, std::memory_order_relaxed); m_wbfmReception.reset(); } else { - SetRXABandpassFreqs(m_channelId, m_config.filterLowHz, m_config.filterHighHz); + // AMD emits carrier/DC along with modulation. Its post-detector + // filter selects positive audio (gain two in RXAbp1Check); the NBP + // retains the independent signed RF cuts ahead of the detector. + SetRXABandpassFreqs(m_channelId, m_config.amReceive ? 50.0 : m_config.filterLowHz, + m_config.amReceive ? std::max(-m_config.filterLowHz, m_config.filterHighHz) + : m_config.filterHighHz); RXANBPSetFreqs(m_channelId, m_config.filterLowHz, m_config.filterHighHz); } + if (m_config.amReceive) { + SetRXAAMDSBMode(m_channelId, 0); + SetRXAAMDFadeLevel(m_channelId, 1); + } applyRxAgc(m_channelId, m_config.agcMode, m_config.maximumAgcGainDb, m_config.agcSlopeDb, m_config.agcFixedGainDb); // Filter length / phase mode. RXASetNC internally stops and restarts @@ -1589,6 +1642,40 @@ bool WdspChannel::open() noexcept SetRXASPCWBandwidth(m_channelId, m_config.apfBandwidthHz); SetRXASPCWGain(m_channelId, m_config.apfGain); SetRXASPCWRun(m_channelId, m_config.apfEnabled ? 1 : 0); + if (m_config.analogReceiveDsp) { + const auto& receive = *m_config.analogReceiveDsp; + // Bounded adaptation strength; the midpoint is RXA.c's default. + const auto gain = [](int level) { return 0.00001 * std::pow(100.0, level / 100.0); }; + SetRXAANRVals(m_channelId, 64, 16, gain(receive.nrLevel), 0.1); + SetRXAANFVals(m_channelId, 64, 16, gain(receive.anfLevel), 0.1); + SetRXAANRRun(m_channelId, receive.nrEnabled ? 1 : 0); + SetRXAANFRun(m_channelId, receive.anfEnabled ? 1 : 0); + RXANBPSetTuneFrequency(m_channelId, 0.0); + int notchIndex = 0; + for (const auto& notch : receive.trackingNotches) { + if (RXANBPAddNotch(m_channelId, notchIndex++, notch.centerHz, + notch.widthHz, notch.active ? 1 : 0) != 0) { + CloseChannel(m_channelId); return false; + } + } + if (receive.manualNotchEnabled) { + if (RXANBPAddNotch(m_channelId, notchIndex++, receive.manualNotchCenterHz, + receive.manualNotchWidthHz, 1) != 0) { + CloseChannel(m_channelId); + return false; + } + } + // A real AM audio tone occupies both RF sidebands. The mirror is + // part of the manual control, leaving all tracking slots available. + if (m_config.amReceive && receive.manualNotchEnabled + && RXANBPAddNotch(m_channelId, notchIndex, -receive.manualNotchCenterHz, + receive.manualNotchWidthHz, 1) != 0) { + CloseChannel(m_channelId); + return false; + } + RXANBPSetNotchesRun(m_channelId, + receive.manualNotchEnabled || !receive.trackingNotches.empty() ? 1 : 0); + } } else { SetTXAMode(m_channelId, wdspMode(m_config.mode)); SetTXABandpassFreqs(m_channelId, m_config.filterLowHz, m_config.filterHighHz); diff --git a/src/core/dsp/WdspChannel.h b/src/core/dsp/WdspChannel.h index 84787bbe26..1e94148e1f 100644 --- a/src/core/dsp/WdspChannel.h +++ b/src/core/dsp/WdspChannel.h @@ -80,6 +80,14 @@ class WdspChannel final bool operator==(const FmReceive&) const = default; }; + // Carrier-spanning RF cuts belong to the pre-detector NBP. The audio + // bandpass removes demodulated carrier/DC at 50 Hz and keeps the wider + // sideband's audio. SAM uses WDSP's PLL and dual-sideband detector. + struct AmReceive + { + bool operator==(const AmReceive&) const = default; + }; + // Broadcast receive recipe from WDSP Guide section 5.3.10. This opt-in // owns an RF FIR before WDSP's 384 -> 192 kHz conversion and normalizes // paired audio before any receiver tap. Other channel owners are unchanged. @@ -93,6 +101,28 @@ class WdspChannel final bool operator==(const WbfmReceive&) const = default; }; + // Optional host receive stages, prepared before the channel starts. Other + // owners retain WDSP's defaults unless they supply this recipe. + struct ReceiveNotch + { + double centerHz = 0; + double widthHz = 200; + bool active = true; + bool operator==(const ReceiveNotch&) const = default; + }; + struct AnalogReceiveDsp + { + std::vector trackingNotches; + bool nrEnabled = false; + int nrLevel = 50; + bool anfEnabled = false; + int anfLevel = 50; + bool manualNotchEnabled = false; + double manualNotchCenterHz = 1500.0; + double manualNotchWidthHz = 400.0; + bool operator==(const AnalogReceiveDsp&) const = default; + }; + struct Config { Direction direction = Direction::Receive; @@ -103,6 +133,8 @@ class WdspChannel final int outputSampleRate = 48000; Mode mode = Mode::Usb; std::optional fmReceive; + std::optional amReceive; + std::optional analogReceiveDsp; std::optional wbfmReceive; // RXA output panel gain, linear, applied after the AGC. Unset keeps // WDSP's create_rxa() default of 4.0 (+12.04 dB, #5942). diff --git a/src/gui/AsrAudioTap.cpp b/src/gui/AsrAudioTap.cpp index 85fd19b724..5770b9c2aa 100644 --- a/src/gui/AsrAudioTap.cpp +++ b/src/gui/AsrAudioTap.cpp @@ -2,6 +2,7 @@ #include "asr/AsrEngine.h" #include "core/AudioEngine.h" +#include "models/DecoderAudioModel.h" #include #include @@ -20,6 +21,20 @@ AsrAudioTap::AsrAudioTap(AudioEngine* audio, AsrEngine* asr, QObject* parent) { } +void AsrAudioTap::setNativeInput(DecoderAudioModel* route) +{ + if (m_nativeInput == route) { return; } + disconnect(m_conn); + disconnect(m_nativeResetConn); + if (m_nativeInput) { m_nativeInput->setEnabled(false); } + m_nativeInput = route; + m_policy.reset(); + m_speechRmsApplied = false; + if (m_asr && m_enabled) { m_asr->restartSession(); } + applySpeechRmsForTapPoint(); + if (m_enabled) { connectSelectedTap(); } +} + void AsrAudioTap::setEnabled(bool on) { if (on == m_enabled) { @@ -40,6 +55,8 @@ void AsrAudioTap::setEnabled(bool on) connectSelectedTap(); } else { disconnect(m_conn); + disconnect(m_nativeResetConn); + if (m_nativeInput) { m_nativeInput->setEnabled(false); } m_policy.reset(); // m_asr->setEnabled(false) above already resets and drops any queued // backlog — no separate reset() needed here. @@ -52,8 +69,8 @@ void AsrAudioTap::setTapPoint(AsrTapPoint point) return; } m_tapPoint = point; - if (!m_enabled) { - return; // the next setEnabled(true) connects the new point + if (!m_enabled || m_nativeInput) { + return; // the saved presentation preference applies when that route returns } disconnect(m_conn); @@ -90,9 +107,15 @@ void AsrAudioTap::setBaseSpeechRms(float rms) void AsrAudioTap::applySpeechRmsForTapPoint() { - if (m_asr == nullptr || m_audio == nullptr || m_baseSpeechRms <= 0.0f) { + if (m_asr == nullptr || m_baseSpeechRms <= 0.0f) { return; // nothing seeded yet } + if (m_nativeInput) { + if (!m_speechRmsApplied) { m_asr->setSpeechRms(m_baseSpeechRms); } + m_speechRmsApplied = true; + return; + } + if (!m_audio) { return; } const bool boost = m_audio->rxBoost(); const float trimDb = m_audio->rxOutputTrimDb(); const float chainDb = m_audio->rxStaticChainMakeupDb(); @@ -119,6 +142,20 @@ void AsrAudioTap::applySpeechRmsForTapPoint() void AsrAudioTap::connectSelectedTap() { + const quint64 generation = ++m_bindingGeneration; + if (m_nativeInput) { + m_conn = connect(m_nativeInput, &DecoderAudioModel::nativePcmReady, this, + [this](const PcmFrame& frame) { + if (m_enabled && m_asr && m_nativeInput && frame.current()) { + m_asr->pushNativePcm(frame); + } + }); + m_nativeResetConn = connect(m_nativeInput, &DecoderAudioModel::sourceReset, this, [this] { + if (m_enabled && m_asr) { m_asr->restartSession(); } + }); + m_nativeInput->setEnabled(true); + return; + } if (m_audio == nullptr) { return; } @@ -129,10 +166,20 @@ void AsrAudioTap::connectSelectedTap() // Every block they carry must reach the engine. if (m_tapPoint == AsrTapPoint::PreDsp) { m_conn = connect(m_audio, &AudioEngine::receivePresentationPreDspAudioReady, - this, &AsrAudioTap::onPreDspAudio, Qt::QueuedConnection); + this, [this, generation](const QString& source, const QString& id, + const QByteArray& pcm, int rate, int channels) { + if (generation == m_bindingGeneration) { + onPreDspAudio(source, id, pcm, rate, channels); + } + }, Qt::QueuedConnection); } else { m_conn = connect(m_audio, &AudioEngine::receivePresentationPostDspAudioReady, - this, &AsrAudioTap::onPostDspAudio, Qt::QueuedConnection); + this, [this, generation](const QString& source, const QString& id, + const QByteArray& pcm, int rate, int channels) { + if (generation == m_bindingGeneration) { + onPostDspAudio(source, id, pcm, rate, channels); + } + }, Qt::QueuedConnection); } } @@ -161,7 +208,7 @@ void AsrAudioTap::onRxAudio(AsrTapPoint from, // posted, so blocks from the old point can still arrive after a switch — // after the engine reset above, where they would open the new session // with audio from the wrong chain. They name their point; drop them. - if (!m_enabled || m_asr == nullptr || from != m_tapPoint) { + if (!m_enabled || m_asr == nullptr || m_nativeInput || from != m_tapPoint) { return; } diff --git a/src/gui/AsrAudioTap.h b/src/gui/AsrAudioTap.h index 061508e2b7..72efd64c4b 100644 --- a/src/gui/AsrAudioTap.h +++ b/src/gui/AsrAudioTap.h @@ -5,6 +5,7 @@ #include #include #include +#include class QByteArray; @@ -12,6 +13,7 @@ namespace AetherSDR { class AudioEngine; class AsrEngine; +class DecoderAudioModel; // App-layer bridge from RX audio to the ASR engine (RFC #4333), composing // AudioEngine and AsrEngine without either knowing the other. When enabled it @@ -35,6 +37,10 @@ class AsrAudioTap : public QObject { AsrAudioTap(AudioEngine* audio, AsrEngine* asr, QObject* parent = nullptr); void setEnabled(bool on); + // A native route supplies selected-slice audio before monitor/client DSP. + // nullptr restores the saved presentation tap without changing its preference. + void setNativeInput(DecoderAudioModel* route); + bool usesNativeInput() const { return !m_nativeInput.isNull(); } bool isEnabled() const { return m_enabled; } // Applied live. Switching while enabled starts transcription over — @@ -68,7 +74,10 @@ class AsrAudioTap : public QObject { AudioEngine* m_audio = nullptr; AsrEngine* m_asr = nullptr; + quint64 m_bindingGeneration = 0; QMetaObject::Connection m_conn; + QMetaObject::Connection m_nativeResetConn; + QPointer m_nativeInput; bool m_enabled = false; AsrTapPoint m_tapPoint = AsrTapPoint::PostDsp; AsrTapPolicy m_policy; diff --git a/src/gui/AudioButtonIcon.h b/src/gui/AudioButtonIcon.h new file mode 100644 index 0000000000..7504c1a150 --- /dev/null +++ b/src/gui/AudioButtonIcon.h @@ -0,0 +1,78 @@ +#pragma once + +#include "core/ThemeManager.h" + +#include +#include +#include +#include +#include + +namespace AetherSDR { + +// Font-independent audio marks follow the current theme and display scale. +enum class AudioIcon { Speaker, Headphone }; + +inline QPixmap buildAudioIcon(AudioIcon kind, bool muted, const QColor& stroke, qreal dpr) +{ + constexpr int kBox = 20; + QPixmap pm(int(kBox * dpr), int(kBox * dpr)); + pm.setDevicePixelRatio(dpr); + pm.fill(Qt::transparent); + + QPainter p(&pm); + p.setRenderHint(QPainter::Antialiasing, true); + QPen pen(stroke, 1.8); + pen.setCapStyle(Qt::RoundCap); + pen.setJoinStyle(Qt::RoundJoin); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + + if (kind == AudioIcon::Speaker) { + // Cone: a small rectangle at the pivot with a flared face. + QPainterPath cone; + cone.moveTo(3.0, 8.0); + cone.lineTo(6.5, 8.0); + cone.lineTo(10.5, 4.5); + cone.lineTo(10.5, 15.5); + cone.lineTo(6.5, 12.0); + cone.lineTo(3.0, 12.0); + cone.closeSubpath(); + p.drawPath(cone); + if (!muted) { + // Two radiating arcs. QPainter angles are sixteenths of a degree. + p.drawArc(QRectF(9.0, 5.5, 6.0, 9.0), -60 * 16, 120 * 16); + p.drawArc(QRectF(9.0, 2.5, 9.0, 15.0), -55 * 16, 110 * 16); + } else { + p.drawLine(QPointF(13.0, 7.0), QPointF(17.5, 13.0)); + p.drawLine(QPointF(17.5, 7.0), QPointF(13.0, 13.0)); + } + } else { + // Headband arc plus two ear cups. + p.drawArc(QRectF(3.5, 3.5, 13.0, 13.0), 0, 180 * 16); + p.drawRoundedRect(QRectF(3.0, 10.0, 3.6, 6.5), 1.6, 1.6); + p.drawRoundedRect(QRectF(13.4, 10.0, 3.6, 6.5), 1.6, 1.6); + if (muted) { + p.drawLine(QPointF(3.5, 16.5), QPointF(16.5, 3.5)); + } + } + p.end(); + return pm; +} + +// Set (or re-set) an audio button's icon at the current DPR and theme colour. +// Called on construction, on every mute toggle, and on theme change. +inline void applyAudioIcon(QPushButton* button, AudioIcon kind, bool muted, int size = 20) +{ + if (!button) { + return; + } + const QColor stroke = + AetherSDR::ThemeManager::instance().color(button, + QStringLiteral("color.text.secondary")); + button->setIcon(QIcon(buildAudioIcon(kind, muted, stroke, + button->devicePixelRatioF()))); + button->setIconSize(QSize(size, size)); +} + +} // namespace AetherSDR diff --git a/src/gui/ControlAvailabilityRegistry.cpp b/src/gui/ControlAvailabilityRegistry.cpp index 31fdde4c94..0f0cdf0838 100644 --- a/src/gui/ControlAvailabilityRegistry.cpp +++ b/src/gui/ControlAvailabilityRegistry.cpp @@ -132,6 +132,42 @@ ControlAvailabilityRegistry::ControlAvailabilityRegistry(RadioModel& model, QObj }); } +ControlAvailabilityRegistry::~ControlAvailabilityRegistry() +{ + for (Entry& entry : m_entries) { + restoreRestriction(entry); + } +} + +void ControlAvailabilityRegistry::registerRestriction(QWidget* widget, QString reason, + AvailabilityPredicate available) +{ + if (!widget || !available) { + return; + } + Entry entry; + entry.widget = widget; + entry.reason = std::move(reason); + entry.available = std::move(available); + entry.restrictionOnly = true; + m_entries.push_back(entry); + applyOne(m_entries.last(), m_model.isConnected(), m_model.backendCapabilities()); +} + +void ControlAvailabilityRegistry::restoreRestriction(Entry& entry) +{ + if (!entry.restricted) { + return; + } + if (QWidget* widget = entry.widget) { + widget->setEnabled(entry.previouslyEnabled); + widget->setToolTip(entry.previousToolTip); + widget->setAccessibleDescription(entry.previousDescription); + ThemeManager::instance().setWidgetForegroundToken(widget, {}); + } + entry.restricted = false; +} + void ControlAvailabilityRegistry::registerWidget(QWidget* widget, QString reason, AvailabilityPredicate available, @@ -197,6 +233,27 @@ void ControlAvailabilityRegistry::applyOne(Entry& entry, : engaged ? ControlAvailability::Active : ControlAvailability::Inactive; + if (entry.restrictionOnly) { + if (available) { + restoreRestriction(entry); + } else if (QWidget* widget = entry.widget) { + if (!entry.restricted) { + // Inherited parent disablement must not become a permanent + // explicit disablement after the receive restriction lifts. + entry.previouslyEnabled = !widget->testAttribute(Qt::WA_ForceDisabled); + entry.previousToolTip = widget->toolTip(); + entry.previousDescription = widget->accessibleDescription(); + entry.restricted = true; + } + widget->setEnabled(false); + widget->setToolTip(entry.reason); + widget->setAccessibleDescription(entry.reason); + ThemeManager::instance().setWidgetForegroundToken( + widget, treatmentToken(ControlAvailability::Unavailable)); + } + return; + } + const bool unavailable = entry.state == ControlAvailability::Unavailable; const auto reasonOnly = [&](const QString&) { return unavailable ? entry.reason : QString(); diff --git a/src/gui/ControlAvailabilityRegistry.h b/src/gui/ControlAvailabilityRegistry.h index 18f56ac8e0..e6c88d56f5 100644 --- a/src/gui/ControlAvailabilityRegistry.h +++ b/src/gui/ControlAvailabilityRegistry.h @@ -46,6 +46,13 @@ class ControlAvailabilityRegistry : public QObject { using EngagedPredicate = std::function; explicit ControlAvailabilityRegistry(RadioModel& model, QObject* parent = nullptr); + ~ControlAvailabilityRegistry() override; + + // Gate an existing control without taking over its available presentation. + // Optional capability records use this so legacy controls retain their own + // checked styling, tooltips and accessibility descriptions. + void registerRestriction(QWidget* widget, QString reason, + AvailabilityPredicate available); // Register a widget. `reason` is REQUIRED: while unavailable it replaces the // tooltip and accessibleDescription, because a dimmed control with no reason @@ -86,11 +93,17 @@ class ControlAvailabilityRegistry : public QObject { EngagedPredicate engaged; ControlAvailability state{ControlAvailability::Unavailable}; bool availableWhenDisconnected = true; + bool restrictionOnly = false; + bool restricted = false; + bool previouslyEnabled = true; + QString previousToolTip; + QString previousDescription; }; void applyAll(bool connected, const RadioCapabilities& caps); void applyOne(Entry& entry, bool connected, const RadioCapabilities& caps); void pruneDead(); + void restoreRestriction(Entry& entry); RadioModel& m_model; QVector m_entries; diff --git a/src/gui/CopyAssistController.cpp b/src/gui/CopyAssistController.cpp index 4f176475ca..700f35261b 100644 --- a/src/gui/CopyAssistController.cpp +++ b/src/gui/CopyAssistController.cpp @@ -16,6 +16,10 @@ #include "core/LogManager.h" #include "core/ThemeManager.h" #include "gui/AsrAudioTap.h" +#include "gui/ControlAvailabilityRegistry.h" +#include "models/DecoderAudioModel.h" +#include "models/RadioModel.h" +#include "models/SliceModel.h" #include @@ -438,6 +442,10 @@ CopyAssistController::CopyAssistController(AudioEngine* audio, CopyAssistPanel* .toString()) == AsrTapPoint::PreDsp); connect(m_settings, &CopyAssistSettingsDialog::rawAudioToggled, this, [this](bool raw) { + if (m_tap && m_tap->usesNativeInput()) { + m_settings->setNativeAudioRoute(true); + return; + } const AsrTapPoint point = raw ? AsrTapPoint::PreDsp : AsrTapPoint::PostDsp; CopyAssistSettings::setValue(QStringLiteral("AsrTapPoint"), asrTapPointToSetting(point)); if (m_tap) { @@ -766,10 +774,66 @@ PersistentDialog* CopyAssistController::settingsDialog() const return m_settings; } +void CopyAssistController::setReceiver(RadioModel& radio, SliceModel* slice) +{ + if (m_radio != &radio) { + if (m_radio) { disconnect(m_radio, nullptr, this, nullptr); } + if (m_tap) { m_tap->setNativeInput(nullptr); } + delete m_nativeAudio; + m_radio = &radio; + delete m_audioAvailability; + m_audioAvailability = new ControlAvailabilityRegistry(radio, this); + m_audioAvailability->registerWidget(m_settings->rawAudioControl(), + tr("The selected receiver supplies independent audio before speaker gain, mute, " + "client noise reduction and RX effects. Post-effects audio is not available for this route."), + [](bool, const RadioCapabilities& caps) { return !caps.receiveAudioExport.has_value(); }); + m_nativeAudio = new DecoderAudioModel(radio, DecoderAudioModel::Consumer::NativeAsr, this); + connect(&radio, &RadioModel::capabilitiesChanged, this, [this] { refreshNativeInput(); }); + connect(m_nativeAudio, &DecoderAudioModel::routeStatusChanged, this, [this] { + if (m_tap && m_tap->isEnabled() && m_tap->usesNativeInput()) { + m_panel->setStatus(listeningStatus()); + } + }); + } + setSelectedSlice(slice); + refreshNativeInput(); +} + +void CopyAssistController::setSelectedSlice(SliceModel* slice) +{ + m_selectedSlice = slice; + if (m_nativeAudio) { m_nativeAudio->setSlice(slice); } +} + +void CopyAssistController::refreshNativeInput() +{ + const bool native = m_radio && m_radio->backendCapabilities().receiveAudioExport.has_value(); + m_settings->setNativeAudioRoute(native); + if (!native) { + m_settings->setRawAudio(asrTapPointFromSetting(CopyAssistSettings::value( + QStringLiteral("AsrTapPoint"), asrTapPointToSetting(AsrTapPoint::PostDsp)).toString()) + == AsrTapPoint::PreDsp); + } + if (m_tap) { m_tap->setNativeInput(native ? m_nativeAudio : nullptr); } +} + +QString CopyAssistController::listeningStatus() const +{ + if (m_tap && m_tap->usesNativeInput()) { + if (!m_nativeAudio || m_nativeAudio->routeStatus() != DecoderAudioModel::RouteStatus::Bound) { + return tr("Waiting for selected receiver — audio before RX effects"); + } + return m_backend == AsrBackendKind::Remote + ? tr("Listening (remote) — selected receiver before RX effects") + : tr("Listening — selected receiver before RX effects"); + } + return m_backend == AsrBackendKind::Remote ? tr("Listening (remote)…") : tr("Listening…"); +} + void CopyAssistController::clearDecode() { m_panel->clearText(); - m_asr->reset(); // drop any half-built utterance so it doesn't cross frequencies + m_asr->restartSession(); // retire queued audio and results from the previous context } void CopyAssistController::onRetune(double freqMhz) @@ -1058,6 +1122,7 @@ void CopyAssistController::buildEngine() break; } m_tap = new AsrAudioTap(m_audio, m_asr, this); + refreshNativeInput(); // Before any enable, so the first connection is already the right signal. m_tap->setTapPoint(asrTapPointFromSetting( CopyAssistSettings::value(QStringLiteral("AsrTapPoint"), @@ -1077,8 +1142,7 @@ void CopyAssistController::buildEngine() m_panel->setStatus(m_gpuFallbackNotice); m_gpuFallbackNotice.clear(); } else { - m_panel->setStatus(m_backend == AsrBackendKind::Remote ? tr("Listening (remote)…") - : tr("Listening…")); + m_panel->setStatus(listeningStatus()); } writeFreqMarkerIfNeeded(); // "on start": head the log with the frequency } @@ -1614,8 +1678,7 @@ void CopyAssistController::restoreListeningStatus() if (!m_enabled || !m_asr->isReady()) { return; // not listening — leave "Disabled"/"Loading model…" alone } - m_panel->setStatus(m_backend == AsrBackendKind::Remote ? tr("Listening (remote)…") - : tr("Listening…")); + m_panel->setStatus(listeningStatus()); // The ready() lambda skips its whole body while a speaker load is pending, // so this resume point owes the log the same "on start" frequency header it // would have written. writeFreqMarkerIfNeeded() dedups, so the ordinary diff --git a/src/gui/CopyAssistController.h b/src/gui/CopyAssistController.h index 3d2f2a5fea..82dbb5397a 100644 --- a/src/gui/CopyAssistController.h +++ b/src/gui/CopyAssistController.h @@ -3,6 +3,7 @@ #include "asr/AsrCrashMarker.h" #include +#include #include #include @@ -10,6 +11,10 @@ namespace AetherSDR { class AudioEngine; +class RadioModel; +class SliceModel; +class DecoderAudioModel; +class ControlAvailabilityRegistry; class CopyAssistPanel; class CopyAssistSettingsDialog; class PersistentDialog; @@ -97,6 +102,8 @@ class CopyAssistController : public QObject { // Seed the current frequency without side effects (used right after the // controller is created, before any retune event fires). void setCurrentFrequency(double freqMhz); + void setReceiver(RadioModel& radio, SliceModel* slice); + void setSelectedSlice(SliceModel* slice); private slots: void onEnableToggled(bool on); @@ -104,6 +111,12 @@ private slots: private: void startGpuDiscovery(); + void refreshNativeInput(); + QString listeningStatus() const; + QPointer m_radio; + QPointer m_selectedSlice; + DecoderAudioModel* m_nativeAudio = nullptr; + ControlAvailabilityRegistry* m_audioAvailability = nullptr; // Surviving an uncatchable ASR fault (#5190) — see asr/AsrCrashMarker.h. // All settings writes here happen on the GUI thread: CopyAssistSettings' // setValue() is a whole-document read-modify-write. diff --git a/src/gui/CopyAssistSettingsDialog.cpp b/src/gui/CopyAssistSettingsDialog.cpp index d3b382d06b..37e52c02bf 100644 --- a/src/gui/CopyAssistSettingsDialog.cpp +++ b/src/gui/CopyAssistSettingsDialog.cpp @@ -147,6 +147,7 @@ CopyAssistSettingsDialog::CopyAssistSettingsDialog(QWidget* parent) "almost everything \u2014 and closing it down costs speech, not noise. Leave " "Sensitivity high here. Silero VAD is not a substitute: on a noisy HF signal " "it rejects most real speech. Switching starts the transcription over.")); + m_presentationAudioToolTip = m_rawAudio->toolTip(); connect(m_rawAudio, &QCheckBox::toggled, this, &CopyAssistSettingsDialog::rawAudioToggled); form->addRow(m_rawAudio); @@ -407,6 +408,20 @@ void CopyAssistSettingsDialog::setRawAudio(bool on) m_rawAudio->setChecked(on); } +void CopyAssistSettingsDialog::setNativeAudioRoute(bool native) +{ + if (native) { + setRawAudio(true); + } else { + m_rawAudio->setToolTip(m_presentationAudioToolTip); + } +} + +QWidget* CopyAssistSettingsDialog::rawAudioControl() const +{ + return m_rawAudio; +} + bool CopyAssistSettingsDialog::isRawAudio() const { return m_rawAudio != nullptr && m_rawAudio->isChecked(); diff --git a/src/gui/CopyAssistSettingsDialog.h b/src/gui/CopyAssistSettingsDialog.h index fc8bdd7972..28393b3a62 100644 --- a/src/gui/CopyAssistSettingsDialog.h +++ b/src/gui/CopyAssistSettingsDialog.h @@ -77,6 +77,8 @@ class CopyAssistSettingsDialog : public PersistentDialog { // Tap point for the ASR feed: unchecked = after client NR and the RX // effects chain (what every build before this did), checked = before them. void setRawAudio(bool on); + void setNativeAudioRoute(bool native); + QWidget* rawAudioControl() const; bool isRawAudio() const; bool useSileroVad() const; void setVadModelPath(const QString& path); @@ -130,6 +132,7 @@ class CopyAssistSettingsDialog : public PersistentDialog { QPushButton* m_logBrowse = nullptr; QCheckBox* m_useSilero = nullptr; QCheckBox* m_rawAudio = nullptr; + QString m_presentationAudioToolTip; QLineEdit* m_vadPath = nullptr; QPushButton* m_vadBrowse = nullptr; QCheckBox* m_labelSpeakers = nullptr; diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index cd68540102..d18ab0f767 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -2911,6 +2911,18 @@ void MainWindow::preparePanadapterUiForShutdown() m_panadapterUiPreparedForShutdown = true; m_shuttingDown = true; +#ifdef AETHER_ASR_ENABLED + // The pane owns Copy Assist's settings dialog. Retire its controller and + // worker before pane destruction, while radio/audio objects remain alive. + // Radio disconnect can otherwise notify a controller whose UI is gone. + { + ShutdownTrace trace("asr.controller.destroy"); + delete m_copyAssistController; + m_copyAssistController = nullptr; + m_copyAssistApplet = nullptr; + } +#endif + if (m_sHistoryExpireTimer) { m_sHistoryExpireTimer->stop(); QObject::disconnect(m_sHistoryExpireTimer, nullptr, this, nullptr); @@ -9017,6 +9029,7 @@ void MainWindow::setActiveSliceInternal(int sliceId, bool revealOffscreen) #endif #ifdef AETHER_ASR_ENABLED + if (m_copyAssistController) { m_copyAssistController->setSelectedSlice(s); } // A retune (or a switch to another slice) is a new listening context — clear // the Copy Assist decode window so text from the old frequency doesn't linger, // and mark the new frequency in the transcript log. @@ -10836,12 +10849,10 @@ void MainWindow::updateKeyerAvailability() // ASR (Copy Assist): available in voice modes only (dimmed in CW and // DIGx/RTTY), gated on the SELECTED slice, which the rest of Copy Assist // follows (setActiveSliceInternal()) — not the TX slice, which may not exist - // (#4825). The slice is a proxy: AsrTapPolicy locks onto a RECEIVER - // (first-block-wins, 2 s release) on whichever presentation signal the - // operator's tap point selects — post-DSP, or its PreDsp twin when they - // transcribe ahead of NR — and on a Flex the stream is all audible slices - // mixed. Hiding the panel calls setAsrEnabled(false), so selecting a CW - // slice stops a running transcription. + // (#4825). Native independent audio exports follow this selected slice + // before client DSP and monitor controls. Other radios retain the chosen + // presentation tap; on Flex that stream mixes audible slices. Hiding the + // panel stops transcription when the selection leaves voice mode. SliceModel* asrSlice = activeSlice(); const bool asrIsVoice = asrSlice && isVoiceMode(asrSlice->mode()); if (m_asrIndicator) { @@ -10899,6 +10910,9 @@ void MainWindow::updateKeyerAvailability() : Qt::ArrowCursor); } #endif + // Commands invoked while Tools is closed must follow the same selection + // and mode edge as their status indicators. + updateToolsMenuState(); } void MainWindow::centerActiveSliceInPanadapter(bool forceRadioCenter, double centerMhz) diff --git a/src/gui/MainWindow_DspApplets.cpp b/src/gui/MainWindow_DspApplets.cpp index 6bff9bd6bf..13aa3d194d 100644 --- a/src/gui/MainWindow_DspApplets.cpp +++ b/src/gui/MainWindow_DspApplets.cpp @@ -224,6 +224,7 @@ void MainWindow::updateAetherDspModePolicy() slice->mode(), slice->audioMute(), slice->audioGain(), + slice->inCapture(), }); } @@ -420,6 +421,12 @@ void MainWindow::wireDspApplets() }); } m_appletPanel->phoneCwApplet()->setTransmitModel(&m_radioModel.transmitModel()); + const auto updatePhoneCwReceiveOnly = [this](bool connected, const RadioCapabilities& caps) { + m_appletPanel->phoneCwApplet()->setTransmitControlsAvailable(!connected || caps.canTransmit); + }; + connect(&m_radioModel, &RadioModel::capabilitiesChanged, this, updatePhoneCwReceiveOnly); + updatePhoneCwReceiveOnly(m_radioModel.isConnected(), m_radioModel.backendCapabilities()); + diff --git a/src/gui/MainWindow_Menus.cpp b/src/gui/MainWindow_Menus.cpp index 215294ed31..a83971d1fb 100644 --- a/src/gui/MainWindow_Menus.cpp +++ b/src/gui/MainWindow_Menus.cpp @@ -2014,6 +2014,7 @@ void MainWindow::showCopyAssist() } m_copyAssistApplet = applet; m_copyAssistController = new CopyAssistController(m_audio, applet->copyAssistPanel(), this); + m_copyAssistController->setReceiver(m_radioModel, activeSlice()); trackPersistentDialog(m_copyAssistController->settingsDialog()); // Seed the current frequency so the first "on start" log marker is correct // even before any retune fires. diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index 5a31b8cd47..bdd26570bd 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -1991,6 +1991,7 @@ void MainWindow::onSliceAdded(SliceModel* s) pushSliceOverlay(s); connect(s, &SliceModel::inCaptureChanged, this, [this, s](bool) { pushSliceOverlay(s); + updateAetherDspModePolicy(); }); // Set the panadapter applet's slice label (e.g. "Slice B") based on @@ -4512,6 +4513,10 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) }; QObject::disconnect(tnf, &TnfModel::tnfChanged, sw, nullptr); QObject::disconnect(tnf, &TnfModel::tnfRemoved, sw, nullptr); + QObject::disconnect(tnf, &TnfModel::notchChangeRequested, sw, nullptr); + connect(tnf, &TnfModel::notchChangeRequested, sw, [tnf, rebuildTnfMarkers] { + if (tnf->confirmsControls()) { rebuildTnfMarkers(); } + }); connect(tnf, &TnfModel::tnfChanged, sw, rebuildTnfMarkers); connect(tnf, &TnfModel::tnfRemoved, sw, rebuildTnfMarkers); connect(tnf, &TnfModel::globalEnabledChanged, @@ -6242,9 +6247,8 @@ void MainWindow::wireVfoWidget(VfoWidget* w, SliceModel* s) // convention as the Kiwi CW BFO (KiwiSdrProtocol.cpp). Flex radios // express CWL as mode "CW" plus the transmit flag; Icom/HL2/sim // express it as slice mode "CWL" and never set the flag — honor both. - const bool cwl = slice->mode() == QLatin1String("CWL") - || m_radioModel.transmitModel().cwlEnabled(); - const int sign = cwl ? -1 : 1; + const int sign = cwReceiveSidebandSign(slice->mode(), m_radioModel.hasCommandPlane(), + m_radioModel.transmitModel().cwlEnabled()); double offsetMhz = sign * (detected - configured) / 1.0e6; applyTuneRequest(slice, slice->frequency() + offsetMhz, TuneIntent::IncrementalTune, "zero-beat"); diff --git a/src/gui/ModeFilterPresets.cpp b/src/gui/ModeFilterPresets.cpp index dcac0b4910..7f0eb1e387 100644 --- a/src/gui/ModeFilterPresets.cpp +++ b/src/gui/ModeFilterPresets.cpp @@ -22,6 +22,15 @@ QString canonicalFmMode(const QString& mode) return mode; } +bool withinRange(const ReceiveFilterMode& range, Edges edges) +{ + const qint64 width = qint64(edges.hi) - edges.lo; + return edges.lo < edges.hi + && edges.lo >= range.minimumLowHz && edges.lo <= range.maximumLowHz + && edges.hi >= range.minimumHighHz && edges.hi <= range.maximumHighHz + && width >= range.minimumWidthHz && width <= range.maximumWidthHz; +} + const ReceiveFilterMode* fmControlFor(const QString& mode, const ReceiveFilterControl* control) { if (!control || !isFmMode(mode)) { @@ -44,11 +53,7 @@ bool acceptsFmEdges(const QString& mode, const ReceiveFilterControl* control, Ed if (!range) { return false; } - const qint64 width = qint64(edges.hi) - edges.lo; - return edges.lo < edges.hi - && edges.lo >= range->minimumLowHz && edges.lo <= range->maximumLowHz - && edges.hi >= range->minimumHighHz && edges.hi <= range->maximumHighHz - && width >= range->minimumWidthHz && width <= range->maximumWidthHz; + return withinRange(*range, edges); } bool fmFilterAdjustable(const QString& mode, const ReceiveFilterControl* control, @@ -71,9 +76,15 @@ bool fmFilterAdjustable(const QString& mode, const ReceiveFilterControl* control bool acceptsFilterEdges(const QString& mode, const ReceiveFilterControl* control, bool radioPublishesWidths, Edges edges) { - if (!isFmMode(mode) || radioPublishesWidths) { + if (!isFmMode(mode)) { + if (control) { + for (const ReceiveFilterMode& range : control->modes) { + if (range.mode == mode) { return withinRange(range, edges); } + } + } return true; } + if (radioPublishesWidths) { return true; } return fmControlFor(mode, control) ? acceptsFmEdges(mode, control, edges) : fmFilterAdjustable(mode, control, false); } @@ -185,13 +196,8 @@ Edges edgesForWidth(const QString& mode, int widthHz, const SliceContext& ctx) int mid = -shift / 2; lo = mid - widthHz / 2; hi = mid + widthHz / 2; - } else if (mode == "CW" || mode == "CWL" || mode == "CWU") { - // Centered on carrier — radio's BFO handles pitch offset. - // CWU belongs with the other two spellings: it was falling through to - // the final else and getting a USB-shaped {95, width} with no carrier - // in it. It is reachable — NetSchedulerDialog lists it as a schedulable - // mode and RadioSetupDialog has it as the CWU/CWL sideband toggle — and - // it was wrong under the old passband convention too, just less visibly. + } else if (isCwMode(mode)) { + // Carrier-relative on both sidebands; the receiver's BFO owns pitch. lo = -widthHz / 2; hi = widthHz / 2; } else if (mode == "AM" || mode == "SAM" || mode == "DSB" diff --git a/src/gui/PhoneCwApplet.cpp b/src/gui/PhoneCwApplet.cpp index a7e5719e17..f84d10f1b2 100644 --- a/src/gui/PhoneCwApplet.cpp +++ b/src/gui/PhoneCwApplet.cpp @@ -645,11 +645,13 @@ void PhoneCwApplet::commitCwPitch(int value) / m_pitchStepHz) * m_pitchStepHz, m_pitchMaxHz); const QSignalBlocker blocker(m_pitchSlider); - m_pitchSlider->setValue(hz); - m_pitchEdit->setText(QString::number(hz)); if (m_model) { m_model->setCwPitch(hz); } + const int displayedHz = m_model && m_model->confirmsReceiveCwPitch() + ? m_model->cwPitch() : hz; + m_pitchSlider->setValue(displayedHz); + m_pitchEdit->setText(QString::number(displayedHz)); } void PhoneCwApplet::buildCwPanel() @@ -1056,6 +1058,29 @@ void PhoneCwApplet::buildCwPanel() // ── Mode switching ─────────────────────────────────────────────────────────── +void PhoneCwApplet::setTransmitControlsAvailable(bool available) +{ + if (m_transmitControlsAvailable == available) { return; } + m_transmitControlsAvailable = available; + // Pitch and APF are receive controls too, so their CW face remains usable. + const QList controls{m_phonePanel, m_delaySlider, m_delayEdit, + m_speedSlider, m_speedEdit, m_sidetoneBtn, m_sidetoneSlider, + m_sidetoneEdit, m_cwPanSlider, m_breakinBtn, m_iambicBtn, m_holdDelayBtn}; + for (QWidget* control : controls) { + if (!available) { + control->setProperty("transmitAvailableTooltip", control->toolTip()); + control->setProperty("transmitAvailableDescription", control->accessibleDescription()); + } + control->setEnabled(available); + control->setToolTip(available + ? control->property("transmitAvailableTooltip").toString() + : tr("Transmit controls are unavailable on this receive-only radio")); + control->setAccessibleDescription(available + ? control->property("transmitAvailableDescription").toString() + : tr("Transmit controls are unavailable on this receive-only radio")); + } +} + void PhoneCwApplet::setMode(const QString& mode) { // A Flex reports bare "CW"; an Icom spells the same mode CWU, and CWL is @@ -1173,6 +1198,9 @@ void PhoneCwApplet::setSlice(SliceModel* slice) m_slice = slice; if (m_slice) { + connect(m_slice, &SliceModel::receiveDspRequested, this, [this] { + if (m_slice && m_slice->receiveControlsConfirmed()) { syncApfFromSlice(); } + }, Qt::QueuedConnection); connect(m_slice, &SliceModel::modeChanged, this, &PhoneCwApplet::setMode); // Radio-side echo drives the UI; the button below only requests @@ -1227,7 +1255,7 @@ void PhoneCwApplet::syncApfFromSlice() m_apfSlider->setEnabled(bound && on); m_apfEdit->setEnabled(bound && on); m_apfSlider->setValue(level); - if (!m_apfEdit->hasFocus()) + if (!m_apfEdit->hasFocus() || (m_slice && m_slice->receiveControlsConfirmed())) m_apfEdit->setText(QString::number(level)); m_updatingFromModel = false; } @@ -1300,7 +1328,7 @@ void PhoneCwApplet::syncPhoneFromModel() // accessible description carry the same distinction for non-visual use. void PhoneCwApplet::updateHoldDelayAffordance() { - if (!m_holdDelayBtn) return; + if (!m_holdDelayBtn || !m_transmitControlsAvailable) return; const bool on = m_model && m_model->holdBreakInDelay(); const bool armed = m_model && m_model->holdBreakInDelayArmed(); const bool unarmed = on && !armed; @@ -1351,7 +1379,7 @@ void PhoneCwApplet::syncCwFromModel() updateHoldDelayAffordance(); m_pitchSlider->setValue(m_model->cwPitch()); - if (!m_pitchEdit->hasFocus()) + if (!m_pitchEdit->hasFocus() || m_model->confirmsReceiveCwPitch()) m_pitchEdit->setText(QString::number(m_model->cwPitch())); m_updatingFromModel = false; diff --git a/src/gui/PhoneCwApplet.h b/src/gui/PhoneCwApplet.h index e174366c01..e8dd60a475 100644 --- a/src/gui/PhoneCwApplet.h +++ b/src/gui/PhoneCwApplet.h @@ -43,6 +43,7 @@ class PhoneCwApplet : public QWidget { explicit PhoneCwApplet(QWidget* parent = nullptr); void setTransmitModel(TransmitModel* model); + void setTransmitControlsAvailable(bool available); // Bind the active slice. TransmitModel is radio-global TX state; APF is a // per-slice receive filter, so the CW panel needs the slice itself to drive @@ -219,6 +220,7 @@ public slots: // ── Shared state ───────────────────────────────────────────────────── + bool m_transmitControlsAvailable{true}; bool m_updatingFromModel{false}; bool m_radeActive{false}; diff --git a/src/gui/RxApplet.cpp b/src/gui/RxApplet.cpp index b826b54ff2..80e5996f29 100644 --- a/src/gui/RxApplet.cpp +++ b/src/gui/RxApplet.cpp @@ -1,4 +1,5 @@ #include "RxApplet.h" +#include "AudioButtonIcon.h" #include #include "AntennaChoiceGate.h" #include "SplitAudioProfile.h" @@ -458,7 +459,7 @@ void RxApplet::buildUI() emit kiwiRxAntennaSelected(slice->sliceId(), profileId); } else { emit flexRxAntennaSelected(slice->sliceId()); - slice->setRxAntenna(token); + if (slice->rxAntenna() != token) { slice->setRxAntenna(token); } } }); menu->popup( @@ -733,7 +734,8 @@ void RxApplet::buildUI() this, [this](int lo, int hi) { if (acceptsFilterEdges(lo, hi)) { m_slice->setFilterWidth(lo, hi); - } else if (m_slice) { + } + if (m_slice && (m_slice->receiveControlsConfirmed() || !acceptsFilterEdges(lo, hi))) { m_filterPassband->setFilter(m_slice->filterLow(), m_slice->filterHigh()); } }); @@ -957,7 +959,11 @@ void RxApplet::buildUI() auto* row = new QHBoxLayout; row->setSpacing(4); - m_muteBtn = new QPushButton(QString::fromUtf8("\xF0\x9F\x94\x8A")); // 🔊 + m_muteBtn = new QPushButton; + applyAudioIcon(m_muteBtn, AudioIcon::Speaker, false, 16); + connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, [this] { + applyAudioIcon(m_muteBtn, AudioIcon::Speaker, m_slice && m_slice->audioMute(), 16); + }); m_muteBtn->setFixedSize(18, 18); AetherSDR::ThemeManager::instance().applyStyleSheet(m_muteBtn, "QPushButton { background: transparent; border: none; font-size: 12px; padding: 0px; }" "QPushButton:hover { background: {{color.background.1}}; border-radius: 3px; }"); @@ -2138,6 +2144,26 @@ void RxApplet::setRadioModel(RadioModel* radioModel) } if (m_radioModel) { m_filterAvailability = new ControlAvailabilityRegistry(*m_radioModel, this); + m_filterAvailability->registerRestriction(m_txAntBtn, + tr("This radio is receive-only; no transmit antenna is available"), + [](bool connected, const RadioCapabilities& caps) { + return !connected || caps.canTransmit; + }); + m_filterAvailability->registerRestriction(m_rxAntBtn, + tr("This receiver has one fixed receive input"), + [this](bool connected, const RadioCapabilities& caps) { + const bool virtualAntennas = m_kiwiSdrManager + && !m_kiwiSdrManager->virtualAntennaTokens().isEmpty(); + return !connected || caps.canTransmit || !m_slice + || m_slice->rxAntennaList().size() != 1 || virtualAntennas; + }); + m_filterAvailability->registerRestriction(m_ritContainer, + tr("Receive incremental tuning is unavailable in this mode"), + [this](bool connected, const RadioCapabilities& caps) { + return !connected || !caps.receiveDspControl + || (m_slice && caps.receiveDspControl->supports( + ReceiveDspControl::Feature::Rit, m_slice->mode())); + }); const auto supportsSquelch = [this](bool connected, const RadioCapabilities& caps) { if (!m_slice) { return !exclusiveSquelchScale(caps.squelchLevelScale); @@ -2182,6 +2208,7 @@ void RxApplet::setRadioModel(RadioModel* radioModel) if (m_exclusiveSquelch && m_slice && !m_clientSquelchScope.hasRadioIdentity()) { setSlice(m_slice); } + updateAntennaButtons(); configureRepeaterReverseControl(); configureFmToneControls(); syncAgcSliderFromSlice(); @@ -2456,6 +2483,11 @@ QString RxApplet::antennaMenuLabel(const QString& token, QStringList RxApplet::rxAntennaOptions() const { + if (m_radioModel && m_radioModel->isConnected() + && !m_radioModel->backendCapabilities().canTransmit && m_slice + && !m_slice->rxAntennaList().isEmpty()) { + return m_slice->rxAntennaList(); + } QStringList options; auto append = [&options](const QString& token) { if (!token.isEmpty() && !options.contains(token)) { @@ -2487,6 +2519,8 @@ QStringList RxApplet::rxAntennaOptions() const QStringList RxApplet::txAntennaOptions() const { + if (m_radioModel && m_radioModel->isConnected() + && !m_radioModel->backendCapabilities().canTransmit) { return {}; } QStringList options; auto append = [&options](const QString& token) { if (!token.isEmpty() && !options.contains(token)) @@ -2515,6 +2549,10 @@ void RxApplet::updateAntennaButton(QPushButton* button, const QString& token, bo return; QString effectiveToken = token; + if (tx && m_radioModel && m_radioModel->isConnected() + && !m_radioModel->backendCapabilities().canTransmit) { + effectiveToken = tr("None"); + } if (!tx && m_kiwiSdrManager && m_slice) { const QString profileId = m_kiwiSdrManager->assignedProfileForSlice(m_slice->sliceId()); @@ -2550,6 +2588,7 @@ void RxApplet::updateAntennaButton(QPushButton* button, const QString& token, bo : QStringLiteral("%1 antenna port: %2") .arg(tx ? QStringLiteral("Transmit") : QStringLiteral("Receive"), full)); + if (m_filterAvailability) { m_filterAvailability->refreshEngaged(); } } void RxApplet::updateAntennaButtons() @@ -2577,6 +2616,12 @@ void RxApplet::refreshFilterWidth() void RxApplet::connectSlice(SliceModel* s) { + connect(s, &SliceModel::receiveAgcRequested, this, [this] { + if (m_slice && m_slice->receiveControlsConfirmed()) { + updateAgcCombo(); + syncAgcSliderFromSlice(); + } + }); // ── Header ───────────────────────────────────────────────────────────── // Slice badge — display follows SliceLetterDisplay AppSettings (#2606). @@ -2697,6 +2742,7 @@ void RxApplet::connectSlice(SliceModel* s) int idx = m_modeCombo->findText(mode); if (idx >= 0) m_modeCombo->setCurrentIndex(idx); updateModeSettings(mode); + syncAgcSliderFromSlice(); }); // Initialize filter/step arrays for the current mode @@ -2784,13 +2830,9 @@ void RxApplet::connectSlice(SliceModel* s) // routes to muteAllToggled via eventFilter). The icon flips when the // radio acks the mute change via audioMuteChanged so the source of // truth is the slice state, not the click. - m_muteBtn->setText(s->audioMute() - ? QString::fromUtf8("\xF0\x9F\x94\x87") - : QString::fromUtf8("\xF0\x9F\x94\x8A")); + applyAudioIcon(m_muteBtn, AudioIcon::Speaker, s->audioMute(), 16); connect(s, &SliceModel::audioMuteChanged, this, [this](bool muted) { - m_muteBtn->setText(muted - ? QString::fromUtf8("\xF0\x9F\x94\x87") - : QString::fromUtf8("\xF0\x9F\x94\x8A")); + applyAudioIcon(m_muteBtn, AudioIcon::Speaker, muted, 16); }); // Audio pan @@ -2958,6 +3000,12 @@ void RxApplet::connectSlice(SliceModel* s) // DSP toggles removed — use VFO DSP tab or spectrum overlay + connect(s, &SliceModel::ritCommandIssued, this, [this] { + if (!m_slice || !m_slice->receiveControlsConfirmed()) { return; } + QSignalBlocker blocker(m_ritOnBtn); + m_ritOnBtn->setChecked(m_slice->ritOn()); + m_ritLabel->setText(formatHz(m_slice->ritFreq())); + }); // RIT { QSignalBlocker b(m_ritOnBtn); @@ -3674,12 +3722,22 @@ void RxApplet::syncAgcSliderFromSlice() } const bool connected = m_radioModel && m_radioModel->isConnected(); - const bool available = !connected || m_slice->externalReceiveReplacementActive() - || m_radioModel->backendCapabilities().hasAgcThreshold; + const RadioCapabilities backendCaps = connected + ? m_radioModel->backendCapabilities() : RadioCapabilities{}; + const bool modeAvailable = !connected || m_slice->externalReceiveReplacementActive() + || !backendCaps.receiveDspControl + || backendCaps.receiveDspControl->supports(ReceiveDspControl::Feature::Agc, m_slice->mode()); + const bool available = modeAvailable && (!connected || m_slice->externalReceiveReplacementActive() + || backendCaps.hasAgcThreshold); m_agcTSlider->setEnabled(available); const RadioCapabilities caps = connected && !m_slice->externalReceiveReplacementActive() ? m_radioModel->backendCapabilities() : RadioCapabilities{}; setAgcModeAvailability(m_agcCombo, caps.agcModes); + m_agcCombo->setEnabled(modeAvailable); + m_agcCombo->setAccessibleDescription(modeAvailable ? tr("Automatic gain control speed") + : tr("AGC is unavailable in this receive mode")); + m_agcTSlider->setAccessibleDescription(available ? tr("Receive AGC gain control") + : tr("AGC threshold and off level are unavailable in this receive mode")); const bool agcOff = m_slice->receiveAgcMode() == QStringLiteral("off"); const int minimum = agcOff ? 0 : agcThresholdMinimum(); const int maximum = agcOff ? 100 : agcThresholdMaximum(); diff --git a/src/gui/SpectrumOverlayMenu.cpp b/src/gui/SpectrumOverlayMenu.cpp index 1aa9e72b62..9c23ee793a 100644 --- a/src/gui/SpectrumOverlayMenu.cpp +++ b/src/gui/SpectrumOverlayMenu.cpp @@ -1237,18 +1237,23 @@ void SpectrumOverlayMenu::refreshAntennaCombo() append(token); } } - for (const QString& token : m_antList) { - append(token); - } - if (m_radioModel) { - for (const QString& token : m_radioModel->knownAntennaTokens()) { + const bool publishedReceiveOnly = targetSlice && !targetSlice->rxAntennaList().isEmpty() + && m_radioModel && m_radioModel->isConnected() + && !m_radioModel->backendCapabilities().canTransmit; + if (!publishedReceiveOnly) { + for (const QString& token : m_antList) { append(token); } - } - append(cur); - if (options.isEmpty()) { - append(QStringLiteral("ANT1")); - append(QStringLiteral("ANT2")); + if (m_radioModel) { + for (const QString& token : m_radioModel->knownAntennaTokens()) { + append(token); + } + } + append(cur); + if (options.isEmpty()) { + append(QStringLiteral("ANT1")); + append(QStringLiteral("ANT2")); + } } if (m_kiwiSdrManager) { for (const QString& token : m_kiwiSdrManager->virtualAntennaTokens()) { diff --git a/src/gui/SpectrumWidget.cpp b/src/gui/SpectrumWidget.cpp index a45adc44f4..0913796c0c 100644 --- a/src/gui/SpectrumWidget.cpp +++ b/src/gui/SpectrumWidget.cpp @@ -11084,18 +11084,24 @@ void SpectrumWidget::mouseMoveEvent(QMouseEvent* ev) const int newWidthHz = std::clamp( static_cast(std::lround(static_cast(m_dragTnfOrigWidthHz) * widthScale)), m_notchMinWidthHz, m_notchMaxWidthHz); - for (auto& t : m_tnfMarkers) { - if (t.id == m_draggingTnfId) { - t.freqMhz = newFreq; - t.widthHz = newWidthHz; - break; + const bool frequencyChanged = !qFuzzyCompare(newFreq + 1.0, m_dragTnfLastFreq + 1.0); + const bool widthChanged = newWidthHz != m_dragTnfLastWidthHz; + // Every preview mutation must dispatch an intent so a confirmed-state + // binding can restore the accepted marker, including repeated motion. + if (frequencyChanged || widthChanged) { + for (auto& t : m_tnfMarkers) { + if (t.id == m_draggingTnfId) { + t.freqMhz = newFreq; + t.widthHz = newWidthHz; + break; + } } } - if (!qFuzzyCompare(newFreq + 1.0, m_dragTnfLastFreq + 1.0)) { + if (frequencyChanged) { m_dragTnfLastFreq = newFreq; emit tnfMoveRequested(m_draggingTnfId, newFreq); } - if (newWidthHz != m_dragTnfLastWidthHz) { + if (widthChanged) { m_dragTnfLastWidthHz = newWidthHz; emit tnfWidthRequested(m_draggingTnfId, newWidthHz); } diff --git a/src/gui/TitleBar.cpp b/src/gui/TitleBar.cpp index 2b8b912881..18f8d837ac 100644 --- a/src/gui/TitleBar.cpp +++ b/src/gui/TitleBar.cpp @@ -1,4 +1,5 @@ #include "TitleBar.h" +#include "AudioButtonIcon.h" #include "RoundedMenu.h" #include "BrandMark.h" #include "FramelessMessageBox.h" @@ -48,6 +49,7 @@ #include #include #include +#include #include "core/VersionNumber.h" #include "core/ThemeManager.h" @@ -139,73 +141,7 @@ QPixmap buildPopOutIcon(const QWidget* widget, bool active) } // ── Audio-cluster icons ───────────────────────────────────────────────────── -// Thin-line marks in the house style (1.8 px stroke, no fill), replacing the -// speaker/headphone emoji the strip used to carry: emoji render in the -// platform's own colour and weight, so they never matched the bar around them -// and could not follow the theme. -enum class AudioIcon { Speaker, Headphone }; -QPixmap buildAudioIcon(AudioIcon kind, bool muted, const QColor& stroke, qreal dpr) -{ - constexpr int kBox = 20; - QPixmap pm(int(kBox * dpr), int(kBox * dpr)); - pm.setDevicePixelRatio(dpr); - pm.fill(Qt::transparent); - - QPainter p(&pm); - p.setRenderHint(QPainter::Antialiasing, true); - QPen pen(stroke, 1.8); - pen.setCapStyle(Qt::RoundCap); - pen.setJoinStyle(Qt::RoundJoin); - p.setPen(pen); - p.setBrush(Qt::NoBrush); - - if (kind == AudioIcon::Speaker) { - // Cone: a small rectangle at the pivot with a flared face. - QPainterPath cone; - cone.moveTo(3.0, 8.0); - cone.lineTo(6.5, 8.0); - cone.lineTo(10.5, 4.5); - cone.lineTo(10.5, 15.5); - cone.lineTo(6.5, 12.0); - cone.lineTo(3.0, 12.0); - cone.closeSubpath(); - p.drawPath(cone); - if (!muted) { - // Two radiating arcs. QPainter angles are sixteenths of a degree. - p.drawArc(QRectF(9.0, 5.5, 6.0, 9.0), -60 * 16, 120 * 16); - p.drawArc(QRectF(9.0, 2.5, 9.0, 15.0), -55 * 16, 110 * 16); - } else { - p.drawLine(QPointF(13.0, 7.0), QPointF(17.5, 13.0)); - p.drawLine(QPointF(17.5, 7.0), QPointF(13.0, 13.0)); - } - } else { - // Headband arc plus two ear cups. - p.drawArc(QRectF(3.5, 3.5, 13.0, 13.0), 0, 180 * 16); - p.drawRoundedRect(QRectF(3.0, 10.0, 3.6, 6.5), 1.6, 1.6); - p.drawRoundedRect(QRectF(13.4, 10.0, 3.6, 6.5), 1.6, 1.6); - if (muted) { - p.drawLine(QPointF(3.5, 16.5), QPointF(16.5, 3.5)); - } - } - p.end(); - return pm; -} - -// Set (or re-set) an audio button's icon at the current DPR and theme colour. -// Called on construction, on every mute toggle, and on theme change. -void applyAudioIcon(QPushButton* button, AudioIcon kind, bool muted) -{ - if (!button) { - return; - } - const QColor stroke = - AetherSDR::ThemeManager::instance().color(button, - QStringLiteral("color.text.secondary")); - button->setIcon(QIcon(buildAudioIcon(kind, muted, stroke, - button->devicePixelRatioF()))); - button->setIconSize(QSize(20, 20)); -} } TitleBar::TitleBar(QWidget* parent) @@ -723,6 +659,18 @@ void TitleBar::setRadioTabs(const QList& radios) if (m_radioTabs) { m_radioTabs->setRadios(radios); } + // A new connection finishes discovery without requiring a heartbeat. + // Repeated snapshots must not cancel a rescan begun while already connected. + QList connectedIds; + for (const RadioTabEntry& radio : radios) { + if (radio.status == RadioTabStatus::Connected) { + connectedIds.append(radio.id); + if (!m_connectedRadioIds.contains(radio.id)) { + setDiscovering(false); + } + } + } + m_connectedRadioIds = std::move(connectedIds); } void TitleBar::setActiveRadio(const QString& id) diff --git a/src/gui/TitleBar.h b/src/gui/TitleBar.h index d3af7fae21..b082d7144f 100644 --- a/src/gui/TitleBar.h +++ b/src/gui/TitleBar.h @@ -237,6 +237,7 @@ class TitleBar : public QWidget { static constexpr int kHeartbeatAlarmThreshold = 3; int m_missedBeats{0}; bool m_blinkEnabled{true}; // persisted via AppSettings "HeartbeatBlinkEnabled" + QList m_connectedRadioIds; bool m_discovering{false}; // solid amber while waiting for connection QString m_throttleFlashColor; // empty = default green; set while adaptive throttle is active QString m_pcAudioInputDevice; diff --git a/src/gui/VfoWidget.cpp b/src/gui/VfoWidget.cpp index d4c71953b0..ecea11bf57 100644 --- a/src/gui/VfoWidget.cpp +++ b/src/gui/VfoWidget.cpp @@ -1,4 +1,5 @@ #include "VfoWidget.h" +#include "AudioButtonIcon.h" #include #include "ControlAvailabilityRegistry.h" #include "AntennaChoiceGate.h" @@ -860,7 +861,7 @@ void VfoWidget::buildUI() emit kiwiRxAntennaSelected(slice->sliceId(), profileId); } else { emit flexRxAntennaSelected(slice->sliceId()); - slice->setRxAntenna(token); + if (slice->rxAntenna() != token) { slice->setRxAntenna(token); } } }); menu->popup(m_rxAntBtn->mapToGlobal(QPoint(0, m_rxAntBtn->height()))); @@ -1497,7 +1498,7 @@ void VfoWidget::buildUI() tabLayout->setContentsMargins(0, 0, 0, 0); tabLayout->setSpacing(0); - const QStringList tabLabels = {"\xF0\x9F\x94\x8A", "DSP", "USB", "X/RIT", "DAX"}; + const QStringList tabLabels = {QString(), "DSP", "USB", "X/RIT", "DAX"}; m_daxTabIndex = tabLabels.indexOf(QLatin1String("DAX")); for (int i = 0; i < tabLabels.size(); ++i) { if (i > 0) { @@ -1518,6 +1519,7 @@ void VfoWidget::buildUI() btn->setFocusPolicy(Qt::TabFocus); connect(btn, &QPushButton::clicked, this, [this, i]() { showTab(i); }); if (i == 0) { + applyAudioIcon(btn, AudioIcon::Speaker, false, 16); // Right-click on speaker tab toggles mute directly btn->setContextMenuPolicy(Qt::CustomContextMenu); connect(btn, &QPushButton::customContextMenuRequested, this, [this](const QPoint&) { @@ -1625,7 +1627,13 @@ void VfoWidget::buildTabContent() // AF row: mute-toggle label + gain slider auto* gainRow = new QHBoxLayout; gainRow->setSpacing(3); - m_muteBtn = new QPushButton(QString::fromUtf8("AF \xF0\x9F\x94\x8A")); // AF 🔊 + m_muteBtn = new QPushButton(QStringLiteral("AF")); + applyAudioIcon(m_muteBtn, AudioIcon::Speaker, false, 16); + connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, [this] { + const bool muted = m_slice && m_slice->audioMute(); + applyAudioIcon(m_muteBtn, AudioIcon::Speaker, muted, 16); + applyAudioIcon(m_tabBtns[0], AudioIcon::Speaker, muted, 16); + }); m_muteBtn->setAccessibleName("Slice audio mute"); m_muteBtn->setCheckable(true); m_muteBtn->setFixedHeight(20); @@ -1839,10 +1847,8 @@ void VfoWidget::buildTabContent() AetherSDR::SplitAudioOperatorEdit op; // #2242: operator-origin m_slice->setAudioMute(on); } - m_muteBtn->setText(on ? QString::fromUtf8("AF \xF0\x9F\x94\x87") // 🔇 AF - : QString::fromUtf8("AF \xF0\x9F\x94\x8A")); // 🔊 AF - m_tabBtns[0]->setText(on ? QString::fromUtf8("\xF0\x9F\x94\x87") - : QString::fromUtf8("\xF0\x9F\x94\x8A")); + applyAudioIcon(m_muteBtn, AudioIcon::Speaker, on, 16); + applyAudioIcon(m_tabBtns[0], AudioIcon::Speaker, on, 16); if (!m_updatingFromModel) emit audioMuteToggled(on); // (#1560) }); // SQL button: when wired to an RxApplet (the normal case in @@ -2940,6 +2946,13 @@ void VfoWidget::buildTabContent() auto* plus = new TriBtn(TriBtn::Right); row->addWidget(plus); + m_ritControls = {m_ritBtn, zero, minus, m_ritLabel, plus}; + m_ritBtn->setAccessibleName(tr("Receive incremental tuning")); + zero->setAccessibleName(tr("Reset receive offset")); + minus->setAccessibleName(tr("Decrease receive offset")); + plus->setAccessibleName(tr("Increase receive offset")); + m_ritLabel->setAccessibleName(tr("Receive offset")); + connect(m_ritBtn, &QPushButton::toggled, this, [this](bool on) { if (!m_updatingFromModel && m_slice) m_slice->setRit(on, m_slice->ritFreq()); }); @@ -3483,21 +3496,28 @@ void VfoWidget::updateExtendedDspVisibility() // capability is applied here. void VfoWidget::applyRadioSideDspVisibility() { - m_nrBtn->setVisible(m_nrModeOk && m_hasRadioSideDsp); + const auto controls = m_radioModel && m_radioModel->isConnected() + ? m_radioModel->backendCapabilities().receiveDspControl : std::nullopt; + using Feature = ReceiveDspControl::Feature; + const auto supplied = [&controls](Feature feature, bool legacy) { + return controls ? controls->features.contains(feature) : legacy; + }; + m_nrBtn->setVisible(m_nrModeOk && (controls || supplied(Feature::Nr, m_hasRadioSideDsp))); // NB is the one member of the trio that does NOT need the radio to run its // own DSP: on a direct-sampling backend this host blanks the IQ itself, so // the button drives something real. OR'd rather than replaced, because on a // Flex the blanker is still the radio's — see // RadioCapabilities::hasHostNoiseBlanker. - m_nbBtn->setVisible(m_nbModeOk && (m_hasRadioSideDsp || m_hasHostNoiseBlanker)); - m_anfBtn->setVisible(m_anfModeOk && m_hasRadioSideDsp); + m_nbBtn->setVisible(m_nbModeOk && (controls || supplied(Feature::Nb, m_hasRadioSideDsp || m_hasHostNoiseBlanker))); + m_anfBtn->setVisible(m_anfModeOk && (controls || supplied(Feature::Anf, m_hasRadioSideDsp))); // The LMS/FFT three carry a SECOND capability on top of the first. A radio // can run its own DSP without running FlexRadio's — see // RadioCapabilities::hasLmsNoiseFilters. m_nrlBtn->setVisible(m_nrlModeOk && m_hasRadioSideDsp && m_hasLmsNoiseFilters); m_anflBtn->setVisible(m_anflModeOk && m_hasRadioSideDsp && m_hasLmsNoiseFilters); m_anftBtn->setVisible(m_anftModeOk && m_hasRadioSideDsp && m_hasLmsNoiseFilters); - m_mnBtn->setVisible(m_mnModeOk && m_hasRadioSideDsp && m_hasManualNotch); + m_mnBtn->setVisible(m_mnModeOk && (controls || supplied(Feature::ManualNotch, m_hasRadioSideDsp && m_hasManualNotch))); + if (m_filterAvailability) { m_filterAvailability->refreshEngaged(); } } void VfoWidget::setHasLmsNoiseFilters(bool has) @@ -4765,6 +4785,15 @@ void VfoWidget::setSlice(SliceModel* slice) return; } + const auto refreshConfirmed = [this] { + if (m_slice && m_slice->receiveControlsConfirmed()) { syncFromSlice(); } + }; + // Restore accepted presentation after the originating widget finishes its + // click handler; pending requests never masquerade as DSP readback. + connect(m_slice, &SliceModel::receiveAgcRequested, this, refreshConfirmed, Qt::QueuedConnection); + connect(m_slice, &SliceModel::receiveDspRequested, this, refreshConfirmed, Qt::QueuedConnection); + connect(m_slice, &SliceModel::ritCommandIssued, this, refreshConfirmed, Qt::QueuedConnection); + // Load per-slice display prefs now that we know the slice ID, then push // them out so the SpectrumWidget's overlay picks up the saved style. This // runs after wireVfoWidget() has connected markerStyleChanged (#1526). @@ -4852,6 +4881,7 @@ void VfoWidget::setSlice(SliceModel* slice) }); // Mode connect(m_slice, &SliceModel::modeChanged, this, [this](const QString& mode) { + updateAgcSliderFromSlice(); m_tabBtns[2]->setText(mode); // update mode tab label updateModeTab(); // Show/hide mode-specific DSP controls @@ -5423,10 +5453,8 @@ void VfoWidget::syncFromSlice() QSignalBlocker sb(m_muteBtn); bool muted = m_slice->audioMute(); m_muteBtn->setChecked(muted); - m_muteBtn->setText(muted ? QString::fromUtf8("AF \xF0\x9F\x94\x87") - : QString::fromUtf8("AF \xF0\x9F\x94\x8A")); - m_tabBtns[0]->setText(muted ? QString::fromUtf8("\xF0\x9F\x94\x87") - : QString::fromUtf8("\xF0\x9F\x94\x8A")); + applyAudioIcon(m_muteBtn, AudioIcon::Speaker, muted, 16); + applyAudioIcon(m_tabBtns[0], AudioIcon::Speaker, muted, 16); } syncSqlVisuals(); { @@ -5584,6 +5612,7 @@ void VfoWidget::syncFromSlice() } m_updatingFromModel = false; + if (m_filterAvailability) { m_filterAvailability->refreshEngaged(); } } void VfoWidget::updateFreqLabel() @@ -5774,6 +5803,7 @@ void VfoWidget::setDspLevelTarget(DspLevelTarget t) m_dspLevelSlider->setValue(level); } m_dspLevelValue->setText(QString::number(level)); + if (m_filterAvailability) { m_filterAvailability->refreshEngaged(); } if (visibilityChanged && m_activeTab == 1 && m_tabStack->isVisible()) { QTimer::singleShot(0, this, [this] { relayoutToCurrentContent(); }); } @@ -5945,13 +5975,23 @@ void VfoWidget::updateAgcSliderFromSlice() if (!m_slice || !m_agcTSlider || !m_agcValueLbl) return; const bool connected = m_radioModel && m_radioModel->isConnected(); - const bool available = !connected || m_slice->externalReceiveReplacementActive() - || m_radioModel->backendCapabilities().hasAgcThreshold; + const RadioCapabilities backendCaps = connected + ? m_radioModel->backendCapabilities() : RadioCapabilities{}; + const bool modeAvailable = !connected || m_slice->externalReceiveReplacementActive() + || !backendCaps.receiveDspControl + || backendCaps.receiveDspControl->supports(ReceiveDspControl::Feature::Agc, m_slice->mode()); + const bool available = modeAvailable && (!connected || m_slice->externalReceiveReplacementActive() + || backendCaps.hasAgcThreshold); m_agcTSlider->setEnabled(available); m_agcValueLbl->setEnabled(available); const RadioCapabilities caps = connected && !m_slice->externalReceiveReplacementActive() ? m_radioModel->backendCapabilities() : RadioCapabilities{}; setAgcModeAvailability(m_agcCmb, caps.agcModes); + m_agcCmb->setEnabled(modeAvailable); + m_agcCmb->setAccessibleDescription(modeAvailable ? tr("Automatic gain control speed") + : tr("AGC is unavailable in this receive mode")); + m_agcTSlider->setAccessibleDescription(available ? tr("Receive AGC gain control") + : tr("AGC threshold and off level are unavailable in this receive mode")); const bool agcOff = (m_slice->receiveAgcMode() == "off"); const int minimum = agcOff ? 0 : agcThresholdMinimum(); const int maximum = agcOff ? 100 : agcThresholdMaximum(); @@ -6739,6 +6779,52 @@ void VfoWidget::setRadioModel(RadioModel* radioModel) ? exclusiveSquelchScaleValue(m_radioModel->backendCapabilities().squelchLevelScale) : std::nullopt; if (m_radioModel) { m_filterAvailability = new ControlAvailabilityRegistry(*m_radioModel, this); + m_filterAvailability->registerRestriction(m_txAntBtn, + tr("This radio is receive-only; no transmit antenna is available"), + [](bool connected, const RadioCapabilities& caps) { + return !connected || caps.canTransmit; + }); + m_filterAvailability->registerRestriction(m_rxAntBtn, + tr("This receiver has one fixed receive input"), + [this](bool connected, const RadioCapabilities& caps) { + const bool virtualAntennas = m_kiwiSdrManager + && !m_kiwiSdrManager->virtualAntennaTokens().isEmpty(); + return !connected || caps.canTransmit || !m_slice + || m_slice->rxAntennaList().size() != 1 || virtualAntennas; + }); + using Feature = ReceiveDspControl::Feature; + const auto signalAvailable = [this](bool connected, const RadioCapabilities& caps, Feature feature) { + return !connected || !caps.receiveDspControl + || (m_slice && caps.receiveDspControl->supports(feature, m_slice->mode())); + }; + const auto registerSignalControl = [this, signalAvailable](QWidget* widget, Feature feature) { + m_filterAvailability->registerRestriction(widget, + tr("This receive control is unavailable in the current mode"), + [signalAvailable, feature](bool connected, const RadioCapabilities& caps) { + return signalAvailable(connected, caps, feature); + }); + }; + registerSignalControl(m_nbBtn, Feature::Nb); + registerSignalControl(m_nrBtn, Feature::Nr); + registerSignalControl(m_anfBtn, Feature::Anf); + registerSignalControl(m_mnBtn, Feature::ManualNotch); + registerSignalControl(m_apfBtn, Feature::Apf); + registerSignalControl(m_apfSlider, Feature::Apf); + registerSignalControl(m_nbFillCombo, Feature::Nb); + m_filterAvailability->registerRestriction(m_dspLevelSlider, + tr("This DSP parameter is unavailable in the current receive mode"), + [this, signalAvailable](bool connected, const RadioCapabilities& caps) { + if (!connected || !caps.receiveDspControl) { return true; } + switch (m_dspLevelTarget) { + case LvlNB: return signalAvailable(connected, caps, Feature::Nb); + case LvlNR: return signalAvailable(connected, caps, Feature::Nr); + case LvlAnf: return signalAvailable(connected, caps, Feature::Anf); + case LvlMn: return signalAvailable(connected, caps, Feature::ManualNotch); + default: return true; // Client-chain targets have their own availability rules. + } + }); + for (QWidget* widget : m_ritControls) { registerSignalControl(widget, Feature::Rit); } + const auto supportsSquelch = [this](bool connected, const RadioCapabilities& caps) { if (!m_slice) { return !exclusiveSquelchScale(caps.squelchLevelScale); @@ -6772,6 +6858,7 @@ void VfoWidget::setRadioModel(RadioModel* radioModel) [this](bool connected, const RadioCapabilities& caps) { m_receiveFilterControl = connected ? caps.receiveFilterControl : std::nullopt; m_exclusiveSquelch = connected ? exclusiveSquelchScaleValue(caps.squelchLevelScale) : std::nullopt; + updateAntennaButtons(); configureRepeaterReverseControl(); configureFmToneControls(); updateAgcSliderFromSlice(); @@ -7005,6 +7092,11 @@ QString VfoWidget::antennaMenuLabel(const QString& token, QStringList VfoWidget::rxAntennaOptions() const { + if (m_radioModel && m_radioModel->isConnected() + && !m_radioModel->backendCapabilities().canTransmit && m_slice + && !m_slice->rxAntennaList().isEmpty()) { + return m_slice->rxAntennaList(); + } QStringList options; auto append = [&options](const QString& token) { if (!token.isEmpty() && !options.contains(token)) { @@ -7036,6 +7128,8 @@ QStringList VfoWidget::rxAntennaOptions() const QStringList VfoWidget::txAntennaOptions() const { + if (m_radioModel && m_radioModel->isConnected() + && !m_radioModel->backendCapabilities().canTransmit) { return {}; } QStringList options; auto append = [&options](const QString& token) { if (!token.isEmpty() && !options.contains(token)) @@ -7064,6 +7158,10 @@ void VfoWidget::updateAntennaButton(QPushButton* button, const QString& token, b return; QString effectiveToken = token; + if (tx && m_radioModel && m_radioModel->isConnected() + && !m_radioModel->backendCapabilities().canTransmit) { + effectiveToken = tr("None"); + } if (!tx && m_kiwiSdrManager && m_slice) { const QString profileId = m_kiwiSdrManager->assignedProfileForSlice(m_slice->sliceId()); @@ -7098,6 +7196,7 @@ void VfoWidget::updateAntennaButton(QPushButton* button, const QString& token, b : QStringLiteral("%1 antenna port: %2") .arg(tx ? QStringLiteral("Transmit") : QStringLiteral("Receive"), full)); + if (m_filterAvailability) { m_filterAvailability->refreshEngaged(); } } void VfoWidget::updateAntennaButtons() diff --git a/src/gui/VfoWidget.h b/src/gui/VfoWidget.h index 108cfb56c9..a60ba0e9b3 100644 --- a/src/gui/VfoWidget.h +++ b/src/gui/VfoWidget.h @@ -832,6 +832,7 @@ class VfoWidget : public QWidget { bool m_anftModeOk{true}; bool m_mnModeOk{true}; // RIT/XIT tab + QList m_ritControls; QPushButton* m_ritBtn{nullptr}; QPushButton* m_xitBtn{nullptr}; ScrollableLabel* m_ritLabel{nullptr}; diff --git a/src/models/DecoderAudioModel.cpp b/src/models/DecoderAudioModel.cpp index 012a561079..031aec7a1c 100644 --- a/src/models/DecoderAudioModel.cpp +++ b/src/models/DecoderAudioModel.cpp @@ -47,6 +47,7 @@ struct DecoderAudioModel::Impl { case Consumer::Cw: return "CW"; case Consumer::Rtty: return "RTTY"; case Consumer::Ax25: return "AX.25"; + case Consumer::NativeAsr: return "ASR"; } return "Decoder"; } @@ -83,6 +84,7 @@ struct DecoderAudioModel::Impl { case Consumer::Cw: return PanadapterStream::DaxConsumer::CwDecoder; case Consumer::Rtty: return PanadapterStream::DaxConsumer::RttyDecoder; case Consumer::Ax25: return PanadapterStream::DaxConsumer::Ax25Decoder; + case Consumer::NativeAsr: break; // Native-only consumers never acquire DAX. } Q_UNREACHABLE(); } @@ -141,7 +143,7 @@ struct DecoderAudioModel::Impl { void drain(const std::shared_ptr& box) { if (box != inbox || !enabled || !radio || !radio->isConnected() - || !slice || radio->slice(slice->sliceId()) != slice) { + || !slice || !slice->inCapture() || radio->slice(slice->sliceId()) != slice) { // Leaving `scheduled` set on a box that is still the live inbox // stops enqueue() ever posting another drain: the route would keep // reporting Bound while every later frame piled up to the overflow @@ -214,9 +216,11 @@ struct DecoderAudioModel::Impl { if (!guard || revision != bindingRevision) { return; } + const bool nativeOnly = consumer == Consumer::NativeAsr; const bool live = enabled && radio && radio->isConnected() && slice - && radio->slice(slice->sliceId()) == slice; - const bool dax = live && radio->hasDaxStreams(); + && radio->slice(slice->sliceId()) == slice && slice->inCapture() + && (!nativeOnly || radio->backendCapabilities().receiveAudioExport.has_value()); + const bool dax = live && !nativeOnly && radio->hasDaxStreams(); const int channel = dax ? slice->daxChannel() : 0; const int wantedHold = channel >= 1 && channel <= 8 ? channel : 0; bool acquired = true; @@ -340,6 +344,7 @@ void DecoderAudioModel::setSlice(SliceModel* slice) auto reset = [this] { m_impl->rebind(); }; d.sliceConnections.append(connect(slice, &SliceModel::frequencyChanged, this, reset)); d.sliceConnections.append(connect(slice, &SliceModel::modeChanged, this, reset)); + d.sliceConnections.append(connect(slice, &SliceModel::inCaptureChanged, this, reset)); d.sliceConnections.append(connect(slice, &SliceModel::daxChannelChanged, this, reset)); d.sliceConnections.append(connect(slice, &QObject::destroyed, this, [this] { m_impl->slice = nullptr; diff --git a/src/models/DecoderAudioModel.h b/src/models/DecoderAudioModel.h index 2ed5f62695..2feebfdbc8 100644 --- a/src/models/DecoderAudioModel.h +++ b/src/models/DecoderAudioModel.h @@ -18,7 +18,7 @@ class SliceModel; class DecoderAudioModel final : public QObject { Q_OBJECT public: - enum class Consumer { Cw, Rtty, Ax25 }; + enum class Consumer { Cw, Rtty, Ax25, NativeAsr }; // Bound means an input subscription exists, not that PCM has arrived. // SharedRxAudio is bound too: a DAX-capable radio with no channel assigned // to the selected slice decodes the radio's shared receive stream instead, diff --git a/src/models/ModeVocabulary.h b/src/models/ModeVocabulary.h index e1c84e80ef..7e2b972832 100644 --- a/src/models/ModeVocabulary.h +++ b/src/models/ModeVocabulary.h @@ -21,15 +21,23 @@ inline bool isVoiceMode(const QString& mode) || mode == QLatin1String("DFM"); } -// AetherSDR's older neutral vocabulary used CW for upper-side CW. Icom -// reports that same mode explicitly as CWU; CWL is the reverse-side mode. -// (The HL2 did too, until Hl2Backend::setSliceMode began collapsing CWU onto -// CW. All three spellings stay here: Icom still produces CWU, and a guard -// that is only correct while every backend canonicalises is the wrong kind.) +// CW/CWU use the upper sideband; CWL/CWR explicitly use the reverse sideband. +inline bool isReverseCwMode(const QString& mode) +{ + return mode == QLatin1String("CWL") || mode == QLatin1String("CWR"); +} + +// Only a command-plane receiver interprets the session CWL offset flag. +// Explicit reverse modes remain authoritative on independent host receivers. +inline int cwReceiveSidebandSign(const QString& mode, bool hasCommandPlane, bool cwlEnabled) +{ + return isReverseCwMode(mode) || (hasCommandPlane && cwlEnabled) ? -1 : 1; +} + inline bool isCwMode(const QString& mode) { return mode == QLatin1String("CW") || mode == QLatin1String("CWU") - || mode == QLatin1String("CWL"); + || isReverseCwMode(mode); } } // namespace AetherSDR diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index a4ee57471a..a5a6808a00 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -1083,19 +1083,6 @@ void RadioModel::setupBackend(const QString& family) // The backend confirms a pan is GONE. Only now is the pane dropped: the // backend can refuse a close (the last receiver on an HL2), and tearing the // model down optimistically would leave a receiver streaming into nothing. - // Notch state reported BY the backend. Only a backend whose notches live in - // this process emits these — a Flex reports TNFs as `tnf …` status on - // its command plane, which handleStatus() already decodes into the same - // model. Both routes end at TnfModel, so the panadapter overlay is drawn - // from one place regardless of where the notch physically is. - connect(m_backend.get(), &IRadioBackend::notchChanged, this, - [this](int notchId, const NotchDelta& delta) { - m_tnfModel.applyNotchDelta(notchId, delta); - }); - connect(m_backend.get(), &IRadioBackend::notchRemoved, this, [this](int notchId) { - m_tnfModel.removeTnf(notchId); - }); - connect(m_backend.get(), &IRadioBackend::panRemoved, this, [this](const QString& backendPanId) { auto* pan = resolveBackendPan(backendPanId); @@ -1306,11 +1293,6 @@ void RadioModel::setupBackend(const QString& family) connect(m_backend.get(), &IRadioBackend::tunerChanged, this, [this](const TunerDelta& delta) { m_tunerModel.applyChanges(delta); }); - // aetherd RFC 2.3 (RadioModel residual): radio-global status decoded in the - // backend drives RadioModel's own state via applyRadioChanges. - connect(m_backend.get(), &IRadioBackend::radioChanged, this, - [this](const RadioDelta& delta) { applyRadioChanges(delta); }); - // aetherd RFC 2.3 (RadioModel residual): GPS / memory-slot / profile status // decoded in the backend drive RadioModel's own state via the apply* methods. connect(m_backend.get(), &IRadioBackend::gpsChanged, this, @@ -1547,6 +1529,29 @@ void RadioModel::wireBackendReceiverState() m_transmitModel.handleControlAnswer(ticket, applied); } }, Qt::QueuedConnection); + + // aetherd RFC 2.3 (RadioModel residual): radio-global status decoded in the + // backend drives RadioModel's own state via applyRadioChanges. + connect(m_backend.get(), &IRadioBackend::radioChanged, this, + [this, generation](const RadioDelta& delta) { + if (generation == m_backendReceiverGeneration) { applyRadioChanges(delta); } + }); + + // Notch state reported BY the backend. Only a backend whose notches live in + // this process emits these — a Flex reports TNFs as `tnf …` status on + // its command plane, which handleStatus() already decodes into the same + // model. Both routes end at TnfModel, so the panadapter overlay is drawn + // from one place regardless of where the notch physically is. + connect(m_backend.get(), &IRadioBackend::notchChanged, this, + [this, generation](int notchId, const NotchDelta& delta) { + if (generation != m_backendReceiverGeneration) { return; } + m_tnfModel.applyNotchDelta(notchId, delta); + }); + connect(m_backend.get(), &IRadioBackend::notchRemoved, this, [this, generation](int notchId) { + if (generation != m_backendReceiverGeneration) { return; } + m_tnfModel.removeTnf(notchId); + }); + // aetherd RFC 2.3: the first converted touchpoint. The backend decodes the // universal pan center/bandwidth from Flex status and emits this normalized // signal; RadioModel drives the addressed PanadapterModel. (Template for the @@ -1677,6 +1682,17 @@ void RadioModel::wireBackendReceiverState() } s = new SliceModel(sliceId, this); wireSliceReceiveIntentsToBackend(s); + connect(s, &SliceModel::ritCommandIssued, this, [this, s, generation](bool on, int hz) { + if (generation != m_backendReceiverGeneration || receiveCommandSource() != s + || m_backend->receiveControlPolicy() != ReceiveControlPolicy::Confirmed) { return; } + const int id = s->sliceId(); + m_backend->setSliceRitEnabled(id, on); + // A synchronous adoption can retire the receiver or disconnect + // this session before the companion offset reaches the seam. + if (generation != m_backendReceiverGeneration || receiveCommandSource() != s + || m_backend->receiveControlPolicy() != ReceiveControlPolicy::Confirmed) { return; } + m_backend->setSliceRitOffset(id, hz); + }); // Tuning capture of the client-owned step, whatever moved it. connect(s, &SliceModel::stepChanged, this, [this](int hz) { @@ -2123,7 +2139,8 @@ RadioModel::RadioModel(QObject* parent) // passband. Not for Flex, which applies `cw pitch` on-radio. connect(&m_transmitModel, &TransmitModel::cwPitchChanged, this, [this](int hz) { - if (m_backend && backendCapabilities().hostModulates) { + if (m_backend && backendCapabilities().hostModulates + && !backendCapabilities().receiveCwPitchControl) { const QPointer alive(this); const QPointer backend(m_backend.get()); const quint64 generation = m_backendReceiverGeneration; @@ -2133,6 +2150,19 @@ RadioModel::RadioModel(QObject* parent) m_cwPitchHandedToBackend = hz; } }); + connect(&m_transmitModel, &TransmitModel::cwPitchCommandIssued, this, + [this](int hz) { + if (m_backend && isConnected() && backendCapabilities().receiveCwPitchControl) { + const auto pitch = backendCapabilities().receiveCwPitchControl; + if (pitch && (hz < pitch->minimumHz || hz > pitch->maximumHz)) { return; } + const QPointer alive(this); + const QPointer backend(m_backend.get()); + const quint64 generation = m_backendReceiverGeneration; + m_backend->setCwPitch(hz); + if (!alive || !backend || m_backendReceiverGeneration != generation) { return; } + m_cwPitchHandedToBackend = hz; + } + }); // Host-keyed radios have nowhere to retain their keyer and sidetone // controls. Persist the complete CW surface only when the live backend @@ -2307,15 +2337,15 @@ RadioModel::RadioModel(QObject* parent) }); connect(&m_tnfModel, &TnfModel::notchChangeRequested, this, [this](int id, const NotchDelta& delta){ - if (m_backend) + if (m_backend && isConnected()) m_backend->setNotch(id, delta); }); connect(&m_tnfModel, &TnfModel::notchRemoveRequested, this, [this](int id){ - if (m_backend) + if (m_backend && isConnected()) m_backend->removeNotch(id); }); connect(&m_tnfModel, &TnfModel::notchesEnabledRequested, this, [this](bool on){ - if (m_backend) + if (m_backend && isConnected()) m_backend->setNotchesEnabled(on); }); // No CWX text while TUNE is active (#5422): the radio keys it at TUNE power. @@ -4368,6 +4398,8 @@ void RadioModel::updateTuneAvailability() void RadioModel::publishCapabilities(bool connected) { const RadioCapabilities caps = backendCapabilities(); + m_tnfModel.setControlPolicy(connected && m_backend + ? m_backend->receiveControlPolicy() : ReceiveControlPolicy::Optimistic); m_meterModel.setCompressionMaximumDb(connected ? caps.compressionMaximumDb : 25.0f); m_cwxModel.setSpeedModifiersEnabled(!connected || caps.cwTextSupportsSpeedModifiers); @@ -4377,6 +4409,9 @@ void RadioModel::publishCapabilities(bool connected) // Capability-driven, not family()!="flex": only a backend that both // host-modulates and may transmit collapses the mic source to PC. (#4449) + m_transmitModel.setReceiveCwPitchConfirmation(connected + && caps.receiveCwPitchControl.has_value() + && m_backend->receiveControlPolicy() == ReceiveControlPolicy::Confirmed); m_transmitModel.setHostModulation(connected && caps.hostModulates && caps.canTransmit); // Whether an antenna tuner exists at all. Same shape and the same diff --git a/src/models/SliceModel.cpp b/src/models/SliceModel.cpp index 4a239c214f..29d249661e 100644 --- a/src/models/SliceModel.cpp +++ b/src/models/SliceModel.cpp @@ -470,6 +470,14 @@ SliceDspRequest SliceModel::currentDspRequest(SliceDspRequest::Feature feature, void SliceModel::setNb(bool on) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Nb, SliceDspRequest::Field::Enabled); + request.enabled = on; + request.kind = on ? NoiseBlankerKind::Impulse : NoiseBlankerKind::Off; + emit receiveDspRequested(request); + return; + } m_nbKind = on ? AetherSDR::NoiseBlankerKind::Impulse : AetherSDR::NoiseBlankerKind::Off; notifyReceiveDspIntent(SliceDspRequest::Feature::Nb, SliceDspRequest::Field::Enabled, @@ -481,6 +489,14 @@ void SliceModel::setNb(bool on) void SliceModel::setNbKind(AetherSDR::NoiseBlankerKind kind) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Nb, SliceDspRequest::Field::Enabled); + request.enabled = kind != NoiseBlankerKind::Off; + request.kind = kind; + emit receiveDspRequested(request); + return; + } m_nbKind = kind; // Off is not a separate verb: the kind carries it, and nbOn() follows. notifyReceiveDspIntent(SliceDspRequest::Feature::Nb, SliceDspRequest::Field::Enabled, @@ -492,6 +508,13 @@ void SliceModel::setNbKind(AetherSDR::NoiseBlankerKind kind) void SliceModel::setNbFill(AetherSDR::NoiseBlankerFill fill) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Nb, SliceDspRequest::Field::Fill); + request.fill = fill; + emit receiveDspRequested(request); + return; + } if (m_nbFill == fill) return; m_nbFill = fill; // No wire text: there is no Flex command for it. This is a parameter of a @@ -502,6 +525,13 @@ void SliceModel::setNbFill(AetherSDR::NoiseBlankerFill fill) void SliceModel::setNr(bool on) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Nr, SliceDspRequest::Field::Enabled); + request.enabled = on; + emit receiveDspRequested(request); + return; + } m_nr = on; notifyReceiveDspIntent(SliceDspRequest::Feature::Nr, SliceDspRequest::Field::Enabled, [this, on] { emit nrChanged(on); }); @@ -509,6 +539,13 @@ void SliceModel::setNr(bool on) void SliceModel::setAnf(bool on) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Anf, SliceDspRequest::Field::Enabled); + request.enabled = on; + emit receiveDspRequested(request); + return; + } m_anf = on; notifyReceiveDspIntent(SliceDspRequest::Feature::Anf, SliceDspRequest::Field::Enabled, [this, on] { emit anfChanged(on); }); @@ -521,6 +558,13 @@ void SliceModel::setAnf(bool on) // look wired on a family that does not have it. void SliceModel::setMn(bool on) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Mn, SliceDspRequest::Field::Enabled); + request.enabled = on; + emit receiveDspRequested(request); + return; + } if (m_mn == on) return; m_mn = on; notifyReceiveDspIntent(SliceDspRequest::Feature::Mn, SliceDspRequest::Field::Enabled, @@ -530,6 +574,13 @@ void SliceModel::setMn(bool on) // v4 DSP toggles — command keys differ from status keys (FlexLib Slice.cs) void SliceModel::setNrl(bool on) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Nrl, SliceDspRequest::Field::Enabled); + request.enabled = on; + emit receiveDspRequested(request); + return; + } m_nrl = on; notifyReceiveDspIntent(SliceDspRequest::Feature::Nrl, SliceDspRequest::Field::Enabled, [this, on] { emit nrlChanged(on); }); @@ -537,6 +588,13 @@ void SliceModel::setNrl(bool on) void SliceModel::setNrs(bool on) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Nrs, SliceDspRequest::Field::Enabled); + request.enabled = on; + emit receiveDspRequested(request); + return; + } m_nrs = on; notifyReceiveDspIntent(SliceDspRequest::Feature::Nrs, SliceDspRequest::Field::Enabled, [this, on] { emit nrsChanged(on); }); @@ -544,6 +602,13 @@ void SliceModel::setNrs(bool on) void SliceModel::setRnn(bool on) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Rnn, SliceDspRequest::Field::Enabled); + request.enabled = on; + emit receiveDspRequested(request); + return; + } m_rnn = on; notifyReceiveDspIntent(SliceDspRequest::Feature::Rnn, SliceDspRequest::Field::Enabled, [this, on] { emit rnnChanged(on); }); @@ -551,6 +616,13 @@ void SliceModel::setRnn(bool on) void SliceModel::setNrf(bool on) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Nrf, SliceDspRequest::Field::Enabled); + request.enabled = on; + emit receiveDspRequested(request); + return; + } m_nrf = on; notifyReceiveDspIntent(SliceDspRequest::Feature::Nrf, SliceDspRequest::Field::Enabled, [this, on] { emit nrfChanged(on); }); @@ -558,6 +630,13 @@ void SliceModel::setNrf(bool on) void SliceModel::setAnfl(bool on) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Anfl, SliceDspRequest::Field::Enabled); + request.enabled = on; + emit receiveDspRequested(request); + return; + } m_anfl = on; notifyReceiveDspIntent(SliceDspRequest::Feature::Anfl, SliceDspRequest::Field::Enabled, [this, on] { emit anflChanged(on); }); @@ -565,6 +644,13 @@ void SliceModel::setAnfl(bool on) void SliceModel::setAnft(bool on) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Anft, SliceDspRequest::Field::Enabled); + request.enabled = on; + emit receiveDspRequested(request); + return; + } m_anft = on; notifyReceiveDspIntent(SliceDspRequest::Feature::Anft, SliceDspRequest::Field::Enabled, [this, on] { emit anftChanged(on); }); @@ -572,6 +658,13 @@ void SliceModel::setAnft(bool on) void SliceModel::setApf(bool on) { + if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Apf, SliceDspRequest::Field::Enabled); + request.enabled = on; + emit receiveDspRequested(request); + return; + } m_apf = on; notifyReceiveDspIntent(SliceDspRequest::Feature::Apf, SliceDspRequest::Field::Enabled, [this, on] { emit apfChanged(on); }); @@ -579,7 +672,14 @@ void SliceModel::setApf(bool on) void SliceModel::setApfLevel(int v) { + if (refuseOffThread(__func__)) { return; } v = std::clamp(v, 0, 100); + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Apf, SliceDspRequest::Field::Level); + request.level = v; + emit receiveDspRequested(request); + return; + } if (m_apfLevel == v) return; m_apfLevel = v; notifyReceiveDspIntent(SliceDspRequest::Feature::Apf, SliceDspRequest::Field::Level, @@ -588,7 +688,14 @@ void SliceModel::setApfLevel(int v) void SliceModel::setNbLevel(int v) { + if (refuseOffThread(__func__)) { return; } v = std::clamp(v, 0, 100); + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Nb, SliceDspRequest::Field::Level); + request.level = v; + emit receiveDspRequested(request); + return; + } if (m_nbLevel == v) return; m_nbLevel = v; notifyReceiveDspIntent(SliceDspRequest::Feature::Nb, SliceDspRequest::Field::Level, @@ -597,7 +704,14 @@ void SliceModel::setNbLevel(int v) void SliceModel::setNrLevel(int v) { + if (refuseOffThread(__func__)) { return; } v = std::clamp(v, 0, 100); + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Nr, SliceDspRequest::Field::Level); + request.level = v; + emit receiveDspRequested(request); + return; + } if (m_nrLevel == v) return; m_nrLevel = v; notifyReceiveDspIntent(SliceDspRequest::Feature::Nr, SliceDspRequest::Field::Level, @@ -606,7 +720,14 @@ void SliceModel::setNrLevel(int v) void SliceModel::setAnfLevel(int v) { + if (refuseOffThread(__func__)) { return; } v = std::clamp(v, 0, 100); + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Anf, SliceDspRequest::Field::Level); + request.level = v; + emit receiveDspRequested(request); + return; + } if (m_anfLevel == v) return; m_anfLevel = v; notifyReceiveDspIntent(SliceDspRequest::Feature::Anf, SliceDspRequest::Field::Level, @@ -619,7 +740,14 @@ void SliceModel::setAnfLevel(int v) // was not given. void SliceModel::setMnLevel(int v) { + if (refuseOffThread(__func__)) { return; } v = std::clamp(v, 0, 100); + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Mn, SliceDspRequest::Field::Level); + request.level = v; + emit receiveDspRequested(request); + return; + } if (m_mnLevel == v) return; m_mnLevel = v; notifyReceiveDspIntent(SliceDspRequest::Feature::Mn, SliceDspRequest::Field::Level, @@ -628,7 +756,14 @@ void SliceModel::setMnLevel(int v) void SliceModel::setNrlLevel(int v) { + if (refuseOffThread(__func__)) { return; } v = std::clamp(v, 0, 100); + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Nrl, SliceDspRequest::Field::Level); + request.level = v; + emit receiveDspRequested(request); + return; + } if (m_nrlLevel == v) return; m_nrlLevel = v; notifyReceiveDspIntent(SliceDspRequest::Feature::Nrl, SliceDspRequest::Field::Level, @@ -637,7 +772,14 @@ void SliceModel::setNrlLevel(int v) void SliceModel::setNrsLevel(int v) { + if (refuseOffThread(__func__)) { return; } v = std::clamp(v, 0, 100); + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Nrs, SliceDspRequest::Field::Level); + request.level = v; + emit receiveDspRequested(request); + return; + } if (m_nrsLevel == v) return; m_nrsLevel = v; notifyReceiveDspIntent(SliceDspRequest::Feature::Nrs, SliceDspRequest::Field::Level, @@ -646,7 +788,14 @@ void SliceModel::setNrsLevel(int v) void SliceModel::setNrfLevel(int v) { + if (refuseOffThread(__func__)) { return; } v = std::clamp(v, 0, 100); + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Nrf, SliceDspRequest::Field::Level); + request.level = v; + emit receiveDspRequested(request); + return; + } if (m_nrfLevel == v) return; m_nrfLevel = v; notifyReceiveDspIntent(SliceDspRequest::Feature::Nrf, SliceDspRequest::Field::Level, @@ -655,7 +804,14 @@ void SliceModel::setNrfLevel(int v) void SliceModel::setAnflLevel(int v) { + if (refuseOffThread(__func__)) { return; } v = std::clamp(v, 0, 100); + if (confirmsControls()) { + auto request = currentDspRequest(SliceDspRequest::Feature::Anfl, SliceDspRequest::Field::Level); + request.level = v; + emit receiveDspRequested(request); + return; + } if (m_anflLevel == v) return; m_anflLevel = v; notifyReceiveDspIntent(SliceDspRequest::Feature::Anfl, SliceDspRequest::Field::Level, @@ -674,6 +830,7 @@ void SliceModel::recallAgcMode(const QString& mode) void SliceModel::applyAgcMode(const QString& mode, SliceAgcRequest::Origin origin) { + if (refuseOffThread(__func__)) { return; } if (m_externalReceiveAudioReplacement) { if (m_externalReceiveAgcMode == mode) { return; @@ -683,6 +840,11 @@ void SliceModel::applyAgcMode(const QString& mode, SliceAgcRequest::Origin origi return; } + if (confirmsControls()) { + emit receiveAgcRequested({SliceAgcRequest::Field::Mode, mode, + m_agcThreshold, m_agcOffLevel, origin}); + return; + } if (m_agcMode == mode) { return; } @@ -743,6 +905,7 @@ void SliceModel::setAgcTKnobLevel(int value) void SliceModel::setAgcThreshold(int value) { + if (refuseOffThread(__func__)) { return; } if (m_externalReceiveAudioReplacement) { value = qBound(KiwiSdrProtocol::kAgcThresholdMinDb, value, KiwiSdrProtocol::kAgcThresholdMaxDb); @@ -755,6 +918,11 @@ void SliceModel::setAgcThreshold(int value) } value = qBound(0, value, 100); + if (confirmsControls()) { + emit receiveAgcRequested({SliceAgcRequest::Field::Threshold, m_agcMode, + value, m_agcOffLevel, SliceAgcRequest::Origin::Operator}); + return; + } if (m_agcThreshold == value) { return; } @@ -776,6 +944,7 @@ void SliceModel::setAgcThreshold(int value) void SliceModel::setAgcOffLevel(int value) { + if (refuseOffThread(__func__)) { return; } value = qBound(0, value, 100); if (m_externalReceiveAudioReplacement) { if (m_externalReceiveAgcOffLevel == value) { @@ -786,6 +955,11 @@ void SliceModel::setAgcOffLevel(int value) return; } + if (confirmsControls()) { + emit receiveAgcRequested({SliceAgcRequest::Field::OffLevel, m_agcMode, + m_agcThreshold, value, SliceAgcRequest::Origin::Operator}); + return; + } if (m_agcOffLevel == value) { return; } @@ -897,6 +1071,10 @@ void SliceModel::setExternalReceiveAutoSquelch(bool on) void SliceModel::setRit(bool on, int hz) { if (refuseOffThread(__func__)) { return; } + if (confirmsControls()) { + emit ritCommandIssued(on, hz); + return; + } const auto field = SliceControlRequest::Field::Rit; hz = std::clamp(hz, -kIncrementalTuningMaxHz, kIncrementalTuningMaxHz); const auto current = captureControlIntent(field); diff --git a/src/models/SliceModel.h b/src/models/SliceModel.h index c79cc2f97e..33c5582cbf 100644 --- a/src/models/SliceModel.h +++ b/src/models/SliceModel.h @@ -88,6 +88,7 @@ class SliceModel : public QObject { bool adaptiveActive() const { return m_adaptiveActive; } bool isActive() const { return m_active; } bool inCapture() const { return m_inCapture; } + bool receiveControlsConfirmed() const { return confirmsControls(); } bool isTxSlice() const { return m_txSlice; } float rfGain() const { return m_rfGain; } float audioGain() const { return m_externalReceiveAudioReplacement diff --git a/src/models/TnfModel.cpp b/src/models/TnfModel.cpp index 3409f6a1ed..22458faf53 100644 --- a/src/models/TnfModel.cpp +++ b/src/models/TnfModel.cpp @@ -101,7 +101,9 @@ void TnfModel::setTnfFreq(int id, double freqMhz) { NotchDelta delta; delta.centerHz = freqMhz * 1.0e6; + const bool confirmed = confirmsControls(); emit notchChangeRequested(id, delta); + if (confirmed) { return; } auto it = m_tnfs.find(id); if (it != m_tnfs.end() && !qFuzzyCompare(it->freqMhz + 1.0, freqMhz + 1.0)) { @@ -115,7 +117,9 @@ void TnfModel::setTnfWidth(int id, int widthHz) const int clampedWidthHz = std::max(10, widthHz); NotchDelta delta; delta.widthHz = static_cast(clampedWidthHz); + const bool confirmed = confirmsControls(); emit notchChangeRequested(id, delta); + if (confirmed) { return; } auto it = m_tnfs.find(id); if (it != m_tnfs.end() && it->widthHz != clampedWidthHz) { @@ -129,7 +133,9 @@ void TnfModel::setTnfDepth(int id, int depthDb) const int clampedDepthDb = std::clamp(depthDb, 1, 3); NotchDelta delta; delta.depthDb = clampedDepthDb; + const bool confirmed = confirmsControls(); emit notchChangeRequested(id, delta); + if (confirmed) { return; } auto it = m_tnfs.find(id); if (it != m_tnfs.end() && it->depthDb != clampedDepthDb) { @@ -142,7 +148,9 @@ void TnfModel::setTnfPermanent(int id, bool on) { NotchDelta delta; delta.permanent = on; + const bool confirmed = confirmsControls(); emit notchChangeRequested(id, delta); + if (confirmed) { return; } // Radio doesn't send status update — update locally auto it = m_tnfs.find(id); if (it != m_tnfs.end()) { @@ -153,14 +161,18 @@ void TnfModel::setTnfPermanent(int id, bool on) void TnfModel::requestRemoveTnf(int id) { + const bool confirmed = confirmsControls(); emit notchRemoveRequested(id); + if (confirmed) { return; } // Radio does not send a removal status — remove optimistically removeTnf(id); } void TnfModel::requestGlobalTnfEnabled(bool on) { + const bool confirmed = confirmsControls(); emit notchesEnabledRequested(on); + if (confirmed) { return; } // Optimistic update — radio echoes tnf_enabled in status, but update // immediately so the UI reflects the change without waiting for the echo. if (m_globalEnabled != on) { diff --git a/src/models/TnfModel.h b/src/models/TnfModel.h index 03814c89cb..47c124f630 100644 --- a/src/models/TnfModel.h +++ b/src/models/TnfModel.h @@ -4,6 +4,7 @@ #include #include "core/backends/NotchDelta.h" +#include "core/backends/SliceDelta.h" namespace AetherSDR { @@ -25,6 +26,8 @@ class TnfModel : public QObject { const QMap& tnfs() const { return m_tnfs; } const TnfEntry* tnf(int id) const; bool globalEnabled() const { return m_globalEnabled; } + void setControlPolicy(ReceiveControlPolicy policy) { m_controlPolicy = policy; } + bool confirmsControls() const { return m_controlPolicy == ReceiveControlPolicy::Confirmed; } // ── Status parsing (called from RadioModel) ───────────────────────── // The Flex path: raw `tnf …` status keys off the command plane. @@ -37,7 +40,7 @@ class TnfModel : public QObject { void removeTnf(int id); void applyGlobalEnabled(bool on); - // ── Commands (emit commandReady) ──────────────────────────────────── + // ── Commands (typed backend intents) ─────────────────────────────── void createTnf(double freqMhz); void setTnfFreq(int id, double freqMhz); void setTnfWidth(int id, int widthHz); @@ -65,6 +68,7 @@ class TnfModel : public QObject { private: QMap m_tnfs; bool m_globalEnabled{true}; + ReceiveControlPolicy m_controlPolicy{ReceiveControlPolicy::Optimistic}; }; } // namespace AetherSDR diff --git a/src/models/TransmitModel.cpp b/src/models/TransmitModel.cpp index 40838f7cd4..856647a7d0 100644 --- a/src/models/TransmitModel.cpp +++ b/src/models/TransmitModel.cpp @@ -26,6 +26,7 @@ bool TransmitModel::refuseControlOffThread() const void TransmitModel::resetState() { + m_confirmReceiveCwPitch = false; for (quint64& revision : m_controlIntentRevisions) { ++revision; } // A request of the ending session is answered, if at all, by a dead link. m_pendingVoxAnswers.clear(); @@ -1103,6 +1104,10 @@ void TransmitModel::setCwPitch(int hz) const auto field = TransmitControlRequest::Field::CwPitch; const auto current = captureControlIntent(field); hz = qBound(100, hz, 6000); + if (m_confirmReceiveCwPitch) { + emit cwPitchCommandIssued(hz); + return; + } if (m_cwPitch != hz) { m_cwPitch = hz; // update local cache so rapid steppers accumulate emit phoneStateChanged(); diff --git a/src/models/TransmitModel.h b/src/models/TransmitModel.h index 2f6476e826..fa1c49c598 100644 --- a/src/models/TransmitModel.h +++ b/src/models/TransmitModel.h @@ -445,6 +445,9 @@ class TransmitModel : public QObject { // ── CW commands ───────────────────────────────────────────────────────── void setCwSpeed(int wpm); void setCwPitch(int hz); + // Receive-only engines acknowledge pitch after their DSP bank is adopted. + void setReceiveCwPitchConfirmation(bool confirmed) { m_confirmReceiveCwPitch = confirmed; } + bool confirmsReceiveCwPitch() const { return m_confirmReceiveCwPitch; } void setCwBreakIn(bool on); void setCwDelay(int ms); void setCwSidetone(bool on); @@ -672,6 +675,7 @@ class TransmitModel : public QObject { // CW state int m_cwSpeed{20}; // 5–100 WPM + bool m_confirmReceiveCwPitch{false}; int m_cwPitch{600}; // 100–6000 Hz bool m_cwBreakIn{false}; int m_cwDelay{500}; // 0–2000 ms diff --git a/tests/aether_dsp_mode_policy_test.cpp b/tests/aether_dsp_mode_policy_test.cpp index a2258db0f2..668c20706f 100644 --- a/tests/aether_dsp_mode_policy_test.cpp +++ b/tests/aether_dsp_mode_policy_test.cpp @@ -25,7 +25,7 @@ int main() for (const QString& mode : {QStringLiteral("DIGU"), QStringLiteral("DIGL"), QStringLiteral("RTTY"), QStringLiteral("CW"), - QStringLiteral("CWU"), QStringLiteral("CWL"), + QStringLiteral("CWU"), QStringLiteral("CWL"), QStringLiteral("CWR"), QStringLiteral("NT")}) { expect(qPrintable(mode + QStringLiteral(" disables AetherDSP")), aetherDspModeRequiresDisable(mode)); @@ -44,6 +44,16 @@ int main() {QStringLiteral("LSB"), false, 50.0f}, {QStringLiteral("CW"), false, 50.0f}, })); + expect("parked CW slice does not restrict mixed stream", + !aetherDspMixRequiresDisable({ + {QStringLiteral("USB"), false, 50.0f, true}, + {QStringLiteral("CW"), false, 50.0f, false}, + })); + expect("resumed CW-R slice restricts mixed stream", + aetherDspMixRequiresDisable({ + {QStringLiteral("USB"), false, 50.0f, true}, + {QStringLiteral("CWR"), false, 50.0f, true}, + })); expect("muted CW slice does not restrict mixed stream", !aetherDspMixRequiresDisable({ {QStringLiteral("LSB"), false, 50.0f}, diff --git a/tests/asr_audio_tap_test.cpp b/tests/asr_audio_tap_test.cpp index 8d0aa99427..1a667b7f36 100644 --- a/tests/asr_audio_tap_test.cpp +++ b/tests/asr_audio_tap_test.cpp @@ -225,6 +225,19 @@ int main(int argc, char** argv) "after a live switch the tap still follows exactly one feed"); } + { + Rig rig; + rig.tap.setEnabled(true); + rig.push(input, kBlocks); + rig.tap.setEnabled(false); + rig.tap.setEnabled(true); + drain(); + expect(rig.chunks == 0, "a same-point restart rejects old queued presentation blocks"); + rig.push(input, kBlocks); + drain(); + expect(rig.chunks == kBlocks, "new presentation binding still delivers audio"); + } + std::printf(g_failures == 0 ? "\nASR audio tap: ALL PASS\n" : "\nASR audio tap: %d FAILURE(S)\n", g_failures); diff --git a/tests/asr_engine_test.cpp b/tests/asr_engine_test.cpp index f9274aeba4..00b25f791c 100644 --- a/tests/asr_engine_test.cpp +++ b/tests/asr_engine_test.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +23,16 @@ #include #include +namespace AetherSDR { +struct AsrEngineTestAccess { + static bool awaitWorker(AsrEngine& engine) { + auto done = std::make_shared(); + QMetaObject::invokeMethod(engine.m_worker, [done] { done->release(); }, Qt::QueuedConnection); + return done->tryAcquire(1, 5000); + } +}; +} + using namespace AetherSDR; namespace { @@ -291,6 +302,53 @@ int main(int argc, char** argv) "failed replacement clears stale cached speaker state"); } + // A real worker finishes inference while main-thread delivery is withheld. + // Restarting must suppress the already-queued old transcript as well as audio. + { + AsrEngine engine(factory(true)); + QSignalSpy readySpy(&engine, &AsrEngine::ready); + engine.setModelPath(QStringLiteral("/fixture")); + expect(readySpy.wait(5000), "generation fixture loads"); + engine.setEnabled(true); + QSignalSpy texts(&engine, &AsrEngine::finalText); + engine.pushAudio(silence(150), kSrcRate); + engine.pushAudio(tone(500), kSrcRate); + engine.pushAudio(silence(700), kSrcRate); + expect(AsrEngineTestAccess::awaitWorker(engine), "old inference and its result are queued"); + engine.restartSession(); + engine.restartSession(); + QCoreApplication::processEvents(); + expect(texts.isEmpty(), "queued transcript from old generation is suppressed"); + engine.pushAudio(silence(150), kSrcRate); + engine.pushAudio(tone(500), kSrcRate); + engine.pushAudio(silence(700), kSrcRate); + expect(texts.wait(5000), "fresh generation still transcribes after repeated restart"); + } + + { + AsrEngine engine(factory(true)); + QSignalSpy readySpy(&engine, &AsrEngine::ready); + engine.setModelPath(QStringLiteral("/fixture")); + expect(readySpy.wait(5000), "native lease fixture loads"); + engine.setEnabled(true); + QSignalSpy texts(&engine, &AsrEngine::finalText); + PcmProducer producer; + producer.start(PcmPurpose::Slice, 3, {kSrcRate, PcmLayout::Mono}); + engine.pushNativePcm(*producer.produce(silence(150))); + engine.pushNativePcm(*producer.produce(tone(500))); + engine.pushNativePcm(*producer.produce(silence(700))); + expect(AsrEngineTestAccess::awaitWorker(engine), "native inference result awaits delivery"); + producer.invalidate(); + QCoreApplication::processEvents(); + expect(texts.isEmpty(), "producer retirement suppresses queued native transcript without a reset callback"); + engine.restartSession(); + producer.start(PcmPurpose::Slice, 3, {kSrcRate, PcmLayout::Mono}); + engine.pushNativePcm(*producer.produce(silence(150))); + engine.pushNativePcm(*producer.produce(tone(500))); + engine.pushNativePcm(*producer.produce(silence(700))); + expect(texts.wait(5000), "new native receiver epoch transcribes after retirement"); + } + // ---- Async load emits ready() ----------------------------------------- { AsrEngine engine(factory(true)); diff --git a/tests/asr_native_receiver_test.cpp b/tests/asr_native_receiver_test.cpp new file mode 100644 index 0000000000..0f0215ea40 --- /dev/null +++ b/tests/asr_native_receiver_test.cpp @@ -0,0 +1,155 @@ +// Socket-free native selected-receiver routing through the production model/tap. +#include "TestSettingsProfile.h" +#include "asr/AsrEngine.h" +#include "core/backends/IRadioBackend.h" +#include "gui/AsrAudioTap.h" +#include "models/DecoderAudioModel.h" +#include "models/RadioModel.h" +#include "models/SliceModel.h" +#include +#include +#include +#include +using namespace AetherSDR; +namespace { +int failures = 0; +void check(bool good, const char* description) { + if (!good) { ++failures; std::fprintf(stderr, "FAIL: %s\n", description); } +} +class Source final : public IRadioBackend { +public: + bool exportAudio = true; + bool live = false; + RadioCapabilities capabilities() const override { + RadioCapabilities caps; + caps.maxSlices = 8; + if (exportAudio) { caps.receiveAudioExport = ReceiveAudioExport{{24000, 48000}, 8}; } + return caps; + } + bool ownsRxAudio() const override { return true; } + void connectRadio(const RadioConnectRequest&) override { live = true; emit connected(); } + void disconnectRadio() override { live = false; emit disconnected(); } + bool isConnected() const override { return live; } + void setSliceFrequency(int, double) override {} + void setSliceMode(int, const QString&) override {} + void setSliceFilter(int, int, int) override {} + void setSliceAgc(int, const QString&, int) override {} + 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 {} +}; +class SilentBackend final : public IAsrBackend { +public: + bool load(const QString&, QString*) override { return true; } + bool isLoaded() const override { return true; } + AsrTranscript transcribe(const std::vector&, QString*) override { return {}; } + void unload() override {} +}; +void drain() { QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); } +} +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("asr-native-receiver")); + QCoreApplication app(argc, argv); + RadioModel radio; + auto backend = std::make_unique(); + Source* source = backend.get(); + radio.setBackendForTest(std::move(backend), QStringLiteral("native-test")); + source->connectRadio({}); + for (int id : {3, 7}) { + SliceDelta delta; + delta.panId = QStringLiteral("native-test-pan"); + delta.frequency = 14.1; + delta.mode = QStringLiteral("USB"); + delta.inUse = true; + emit source->sliceChanged(id, delta); + } + SliceModel* a = radio.slice(3); + SliceModel* b = radio.slice(7); + check(a && b, "normalized receiver models exist"); + if (!a || !b) { return 1; } + DecoderAudioModel route(radio, DecoderAudioModel::Consumer::NativeAsr); + AsrEngine asr([] { return std::make_unique(); }); + AsrAudioTap tap(nullptr, &asr); + int chunks = 0; + int resets = 0; + int rate = 0; + QVector delivered; + QObject::connect(&asr, &AsrEngine::requestProcess, &app, + [&](const QVector& pcm, int hz) { ++chunks; delivered = pcm; rate = hz; }); + QObject::connect(&asr, &AsrEngine::requestReset, &app, [&] { ++resets; }); + route.setSlice(a); + tap.setNativeInput(&route); + tap.setTapPoint(AsrTapPoint::PostDsp); + tap.setEnabled(true); + check(tap.usesNativeInput() && tap.tapPoint() == AsrTapPoint::PostDsp, + "native input does not rewrite the saved presentation preference"); + PcmProducer pa, pb, mix; + pa.start(PcmPurpose::Slice, 3, {48000, PcmLayout::Mono}); + pb.start(PcmPurpose::Slice, 7, {24000, PcmLayout::Mono}); + mix.start(); + emit source->sliceAudioFrameReady(7, *pb.produce({0.8f})); + emit source->audioFrameReady(*mix.produce({0.9f, 0.9f})); + emit source->sliceAudioFrameReady(3, *pa.produce({0.125f, 0.25f})); + drain(); + check(chunks == 1 && rate == 48000 && delivered == QVector({0.125f, 0.25f}), + "selected native rate/audio survives with no output device and no sibling/mix input"); + a->setAudioGain(0); a->setAudioMute(true); a->setAudioPan(0); + emit source->sliceAudioFrameReady(3, *pa.produce({0.375f})); + drain(); + check(chunks == 2 && delivered == QVector({0.375f}), "monitor mute/gain/pan do not affect ASR"); + for (const QString& mode : {QStringLiteral("AM"), QStringLiteral("SAM")}) { + const int beforeMode = chunks; + SliceDelta changed; changed.mode = mode; + emit source->sliceChanged(3, changed); + pa.start(PcmPurpose::Slice, 3, {48000, PcmLayout::Mono}); + emit source->sliceAudioFrameReady(3, *pa.produce({0.45f})); + drain(); + check(chunks == beforeMode + 1 && delivered == QVector({0.45f}) && tap.usesNativeInput(), + "AM and SAM selected audio use native pre-monitor ASR routing"); + } + chunks -= 2; // Keep the lifecycle census below relative to its original two blocks. + emit source->sliceAudioFrameReady(3, *pa.produce({0.5f})); + const int beforeSwitch = resets; + route.setSlice(b); + drain(); + check(chunks == 2 && resets > beforeSwitch, "selection retires queued old receiver input"); + emit source->sliceAudioFrameReady(7, *pb.produce({0.6f})); + drain(); + check(chunks == 3 && rate == 24000 && delivered == QVector({0.6f}), "selected sibling preserves its rate"); + const int beforePark = resets; + emit source->sliceAudioFrameReady(7, *pb.produce({0.7f})); + SliceDelta parked; parked.inCapture = false; + emit source->sliceChanged(7, parked); + drain(); + check(chunks == 3 && resets > beforePark && route.routeStatus() == DecoderAudioModel::RouteStatus::Inactive, + "park closes input immediately and drops its pending audio"); + emit source->sliceAudioFrameReady(3, *pa.produce({0.8f})); + drain(); + check(chunks == 3, "parked selection never falls back to a live sibling"); + parked.inCapture = true; emit source->sliceChanged(7, parked); + pb.start(PcmPurpose::Slice, 7, {48000, PcmLayout::Mono}); + emit source->sliceAudioFrameReady(7, *pb.produce({0.2f})); + drain(); + check(chunks == 4 && rate == 48000, "unpark admits new producer epoch"); + emit source->sliceAudioFrameReady(7, *pb.produce({0.3f})); + pb.invalidate(); + drain(); + check(chunks == 4, "lease revoked between enqueue and drain cannot reach ASR"); + pb.start(PcmPurpose::Slice, 7, {48000, PcmLayout::Mono}); + emit source->sliceAudioFrameReady(7, *pb.produce({0.4f})); + source->disconnectRadio(); + drain(); + check(chunks == 4, "backend disconnect rejects queued selected input before the lifecycle notification"); + // setBackendForTest injects PCM/receiver wiring, not the normal backend + // lifecycle connections. Supply the normalized edge used in production. + const int beforeDisconnect = resets; + emit radio.connectionStateChanged(false); + drain(); + check(chunks == 4 && resets > beforeDisconnect + && route.routeStatus() == DecoderAudioModel::RouteStatus::Inactive, + "normalized disconnect resets and withdraws the selected route"); + tap.setEnabled(false); + std::printf("Native ASR receiver: %d failures\n", failures); + return failures ? 1 : 0; +} diff --git a/tests/automation_dsp_backend_readback_test.cpp b/tests/automation_dsp_backend_readback_test.cpp index acf425d1e4..be7f2bdd7e 100644 --- a/tests/automation_dsp_backend_readback_test.cpp +++ b/tests/automation_dsp_backend_readback_test.cpp @@ -29,6 +29,7 @@ #include "core/AutomationServer.h" #include "core/backends/IRadioBackend.h" #include "models/RadioModel.h" +#include "models/SliceModel.h" #include #include @@ -78,10 +79,22 @@ class StubBackend : public IRadioBackend public: explicit StubBackend(QVariantList chains) : m_chains(std::move(chains)) {} - RadioCapabilities capabilities() const override { return {}; } + RadioCapabilities caps; + bool connected = false; + QList requests; + QList receivers; + RadioCapabilities capabilities() const override { return caps; } + ReceiveControlPolicy receiveControlPolicy() const override + { return ReceiveControlPolicy::Confirmed; } + ReceiveDispatch requestSliceDsp(int id, const SliceDspRequest& request) override + { + receivers.append(id); + requests.append(request); + return ReceiveDispatch::Dispatched; + } void connectRadio(const RadioConnectRequest&) override {} void disconnectRadio() override {} - bool isConnected() const override { return false; } + bool isConnected() const override { return connected; } void setSliceFrequency(int, double) override {} void setSliceMode(int, const QString&) override {} void setSliceFilter(int, int, int) override {} @@ -252,6 +265,94 @@ int main(int argc, char** argv) "and the property form agrees, rather than answering an empty object"); } + // Explicit receive DSP records override legacy radio-side flags and apply + // to the selected slice's mode. Accepted state still comes from readback. + { + auto owned = std::make_unique(QVariantList{}); + StubBackend* source = owned.get(); + source->connected = true; + source->caps.hasHostNoiseBlanker = true; + source->caps.receiveDspControl = ReceiveDspControl{ + {QStringLiteral("USB"), QStringLiteral("LSB"), QStringLiteral("CW"), QStringLiteral("CWR")}, + {ReceiveDspControl::Feature::Nr, ReceiveDspControl::Feature::Anf, + ReceiveDspControl::Feature::Nb}}; + radio.setBackendForTest(std::move(owned), QStringLiteral("receive-dsp-test")); + SliceDelta initial; + initial.inUse = true; + initial.active = false; + initial.mode = QStringLiteral("USB"); + initial.nr = false; + initial.nrLevel = 10; + emit source->sliceChanged(0, initial); + initial.active = true; + emit source->sliceChanged(1, initial); + const auto command = [&server](const QByteArray& argument) { + return AutomationServerTestAccess::handleLine(server, QByteArray("slice dsp ") + argument); + }; + check(command("nr on 50").value(QStringLiteral("ok")).toBool() + && source->requests.size() == 2 && source->receivers == QList{1, 1} + && source->requests[0].feature == SliceDspRequest::Feature::Nr + && source->requests[0].field == SliceDspRequest::Field::Level + && source->requests[0].level == 50 + && source->requests[1].field == SliceDspRequest::Field::Enabled + && source->requests[1].enabled, + "record-backed NR admits level and enable intents without radio-side DSP"); + check(radio.slice(1) && !radio.slice(1)->nrOn() && radio.slice(1)->nrLevel() == 10, + "automation success does not fabricate confirmed NR state"); + source->requests.clear(); + check(command("anf on 57").value(QStringLiteral("ok")).toBool() + && source->requests.size() == 2 + && source->requests[0].feature == SliceDspRequest::Feature::Anf + && source->requests[0].field == SliceDspRequest::Field::Level + && source->requests[0].level == 57 && source->requests[1].enabled, + "record-backed ANF forwards its advertised level and enable"); + SliceDelta fm; + fm.mode = QStringLiteral("FM"); + emit source->sliceChanged(1, fm); + source->requests.clear(); + for (const QByteArray& argument : {QByteArray("nr on 50"), QByteArray("anf on 57"), + QByteArray("nb on 30"), QByteArray("nb2 on 30 4")}) { + const QJsonObject reply = command(argument); + check(!reply.value(QStringLiteral("ok")).toBool() + && reply.value(QStringLiteral("error")).toString().contains(QStringLiteral("FM")) + && source->requests.isEmpty(), + "unsupported selected mode refuses DSP before any level or enable intent"); + } + for (const QByteArray& argument : {QByteArray("nr off"), QByteArray("anf off"), + QByteArray("nb off"), QByteArray("nb2 off")}) { + source->requests.clear(); + const QJsonObject reply = command(argument); + check(reply.value(QStringLiteral("ok")).toBool() + && source->requests.size() == 1 && !source->requests.first().enabled, + "unsupported selected mode still admits an idempotent DSP off intent"); + } + source->requests.clear(); + fm.mode = QStringLiteral("CWR"); + emit source->sliceChanged(1, fm); + check(command("nb2 on 30 4").value(QStringLiteral("ok")).toBool() + && source->requests.size() == 3, + "supported reverse CW mode admits declared blanker controls"); + source->requests.clear(); + source->caps.hasRadioSideDsp = true; + source->caps.receiveDspControl->features.removeAll(ReceiveDspControl::Feature::Nr); + check(!command("nr on 50").value(QStringLiteral("ok")).toBool() + && source->requests.isEmpty(), + "explicit missing feature takes precedence over a legacy DSP flag"); + source->caps.receiveDspControl.reset(); + check(command("nr on 50").value(QStringLiteral("ok")).toBool() + && source->requests.size() == 2, + "absent record retains legacy radio-side NR admission"); + source->requests.clear(); + source->caps.hasRadioSideDsp = false; + check(!command("nr on 50").value(QStringLiteral("ok")).toBool() + && !command("anf on 50").value(QStringLiteral("ok")).toBool() + && source->requests.isEmpty(), + "absent record retains legacy unsupported NR and ANF refusal"); + check(command("nr off").value(QStringLiteral("ok")).toBool() + && source->requests.size() == 1, + "legacy unsupported NR can still be switched off"); + } + if (failures > 0) { std::printf("\n%d check(s) failed\n", failures); return 1; diff --git a/tests/control_availability_registry_test.cpp b/tests/control_availability_registry_test.cpp index 5271b1ce3c..b314ec8b72 100644 --- a/tests/control_availability_registry_test.cpp +++ b/tests/control_availability_registry_test.cpp @@ -74,6 +74,25 @@ int main(int argc, char** argv) RadioModel model; // not connected ControlAvailabilityRegistry registry(model); + // Feature-specific mode restrictions can narrow, never expand, a record. + { + using Feature = ReceiveDspControl::Feature; + ReceiveDspControl control{{QStringLiteral("USB"), QStringLiteral("CW")}, + {Feature::Nr, Feature::Apf}}; + check(control.supports(Feature::Apf, QStringLiteral("USB")), + "an absent per-feature restriction retains the common mode list"); + control.featureModes.insert(Feature::Apf, {QStringLiteral("CW"), QStringLiteral("FM")}); + check(!control.supports(Feature::Apf, QStringLiteral("USB")) + && control.supports(Feature::Apf, QStringLiteral("CW")) + && !control.supports(Feature::Apf, QStringLiteral("FM")) + && control.supports(Feature::Nr, QStringLiteral("USB")), + "a per-feature restriction narrows only its declared feature and modes"); + control.featureModes.insert(Feature::Apf, {}); + control.featureModes.insert(Feature::Anf, {QStringLiteral("CW")}); + check(!control.supports(Feature::Apf, QStringLiteral("CW")) + && !control.supports(Feature::Anf, QStringLiteral("CW")), + "an empty restriction disables support and cannot add an undeclared feature"); + } const QString reason = QStringLiteral("Not supported by this radio"); // ---- permissive while disconnected ---- @@ -265,6 +284,72 @@ int main(int argc, char** argv) check(registry.stateOf(&button) == ControlAvailability::Inactive, "disconnect always returns to Inactive even with retained engagement"); } + // Optional receive restrictions leave existing Flex presentation owned by + // the control, including after an unsupported mode or a backend switch. + for (const QString& themeName : {QStringLiteral("Default Dark"), + QStringLiteral("Default Light")}) { + check(theme.setActiveTheme(themeName), "load theme for optional receive restriction"); + QPushButton button(QStringLiteral("NR")); + button.setCheckable(true); + button.setChecked(true); + const QString help = QStringLiteral("Noise reduction: adjust the DSP level"); + const QString description = QStringLiteral("Noise reduction enabled"); + button.setToolTip(help); + button.setAccessibleDescription(description); + theme.applyStyleSheet(&button, QStringLiteral( + "QPushButton:checked { color: {{color.accent}}; padding: 7px; }")); + button.show(); + QApplication::processEvents(); + const QString baseStyle = button.styleSheet(); + const QImage basePixels = button.grab().toImage(); + RadioCapabilities restrictedCaps; + restrictedCaps.receiveDspControl = ReceiveDspControl{ + {QStringLiteral("USB")}, {ReceiveDspControl::Feature::Nr}}; + const auto supported = [](bool, const RadioCapabilities& caps) { + return !caps.receiveDspControl || caps.receiveDspControl->supports( + ReceiveDspControl::Feature::Nr, QStringLiteral("FM")); + }; + { + ControlAvailabilityRegistry restriction(model); + restriction.registerRestriction(&button, reason, supported); + check(button.toolTip() == help && button.accessibleDescription() == description + && button.styleSheet() == baseStyle && button.isChecked(), + "absent optional record preserves checked legacy styling and help"); + emit model.capabilitiesChanged(true, restrictedCaps); + emit model.capabilitiesChanged(true, restrictedCaps); + check(!button.isEnabled() && button.isChecked() && button.toolTip() == reason + && button.accessibleDescription() == reason && !button.isHidden(), + "unsupported receive mode is disabled with a reason without changing its value"); + emit model.capabilitiesChanged(true, {}); + QApplication::processEvents(); + check(button.isEnabled() && button.isChecked() && button.toolTip() == help + && button.accessibleDescription() == description + && button.styleSheet() == baseStyle && button.grab().toImage() == basePixels, + "switch to legacy backend restores original checked pixels and help"); + emit model.capabilitiesChanged(true, restrictedCaps); + emit model.capabilitiesChanged(false, restrictedCaps); + check(button.isEnabled() && button.toolTip() == help, + "disconnect lifts optional restriction and restores help"); + emit model.capabilitiesChanged(true, restrictedCaps); + } + check(button.isEnabled() && button.styleSheet() == baseStyle && button.toolTip() == help + && button.accessibleDescription() == description, + "replacing a registry restores restricted widgets before the next backend registers"); + + QWidget disabledParent; + QPushButton inherited(&disabledParent); + QPushButton explicitlyDisabled; + explicitlyDisabled.setEnabled(false); + disabledParent.setEnabled(false); + ControlAvailabilityRegistry restriction(model); + restriction.registerRestriction(&inherited, reason, supported); + restriction.registerRestriction(&explicitlyDisabled, reason, supported); + emit model.capabilitiesChanged(true, restrictedCaps); + emit model.capabilitiesChanged(true, {}); + disabledParent.setEnabled(true); + check(inherited.isEnabled() && !explicitlyDisabled.isEnabled(), + "restriction restoration distinguishes inherited and explicit disablement"); + } { bool engaged = false; QLabel label(QStringLiteral("Availability text")); diff --git a/tests/cw_mode_gate_test.cpp b/tests/cw_mode_gate_test.cpp new file mode 100644 index 0000000000..6ee22a325e --- /dev/null +++ b/tests/cw_mode_gate_test.cpp @@ -0,0 +1,27 @@ +#include "gui/VoiceModeGate.h" +#include +using namespace AetherSDR; +int main() +{ + int failures = 0; + for (const QString& mode : {QStringLiteral("CW"), QStringLiteral("CWU"), QStringLiteral("CWL"), QStringLiteral("CWR")}) { + if (!isCwMode(mode) || isVoiceMode(mode)) { ++failures; } + const bool reverse = mode == QStringLiteral("CWL") || mode == QStringLiteral("CWR"); + if (isReverseCwMode(mode) != reverse) { ++failures; } + // Zero Beat uses this shared sideband decision for the correction sign. + const double correction = (isReverseCwMode(mode) ? -1.0 : 1.0) * (750.0 - 600.0); + if (correction != (reverse ? -150.0 : 150.0)) { ++failures; } + } + for (const QString& mode : {QStringLiteral("USB"), QStringLiteral("LSB")}) { + if (isCwMode(mode) || isReverseCwMode(mode) || !isVoiceMode(mode)) { ++failures; } + } + // A Flex CWL flag can remain cached when the next session uses explicit + // modes. It must affect genuine Flex CW, but neither RTL CW nor CWR. + if (cwReceiveSidebandSign("CW", true, true) != -1 + || cwReceiveSidebandSign("CW", true, false) != 1 + || cwReceiveSidebandSign("CW", false, true) != 1 + || cwReceiveSidebandSign("CWR", false, true) != -1 + || cwReceiveSidebandSign("CWR", false, false) != -1) { ++failures; } + std::printf("CW/CWR mode gates: %d failures\n", failures); + return failures ? 1 : 0; +} diff --git a/tests/fm_filter_controls_test.cpp b/tests/fm_filter_controls_test.cpp index 3bf184978a..d671595429 100644 --- a/tests/fm_filter_controls_test.cpp +++ b/tests/fm_filter_controls_test.cpp @@ -2,6 +2,7 @@ // capability data only; this is not a simulator peer or a hardware test. #include "TestSettingsProfile.h" #include "core/AppSettings.h" +#include "core/KiwiSdrManager.h" #include "core/backends/SliceDelta.h" #include "gui/RxApplet.h" #include "gui/FilterPassbandWidget.h" @@ -13,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -72,6 +74,65 @@ QPushButton* button(QWidget& widget, const QString& text) class FmFilterControlsTest : public QObject { Q_OBJECT private slots: + void receiveOnlyAntennaControlsUsePublishedPorts() + { + RadioModel model; + auto backend = std::make_unique(); + FilterBackend* source = backend.get(); + model.setBackendForTest(std::move(backend), QStringLiteral("test")); + SliceDelta initial; + initial.rxAntenna = "RX"; initial.rxAntennaList = QStringList{"RX"}; + initial.txAntenna = QString(); initial.txAntennaList = QStringList{}; + emit source->sliceChanged(3, initial); + QVERIFY(model.slice(3)); + KiwiSdrManager virtualReceivers; + RxApplet rx; + VfoWidget vfo; + rx.setSlice(model.slice(3)); vfo.setSlice(model.slice(3)); + rx.setRadioModel(&model); vfo.setRadioModel(&model); + const auto namedButton = [](QWidget& widget, const QString& name) { + for (auto* candidate : widget.findChildren()) { + if (candidate->accessibleName() == name) { return candidate; } + } + return static_cast(nullptr); + }; + for (QWidget* widget : {static_cast(&rx), static_cast(&vfo)}) { + auto* receive = namedButton(*widget, "RX antenna"); + auto* transmit = namedButton(*widget, "TX antenna"); + QVERIFY(receive && transmit); + QCOMPARE(receive->text(), QStringLiteral("RX")); + QVERIFY(!receive->isEnabled()); + QVERIFY(receive->accessibleDescription().contains("fixed")); + QCOMPARE(transmit->text(), QStringLiteral("None")); + QVERIFY(!transmit->isEnabled()); + QVERIFY(transmit->accessibleDescription().contains("receive-only")); + } + const QString profileId = virtualReceivers.addProfile("Virtual receiver", "kiwi.example.test:8073"); + rx.setKiwiSdrManager(&virtualReceivers); vfo.setKiwiSdrManager(&virtualReceivers); + for (QWidget* widget : {static_cast(&rx), static_cast(&vfo)}) { + auto* receive = namedButton(*widget, "RX antenna"); + QVERIFY(receive->isEnabled()); + receive->click(); + auto* menu = receive->findChild(); + QVERIFY(menu); + QStringList ports; + for (auto* action : menu->actions()) { ports.append(action->data().toString()); } + QCOMPARE(ports, (QStringList{"RX", virtualReceivers.virtualAntennaToken(profileId)})); + menu->close(); + } + source->caps.canTransmit = true; + initial.rxAntenna = "ANT1"; initial.rxAntennaList = QStringList{"ANT1", "ANT2"}; + initial.txAntenna = "ANT1"; initial.txAntennaList = QStringList{"ANT1", "ANT2"}; + emit source->sliceChanged(3, initial); + emit model.capabilitiesChanged(true, source->caps); + for (QWidget* widget : {static_cast(&rx), static_cast(&vfo)}) { + auto* receive = namedButton(*widget, "RX antenna"); + auto* transmit = namedButton(*widget, "TX antenna"); + QVERIFY(receive->isEnabled() && transmit->isEnabled()); + QCOMPARE(transmit->text(), QStringLiteral("ANT1")); + } + } + void captureActionUsesLiveCapabilityAndModelIntent() { RadioModel model; diff --git a/tests/mode_filter_presets_test.cpp b/tests/mode_filter_presets_test.cpp index b978eae6b4..1cfd6318e2 100644 --- a/tests/mode_filter_presets_test.cpp +++ b/tests/mode_filter_presets_test.cpp @@ -16,6 +16,7 @@ class ModeFilterPresetsTest : public QObject { private slots: void ssbPinsItsLowCutAndDerivesTheHigh(); void cwCentresOnTheCarrier(); + void narrowbandBoundsRespectSignedEdges(); void amAndFmStraddleIt_data(); void amAndFmStraddleIt(); void diguCentresOnItsOffsetAndClampsAt95(); @@ -47,13 +48,45 @@ void ModeFilterPresetsTest::cwCentresOnTheCarrier() { // The radio's BFO handles the pitch offset, so the filter sits on the // carrier rather than on the tone. - for (const char* mode : {"CW", "CWL", "CWU"}) { + for (const char* mode : {"CW", "CWL", "CWU", "CWR"}) { const Edges e = edgesForWidth(QString::fromLatin1(mode), 500, {}); QCOMPARE(e.lo, -250); QCOMPARE(e.hi, 250); } } +void ModeFilterPresetsTest::narrowbandBoundsRespectSignedEdges() +{ + const AetherSDR::ReceiveFilterControl control{ + AetherSDR::SliceFrequencyControl::Authority::Engine, + {{QStringLiteral("USB"), 0, 11998, 2, 12000, 2, 12000}, + {QStringLiteral("LSB"), -12000, -2, -11998, 0, 2, 12000}, + {QStringLiteral("CW"), -12000, -1, 1, 12000, 2, 24000}, + {QStringLiteral("CWR"), -12000, -1, 1, 12000, 2, 24000}}}; + for (const QString mode : {QStringLiteral("USB"), QStringLiteral("LSB"), + QStringLiteral("CW"), QStringLiteral("CWR")}) { + for (int width : widthsForMode(mode, &control)) { + const Edges edges = edgesForWidth(mode, width, {}); + QVERIFY(acceptsFilterEdges(mode, &control, false, edges)); + QCOMPARE(widthForEdges(mode, edges.lo, edges.hi), width); + } + } + QVERIFY(acceptsFilterEdges("USB", &control, false, {0, 12000})); + QVERIFY(!acceptsFilterEdges("USB", &control, false, {-1, 2900})); + QVERIFY(!acceptsFilterEdges("USB", &control, false, {100, 12001})); + QVERIFY(acceptsFilterEdges("LSB", &control, false, {-12000, 0})); + QVERIFY(!acceptsFilterEdges("LSB", &control, false, {-2900, 1})); + QVERIFY(!acceptsFilterEdges("LSB", &control, false, {-12001, -100})); + for (const QString mode : {QStringLiteral("CW"), QStringLiteral("CWR")}) { + QVERIFY(acceptsFilterEdges(mode, &control, false, {-1, 1})); + QVERIFY(acceptsFilterEdges(mode, &control, false, {-12000, 12000})); + QVERIFY(!acceptsFilterEdges(mode, &control, false, {0, 500})); + QVERIFY(!acceptsFilterEdges(mode, &control, false, {-500, 0})); + } + QVERIFY(acceptsFilterEdges("USB", nullptr, false, {-1, 12001})); + QVERIFY(acceptsFilterEdges("AM", &control, false, {-14000, 14000})); +} + void ModeFilterPresetsTest::amAndFmStraddleIt_data() { QTest::addColumn("mode"); diff --git a/tests/phone_cw_applet_apf_test.cpp b/tests/phone_cw_applet_apf_test.cpp index c42b0a6d44..639d9927a8 100644 --- a/tests/phone_cw_applet_apf_test.cpp +++ b/tests/phone_cw_applet_apf_test.cpp @@ -333,6 +333,41 @@ int main(int argc, char** argv) check("4.5 the row comes back when a capable radio attaches", !apfRow->isHidden() && apfBtn->isEnabled()); + applet.setMode(QStringLiteral("CWR")); + applet.setTransmitControlsAvailable(false); + check("receive-only CW keeps pitch and APF reachable while disabling keyer controls", + pitch->isEnabled() && apfBtn->isEnabled() && !speed->isEnabled() + && !speedText->isEnabled() && !speed->accessibleDescription().isEmpty()); + tx.setReceiveCwPitchConfirmation(true); + QSignalSpy receivePitch(&tx, &AetherSDR::TransmitModel::cwPitchCommandIssued); + cwCommands.clear(); + const int acceptedReceivePitch = tx.cwPitch(); + pitch->setText(QStringLiteral("720")); + QMetaObject::invokeMethod(pitch, "editingFinished", Qt::DirectConnection); + check("receive pitch editor shows accepted pitch until adoption", + receivePitch.size() == 1 && receivePitch.last().at(0).toInt() == 720 + && pitch->text() == QString::number(acceptedReceivePitch) + && pitchSlider->value() == acceptedReceivePitch && cwCommands.isEmpty()); + AetherSDR::TransmitDelta acceptedPitch; + acceptedPitch.cwPitch = 720; + tx.applyChanges(acceptedPitch); + check("accepted receive pitch reaches the existing CW editor", + pitch->text() == QStringLiteral("720") && pitchSlider->value() == 720 + && receivePitch.size() == 1); + pitchSlider->setValue(800); + check("receive pitch slider retains accepted state until adoption", + receivePitch.size() == 2 && receivePitch.last().at(0).toInt() == 800 + && pitchSlider->value() == 720 && pitch->text() == QStringLiteral("720") + && tx.cwPitch() == 720 && cwCommands.isEmpty()); + acceptedPitch.cwPitch = 800; + tx.applyChanges(acceptedPitch); + check("accepted receive pitch updates both controls without another request", + pitchSlider->value() == 800 && pitch->text() == QStringLiteral("800") + && receivePitch.size() == 2); + applet.setTransmitControlsAvailable(true); + check("next transmitter session restores the existing keyer controls", + speed->isEnabled() && speedText->isEnabled() && pitch->isEnabled()); + std::printf("\n%s — %d failure(s)\n", g_failures ? "FAILED" : "PASSED", g_failures); return g_failures ? 1 : 0; diff --git a/tests/radiomodel_tnf_removal_status_test.cpp b/tests/radiomodel_tnf_removal_status_test.cpp index 4978eb3e3f..22909a4192 100644 --- a/tests/radiomodel_tnf_removal_status_test.cpp +++ b/tests/radiomodel_tnf_removal_status_test.cpp @@ -60,6 +60,52 @@ int main(int argc, char** argv) model.handleStatusForTest(QStringLiteral("tnf 2"), tnf1); check(model.tnfModel().tnf(2) != nullptr, "a normal 'tnf 2 ' status still creates an entry"); + TnfModel confirmed; + confirmed.setControlPolicy(ReceiveControlPolicy::Confirmed); + NotchDelta initial; + initial.centerHz = 14'200'000; initial.widthHz = 400; + confirmed.applyNotchDelta(9, initial); + int changes = 0; + QObject::connect(&confirmed, &TnfModel::notchChangeRequested, &confirmed, + [&](int, const NotchDelta&) { ++changes; }); + confirmed.setTnfFreq(9, 14.201); + confirmed.setTnfWidth(9, 800); + confirmed.requestGlobalTnfEnabled(false); + check(changes == 2 && confirmed.tnf(9)->freqMhz == 14.2 + && confirmed.tnf(9)->widthHz == 400 && confirmed.globalEnabled(), + "confirmed notch edits remain intents until graph adoption"); + // The backend may normalize a width and report it synchronously. Its + // accepted observation must survive the remainder of the setter. + const auto normalize = QObject::connect(&confirmed, &TnfModel::notchChangeRequested, + &confirmed, [&](int id, const NotchDelta& request) { + if (request.widthHz) { + NotchDelta accepted; accepted.widthHz = 400; + confirmed.applyNotchDelta(id, accepted); + } + }); + confirmed.setTnfWidth(9, 100); + check(confirmed.tnf(9)->widthHz == 400, + "confirmed normalized width echo is not overwritten by requested width"); + QObject::disconnect(normalize); + NotchDelta accepted; accepted.centerHz = 14'201'000; accepted.widthHz = 800; + confirmed.applyNotchDelta(9, accepted); + confirmed.applyGlobalEnabled(false); + check(confirmed.tnf(9)->freqMhz == 14.201 && confirmed.tnf(9)->widthHz == 800 + && !confirmed.globalEnabled(), "accepted notch and global bypass advance presentation"); + confirmed.requestRemoveTnf(9); + check(confirmed.tnf(9) != nullptr, "pending removal keeps the accepted notch visible"); + confirmed.removeTnf(9); + check(confirmed.tnf(9) == nullptr, "adopted removal deletes the notch"); + confirmed.setControlPolicy(ReceiveControlPolicy::Optimistic); + confirmed.applyNotchDelta(9, initial); + confirmed.setTnfFreq(9, 14.202); + confirmed.setTnfWidth(9, 1200); + confirmed.requestGlobalTnfEnabled(true); + check(confirmed.tnf(9)->freqMhz == 14.202 && confirmed.tnf(9)->widthHz == 1200 + && confirmed.globalEnabled(), "legacy sessions retain optimistic notch edits"); + confirmed.requestRemoveTnf(9); + check(!confirmed.tnf(9), "legacy sessions retain immediate removal"); + if (g_failures == 0) std::printf("ALL PASS\n"); return g_failures == 0 ? 0 : 1; } diff --git a/tests/receive_intent_routing_test.cpp b/tests/receive_intent_routing_test.cpp index 04f9558ff3..74798b08a7 100644 --- a/tests/receive_intent_routing_test.cpp +++ b/tests/receive_intent_routing_test.cpp @@ -21,6 +21,7 @@ namespace AetherSDR { class RadioModelSliceLifecycleTestAccess { public: + static void publishCapabilities(RadioModel& radio) { radio.publishCapabilities(true); } static void wire(RadioModel& radio, SliceModel* slice) { radio.wireSliceReceiveIntentsToBackend(slice); @@ -53,6 +54,16 @@ void check(bool ok, const char* message) class Backend final : public IRadioBackend { public: bool connected = false; + bool confirmed = false; + QList pitches; + QList ritEnables; + QList ritOffsets; + void setSliceRitEnabled(int id, bool enabled) override + { ritEnables.append(enabled); if (observe) { observe(id); } } + void setSliceRitOffset(int, int hz) override { ritOffsets.append(hz); } + ReceiveControlPolicy receiveControlPolicy() const override + { return confirmed ? ReceiveControlPolicy::Confirmed : ReceiveControlPolicy::Optimistic; } + void setCwPitch(int hz) override { pitches.append(hz); } QList tunes; QList filters; QList agcs; @@ -66,7 +77,12 @@ class Backend final : public IRadioBackend { int pairedAgcCalls = 0; int keyCalls = 0; std::function observe; - RadioCapabilities capabilities() const override { return {}; } + RadioCapabilities capabilities() const override + { + RadioCapabilities caps; + if (confirmed) { caps.receiveCwPitchControl = ReceiveCwPitchControl{}; } + return caps; + } bool isConnected() const override { return connected; } void connectRadio(const RadioConnectRequest&) override { connected = true; } void disconnectRadio() override { connected = false; } @@ -156,10 +172,11 @@ SliceDelta report(double frequency = 14.2) struct Fixture { RadioModel radio; Backend* backend; - explicit Fixture(bool commandPlaneCreation = false) + explicit Fixture(bool commandPlaneCreation = false, bool confirmed = false) { auto owned = std::make_unique(); backend = owned.get(); + backend->confirmed = confirmed; radio.setBackendForTest(std::move(owned), QStringLiteral("receive-intent-test")); if (commandPlaneCreation) { check(radio.automationApplySliceFixture(0, QStringLiteral("A")), @@ -172,6 +189,155 @@ struct Fixture { SliceModel* slice() { return radio.slice(0); } }; +void confirmedReceiveControls() +{ + Fixture f(false, true); + SliceModel* s = f.slice(); + QSignalSpy changed(s, &SliceModel::agcModeChanged); + QSignalSpy nbChanged(s, &SliceModel::nbChanged); + QSignalSpy raw(s, &SliceModel::commandReady); + s->setAgcMode(QStringLiteral("fast")); + s->setAgcThreshold(37); + s->setAgcOffLevel(22); + check(s->agcMode() == QStringLiteral("med") && s->agcThreshold() == 65 + && s->agcOffLevel() == 10 && changed.isEmpty(), + "confirmed AGC edits do not publish requested state before adoption"); + check(f.backend->agcs.size() == 3 + && f.backend->agcs[0].mode == QStringLiteral("fast") + && f.backend->agcs[1].threshold == 37 && f.backend->agcs[2].offLevel == 22, + "independent AGC field intents reach backend without stale paired overwrite"); + s->setNbKind(NoiseBlankerKind::Advanced); + s->setNbLevel(73); + s->setApf(true); + s->setApfLevel(61); + s->setRit(true, 130); + check(!s->nbOn() && !s->apfOn() && !s->ritOn() && s->ritFreq() == 0 + && nbChanged.isEmpty() && raw.isEmpty(), + "confirmed DSP and RIT edits remain intent and emit no raw wire command"); + check(f.backend->dsps.size() == 4 + && f.backend->dsps[0].requestedBlanker() == NoiseBlankerKind::Advanced + && f.backend->dsps[1].level == 73 && f.backend->dsps[2].enabled + && f.backend->dsps[3].level == 61, + "confirmed DSP requests retain selected feature and field"); + SliceDelta accepted; + accepted.agcMode = QStringLiteral("fast"); accepted.agcThreshold = 37; + accepted.agcOffLevel = 22; accepted.apf = true; accepted.apfLevel = 61; + accepted.ritOn = true; accepted.ritFreq = 130; + accepted.nbKind = NoiseBlankerKind::Advanced; accepted.nbLevel = 73; + emit f.backend->sliceChanged(0, accepted); + check(s->agcMode() == QStringLiteral("fast") && s->agcThreshold() == 37 + && s->agcOffLevel() == 22 && s->apfOn() && s->apfLevel() == 61 + && s->ritOn() && s->ritFreq() == 130 && changed.size() == 1 + && s->nbKind() == NoiseBlankerKind::Advanced && s->nbLevel() == 73, + "only accepted readback advances AGC DSP and RIT state"); + // Correct back to the currently accepted value while an edit is pending. + // Equal-to-observed is still a new intent; it must supersede the pending one. + s->setAgcThreshold(70); s->setAgcThreshold(37); + check(f.backend->agcs.last().threshold == 37, + "confirmed same-value correction is dispatched to cancel pending AGC edit"); + + const int priorDspRequests = f.backend->dsps.size(); + s->setNr(true); s->setNrLevel(72); + s->setAnf(true); s->setAnfLevel(63); + s->setMn(true); s->setMnLevel(28); + check(!s->nrOn() && !s->anfOn() && !s->mnOn() && s->mnLevel() == 50, + "NR ANF and manual notch remain accepted state while edits are pending"); + check(f.backend->dsps.size() == priorDspRequests + 6 + && f.backend->dsps[priorDspRequests].feature == SliceDspRequest::Feature::Nr + && f.backend->dsps[priorDspRequests + 2].feature == SliceDspRequest::Feature::Anf + && f.backend->dsps.last().feature == SliceDspRequest::Feature::Mn + && f.backend->dsps.last().field == SliceDspRequest::Field::Level + && f.backend->dsps.last().level == 28 && raw.isEmpty(), + "NR ANF and manual notch dispatch selected fields without raw commands"); + SliceDelta acceptedFilters; + acceptedFilters.nr = true; acceptedFilters.nrLevel = 72; + acceptedFilters.anf = true; acceptedFilters.anfLevel = 63; + acceptedFilters.mn = true; acceptedFilters.mnLevel = 28; + emit f.backend->sliceChanged(0, acceptedFilters); + check(s->nrOn() && s->nrLevel() == 72 && s->anfOn() && s->anfLevel() == 63 + && s->mnOn() && s->mnLevel() == 28, + "adopted NR ANF and manual notch readbacks publish their actual state"); + s->setMn(false); s->setMn(true); + check(f.backend->dsps.last().enabled, + "same-value manual notch correction supersedes pending disable"); + + RadioModelSliceLifecycleTestAccess::publishCapabilities(f.radio); + TransmitModel& tx = f.radio.transmitModel(); + QSignalSpy pitchChanged(&tx, &TransmitModel::cwPitchChanged); + QSignalSpy pitchRaw(&tx, &TransmitModel::commandReady); + tx.setCwPitch(720); + check(tx.cwPitch() == 600 && pitchChanged.isEmpty() && pitchRaw.isEmpty() + && f.backend->pitches == QList{720} && f.backend->keyCalls == 0, + "receive pitch sends one intent without optimistic readback wire command or TX"); + TransmitDelta pitchAccepted; pitchAccepted.cwPitch = 720; + tx.applyChanges(pitchAccepted); + check(tx.cwPitch() == 720 && pitchChanged.size() == 1 && f.backend->pitches.size() == 1, + "accepted receive pitch updates presentation without feeding back into backend"); + f.backend->confirmed = false; + RadioModelSliceLifecycleTestAccess::publishCapabilities(f.radio); + QSignalSpy pitchControls(&tx, &TransmitModel::controlRequested); + tx.setCwPitch(800); + check(tx.cwPitch() == 800 && pitchRaw.isEmpty() && pitchControls.size() == 1 + && qvariant_cast(pitchControls.first().first()).field + == TransmitControlRequest::Field::CwPitch + && std::get(qvariant_cast(pitchControls.first().first()).value) == 800, + "optimistic pitch uses the typed upstream control route once without raw wire text"); +} + +void ritLifecycle() +{ + Fixture f(false, true); + SliceModel* original = f.slice(); + original->setRit(true, 120); + check(f.backend->ritEnables == QList{true} && f.backend->ritOffsets == QList{120}, + "connected current receiver dispatches both RIT fields"); + f.backend->observe = [&](int) { f.backend->connected = false; }; + original->setRit(true, 230); + check(f.backend->ritEnables.size() == 2 && f.backend->ritOffsets.size() == 1, + "disconnect during RIT enable suppresses companion offset"); + original->setRit(true, 340); + check(f.backend->ritEnables.size() == 2, "disconnected RIT intent never reaches backend"); + f.backend->connected = true; + f.backend->observe = [&](int id) { + emit f.backend->sliceRemoved(id); + emit f.backend->sliceChanged(id, report()); + }; + original->setRit(true, 450); + check(f.slice() != original && f.backend->ritEnables.size() == 3 + && f.backend->ritOffsets.size() == 1, + "receiver retirement during RIT enable cannot offset replacement"); + original->setRit(true, 560); + check(f.backend->ritEnables.size() == 3, "retired receiver cannot send another RIT intent"); + f.backend->observe = {}; + f.slice()->setRit(true, 670); + check(f.backend->ritEnables.size() == 4 && f.backend->ritOffsets.last() == 670, + "replacement receiver retains functional RIT controls"); +} + +void notchReadbackLifecycle() +{ + Fixture f(false, true); + RadioModelSliceLifecycleTestAccess::publishCapabilities(f.radio); + NotchDelta initial; initial.centerHz = 14'200'000; initial.widthHz = 400; + emit f.backend->notchChanged(11, initial); + check(f.radio.tnfModel().tnf(11) != nullptr, "backend notch observation reaches shared model"); + RadioDelta global; global.tnfEnabled = false; + emit f.backend->radioChanged(global); + check(!f.radio.tnfModel().globalEnabled(), "backend global notch bypass reaches shared model"); + + f.radio.tnfModel().setTnfFreq(11, 14.201); + check(f.radio.tnfModel().tnf(11)->freqMhz == 14.2, + "capability publication selects confirmed tracking-notch policy"); + std::thread delayed([&] { emit f.backend->notchChanged(12, initial); }); + delayed.join(); + auto next = std::make_unique(); + f.backend = next.get(); + f.radio.setBackendForTest(std::move(next), QStringLiteral("replacement")); + QCoreApplication::processEvents(); + check(f.radio.tnfModel().tnf(12) == nullptr, + "queued notch observation from retired backend cannot populate new session"); +} + void externalReceiveHandoffOrdering() { Fixture f; @@ -669,6 +835,9 @@ int main(int argc, char** argv) qputenv("AETHER_AUTOMATION", "1"); QCoreApplication app(argc, argv); if (!profile.isValid()) { return 1; } + confirmedReceiveControls(); + ritLifecycle(); + notchReadbackLifecycle(); creationPaths(); externalReceiveHandoffOrdering(); receiveControls(); diff --git a/tests/rtl_am_sam_pipeline_test.cpp b/tests/rtl_am_sam_pipeline_test.cpp new file mode 100644 index 0000000000..51fca17b89 --- /dev/null +++ b/tests/rtl_am_sam_pipeline_test.cpp @@ -0,0 +1,446 @@ +#include "core/backends/rtl/RtlReceivePipeline.h" +#include "core/backends/rtl/RtlReceiveRecipe.h" +#include "core/backends/rtl/RtlDcBlocker.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using Pipeline = AetherSDR::rtl::RtlReceivePipeline; +using T = AetherSDR::rtl::RtlCaptureTransaction; +using Recipe = AetherSDR::rtl::RtlReceiveRecipe; +using namespace std::chrono_literals; +namespace { +int failures = 0; +void check(bool ok, const char* message) +{ if (!ok) { ++failures; std::fprintf(stderr, "FAIL: %s\n", message); } } +constexpr int kRate = 240000, kChunk = 1200; +constexpr double kCenter = 100000000, kTau = 2 * std::numbers::pi; +bool ready(Pipeline& pipeline) +{ + const auto deadline = std::chrono::steady_clock::now() + 15s; + while (std::chrono::steady_clock::now() < deadline) { + const auto status = pipeline.service(); + if (status != Pipeline::Preparation::Pending) { return status == Pipeline::Preparation::Ready; } + std::this_thread::sleep_for(1ms); + } + return false; +} +T::State state(std::span modes) +{ + T::State result; + result.token = {1, 1}; + result.capture = {1, 1, kCenter, kRate, kRate * .45, kRate * .45}; + result.hardware.centerHz = std::uint32_t(kCenter); result.hardware.sampleRateHz = kRate; + for (std::size_t id = 0; id < modes.size(); ++id) { + auto [low, high] = Recipe::defaultFilter(modes[id]); + if (modes[id] == T::Mode::Fm) { low = -8000; high = 8000; } + T::Receiver receiver{{int(id), kCenter + 20000, double(low), double(high), 0, 3000, 3000}, modes[id]}; + receiver.agcMode = 0; + receiver.audioMute = true; // Independent PCM must survive monitor mute. + result.receivers.push_back(receiver); result.receivingIds.push_back(int(id)); + } + return result; +} +using Audio = std::array, 8>; +using Signal = std::function(double)>; +struct Identity { std::uint64_t instance = 0, epoch = 0, capture = 0; bool discontinuity = false; }; +struct Run { Audio audio; std::array identities{}; std::array stereoError{}; }; +Run feed(Pipeline& pipeline, std::uint64_t& position, double seconds, const Signal& signal) +{ + Run result; + std::array, kChunk> iq{}; + const std::uint64_t start = position, end = start + std::uint64_t(seconds * kRate); + AetherSDR::rtl::RtlDcBlocker dc; + dc.configure(true, kRate); + while (position < end) { + for (std::size_t index = 0; index < iq.size(); ++index) { + const auto value = signal(double(position + index) / kRate); + iq[index] = {float(value.real()), float(value.imag())}; + } + dc.process(iq); + check(pipeline.process(position, iq), "production acquisition accepts analytic AM capture"); + Pipeline::Packet packet; + while (pipeline.takePacket(packet)) { + if (packet.slot < 0 || packet.slot >= 8) { continue; } + auto& pcm = result.audio[packet.slot]; + auto& identity = result.identities[packet.slot]; + if (!identity.instance) { + identity = {packet.instance, packet.receiverEpoch, packet.captureEpoch, packet.discontinuity}; + } + for (std::size_t index = 0; index < packet.frames; ++index) { + check(std::isfinite(packet.samples[2 * index]), "AM PCM is finite"); + result.stereoError[packet.slot] = std::max(result.stereoError[packet.slot], + double(std::abs(packet.samples[2 * index] - packet.samples[2 * index + 1]))); + // Ignore startup/reconfiguration latency and carrier leveler settling. + if (packet.firstSample + index >= start / 5 + 48000) { + pcm.push_back(packet.samples[2 * index]); + } + } + } + position += kChunk; + std::this_thread::sleep_for(6ms); + } + check(!pipeline.needsRepair(), "AM workload has no receiver withdrawal"); + return result; +} +Run render(const T::State& recipe, double seconds, const Signal& signal) +{ + // The fixed mixer buffers exceed the default Windows thread stack. + const auto pipelineOwner = std::make_unique(recipe.receivers.size()); + Pipeline& pipeline = *pipelineOwner; + if (!pipeline.prepare(recipe, true) || !ready(pipeline) || !pipeline.adopt()) { + check(false, "AM/SAM bank prepares and adopts"); return {}; + } + check(!pipeline.legacy(), "AM and SAM use native independent receivers"); + std::uint64_t position = 0; + Run output = feed(pipeline, position, seconds, signal); + pipeline.stop(); return output; +} +double magnitude(std::span pcm, double hz) +{ + if (pcm.size() < 2) { return 0; } + std::complex sum{}; double weight = 0; + for (std::size_t index = 0; index < pcm.size(); ++index) { + const double window = .5 - .5 * std::cos(kTau * index / (pcm.size() - 1)); + sum += double(pcm[index]) * window * std::polar(1.0, -kTau * hz * index / 48000); + weight += window; + } + return 2 * std::abs(sum) / weight; +} +double ratioDb(double a, double b) { return 20 * std::log10(std::max(a, 1e-12) / std::max(b, 1e-12)); } +double energy(std::span pcm) +{ + double sum = 0; + for (const float value : pcm) { sum += double(value) * value; } + return pcm.empty() ? 0 : sum / pcm.size(); +} +std::complex am(double time, double carrier, double audio, double amplitude = .04, double modulation = .6) +{ return std::polar(amplitude * (1 + modulation * std::cos(kTau * audio * time)), kTau * carrier * time); } +void fidelityAndPll() +{ + constexpr std::array modes{T::Mode::Am, T::Mode::Sam, T::Mode::Sam, T::Mode::Sam}; + auto recipe = state(modes); + for (int id = 0; id < 4; ++id) { recipe.receivers[id].passband.carrierHz = kCenter - 60000 + id * 40000; } + const auto result = render(recipe, 3, [](double time) { + return am(time, -60000, 700) + am(time, -20000, 1100) + + am(time, 20000 + 1500, 1300) + am(time, 60000 - 1500, 1700); + }); + const std::array tones{700., 1100., 1300., 1700.}; + for (int id = 0; id < 4; ++id) { + const auto& pcm = result.audio[id]; + const double tone = magnitude(pcm, tones[id]); + const double distortion = ratioDb(tone, magnitude(pcm, tones[id] * 2)); + const double dc = pcm.empty() ? 1 : std::abs(std::accumulate(pcm.begin(), pcm.end(), 0.0) / pcm.size()); + std::printf("AM fidelity receiver=%d tone=%.6f distortion=%.1fdB dc=%.8f frames=%zu\n", id, tone, distortion, dc, pcm.size()); + check(pcm.size() > 48000 && std::abs(tone / (.04 * .6 * std::sqrt(10.0)) - 1) < .05, "AM/SAM deliver useful unclipped calibrated modulation level"); + check(distortion > 35, "AM/SAM second harmonic distortion below -35 dBc including +/-1500 Hz acquisition"); + check(dc < tone * .01, "demodulated carrier DC suppressed below one percent of wanted tone"); + check(result.stereoError[id] < 1e-7, "AM/SAM deliver equal real mono channels without opposite quadrature audio"); + for (int other = 0; other < 4; ++other) { + if (id != other) { check(ratioDb(tone, magnitude(pcm, tones[other])) > 35, "independent AM/SAM reject other receiver audio"); } + } + } + constexpr std::array lowModes{T::Mode::Am, T::Mode::Sam}; + const auto lowAudio = render(state(lowModes), 3, [](double time) { return am(time, 20000, 150); }); + for (const int id : {0, 1}) { + check(std::abs(magnitude(lowAudio.audio[id], 150) / (.04 * .6 * std::sqrt(10.0)) - 1) < .08, + "carrier DC removal preserves wanted low-frequency audio at 150 Hz"); + } + // A quadrature interferer changes an envelope detector's magnitude but + // does not belong to the coherent in-phase audio. This fails if SAM is AM. + constexpr std::array compareModes{T::Mode::Am, T::Mode::Sam}; + const auto coherent = render(state(compareModes), 3, [](double time) { + return .04 * std::complex(1 + .3 * std::cos(kTau * 700 * time), .5 * std::cos(kTau * 1500 * time)) + * std::polar(1.0, kTau * 20300 * time); + }); + const double rejection = ratioDb(magnitude(coherent.audio[0], 3000), magnitude(coherent.audio[1], 3000)); + std::printf("SAM quadrature-envelope artifact rejection=%.1fdB\n", rejection); + check(rejection > 15, "SAM coherently rejects a quadrature artifact that envelope AM demodulates"); + check(magnitude(coherent.audio[1], 700) > .01, "SAM retains real AM modulation while rejecting quadrature artifact"); +} +void asymmetricFiltersAndRit() +{ + constexpr std::array modes{T::Mode::Sam, T::Mode::Sam, T::Mode::Sam, T::Mode::Am}; + auto recipe = state(modes); + recipe.receivers[0].passband.filterLowHz = -500; + recipe.receivers[1].passband.filterHighHz = 500; + recipe.receivers[2].ritEnabled = true; recipe.receivers[2].ritOffsetHz = 1200; + recipe.receivers[2].passband.translationHz = 1200; + recipe.receivers[2].passband.filterLowHz = -500; + recipe.receivers[2].passband.filterHighHz = 500; + recipe.receivers[3].passband.filterLowHz = -500; + recipe.receivers[3].passband.filterHighHz = 500; + const auto result = render(recipe, 3, [](double time) { + return std::polar(.04, kTau * 20000 * time) + + std::polar(.008, kTau * 20700 * time) + + std::polar(.008, kTau * 18700 * time) + + am(time, 21200, 250, .01, .4); + }); + check(ratioDb(magnitude(result.audio[0], 700), magnitude(result.audio[0], 1300)) > 35, + "SAM upper RF cut rejects lower-sideband interference"); + check(ratioDb(magnitude(result.audio[1], 1300), magnitude(result.audio[1], 700)) > 35, + "SAM lower RF cut rejects upper-sideband interference with correct IQ orientation"); + check(magnitude(result.audio[2], 250) > .005, "RIT selects displaced carrier without changing dial"); + check(magnitude(result.audio[3], 700) < .001, "AM narrow RF cuts reject out-of-passband modulation"); +} +void fadesRetunesAndEpochs() +{ + constexpr std::array modes{T::Mode::Sam, T::Mode::Am}; + auto recipe = state(modes); + const auto pipelineOwner = std::make_unique(2); + Pipeline& pipeline = *pipelineOwner; + check(pipeline.prepare(recipe, true) && ready(pipeline) && pipeline.adopt(), "fade fixture adopts"); + std::uint64_t position = 0; + const auto first = feed(pipeline, position, 2.5, [](double t) { return am(t, 20900, 1000); }); + const double baseline = magnitude(first.audio[0], 1000); + feed(pipeline, position, 1.5, [](double) { return std::complex{}; }); + const auto recovered = feed(pipeline, position, 3, [](double t) { return am(t, 20900, 1000, .004); }); + const double after = magnitude(recovered.audio[0], 1000); + std::printf("SAM dropout fade recovered normalized_gain=%.3f\n", after * 10 / baseline); + check(after * 10 > baseline * .85 && after * 10 < baseline * 1.15, + "SAM recovers its carrier after complete dropout and 20 dB fade"); + recipe.token.revision++; recipe.receivers[0].passband.carrierHz += 5000; + check(pipeline.prepare(recipe) && ready(pipeline) && pipeline.adopt(), "SAM retune adopts atomically"); + const auto retuned = feed(pipeline, position, 2.5, [](double t) { return am(t, 25300, 1000); }); + check(magnitude(retuned.audio[0], 1000) > baseline * .85, "retuned SAM acquires fresh PLL"); + check(retuned.identities[0].epoch != first.identities[0].epoch + && retuned.identities[0].capture != first.identities[0].capture, + "SAM retune retires receiver and presentation epochs"); + recipe.token.revision++; recipe.receivingIds = {1}; + check(pipeline.prepare(recipe) && ready(pipeline) && pipeline.adopt(), "SAM receiver parks"); + const auto parked = feed(pipeline, position, 1.2, [](double t) { return am(t, 25300, 1000); }); + check(!parked.identities[0].instance, "parked SAM produces no PCM"); + recipe.token.revision++; recipe.receivingIds = {0, 1}; + // Retirement is asynchronous; the public admission result identifies its retry. + const auto deadline = std::chrono::steady_clock::now() + 10s; + auto submission = Pipeline::Submission::RetryRetiringSlot; + while (submission == Pipeline::Submission::RetryRetiringSlot && std::chrono::steady_clock::now() < deadline) { + submission = pipeline.prepareDetailed(recipe); std::this_thread::sleep_for(1ms); + } + check(submission == Pipeline::Submission::Accepted && ready(pipeline) && pipeline.adopt(), "parked SAM recreates"); + const auto resumed = feed(pipeline, position, 2.5, [](double t) { return am(t, 25300, 1000); }); + check(resumed.identities[0].instance != retuned.identities[0].instance + && resumed.identities[0].discontinuity && magnitude(resumed.audio[0], 1000) > baseline * .85, + "resumed SAM has a new identity and acquired audio"); + recipe.token.revision++; recipe.receivers[0].mode = T::Mode::Am; + check(pipeline.prepare(recipe) && ready(pipeline) && pipeline.adopt(), "SAM to AM transition adopts"); + const auto envelope = feed(pipeline, position, 2.5, [](double t) { return am(t, 25300, 1000); }); + check(envelope.identities[0].epoch != resumed.identities[0].epoch + && magnitude(envelope.audio[0], 1000) > baseline * .85, "AM transition retires SAM DSP and preserves wanted level"); + pipeline.stop(); +} +void mixedEight() +{ + constexpr std::array modes{T::Mode::Am, T::Mode::Sam, T::Mode::Usb, T::Mode::Lsb, + T::Mode::Cw, T::Mode::Cwr, T::Mode::Fm, T::Mode::Sam}; + auto recipe = state(modes); + constexpr std::array offsets{-84000., -60000., -36000., -12000., 12000., 36000., 60000., 84000.}; + constexpr std::array tones{700., 900., 1100., 1300., 600., 600., 1500., 1700.}; + for (int id = 0; id < 8; ++id) { recipe.receivers[id].passband.carrierHz = kCenter + offsets[id]; } + const auto result = render(recipe, 3, [&](double t) { + return am(t, offsets[0], tones[0], .015) + am(t, offsets[1] + 500, tones[1], .015) + + std::polar(.015, kTau * (offsets[2] + tones[2]) * t) + + std::polar(.015, kTau * (offsets[3] - tones[3]) * t) + + std::polar(.015, kTau * offsets[4] * t) + std::polar(.015, kTau * offsets[5] * t) + + std::polar(.015, kTau * offsets[6] * t + (2500 / tones[6]) * std::sin(kTau * tones[6] * t)) + + am(t, offsets[7] - 500, tones[7], .015); + }); + for (int id = 0; id < 8; ++id) { + const double wanted = magnitude(result.audio[id], tones[id]); + std::printf("mixed receiver=%d mode=%d tone=%.5f frames=%zu\n", id, int(modes[id]), wanted, result.audio[id].size()); + check(result.audio[id].size() > 48000 && wanted > .008, "all eight mixed AM/SAM/SSB/CW/FM receivers demodulate wanted RF"); + for (int other = 0; other < 8; ++other) { + if (tones[id] != tones[other]) { check(ratioDb(wanted, magnitude(result.audio[id], tones[other])) > 30, + "mixed analog bank keeps unrelated audio isolated"); } + } + } +} +void gainNoiseAndNotches() +{ + constexpr std::array modes{T::Mode::Am, T::Mode::Sam, T::Mode::Sam, T::Mode::Sam}; + auto recipe = state(modes); + recipe.receivers[1].agcOffLevel = 30; + recipe.receivers[2].mnEnabled = true; + recipe.receivers[2].mnLevel = 40; // 1700 Hz, paired sidebands. + recipe.receivers[3].anfEnabled = true; recipe.receivers[3].anfLevel = 100; + const auto result = render(recipe, 3, [](double t) { return am(t, 20000, 1700, .01); }); + const double reference = magnitude(result.audio[0], 1700); + const double gainDb = ratioDb(magnitude(result.audio[1], 1700), reference); + const double mnDb = ratioDb(reference, magnitude(result.audio[2], 1700)); + const double anfDb = ratioDb(reference, magnitude(result.audio[3], 1700)); + std::printf("AM controls gain=%.2fdB manual_notch=%.1fdB ANF=%.1fdB\n", gainDb, mnDb, anfDb); + check(std::abs(gainDb - 12) < .6, "AM/SAM share linear level and mapped AGC-off gain"); + check(mnDb > 25, "AM manual notch removes both sidebands of its selected audio tone"); + check(anfDb > 10, "AM automatic notch removes an actual coherent audio tone"); + + recipe = state(modes); + for (auto& rx : recipe.receivers) { rx.agcMode = 4; } + recipe.receivers[1].agcMode = 2; + recipe.receivers[2].agcThreshold = 35; + recipe.receivers[3].agcMode = 0; + const auto agc = render(recipe, 4.5, [](double t) { return am(t, 20000, 1000, t < 1.5 ? .1 : .0001); }); + const auto level = [&](int id, double first, double last) { + const auto& pcm = agc.audio[id]; + const std::size_t begin = std::size_t((first - 1) * 48000), count = std::size_t((last - first) * 48000); + return pcm.size() >= begin + count ? magnitude(std::span(pcm).subspan(begin, count), 1000) : 0; + }; + const double fast = level(0, 2, 2.3), slow = level(1, 2, 2.3); + const double late = level(0, 3.9, 4.2), lowThreshold = level(2, 3.9, 4.2); + std::printf("AM AGC fast_vs_slow=%.1fdB threshold=%.1fdB\n", ratioDb(fast, slow), ratioDb(late, lowThreshold)); + check(ratioDb(fast, slow) > 6, "AM/SAM fast AGC recovers before slow hang"); + check(ratioDb(late, lowThreshold) > 6, "AM/SAM AGC-T changes actual weak-signal gain"); + check(late > level(3, 3.9, 4.2) * 3, "AM AGC raises weak modulation relative to fixed gain"); + + recipe = state(modes); + recipe.receivers[1].nbKind = AetherSDR::NoiseBlankerKind::Impulse; + recipe.receivers[2].nbKind = AetherSDR::NoiseBlankerKind::Advanced; + recipe.receivers[2].nbFill = AetherSDR::NoiseBlankerFill::Interpolate; + recipe.receivers[1].nbLevel = 80; recipe.receivers[2].nbLevel = 80; + const auto blanked = render(recipe, 3, [](double t) { + const auto n = std::llround(t * kRate); + return am(t, 20000, 1000, .002) + std::complex(n % 10000 == 0 ? .5 : 0, 0); + }); + const auto residual = [](const auto& pcm) { + const double tone = magnitude(pcm, 1000); + return std::max(1e-12, energy(pcm) - tone * tone / 2); + }; + for (const int id : {1, 2}) { + // Compare SAM blankers with the SAM reference, keeping demodulator identical. + const double reduction = 10 * std::log10(residual(blanked.audio[3]) / residual(blanked.audio[id])); + std::printf("AM blanker=%d residual_reduction=%.1fdB\n", id, reduction); + check(reduction > 3, "AM/SAM receiver blanker reduces actual impulse artifacts"); + check(magnitude(blanked.audio[id], 1000) > magnitude(blanked.audio[3], 1000) * .8, + "AM blanker preserves desired modulation"); + } + recipe = state(modes); + recipe.receivers[2].nrEnabled = true; recipe.receivers[2].nrLevel = 100; + recipe.receivers[3].mode = T::Mode::Am; + recipe.receivers[3].nrEnabled = true; recipe.receivers[3].nrLevel = 100; + std::uint32_t noiseState = 1; + const auto reduced = render(recipe, 3, [&](double t) { + const auto noise = [&] { + noiseState = 1664525u * noiseState + 1013904223u; + return (double(noiseState) / 4294967296.0 - .5) * .05; + }; + // Explicit sequencing gives every compiler the same complex noise fixture. + const double noiseQ = noise(); + const double noiseI = noise(); + return am(t, 20000, 1500, .04, .3) + std::complex(noiseI, noiseQ); + }); + const auto snr = [](const auto& pcm) { + const double signal = std::pow(magnitude(pcm, 1500), 2) / 2; + return signal / std::max(1e-12, energy(pcm) - signal); + }; + for (const auto [plain, filtered] : {std::pair{0, 3}, std::pair{1, 2}}) { + const double improvement = 10 * std::log10(snr(reduced.audio[filtered]) / snr(reduced.audio[plain])); + std::printf("AM NR mode=%d SNR_improvement=%.1fdB\n", plain, improvement); + check(improvement > 3, "AM/SAM adaptive noise reduction improves actual audio SNR"); + check(magnitude(reduced.audio[filtered], 1500) > magnitude(reduced.audio[plain], 1500) * .25, + "AM/SAM NR preserves wanted modulation"); + } + recipe = state(modes); + recipe.trackingNotches.entries = {{1, kCenter + 20000 + 1700, 400, true}, + {2, kCenter + 20000 - 1700, 400, true}}; + const auto tracked = render(recipe, 3, [](double t) { return am(t, 20000, 1700, .01); }); + for (const int id : {0, 1}) { + check(ratioDb(reference, magnitude(tracked.audio[id], 1700)) > 25, + "absolute RF tracking notches remove both AM sidebands without a center-frequency shortcut"); + } +} +void refusals() +{ + constexpr std::array modes{T::Mode::Am, T::Mode::Sam}; + const auto good = state(modes); + const auto pipelineOwner = std::make_unique(2); + Pipeline& pipeline = *pipelineOwner; + for (int invalid = 0; invalid < 6; ++invalid) { + auto bad = good; + auto& rx = bad.receivers[0]; + switch (invalid) { + case 0: rx.passband.filterLowHz = 0; break; + case 1: rx.passband.filterHighHz = 99; break; + case 2: rx.passband.filterLowHz = -12001; break; + case 3: rx.passband.translationHz = 100; break; + case 4: rx.agcMode = 1; break; + case 5: rx.agcOffLevel = 101; break; + } + check(!pipeline.prepare(bad), "AM/SAM boundary rejects invalid filter/RIT/gain before bank mutation"); + } + auto full = good; + full.receivers[0].mnEnabled = true; + for (int id = 0; id < AetherSDR::rtl::RtlTrackingNotches::kCapacity; ++id) { + full.trackingNotches.entries.push_back({id, kCenter + 20000 + 500 + id * 100, 200, false}); + } + check(pipeline.prepare(full) && ready(pipeline) && pipeline.adopt(), + "paired AM manual notch coexists with the entire advertised tracking notch bank"); + pipeline.stop(); +} +int externalFixture(const char* inputPath, const char* outputPath) +{ + std::ifstream input(inputPath, std::ios::binary); + std::ofstream output(outputPath, std::ios::binary); + if (!input || !output) { return 2; } + constexpr std::array modes{T::Mode::Sam}; + auto recipe = state(modes); + if (const char* mode = std::getenv("AETHER_RTL_AM_MODE"); mode && std::string_view(mode) == "AM") { + recipe.receivers[0].mode = T::Mode::Am; + } + recipe.receivers.resize(1); recipe.receivingIds = {0}; + recipe.receivers[0].passband.carrierHz = kCenter + 40000; + const auto pipelineOwner = std::make_unique(); + Pipeline& pipeline = *pipelineOwner; + if (!pipeline.prepare(recipe, true) || !ready(pipeline) || !pipeline.adopt()) { return 3; } + std::array raw{}; + std::array, kChunk> iq{}; + std::uint64_t first = 0, frames = 0; + while (first < std::uint64_t(kRate) * 300 && input.read(reinterpret_cast(raw.data()), sizeof(raw))) { + for (std::size_t index = 0; index < iq.size(); ++index) { + if (!std::isfinite(raw[2 * index]) || !std::isfinite(raw[2 * index + 1])) { return 4; } + iq[index] = {raw[2 * index], raw[2 * index + 1]}; + } + if (!pipeline.process(first, iq)) { return 5; } + Pipeline::Packet packet; + while (pipeline.takePacket(packet)) { + if (packet.slot != 0) { continue; } + for (std::size_t index = 0; index < packet.frames; ++index) { + const float sample = packet.samples[index * 2]; + output.write(reinterpret_cast(&sample), sizeof(sample)); + ++frames; + } + } + first += iq.size(); + std::this_thread::sleep_for(6ms); + } + const bool success = !pipeline.needsRepair() && frames > 24000 && output.good(); + pipeline.stop(); + std::printf("external IQ capture_frames=%llu mono_pcm_frames=%llu sample_rate=48000\n", + static_cast(first), static_cast(frames)); + return success ? 0 : 6; +} +} +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + const char* iq = std::getenv("AETHER_RTL_AM_INPUT_IQ"); + const char* pcm = std::getenv("AETHER_RTL_AM_OUTPUT_PCM"); + if (iq && pcm) { return externalFixture(iq, pcm); } + fidelityAndPll(); asymmetricFiltersAndRit(); fadesRetunesAndEpochs(); mixedEight(); gainNoiseAndNotches(); refusals(); + QThreadPool::globalInstance()->waitForDone(); + std::printf("rtl_am_sam_pipeline_test: %d failures\n", failures); + return failures ? 1 : 0; +} diff --git a/tests/rtl_capture_transaction_test.cpp b/tests/rtl_capture_transaction_test.cpp index 007d94ed11..d2f163327a 100644 --- a/tests/rtl_capture_transaction_test.cpp +++ b/tests/rtl_capture_transaction_test.cpp @@ -100,8 +100,8 @@ int main() { T tx({8, 4}); Device usb; if (!establish(tx, usb)) { return 1; } auto configured = desired(); - configured.receivers.push_back({{7, 98'948'000, -8000, 8000, 0, 20'000, 20'000}, T::Mode::Usb}); - configured.receivers.push_back({{3, 101'052'000, -8000, 8000, 0, 20'000, 20'000}, T::Mode::Usb}); + configured.receivers.push_back({{7, 98'948'000, -8000, 8000, 0, 20'000, 20'000}, T::Mode::Am}); + configured.receivers.push_back({{3, 101'052'000, -8000, 8000, 0, 20'000, 20'000}, T::Mode::Am}); check(bool(tx.submit(configured)), "receivers at both guarded capture edges admitted"); auto job = tx.takeWork(); check(job && job->target.receivingIds == (std::vector{0, 3, 7}), @@ -453,6 +453,34 @@ int main() "DC placement does not switch automatic front-end mode across 24 MHz"); check(job->target.receivers == next.receivers, "band-edge placement never rewrites RF or filters"); } + for (const auto mode : {T::Mode::Cw, T::Mode::Cwr}) { + T tx({8, 1}); Device usb; tx.beginSession(); + auto next = desired(); + next.dcSuppression = true; + next.receivers = {{{3, 100'000'000, -250, 250, 100, 3000, 3000}, mode}}; + next.receivers[0].ritEnabled = true; next.receivers[0].ritOffsetHz = 100; + next.followReceiverId = 3; next.avoidDc = true; + check(bool(tx.submit(next)), "CW/CWR admission places its RIT-adjusted carrier away from converter DC"); + auto job = tx.takeWork(); + check(job && T::dcClear(job->target), "DC suppression cannot erase the followed CW carrier"); + if (!job) { continue; } + check(job->target.receivers == next.receivers, + "DC placement preserves RF marker, RIT, pitch and carrier-relative filter"); + check(tx.complete(T::execute(*job, usb)) == T::Completion::Published, "CW placement adopts readback"); + next.followReceiverId.reset(); next.avoidDc = false; + next.hardware.centerHz = 103'000'000; + check(bool(tx.submit(next)), "free pan remains independent of CW DC placement"); + job = tx.takeWork(); + check(job && job->target.receivingIds.empty() && job->target.hardware.centerHz == 103'000'000, + "free pan parks the CW receiver without snapping capture back"); + if (!job) { continue; } + tx.complete(T::execute(*job, usb)); + next.receivers[0].cwPitchHz = 900; + check(bool(tx.submit(next)), "parked CW accepts a pitch recipe change"); + job = tx.takeWork(); + check(job && !job->hardwareChanged && job->target.receivingIds.empty(), + "parked pitch change does not retune hardware or resume audio"); + } std::fprintf(stderr, "rtl_capture_transaction_test: %d checks, %d failures\n", checks, failures); return failures ? 1 : 0; } diff --git a/tests/rtl_model_acceptance_test.cpp b/tests/rtl_model_acceptance_test.cpp index 43324c1025..bc6c2fdd2a 100644 --- a/tests/rtl_model_acceptance_test.cpp +++ b/tests/rtl_model_acceptance_test.cpp @@ -10,6 +10,8 @@ #include "models/PanadapterModel.h" #include #include +#include +#include #include #include #include @@ -49,7 +51,7 @@ struct RtlCaptureBackendTestAccess { // 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) { + if (capacity > 1 && backend.m_savedSettings.document.slices.isEmpty()) { // 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}; @@ -286,13 +288,12 @@ static void analogBankSelection(int capacity = 4) && !model.slice(2)->wfmForceMono() && model.slice(2)->wfmDeemphasisUs() == 75 && selected(3), "WFM recipes and focus survive sibling publications"); - const auto beforeRefusal = Access::state(backend); - int warnings = 0; - QObject::connect(&backend, &IRadioBackend::configurationWarning, &model, [&](const QString&) { ++warnings; }); model.slice(0)->setMode("AM"); - check(Access::idle(backend) && Access::state(backend).token == beforeRefusal.token - && model.slice(0)->mode() == "FM" && model.slice(1)->wfmForceMono() - && warnings >= 1, "unsupported legacy combinations refuse without altering recipes"); + check(waitFor([&] { return settled() && model.slice(0)->mode() == "AM"; }) + && model.slice(1)->wfmForceMono(), "AM adopts beside native WFM without altering sibling recipe"); + model.slice(0)->setMode("SAM"); + check(waitFor([&] { return settled() && model.slice(0)->mode() == "SAM"; }), + "SAM is an accepted independent mode through the public model caller"); model.slice(2)->setActive(true); auto* pan = model.panadapter(model.slice(2)->panId()); check(pan != nullptr, "multi receiver pan materialized"); @@ -343,6 +344,458 @@ static void analogBankSelection(int capacity = 4) backend.disconnectRadio(); } +static void invalidLegacySidebandRestore() +{ + using Access = rtl::RtlCaptureBackendTestAccess; + const RadioSettingsScope scope("rtl", "legacy-sideband-filter"); + const QJsonObject legacy{{"rfFrequencyHz", 100'000'000.0}, {"mode", "USB"}, + {"filterLowHz", -3000}, {"filterHighHz", 3000}, {"sampleRateHz", 2'400'000}, + {"futureLegacy", "preserve"}}; + check(scope.setFeature("OperatingState", 3, legacy), "invalid legacy USB cuts seeded without normalization"); + RadioModel model; + check(model.rebuildBackendForTest("rtl"), "legacy sideband restore uses real backend"); + RadioModelSliceLifecycleTestAccess::restore(model, "legacy-sideband-filter"); + auto& backend = *static_cast(model.backend()); + QStringList warnings; + QObject::connect(&backend, &IRadioBackend::configurationWarning, &model, + [&](const QString& warning) { warnings.push_back(warning); }); + auto usb = std::make_shared(); + Access::start(backend, std::make_unique(usb)); + usb->releaseReadback(); CaptureClock clock(usb); + check(waitFor([&] { clock.advance(); return Access::idle(backend) && model.slice(0) + && !warnings.empty(); }), "invalid legacy cuts connect safely and report restore refusal"); + if (model.slice(0)) { + check(model.slice(0)->mode() == "USB" && model.slice(0)->audioMute(), + "invalid legacy cuts never become an audible or accepted native recipe"); + } + RadioModelSliceLifecycleTestAccess::flush(model); + const auto migrated = RtlSliceSettings(scope).load(); + check(migrated.status == RtlSliceSettings::ReadStatus::Ready + && migrated.document.slices.value(0).mode == "USB" + && migrated.document.slices.value(0).filterLowHz == -3000 + && migrated.document.slices.value(0).filterHighHz == 3000, + "muted fallback cannot overwrite the migrated original USB settings"); + const auto retained = scope.featureExact("OperatingState"); + check(retained.value("mode") == legacy.value("mode") + && retained.value("filterLowHz") == legacy.value("filterLowHz") + && retained.value("filterHighHz") == legacy.value("filterHighHz") + && retained.value("futureLegacy") == legacy.value("futureLegacy"), + "legacy downgrade record and unknown data remain intact"); + check(std::ranges::any_of(warnings, [](const QString& warning) { + return warning.contains("Saved AM/SAM/SSB/CW") && warning.contains("preserved"); + }), "restore warning explicitly describes preserved unsupported settings"); + backend.disconnectRadio(); + rtl::RtlTrackingNotches preserved; + preserved.entries = {{19, 100'000'700, 400, true}}; + check(RtlSliceSettings(scope).patch(100'000'000, 2'400'000, + {migrated.document.slices.value(0)}, {}, preserved), "valid notches coexist with refused saved cuts"); + RadioModel again; + check(again.rebuildBackendForTest("rtl"), "canonical invalid receiver reconnect initializes"); + RadioModelSliceLifecycleTestAccess::restore(again, "legacy-sideband-filter"); + auto& next = *static_cast(again.backend()); + auto nextUsb = std::make_shared(); + Access::start(next, std::make_unique(nextUsb)); + nextUsb->releaseReadback(); CaptureClock nextClock(nextUsb); + check(waitFor([&] { nextClock.advance(); return Access::idle(next) && again.slice(0); }), + "canonical invalid receiver retains safe bootstrap"); + RadioModelSliceLifecycleTestAccess::flush(again); + check(RtlSliceSettings(scope).load().document.trackingNotches == preserved, + "refused receiver restore cannot erase the independently valid global notch bank"); + next.disconnectRadio(); +} + +static void perModeFilterRecall() +{ + using Access = rtl::RtlCaptureBackendTestAccess; + const RadioSettingsScope scope("rtl", "mode-filter-recall"); + RtlSliceSettings::Slice saved; + saved.id = 3; saved.frequencyHz = 100'000'000; saved.mode = "USB"; + saved.filterLowHz = 200; saved.filterHighHz = 2700; + saved.modeFilters.insert("LSB", {100, 2900}); + RtlSliceSettings::Slice sibling = saved; + sibling.id = 4; sibling.frequencyHz = 100'200'000; + sibling.filterLowHz = 400; sibling.filterHighHz = 2400; + check(RtlSliceSettings(scope).patch(100'600'000, 2'400'000, {saved, sibling}), + "per-mode sparse receivers seeded"); + RadioModel model; + check(model.rebuildBackendForTest("rtl"), "filter recall model initialized"); + RadioModelSliceLifecycleTestAccess::restore(model, "mode-filter-recall"); + auto& backend = *static_cast(model.backend()); + auto usb = std::make_shared(); + Access::start(backend, std::make_unique(usb), 2); + usb->releaseReadback(); CaptureClock clock(usb); + const auto settle = [&] { clock.advance(); return Access::idle(backend); }; + check(waitFor([&] { return settle() && model.slice(3) && model.slice(4); }), + "filter recall receivers restored"); + auto* slice = model.slice(3); + if (!slice || !model.slice(4)) { backend.disconnectRadio(); return; } + check(slice->rxAntenna() == "RX" && slice->rxAntennaList() == QStringList{"RX"} + && slice->txAntenna().isEmpty() && slice->txAntennaList().isEmpty(), + "RTL publishes only its fixed physical receive input and no transmit ports"); + slice->setMode("FM"); + check(waitFor([&] { return settle() && slice->mode() == "FM"; }) + && slice->filterLow() == -8000 && slice->filterHigh() == 8000, + "first FM entry uses its default filter"); + slice->setFilterWidth(-6500, 6500); + check(waitFor([&] { return settle() && slice->filterLow() == -6500; }), + "custom FM filter accepted"); + slice->setMode("USB"); + check(waitFor([&] { return settle() && slice->mode() == "USB"; }) + && slice->filterLow() == 200 && slice->filterHigh() == 2700, + "USB to FM to USB recalls accepted custom filter"); + RadioModelSliceLifecycleTestAccess::flush(model); + const auto before = scope.featureExact("RtlSlices"); + slice->setFilterWidth(300, 2300); + RadioModelSliceLifecycleTestAccess::flush(model); + check(scope.featureExact("RtlSlices") == before, + "pending filter intent does not overwrite per-mode recall"); + slice->setFilterWidth(200, 2700); + check(waitFor(settle), "superseded filter request settles"); + slice->setFilterWidth(-200, 2700); + check(Access::idle(backend) && slice->filterLow() == 200, + "invalid USB passband cannot enter recall history"); + slice->setMode("LSB"); + check(waitFor([&] { return settle() && slice->mode() == "LSB"; }) + && slice->filterLow() == -2900 && slice->filterHigh() == -100, + "invalid signed LSB history falls back to its mode default"); + slice->setFilterWidth(-2300, -300); + check(waitFor([&] { return settle() && slice->filterLow() == -2300; }), + "custom LSB filter accepted"); + slice->setMode("CW"); + check(waitFor([&] { return settle() && slice->mode() == "CW"; }) + && slice->filterLow() == -250 && slice->filterHigh() == 250, + "first CW entry uses independent mode default"); + slice->setFilterWidth(-150, 150); + check(waitFor([&] { return settle() && slice->filterLow() == -150; }), + "custom CW filter accepted"); + slice->setMode("FM"); + check(waitFor([&] { return settle() && slice->mode() == "FM"; }) + && slice->filterLow() == -6500 && slice->filterHigh() == 6500, + "FM recalls its accepted custom filter"); + check(model.slice(4)->mode() == "USB" && model.slice(4)->filterLow() == 400 + && model.slice(4)->filterHigh() == 2400, "filter memory remains per slice"); + RadioModelSliceLifecycleTestAccess::flush(model); + const auto filters = RtlSliceSettings(scope).load().document.slices[3].modeFilters; + check(filters.value("USB") == RtlSliceSettings::Filter{200, 2700} + && filters.value("LSB") == RtlSliceSettings::Filter{-2300, -300} + && filters.value("CW") == RtlSliceSettings::Filter{-150, 150}, + "only accepted per-mode filters are persisted together"); + backend.disconnectRadio(); + RadioModel restored; + check(restored.rebuildBackendForTest("rtl"), "filter recall reconnect initialized"); + RadioModelSliceLifecycleTestAccess::restore(restored, "mode-filter-recall"); + auto& second = *static_cast(restored.backend()); + auto nextUsb = std::make_shared(); + Access::start(second, std::make_unique(nextUsb), 2); + nextUsb->releaseReadback(); CaptureClock nextClock(nextUsb); + const auto nextSettle = [&] { nextClock.advance(); return Access::idle(second); }; + check(waitFor([&] { return nextSettle() && restored.slice(3); }), "filter history reconnect restores"); + if (auto* resumed = restored.slice(3)) { + resumed->setMode("USB"); + check(waitFor([&] { return nextSettle() && resumed->mode() == "USB"; }) + && resumed->filterLow() == 200 && resumed->filterHigh() == 2700, + "reconnect retains custom USB filter while saved in FM"); + resumed->setMode("CW"); + check(waitFor([&] { return nextSettle() && resumed->mode() == "CW"; }) + && resumed->filterLow() == -150 && resumed->filterHigh() == 150, + "reconnect retains custom CW filter independently"); + } + second.disconnectRadio(); +} + +static void sidebandControlsAndPersistence() +{ + using Access = rtl::RtlCaptureBackendTestAccess; + const RadioSettingsScope scope("rtl", "sideband-controls"); + RtlSliceSettings::Slice saved; + saved.id = 3; saved.frequencyHz = 100'000'000; saved.mode = "CW"; + saved.filterLowHz = -250; saved.filterHighHz = 250; + saved.agcMode = "slow"; saved.agcThreshold = 35; + RtlSliceSettings::Slice fm; + fm.id = 4; fm.frequencyHz = 100'200'000; fm.mode = "FM"; + fm.filterLowHz = -8000; fm.filterHighHz = 8000; + check(RtlSliceSettings(scope).patch(100'600'000, 2'400'000, {saved, fm}), "CW and FM receiver document seeded"); + RadioModel model; + check(model.rebuildBackendForTest("rtl"), "SSB/CW real model backend initialized"); + RadioModelSliceLifecycleTestAccess::restore(model, "sideband-controls"); + auto& backend = *static_cast(model.backend()); + auto usb = std::make_shared(); + check(backend.capabilities().maxNotchFilters == 0, "tracking notches await an adopted sideband receiver"); + Access::start(backend, std::make_unique(usb), 2); + usb->releaseReadback(); + CaptureClock clock(usb); + const auto settle = [&] { clock.advance(); return Access::idle(backend); }; + check(waitFor([&] { return settle() && model.slice(3); }), "CW sparse receiver restored through worker adoption"); + auto* slice = model.slice(3); + if (!slice) { backend.disconnectRadio(); return; } + check(slice->mode() == "CW" && slice->agcMode() == "slow" && slice->agcThreshold() == 35, + "accepted CW restore reports actual AGC recipe"); + const auto caps = backend.capabilities(); + check(caps.receiveDspControl.has_value(), "RTL declares receive DSP controls"); + if (caps.receiveDspControl) { + using Feature = ReceiveDspControl::Feature; + for (const QString& mode : {QStringLiteral("AM"), QStringLiteral("SAM"), + QStringLiteral("USB"), QStringLiteral("LSB"), + QStringLiteral("CW"), QStringLiteral("CWR")}) { + check(caps.receiveDspControl->supports(Feature::Apf, mode) + == (mode == "CW" || mode == "CWR"), + "APF capability matches the CW-only backend dispatch contract"); + check(caps.receiveDspControl->supports(Feature::Nr, mode), + "APF restriction does not disable NR in other analog modes"); + } + check(!caps.receiveDspControl->supports(Feature::Apf, QStringLiteral("FM")), + "APF is unavailable outside the qualified receive modes"); + } + check(caps.maxNotchFilters == 32 && caps.notchMinWidthHz == 200 + && caps.notchMaxWidthHz == 12000 && !caps.notchHasDepth, + "adopted sideband advertises measured tracking-notch capacity and width limits"); + PcmFrame fmFrame; + QObject::connect(&backend, &IRadioBackend::sliceAudioFrameReady, &model, + [&](int id, const PcmFrame& frame) { if (id == 4) { fmFrame = frame; } }); + PcmFrame speakerFrame; + int speakerDiscontinuities = 0; + QObject::connect(&backend, &IRadioBackend::audioFrameReady, &model, [&](const PcmFrame& frame) { + speakerFrame = frame; + if (frame.discontinuity()) { ++speakerDiscontinuities; } + }); + check(waitFor([&] { clock.advance(); return speakerFrame.current() && fmFrame.current(); }), + "restored CW supplies typed speaker PCM before graph edits"); + const PcmFrame beforeMonitor = speakerFrame; + slice->setAudioGain(23); slice->setAudioMute(true); + check(waitFor([&] { return settle() && slice->audioGain() == 23 && slice->audioMute(); }), + "monitor edits adopt independently of receiver DSP"); + check(beforeMonitor.current(), "gain and mute preserve the speaker epoch"); + const PcmFrame beforeGraph = speakerFrame; + const PcmFrame fmBeforeGraph = fmFrame; + const int discontinuitiesBeforeGraph = speakerDiscontinuities; + RadioModelSliceLifecycleTestAccess::flush(model); + const auto before = scope.featureExact("RtlSlices"); + slice->setAgcMode("fast"); slice->setAgcThreshold(42); slice->setAgcOffLevel(19); + slice->setNbKind(NoiseBlankerKind::Advanced); slice->setNbLevel(73); + slice->setNbFill(NoiseBlankerFill::Interpolate); + slice->setApf(true); slice->setApfLevel(80); + slice->setNr(true); slice->setNrLevel(62); + slice->setAnf(true); slice->setAnfLevel(57); + slice->setMn(true); slice->setMnLevel(30); + slice->setRit(true, 120); + backend.setCwPitch(800); + check(slice->agcMode() == "slow" && slice->agcThreshold() == 35 + && slice->agcOffLevel() == 10 && !slice->apfOn() && !slice->ritOn(), + "pending receive DSP controls cannot publish optimistic model state"); + RadioModelSliceLifecycleTestAccess::flush(model); + check(scope.featureExact("RtlSlices") == before, "pending DSP intent never overwrites accepted saved state"); + check(waitFor([&] { return settle() && slice->agcMode() == "fast" && slice->agcThreshold() == 42 + && slice->agcOffLevel() == 19 && slice->apfOn() && slice->apfLevel() == 80 + && slice->nbKind() == NoiseBlankerKind::Advanced && slice->nbLevel() == 73 + && slice->nbFill() == NoiseBlankerFill::Interpolate && slice->ritOn() && slice->ritFreq() == 120 + && model.transmitModel().cwPitch() == 800 + && slice->nrOn() && slice->nrLevel() == 62 && slice->anfOn() && slice->anfLevel() == 57 + && slice->mnOn() && slice->mnLevel() == 30; }), + "coalesced AGC/NB/NR/ANF/MN/APF/RIT/pitch edits preserve each field through prepared-bank adoption"); + check(fmBeforeGraph.current(), "CW pitch and sideband DSP changes preserve the FM sibling decoder lease"); + check(!beforeGraph.current(), "accepted sideband graph edits revoke already queued speaker PCM"); + check(waitFor([&] { clock.advance(); return speakerFrame.current() + && speakerDiscontinuities > discontinuitiesBeforeGraph; }), + "rebuilt sideband speaker resumes with an explicit discontinuity"); + const auto accepted = Access::state(backend).receivers.front(); + check(accepted.passband.carrierHz == 100'000'000 && accepted.passband.translationHz == 120 + && accepted.passband.filterLowHz == -250 && accepted.passband.filterHighHz == 250, + "RIT changes the RF extraction interval without changing the displayed carrier/filter"); + RadioModelSliceLifecycleTestAccess::flush(model); + const auto persisted = RtlSliceSettings(scope).load().document.slices.value(3); + check(persisted.agcMode == "fast" && persisted.agcThreshold == 42 && persisted.agcOffLevel == 19 + && persisted.cwPitchHz == 800 && persisted.ritEnabled && persisted.ritOffsetHz == 120 + && persisted.nbKind == NoiseBlankerKind::Advanced && persisted.nbLevel == 73 + && persisted.nbFill == NoiseBlankerFill::Interpolate && persisted.apfEnabled && persisted.apfLevel == 80, + "only adopted complete receiver DSP state reaches RtlSlices"); + const auto savedAccepted = scope.featureExact("RtlSlices"); + backend.setCwPitch(0); backend.setSliceRitOffset(3, 100000); + backend.setSliceAgc(3, "invalid", 45); + check(Access::idle(backend) && model.transmitModel().cwPitch() == 800, + "invalid DSP values do not prepare or publish"); + RadioModelSliceLifecycleTestAccess::flush(model); + check(scope.featureExact("RtlSlices") == savedAccepted, "invalid DSP values cannot corrupt persistence"); + TnfModel& notches = model.tnfModel(); + check(notches.confirmsControls(), "RTL tracking-notch model uses confirmed policy"); + RadioModelSliceLifecycleTestAccess::flush(model); + const auto beforeNotches = scope.featureExact("RtlSlices"); + notches.createTnf(100.00015); notches.createTnf(100.00030); + check(notches.tnfs().isEmpty(), "pending tracking-notch creates publish no markers"); + RadioModelSliceLifecycleTestAccess::flush(model); + check(scope.featureExact("RtlSlices") == beforeNotches, "pending notch creates do not persist"); + check(waitFor([&] { return settle() && notches.tnfs().size() == 2; }), + "two coalesced tracking notches reach accepted model"); + if (notches.tnfs().size() != 2) { backend.disconnectRadio(); return; } + const int firstNotch = notches.tnfs().firstKey(); + const int removedNotch = notches.tnfs().lastKey(); + check(notches.tnf(firstNotch)->widthHz == 200, "adopted notch reports real minimum width"); + notches.setTnfFreq(firstNotch, 100.00020); + notches.setTnfWidth(firstNotch, 800); + notches.requestGlobalTnfEnabled(false); + check(notches.tnf(firstNotch)->widthHz == 200 && notches.globalEnabled(), + "notch move width and bypass remain accepted while pending"); + check(waitFor([&] { return settle() && notches.tnf(firstNotch)->widthHz == 800 + && std::abs(notches.tnf(firstNotch)->freqMhz - 100.00020) < 1e-9 + && !notches.globalEnabled(); }), "coalesced notch edits adopt together"); + notches.requestRemoveTnf(removedNotch); + check(notches.tnf(removedNotch), "pending removal retains accepted notch marker"); + check(waitFor([&] { return settle() && !notches.tnf(removedNotch); }), + "adopted removal removes only selected notch"); + RadioModelSliceLifecycleTestAccess::flush(model); + const auto notchDocument = RtlSliceSettings(scope).load().document.trackingNotches; + check(!notchDocument.enabled && notchDocument.entries.size() == 1 + && notchDocument.entries.front().id == firstNotch + && notchDocument.entries.front().widthHz == 800, + "accepted global notch bank and bypass persist together"); + const auto validNotchDocument = scope.featureExact("RtlSlices"); + notches.setTnfWidth(firstNotch, 13000); + check(Access::idle(backend) && notches.tnf(firstNotch)->widthHz == 800, + "invalid notch width cannot change accepted model"); + RadioModelSliceLifecycleTestAccess::flush(model); + check(scope.featureExact("RtlSlices") == validNotchDocument, + "rejected notch edits cannot change saved bank"); + auto* pan = model.panadapter(slice->panId()); + if (pan) { + model.requestPanCenter(pan->panId(), 104.0, -1.0, IRadioBackend::PanCenterIntent::Drag); + check(waitFor([&] { return settle() && !slice->inCapture(); }), "free-pan parks configured CW receiver"); + check(backend.capabilities().maxNotchFilters == 32, + "configured parked sideband keeps tracking-notch editing available"); + const auto center = Access::state(backend).hardware.centerHz; + const int writes = usb->writes; + slice->setAgcThreshold(47); backend.setCwPitch(900); + check(waitFor([&] { return settle() && slice->agcThreshold() == 47 + && model.transmitModel().cwPitch() == 900; }), "parked receiver accepts DSP configuration"); + notches.setTnfFreq(firstNotch, 100.00025); + check(waitFor([&] { return settle() && std::abs(notches.tnf(firstNotch)->freqMhz - 100.00025) < 1e-9; }), + "parked receiver retains editable tracking-notch bank"); + check(!slice->inCapture() && Access::state(backend).hardware.centerHz == center && usb->writes == writes, + "parked DSP changes cannot retune USB or revive audio"); + } + RadioModelSliceLifecycleTestAccess::flush(model); + const auto finalNotches = RtlSliceSettings(scope).load().document.trackingNotches; + backend.disconnectRadio(); + RadioModel restored; + check(restored.rebuildBackendForTest("rtl"), "reconnect model initialized"); + RadioModelSliceLifecycleTestAccess::restore(restored, "sideband-controls"); + auto& second = *static_cast(restored.backend()); + auto nextUsb = std::make_shared(); + Access::start(second, std::make_unique(nextUsb), 2); + nextUsb->releaseReadback(); CaptureClock nextClock(nextUsb); + check(waitFor([&] { nextClock.advance(); return Access::idle(second) && restored.slice(3); }), + "reconnect restores sparse CW identity"); + if (auto* resumed = restored.slice(3)) { + check(resumed->agcMode() == "fast" && resumed->agcThreshold() == 47 + && resumed->nbKind() == NoiseBlankerKind::Advanced && resumed->apfOn() + && resumed->ritOn() && resumed->ritFreq() == 120 && restored.transmitModel().cwPitch() == 900, + "reconnect restores independent accepted DSP controls, including parked configuration"); + } + check(restored.tnfModel().tnfs().size() == 1 && !restored.tnfModel().globalEnabled() + && restored.tnfModel().tnf(firstNotch) + && restored.tnfModel().tnf(firstNotch)->widthHz == 800 + && Access::state(second).trackingNotches == finalNotches, + "reconnect restores accepted notch identity bank and global bypass"); + second.disconnectRadio(); +} + +static void amControlsAndRestore() +{ + using Access = rtl::RtlCaptureBackendTestAccess; + const RadioSettingsScope scope("rtl", "am-sam-controls"); + RtlSliceSettings::Slice saved; + saved.id = 6; saved.frequencyHz = 100'000'000; saved.mode = "SAM"; + saved.filterLowHz = -1800; saved.filterHighHz = 4000; + saved.agcMode = "slow"; saved.audioMute = true; + // An invalid earlier record must not steal the singleton reservation from ID 6. + auto invalid = saved; invalid.id = 2; invalid.filterLowHz = 100; + check(RtlSliceSettings(scope).patch(100'600'000, 2'400'000, {invalid, saved}), "sparse SAM restore with invalid earlier record seeded"); + RadioModel model; + check(model.rebuildBackendForTest("rtl"), "AM/SAM model initializes real backend"); + RadioModelSliceLifecycleTestAccess::restore(model, "am-sam-controls"); + auto& backend = *static_cast(model.backend()); + auto usb = std::make_shared(); + Access::start(backend, std::make_unique(usb)); + usb->releaseReadback(); CaptureClock clock(usb); + const auto settle = [&] { clock.advance(); return Access::idle(backend); }; + check(waitFor([&] { return settle() && model.slice(6) && model.slice(6)->mode() == "SAM"; }), + "first restorable sparse SAM ID acquires singleton reservation"); + auto* slice = model.slice(6); + if (!slice) { backend.disconnectRadio(); return; } + check(!model.slice(0) && !model.slice(2) && Access::state(backend).hardware.centerHz == 100'600'000, + "AM bootstrap preserves sparse identity and exact saved capture center"); + slice->setAgcMode("fast"); slice->setAgcThreshold(48); slice->setAgcOffLevel(22); + slice->setNbKind(NoiseBlankerKind::Advanced); slice->setNbLevel(65); + slice->setNr(true); slice->setNrLevel(60); slice->setAnf(true); slice->setMn(true); slice->setMnLevel(40); + slice->setRit(true, 250); slice->setFilterWidth(-2000, 5000); + check(slice->agcMode() == "slow" && !slice->ritOn(), "SAM controls remain accepted-only while preparing"); + check(waitFor([&] { return settle() && slice->agcMode() == "fast" && slice->agcThreshold() == 48 + && slice->agcOffLevel() == 22 && slice->nbKind() == NoiseBlankerKind::Advanced + && slice->nrOn() && slice->anfOn() && slice->mnOn() && slice->mnLevel() == 40 + && slice->ritOn() && slice->ritFreq() == 250 && slice->filterLow() == -2000 && slice->filterHigh() == 5000; }), + "SAM AGC blankers NR ANF notch RIT and asymmetric RF cuts adopt through public model setters"); + const auto token = Access::state(backend).token; + slice->setFilterWidth(0, 4000); slice->setFilterWidth(-13000, 4000); + slice->setApf(true); slice->setSquelch(true, 30); + check(Access::idle(backend) && Access::state(backend).token == token && !slice->apfOn() && !slice->squelchOn(), + "AM refuses carrier-excluding filters, out-of-range cuts, CW APF and unsupported squelch"); + RadioModelSliceLifecycleTestAccess::flush(model); + const auto persisted = RtlSliceSettings(scope).load().document.slices.value(6); + check(persisted.mode == "SAM" && persisted.filterLowHz == -2000 && persisted.filterHighHz == 5000 + && persisted.agcThreshold == 48 && persisted.nrEnabled && persisted.mnEnabled && persisted.ritOffsetHz == 250, + "only accepted SAM state persists"); + slice->setMode("AM"); + check(waitFor([&] { return settle() && slice->mode() == "AM"; }), "SAM to AM public transition adopts"); + check(slice->agcThreshold() == 48 && slice->ritOn() && slice->ritFreq() == 250, + "AM transition preserves independent AGC and RIT controls"); + check(slice->filterLow() == -4000 && slice->filterHigh() == 4000, + "first AM entry uses its default independently of SAM cuts"); + slice->setFilterWidth(-3500, 2500); + check(waitFor([&] { return settle() && slice->filterLow() == -3500; }), + "AM asymmetric filter accepts independently"); + slice->setMode("SAM"); + check(waitFor([&] { return settle() && slice->mode() == "SAM"; }) + && slice->filterLow() == -2000 && slice->filterHigh() == 5000 + && Access::state(backend).receivers.front().passband.translationHz == 250, + "SAM recalls accepted asymmetric cuts with active RIT"); + slice->setMode("AM"); + check(waitFor([&] { return settle() && slice->mode() == "AM"; }) + && slice->filterLow() == -3500 && slice->filterHigh() == 2500, + "AM recalls its own accepted asymmetric cuts"); + const double converterCenter = Access::state(backend).hardware.centerHz; + slice->setFrequency(converterCenter / 1e6); + check(waitFor([&] { return settle() && std::abs(slice->frequency() * 1e6 - converterCenter) < 1 + && std::abs(double(Access::state(backend).hardware.centerHz) - converterCenter) > 5000; }), + "explicit AM tune onto converter DC preserves station RF and moves capture clear"); + auto* pan = model.panadapter(slice->panId()); + if (pan) { + model.requestPanCenter(pan->panId(), 104.0, -1, IRadioBackend::PanCenterIntent::Drag); + check(waitFor([&] { return settle() && !slice->inCapture(); }), "AM free-pan parks receiver"); + slice->setAgcThreshold(49); + check(waitFor([&] { return settle() && slice->agcThreshold() == 49; }), "parked AM accepts saved controls without reception"); + } + RadioModelSliceLifecycleTestAccess::flush(model); + backend.disconnectRadio(); + RadioModel restored; + check(restored.rebuildBackendForTest("rtl"), "AM reconnect model initializes"); + RadioModelSliceLifecycleTestAccess::restore(restored, "am-sam-controls"); + auto& next = *static_cast(restored.backend()); + auto nextUsb = std::make_shared(); + Access::start(next, std::make_unique(nextUsb)); + nextUsb->releaseReadback(); CaptureClock nextClock(nextUsb); + check(waitFor([&] { nextClock.advance(); return Access::idle(next) && restored.slice(6) + && restored.slice(6)->mode() == "AM"; }), "parked sparse AM restores after reconnect"); + if (auto* resumed = restored.slice(6)) { + check(!resumed->inCapture() && resumed->agcThreshold() == 49 && resumed->ritFreq() == 250, + "AM reconnect preserves parking and independent controls"); + check(resumed->filterLow() == -3500 && resumed->filterHigh() == 2500, + "AM reconnect retains its own accepted cuts"); + resumed->setMode("SAM"); + check(waitFor([&] { nextClock.advance(); return Access::idle(next) && resumed->mode() == "SAM"; }) + && resumed->filterLow() == -2000 && resumed->filterHigh() == 5000, + "reconnect recalls SAM history independently while parked"); + } + next.disconnectRadio(); +} + int main(int argc, char** argv) { TestSettingsProfile profile(QStringLiteral("rtl-model-acceptance")); @@ -351,6 +804,10 @@ int main(int argc, char** argv) analogBankSelection(); analogBankSelection(8); analogBankRestoreAndSharedCapture(); + invalidLegacySidebandRestore(); + perModeFilterRecall(); + sidebandControlsAndPersistence(); + amControlsAndRestore(); const RadioSettingsScope scope("rtl", "model-accepted"); RtlSliceSettings::Slice saved; saved.id = 3; saved.frequencyHz = 99'900'000; saved.mode = "FM"; diff --git a/tests/rtl_receive_pipeline_test.cpp b/tests/rtl_receive_pipeline_test.cpp index 7ccd731ddb..06c50552ca 100644 --- a/tests/rtl_receive_pipeline_test.cpp +++ b/tests/rtl_receive_pipeline_test.cpp @@ -674,9 +674,65 @@ static void measureParkAndResume() pipeline->stop(); } +static void trackingNotchCallbackAllocations() +{ + auto pipeline = std::make_unique(); + T::State state; + state.token = {83, 1}; state.hardware.centerHz = 100000000; + state.hardware.sampleRateHz = 240000; + state.capture = {83, 1, 100000000, 240000, 108000, 108000}; + state.receivers = {{{0, 100020000, 100, 2900, 0, 3000, 3000}, T::Mode::Usb}}; + state.receivingIds = {0}; + // Populate the vector-bearing DSP recipe at its admitted capacity. Both + // adoption and sample processing must retain prepared storage by reference. + for (int id = 0; id < 32; ++id) { + state.trackingNotches.entries.push_back({id, 100020200.0 + id * 80, 200, true}); + } + std::array, 1200> iq{}; + Pipeline::Packet packet; + std::uint64_t first = 0; + (void)WdspChannel::threadAllocationSequenceForTest(); + for (int epoch = 0; epoch < 2; ++epoch) { + state.token.revision = epoch + 1; + state.trackingNotches.enabled = epoch == 0; + if (!pipeline->prepare(state, epoch == 0) || !ready(*pipeline)) { + check(false, "full tracking-notch bank prepares outside acquisition"); + break; + } + const std::size_t allocationsBefore = callbackAllocations; + const auto wdspBefore = WdspChannel::threadAllocationSequenceForTest(); + inCallback = true; + const bool adopted = pipeline->adopt(); + inCallback = false; + check(adopted, "tracking-notch enable and bypass banks adopt"); + bool sawSlice = false; + for (int block = 0; block < 60; ++block) { + inCallback = true; + const bool accepted = pipeline->process(first, iq); + while (pipeline->takePacket(packet)) { + sawSlice |= packet.slot == 0 && packet.token == state.token; + } + inCallback = false; + check(accepted, "notched sideband callback accepts acquisition"); + first += iq.size(); + std::this_thread::sleep_for(6ms); + } + check(sawSlice && !pipeline->needsRepair(), "tracking-notch bank produces current independent PCM"); + check(callbackAllocations == allocationsBefore, + "vector-bearing notch adoption and processing allocate no ordinary callback memory"); + check(WdspChannel::threadAllocationSequenceForTest() == wdspBefore, + "tracking-notch acquisition performs no WDSP allocator operation"); + } + pipeline->stop(); + pipeline.reset(); + check(QThreadPool::globalInstance()->waitForDone(15000), + "tracking-notch fixture releases its prepared banks before independent registry workloads"); +} + int main(int argc, char** argv) { QCoreApplication app(argc, argv); + trackingNotchCallbackAllocations(); { auto rf = std::make_unique(); check(AetherSDR::rtl::RtlReceivePipelineTestAccess::rfObservation(*rf), diff --git a/tests/rtl_runtime_settings_test.cpp b/tests/rtl_runtime_settings_test.cpp index 353248474c..eb935e6c67 100644 --- a/tests/rtl_runtime_settings_test.cpp +++ b/tests/rtl_runtime_settings_test.cpp @@ -8,6 +8,7 @@ #include "core/backends/rtl/RtlSdrBackend.h" #include #include +#include #include #include #include @@ -125,6 +126,13 @@ static void reconnectAtCaptureLimits() check(RtlSliceSettings(scope).patch(capture.centerHz, capture.rateHz, {saved}), "capture-limit reconnect fixture preserves a valid saved receiver"); const QJsonObject original = scope.featureExact("RtlSlices"); + QJsonObject expected = original; + QJsonObject entries = expected.value("slices").toObject(); + QJsonObject entry = entries.value("1").toObject(); + entry.insert("modeFilters", QJsonObject{{saved.mode, + QJsonObject{{"low", saved.filterLowHz}, {"high", saved.filterHighHz}}}}); + entries.insert("1", entry); + expected.insert("slices", entries); rtl::RtlSdrBackend receiver; for (int attempt = 0; attempt < 2; ++attempt) { std::fprintf(stderr, "RTL_RECONNECT center=%u rate=%u mode=%s attempt=%d\n", @@ -177,8 +185,13 @@ static void reconnectAtCaptureLimits() 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"); + if (scope.featureExact("RtlSlices") != expected) { + std::fprintf(stderr, "RESTORE_EXPECTED %s\nRESTORE_ACTUAL %s\n", + QJsonDocument(expected).toJson(QJsonDocument::Compact).constData(), + QJsonDocument(scope.featureExact("RtlSlices")).toJson(QJsonDocument::Compact).constData()); + } + check(scope.featureExact("RtlSlices") == expected, + "reconnect adds only the accepted current-mode filter history to the saved document"); receiver.disconnectRadio(); } } @@ -219,13 +232,13 @@ static void unsupportedStoredWfmRemainsIntact() 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"); + check(receiver.currentOperatingState().mode == QLatin1String("USB"), + "real restored-state application chooses the provisional USB 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"); + "unsupported saved WFM never starts audible provisional USB"); device->releaseReadback(); QElapsedTimer deadline; deadline.start(); int queued = 0; @@ -244,9 +257,9 @@ static void unsupportedStoredWfmRemainsIntact() && 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().mode == rtl::RtlCaptureTransaction::Mode::Usb && accepted->receivers.front().audioMute, - "unsupported WFM remains unadopted while the confirmed provisional AM receiver stays muted"); + "unsupported WFM remains unadopted while the confirmed provisional USB receiver stays muted"); bool explained = false; for (const QString& warning : warnings) { const QString text = warning.toLower(); diff --git a/tests/rtl_slice_settings_test.cpp b/tests/rtl_slice_settings_test.cpp index 5c51c7fd27..d584525243 100644 --- a/tests/rtl_slice_settings_test.cpp +++ b/tests/rtl_slice_settings_test.cpp @@ -571,5 +571,114 @@ int main(int argc, char** argv) && bookmarks.entries(RadioSettingsScope("flex", "")).isEmpty(), "identified bookmark scope works and ordinary empty scopes still refuse"); } + { + const RadioSettingsScope dspScope("rtl", "dsp-settings"); + RtlSliceSettings dspSettings(dspScope); + auto entry = slice(5); + entry.mode = "CWR"; entry.filterLowHz = -100; entry.filterHighHz = 100; + entry.agcOffLevel = 31; entry.cwPitchHz = 900; + entry.ritEnabled = true; entry.ritOffsetHz = -321; + entry.nbKind = NoiseBlankerKind::Advanced; entry.nbLevel = 72; + entry.nbFill = NoiseBlankerFill::Interpolate; entry.apfEnabled = true; entry.apfLevel = 88; + entry.nrEnabled = true; entry.nrLevel = 71; entry.anfEnabled = true; entry.anfLevel = 62; + entry.mnEnabled = true; entry.mnLevel = 39; + check(dspSettings.patch(100'000'000, 2'400'000, {entry}), "receive DSP object saves atomically"); + const auto read = dspSettings.load(); + const auto roundTrip = read.document.slices.value(5); + check(read.status == Read::Ready && roundTrip.agcOffLevel == 31 && roundTrip.cwPitchHz == 900 + && roundTrip.ritEnabled && roundTrip.ritOffsetHz == -321 + && roundTrip.nbKind == NoiseBlankerKind::Advanced && roundTrip.nbLevel == 72 + && roundTrip.nbFill == NoiseBlankerFill::Interpolate && roundTrip.apfEnabled && roundTrip.apfLevel == 88, + "all receive DSP controls survive typed settings roundtrip"); + check(roundTrip.nrEnabled && roundTrip.nrLevel == 71 && roundTrip.anfEnabled && roundTrip.anfLevel == 62 + && roundTrip.mnEnabled && roundTrip.mnLevel == 39, "noise and notch settings survive roundtrip"); + const auto good = dspScope.featureExact(feature); + for (const auto& [key, value] : QList>{ + {"cwPitchHz", 0}, {"ritOffsetHz", 100000}, {"agcOffLevel", 101}, + {"nbKind", 3}, {"nbLevel", -1}, {"nbFill", 5}, {"apfLevel", 101}, {"apfEnabled", "true"}, + {"nrLevel", -1}, {"anfLevel", 101}, {"mnLevel", 101}, {"nrEnabled", 1}}) { + auto invalid = good; + auto slices = invalid.value("slices").toObject(); + auto candidate = slices.value("5").toObject(); + auto dsp = candidate.value("receiveDsp").toObject(); + dsp.insert(key, value); candidate.insert("receiveDsp", dsp); + slices.insert("5", candidate); invalid.insert("slices", slices); + RtlSliceSettings::Document decoded; QString reason; + check(!RtlSliceSettings::decode(invalid, decoded, reason), "malformed receive DSP object is refused"); + } + auto legacyDocument = good; + auto slices = legacyDocument.value("slices").toObject(); + auto candidate = slices.value("5").toObject(); candidate.remove("receiveDsp"); + slices.insert("5", candidate); legacyDocument.insert("slices", slices); + RtlSliceSettings::Document decoded; QString reason; + check(RtlSliceSettings::decode(legacyDocument, decoded, reason) + && decoded.slices.value(5).cwPitchHz == 600 && !decoded.slices.value(5).ritEnabled + && decoded.slices.value(5).nbKind == NoiseBlankerKind::Off, + "schema-1 documents without receive DSP use established defaults"); + } + { + const RadioSettingsScope scope("rtl", "tracking-settings"); + RtlSliceSettings settings(scope); + rtl::RtlTrackingNotches notches; + notches.enabled = false; + notches.entries = {{7, 100'000'700, 200, true}, {19, 100'001'900, 400, false}}; + check(settings.patch(100'000'000, 2'400'000, {slice(0)}, {}, notches), + "tracking bank and configured receivers save in one atomic document"); + check(settings.load().document.trackingNotches == notches, + "tracking IDs, RF frequency, width, individual and global bypass roundtrip"); + check(settings.patch(100'000'000, 2'400'000, {slice(0)}) + && settings.load().document.trackingNotches == notches, + "partial slice-only patch preserves the global notch bank"); + const auto original = scope.featureExact(RtlSliceSettings::featureName()); + notches.entries[0].widthHz = 199; + check(!settings.patch(100'000'000, 2'400'000, {slice(0)}, {}, notches) + && scope.featureExact(RtlSliceSettings::featureName()) == original, + "invalid notch bank cannot overwrite accepted settings"); + notches.entries[0].widthHz = 200; + notches.entries[1].id = notches.entries[0].id; + check(!settings.patch(100'000'000, 2'400'000, {slice(0)}, {}, notches) + && scope.featureExact(RtlSliceSettings::featureName()) == original, + "duplicate caller-supplied notch IDs cannot overwrite accepted settings"); + notches.entries.clear(); notches.enabled = true; + check(settings.patch(100'000'000, 2'400'000, {slice(0)}, {}, notches) + && settings.load().document.trackingNotches == notches, + "explicit empty accepted notch bank removes old saved notches"); + } + { + const RadioSettingsScope scope("rtl", "mode-filter-settings"); + RtlSliceSettings owner(scope); + auto accepted = slice(3); + accepted.modeFilters = {{"USB", {200, 2700}}, {"FM", {-7000, 7000}}}; + check(owner.patch(100'000'000, 2'400'000, {accepted}), "mode filters stored atomically with receiver"); + check(owner.load().document.slices.value(3).modeFilters == accepted.modeFilters, + "per-mode filters roundtrip without sharing between modes"); + const auto good = scope.featureExact(feature); + for (const QJsonValue& malformed : QList{ + false, QJsonObject{{"UNKNOWN", QJsonObject{{"low", 0}, {"high", 2500}}}}, + QJsonObject{{"USB", QJsonObject{{"low", 2700}, {"high", 200}}}}, + QJsonObject{{"USB", QJsonObject{{"low", 200.5}, {"high", 2700}}}}, + QJsonObject{{"USB", QJsonObject{{"low", 200}, {"high", 100001}}}}, + QJsonObject{{"USB", QJsonObject{{"low", "200"}, {"high", 2700}}}}}) { + auto invalid = good; + auto entries = invalid.value("slices").toObject(); + auto entry = entries.value("3").toObject(); + entry.insert("modeFilters", malformed); + entries.insert("3", entry); invalid.insert("slices", entries); + RtlSliceSettings::Document decoded; QString reason; + check(!RtlSliceSettings::decode(invalid, decoded, reason), + "malformed remembered filter is rejected before publishing parsed settings"); + } + auto legacy = good; + auto entries = legacy.value("slices").toObject(); + auto entry = entries.value("3").toObject(); entry.remove("modeFilters"); + entries.insert("3", entry); legacy.insert("slices", entries); + RtlSliceSettings::Document decoded; QString reason; + check(RtlSliceSettings::decode(legacy, decoded, reason) + && decoded.slices.value(3).modeFilters.isEmpty(), + "old schema-1 documents have no invented filter history"); + accepted.modeFilters["USB"] = {300, 100001}; + check(!owner.patch(100'000'000, 2'400'000, {accepted}) + && scope.featureExact(feature) == good, "invalid history cannot replace the saved document"); + } return failures == 0 ? 0 : 1; } diff --git a/tests/rtl_ssb_cw_pipeline_test.cpp b/tests/rtl_ssb_cw_pipeline_test.cpp new file mode 100644 index 0000000000..da5b26b872 --- /dev/null +++ b/tests/rtl_ssb_cw_pipeline_test.cpp @@ -0,0 +1,588 @@ +#include "core/backends/rtl/RtlReceivePipeline.h" +#include "core/backends/rtl/RtlReceiveRecipe.h" +#include "core/backends/rtl/RtlDcBlocker.h" +#include "models/CwRxModel.h" +#include "core/DecoderPcmAdapter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using Pipeline = AetherSDR::rtl::RtlReceivePipeline; +using T = AetherSDR::rtl::RtlCaptureTransaction; +using Recipe = AetherSDR::rtl::RtlReceiveRecipe; +using namespace std::chrono_literals; +namespace { +int failures = 0; +void check(bool condition, const char* message) +{ + if (!condition) { ++failures; std::fprintf(stderr, "FAIL: %s\n", message); } +} +bool ready(Pipeline& pipeline) +{ + const auto deadline = std::chrono::steady_clock::now() + 15s; + while (std::chrono::steady_clock::now() < deadline) { + const auto result = pipeline.service(); + if (result != Pipeline::Preparation::Pending) { return result == Pipeline::Preparation::Ready; } + std::this_thread::sleep_for(1ms); + } + return false; +} +constexpr int kRate = 240000; +constexpr int kChunk = 1200; +constexpr double kCenter = 100000000; +constexpr std::array kOffsets{-60000, -20000, 20000, 60000}; +T::State state(int rate = kRate) +{ + T::State result; + result.token = {1, 1}; + result.capture = {1, 1, kCenter, double(rate), rate * .45, rate * .45}; + result.hardware.centerHz = static_cast(kCenter); + result.hardware.sampleRateHz = rate; + constexpr std::array modes{T::Mode::Usb, T::Mode::Lsb, T::Mode::Cw, T::Mode::Cwr}; + for (int id = 0; id < 4; ++id) { + const auto [low, high] = Recipe::defaultFilter(modes[id]); + T::Receiver receiver{{id, kCenter + kOffsets[id], double(low), double(high), 0, 3000, 3000}, modes[id]}; + receiver.agcMode = 0; + receiver.audioMute = id == 2; // Decoder tap must survive speaker mute. + result.receivers.push_back(receiver); + result.receivingIds.push_back(id); + } + return result; +} +struct Tone { double hz; double amplitude; }; +using Audio = std::array, 4>; +Audio render(T::State recipe, const std::vector& tones, double seconds = 1.6, + double noise = 0.0, double impulse = 0.0, bool agcStep = false, bool quantize = false) +{ + // The fixed mixer buffers exceed the default Windows thread stack. + const auto pipelineOwner = std::make_unique(4); + Pipeline& pipeline = *pipelineOwner; + Audio audio; + if (!pipeline.prepare(recipe, true) || !ready(pipeline) || !pipeline.adopt()) { + check(false, "real sideband receiver bank prepares and adopts"); return audio; + } + check(!pipeline.legacy(), "SSB/CW must never fall back to legacy demodulation"); + const int rate = static_cast(recipe.capture.achievedSampleRateHz); + const int chunk = rate / 200; // Five milliseconds at either capture rate. + std::vector> iq(chunk); + AetherSDR::rtl::RtlDcBlocker dc; + dc.configure(true, rate); + const std::uint64_t end = static_cast(seconds * rate); + std::uint32_t random = 0x146713; + const auto white = [&]() { + random = random * 1664525u + 1013904223u; + return (double(random) / 4294967296.0 - .5) * noise; + }; + for (std::uint64_t first = 0; first < end; first += chunk) { + for (std::size_t index = 0; index < iq.size(); ++index) { + std::complex sample{}; + for (const Tone& tone : tones) { + const double phase = 2 * std::numbers::pi * tone.hz * double(first + index) / rate; + sample += std::polar(tone.amplitude, phase); + } + if (agcStep && first + index < std::uint64_t(rate)) { sample *= 1000.0; } + sample += std::complex(white(), white()); + // A single-sample real impulse is broadband. Rectangular multi- + // sample pulses have RF spectral nulls and bias receiver comparisons. + if (first + index >= std::uint64_t(rate) && (first + index) % 10000 == 0) { sample += impulse; } + if (quantize) { + const auto adc = [](double value) { + const double code = std::round(std::clamp(value * 127.5 + 127.5, 0.0, 255.0)); + return (code - 127.5) / 127.5; + }; + sample = {adc(sample.real()), adc(sample.imag())}; + } + iq[index] = {float(sample.real()), float(sample.imag())}; + } + dc.process(iq); + check(pipeline.process(first, iq), "production acquisition processes generated IQ"); + Pipeline::Packet packet; + while (pipeline.takePacket(packet)) { + if (packet.slot < 0 || packet.slot >= 4) { continue; } + for (std::size_t index = 0; index < packet.frames; ++index) { + const float value = packet.samples[index * 2]; + check(std::isfinite(value), "demodulated PCM is finite"); + if (packet.firstSample + index >= 24000) { audio[packet.slot].push_back(value); } + } + } + // Pace the real asynchronous WDSP worker; burst feeding tests its ring + // underrun rather than its RF transfer function. + std::this_thread::sleep_for(6ms); + } + check(!pipeline.needsRepair(), "generated-IQ workload has no receiver withdrawal"); + pipeline.stop(); + return audio; +} +double magnitude(std::span samples, double hz) +{ + if (samples.empty()) { return 0; } + // A Hann window separates image rejection from non-integral record leakage. + std::complex sum{}; + double weight = 0; + for (std::size_t index = 0; index < samples.size(); ++index) { + const double window = .5 - .5 * std::cos(2 * std::numbers::pi * index / (samples.size() - 1)); + const double phase = -2 * std::numbers::pi * hz * index / 48000; + sum += double(samples[index]) * window * std::polar(1.0, phase); + weight += window; + } + return 2 * std::abs(sum) / weight; +} +double rms(std::span samples) +{ + double power = 0; + for (float sample : samples) { power += double(sample) * sample; } + return samples.empty() ? 0 : std::sqrt(power / samples.size()); +} +void sidebandsAndCw() +{ + const auto audio = render(state(), { + {kOffsets[0] + 1000, .015}, {kOffsets[0] - 1700, .015}, + {kOffsets[1] - 1300, .015}, {kOffsets[1] + 1900, .015}, + {kOffsets[2], .015}, {kOffsets[2] + 2000, .015}, + {kOffsets[3], .015}, {kOffsets[3] - 2000, .015}}); + constexpr std::array wanted{1000, 1300, 600, 600}; + constexpr std::array rejected{1700, 1900, 2600, 2600}; + for (int id = 0; id < 4; ++id) { + const double signal = magnitude(audio[id], wanted[id]); + const double image = magnitude(audio[id], rejected[id]); + const double rejection = 20 * std::log10(signal / std::max(image, 1e-12)); + std::printf("receiver=%d tone=%.0fHz wanted=%.6f image=%.6f rejection=%.1fdB frames=%zu\n", + id, wanted[id], signal, image, rejection, audio[id].size()); + check(audio[id].size() > 24000 && signal > .005, "wanted RF produces expected audio tone on each independent tap"); + check(rejection > 40, "opposite sideband or out-of-passband carrier rejection exceeds 40 dB"); + } +} +void hardwareCaptureRate() +{ + // Exercise the 50:1 RF decimator used by the physical 2.4 MS/s capture, + // including opposite RF offsets and BFO signs. Equal RF amplitudes must + // produce equal PCM levels regardless of the capture sample rate. + std::array reference{}; + constexpr std::array wanted{1000, 1300, 600, 600}; + constexpr std::array rejected{1700, 1900, 2600, 2600}; + for (int rate : {kRate, 2400000}) { + auto recipe = state(rate); + const double scale = double(rate) / kRate; + std::array offsets{}; + for (int id = 0; id < 4; ++id) { + offsets[id] = kOffsets[id] * scale; + recipe.receivers[id].passband.carrierHz = kCenter + offsets[id]; + } + const auto audio = render(recipe, { + {offsets[0] + 1000, .015}, {offsets[0] - 1700, .015}, + {offsets[1] - 1300, .015}, {offsets[1] + 1900, .015}, + {offsets[2], .015}, {offsets[2] + 2000, .015}, + {offsets[3], .015}, {offsets[3] - 2000, .015}}); + for (int id = 0; id < 4; ++id) { + const double signal = magnitude(audio[id], wanted[id]); + const double image = magnitude(audio[id], rejected[id]); + const double rejection = 20 * std::log10(signal / std::max(image, 1e-12)); + std::printf("capture_rate=%d receiver=%d wanted=%.6f rejection=%.1fdB\n", + rate, id, signal, rejection); + check(audio[id].size() > 24000 && signal > .005, + "hardware-rate capture produces expected SSB/CW audio on all four taps"); + check(rejection > 40, "hardware-rate SSB/CW rejects opposite or out-of-band RF"); + if (rate == kRate) { reference[id] = signal; } + else { + const double deltaDb = 20 * std::log10(signal / std::max(reference[id], 1e-12)); + check(std::abs(deltaDb) < .5, "50:1 RF extraction preserves amplitude within half a dB"); + } + } + } + + auto recipe = state(2400000); + constexpr std::array thresholds{45, 65, 100, 45}; + for (int id = 0; id < 4; ++id) { + auto& receiver = recipe.receivers[id]; + receiver.mode = T::Mode::Usb; + receiver.passband.carrierHz = kCenter + 400000; + receiver.passband.filterLowHz = 100; + receiver.passband.filterHighHz = 2900; + receiver.agcMode = id == 3 ? 0 : 4; + receiver.agcThreshold = thresholds[id]; + } + // A -80 dBFS wanted carrier probes the gain ceiling, below compression at + // the user-reported 45 setting. Wideband noise dithers the real RTL byte + // quantization; all chains receive the same quantized acquisition samples. + const auto audio = render(recipe, {{401500, .0001}}, 2.0, .02, 0, false, true); + std::array signal{}; + for (int id = 0; id < 4; ++id) { + signal[id] = magnitude(audio[id], 1500); + std::printf("weak_input capture_rate=2400000 AGC=%s threshold=%d tone=%.7f rms=%.7f\n", + id == 3 ? "off" : "fast", thresholds[id], signal[id], rms(audio[id])); + check(signal[id] > .0001, "weak input survives hardware-rate SSB extraction and gain"); + } + const double gainStep = 20 * std::log10(signal[1] / std::max(signal[0], 1e-12)); + check(std::abs(gainStep - 12) < .5, "AGC 45 to 65 supplies the mapped 12 dB weak-signal gain"); + check(signal[2] > signal[1] * 3, "upper AGC range raises a weak hardware-rate signal"); + check(signal[0] > signal[3] * 3, "AGC at 45 raises a weak signal above fixed off gain"); +} +void pitchRitAndFilter() +{ + auto recipe = state(); + recipe.receivers[0].passband.filterHighHz = 800; + recipe.receivers[1].passband.filterLowHz = -800; + recipe.receivers[2].cwPitchHz = 900; + recipe.receivers[3].cwPitchHz = 400; + for (int id : {2, 3}) { + recipe.receivers[id].ritEnabled = true; + recipe.receivers[id].ritOffsetHz = 100; + recipe.receivers[id].passband.translationHz = 100; + recipe.receivers[id].passband.filterLowHz = -100; + recipe.receivers[id].passband.filterHighHz = 100; + } + const auto audio = render(recipe, { + {kOffsets[0] + 500, .015}, {kOffsets[0] + 1800, .015}, + {kOffsets[1] - 500, .015}, {kOffsets[1] - 1800, .015}, + {kOffsets[2] + 125, .015}, {kOffsets[3] + 125, .015}}); + for (int id : {0, 1}) { + const double rejection = 20 * std::log10(magnitude(audio[id], 500) + / std::max(magnitude(audio[id], 1800), 1e-12)); + check(rejection > 40, "operator filter cuts reject an in-sideband unwanted tone"); + } + // Receiver is shifted +100 Hz by RIT; the remaining +25 Hz RF detune + // raises CW pitch and lowers reverse-CW pitch. + check(magnitude(audio[2], 925) > .005, "CW pitch and RIT preserve carrier-centered passband geometry"); + check(magnitude(audio[3], 375) > .005, "CWR pitch has the reverse RF-detune slope"); + check(magnitude(audio[2], 925) > 4 * magnitude(audio[2], 900), "CW detune is not lost at the marker"); + check(magnitude(audio[3], 375) > 4 * magnitude(audio[3], 400), "CWR detune is not lost at the marker"); +} +void gainAndPeaking() +{ + auto recipe = state(); + for (auto& receiver : recipe.receivers) { + receiver.mode = T::Mode::Cw; + receiver.passband.filterLowHz = -500; + receiver.passband.filterHighHz = 500; + } + recipe.receivers[1].agcOffLevel = 30; // 12 dB above receiver zero. + recipe.receivers[2].apfEnabled = true; + recipe.receivers[2].apfLevel = 100; + recipe.receivers[3].apfEnabled = false; + const auto audio = render(recipe, { + {kOffsets[0], .003}, {kOffsets[1], .003}, + {kOffsets[2], .003}, {kOffsets[2] + 250, .003}, + {kOffsets[3], .003}, {kOffsets[3] + 250, .003}}); + const double gainDb = 20 * std::log10(rms(audio[1]) / std::max(rms(audio[0]), 1e-12)); + std::printf("AGC-off level step measured=%.2fdB\n", gainDb); + check(std::abs(gainDb - 12) < .5, "accepted off-level recipe changes gain by the mapped 12 dB"); + const double peakedRatio = magnitude(audio[2], 600) / std::max(magnitude(audio[2], 850), 1e-12); + const double plainRatio = magnitude(audio[3], 600) / std::max(magnitude(audio[3], 850), 1e-12); + std::printf("APF relative selectivity=%.2fdB\n", 20 * std::log10(peakedRatio / plainRatio)); + check(peakedRatio > 2 * plainRatio, "APF changes actual demodulated tone selectivity independently"); +} +void agcResponse() +{ + auto recipe = state(); + for (auto& receiver : recipe.receivers) { + receiver.mode = T::Mode::Usb; + receiver.passband.filterLowHz = 100; receiver.passband.filterHighHz = 2900; + receiver.agcMode = 4; + } + recipe.receivers[1].agcMode = 2; + recipe.receivers[2].agcThreshold = 35; + recipe.receivers[3].agcMode = 0; + std::vector tones; + for (double carrier : kOffsets) { tones.push_back({carrier + 1500, .0001}); } + const auto audio = render(recipe, tones, 4.5, 0, 0, true); + // render discards the first half-second. Leave a generous gap after the + // strong-to-weak edge for RF/DSP latency; fast AGC has recovered while the + // slow mode's two-second hang must still hold its reduced gain. + const auto level = [&](int id, double first, double last) { + const std::size_t begin = std::size_t((first - .5) * 48000); + const std::size_t count = std::size_t((last - first) * 48000); + if (audio[id].size() < begin + count) { return 0.0; } + return magnitude(std::span(audio[id]).subspan(begin, count), 1500); + }; + const double earlyFast = level(0, 1.6, 1.9), earlySlow = level(1, 1.6, 1.9); + const double lateFast = level(0, 3.9, 4.2), lateSlow = level(1, 3.9, 4.2); + const double lowerThreshold = level(2, 3.9, 4.2), off = level(3, 3.9, 4.2); + const double modeDb = 20 * std::log10(earlyFast / std::max(earlySlow, 1e-12)); + const double recoveryDb = 20 * std::log10(lateSlow / std::max(earlySlow, 1e-12)); + const double thresholdDb = 20 * std::log10(lateFast / std::max(lowerThreshold, 1e-12)); + std::printf("AGC fast_vs_slow_hang=%.1f dB slow_recovery=%.1f dB threshold_difference=%.1f dB\n", + modeDb, recoveryDb, thresholdDb); + check(modeDb > 6, "fast AGC recovers earlier than slow AGC after strong interference"); + check(recoveryDb > 6, "slow AGC recovers after its hang and decay"); + check(thresholdDb > 6, "AGC threshold changes actual weak-signal gain"); + check(lateFast > off * 3, "AGC enabled raises weak-signal gain relative to fixed off gain"); +} + +double energy(std::span samples) +{ + double sum = 0; + for (float sample : samples) { sum += double(sample) * sample; } + return samples.empty() ? 0 : sum / samples.size(); +} +void noiseAndNotches() +{ + auto recipe = state(); + for (auto& receiver : recipe.receivers) { + receiver.mode = T::Mode::Usb; + receiver.passband.filterLowHz = 100; receiver.passband.filterHighHz = 2900; + } + recipe.receivers[1].mnEnabled = true; recipe.receivers[1].mnLevel = 50; // 1500 Hz + recipe.receivers[2].anfEnabled = true; recipe.receivers[2].anfLevel = 100; + recipe.receivers[3].nrEnabled = true; recipe.receivers[3].nrLevel = 100; + std::vector tones; + for (double carrier : kOffsets) { tones.push_back({carrier + 1500, .02}); } + const auto audio = render(recipe, tones, 3.0, .05); + const double reference = magnitude(audio[0], 1500); + const double mnDb = 20 * std::log10(reference / magnitude(audio[1], 1500)); + const double anfDb = 20 * std::log10(reference / magnitude(audio[2], 1500)); + const auto snr = [](const auto& pcm) { + const double tone = magnitude(pcm, 1500); + const double signal = tone * tone / 2; + return signal / std::max(1e-12, energy(pcm) - signal); + }; + const double improvement = 10 * std::log10(snr(audio[3]) / snr(audio[0])); + std::printf("DSP manual_notch=%.1f dB auto_notch=%.1f dB NR_SNR_improvement=%.1f dB\n", mnDb, anfDb, improvement); + check(mnDb > 25, "manual notch position removes the real RF tone"); + check(anfDb > 10, "automatic notch suppresses a coherent interferer"); + check(improvement > 3, "adaptive noise reduction improves tone SNR"); + check(magnitude(audio[3], 1500) > reference * .25, "NR preserves the wanted coherent signal"); + + recipe.receivers[1].mnEnabled = false; + recipe.receivers[2].anfEnabled = false; + recipe.receivers[3].nrEnabled = false; + recipe.receivers[1].nbKind = AetherSDR::NoiseBlankerKind::Impulse; + recipe.receivers[2].nbKind = AetherSDR::NoiseBlankerKind::Advanced; + recipe.receivers[2].nbFill = AetherSDR::NoiseBlankerFill::Interpolate; + recipe.receivers[1].nbLevel = 80; recipe.receivers[2].nbLevel = 80; + // Identical RF geometry is essential: every chain must see the same + // interference amplitude and phase before comparing blanker performance. + for (auto& receiver : recipe.receivers) { receiver.passband.carrierHz = kCenter + 20000; } + const auto blanked = render(recipe, {{21500, .002}}, 2.6, 0, .5); + const auto residual = [](const auto& pcm) { + const double tone = magnitude(pcm, 1500); + return std::max(1e-12, energy(pcm) - tone * tone / 2); + }; + for (int id : {1, 2}) { + const double db = 10 * std::log10(residual(blanked[0]) / residual(blanked[id])); + std::printf("DSP blanker=%d impulse_energy_reduction=%.1f dB\n", id, db); + check(db > 3, "real noise blanker reduces impulsive RF interference"); + check(magnitude(blanked[id], 1500) > magnitude(blanked[0], 1500) * .8, + "noise blanker preserves wanted signal while removing impulses"); + } +} + +void trackingNotches() +{ + auto recipe = state(); + const double carrier = kCenter + 20000; + for (auto& receiver : recipe.receivers) { receiver.passband.carrierHz = carrier; } + const std::vector tones{{20000 + 1500, .015}, {20000 - 1500, .015}, {20000, .015}}; + const auto baseline = render(recipe, tones); + recipe.trackingNotches.entries = {{7, carrier + 1500, 400, true}, + {19, carrier - 1500, 400, true}, {30, carrier, 400, true}}; + const auto blocked = render(recipe, tones); + constexpr std::array pitches{1500, 1500, 600, 600}; + for (int id = 0; id < 4; ++id) { + const double db = 20 * std::log10(magnitude(baseline[id], pitches[id]) + / std::max(1e-12, magnitude(blocked[id], pitches[id]))); + std::printf("tracking_notch receiver=%d rejection=%.1f dB\n", id, db); + check(db > 25, "absolute RF tracking notch suppresses USB/LSB/CW/CWR interferer"); + } + recipe.receivers[0].passband.carrierHz += 300; + recipe.receivers[1].passband.carrierHz -= 300; + for (int id : {2, 3}) { + recipe.receivers[id].cwPitchHz = 900; + recipe.receivers[id].ritEnabled = true; recipe.receivers[id].ritOffsetHz = 100; + recipe.receivers[id].passband.translationHz = 100; + } + const auto tracked = render(recipe, tones); + constexpr std::array shiftedPitch{1200, 1200, 800, 1000}; + for (int id = 0; id < 4; ++id) { + check(magnitude(tracked[id], shiftedPitch[id]) < magnitude(baseline[id], pitches[id]) * .056, + "tracking notch stays on absolute RF after tuning, RIT and CW pitch changes"); + } + recipe = state(); + for (auto& receiver : recipe.receivers) { receiver.passband.carrierHz = carrier; } + recipe.trackingNotches.entries = {{7, carrier + 1500, 400, true}, + {19, carrier - 1500, 400, true}, {30, carrier, 400, true}}; + recipe.trackingNotches.enabled = false; + recipe.receivers[0].mnEnabled = true; recipe.receivers[0].mnLevel = 50; + const auto bypassed = render(recipe, tones); + check(magnitude(bypassed[0], 1500) < magnitude(baseline[0], 1500) * .056, + "global tracking bypass does not disable the independent manual notch"); + for (int id : {1, 2, 3}) { + const double ratio = magnitude(bypassed[id], pitches[id]) / magnitude(baseline[id], pitches[id]); + check(ratio > .8 && ratio < 1.2, "global tracking bypass restores each unaffected receiver"); + } +} + +void morseThroughRadioDsp() +{ + using namespace AetherSDR; + // Carrier keying encodes twelve Vs (...-) at 20 WPM. The input contains + // no audio-frequency tone: the real receiver BFO must create that tone. + std::vector envelope(kRate / 2, 0); + const auto silence = [&](int units) { envelope.resize(envelope.size() + units * kRate * 3 / 50); }; + for (int letter = 0; letter < 12; ++letter) { + if (letter) { silence(letter % 3 ? 3 : 7); } + for (int mark = 0; mark < 4; ++mark) { + if (mark) { silence(1); } + const int count = (mark == 3 ? 3 : 1) * kRate * 3 / 50; + for (int index = 0; index < count; ++index) { + const double edge = std::min({1.0, index / (.005 * kRate), (count - 1 - index) / (.005 * kRate)}); + envelope.push_back(float(.025 * edge)); + } + } + } + silence(35); + envelope.resize((envelope.size() + kChunk - 1) / kChunk * kChunk); + auto recipe = state(); + recipe.receivers = {recipe.receivers[2]}; + recipe.receivers[0].passband.stableId = 0; + recipe.receivers[0].audioMute = true; + recipe.receivingIds = {0}; + const auto pipelineOwner = std::make_unique(); + Pipeline& pipeline = *pipelineOwner; + if (!pipeline.prepare(recipe, true) || !ready(pipeline) || !pipeline.adopt()) { + check(false, "CW Morse production bank adopts"); return; + } + CwRxModel decoder; + QString decoded; + QObject::connect(&decoder, &CwRxModel::textDecoded, &decoder, + [&](const QString& text, float) { decoded += text; }); + decoder.setPitchRange(550, 650); decoder.setSpeedRange(18, 22); decoder.start(); + PcmProducer producer; + check(producer.start(PcmPurpose::Slice, 0, {48000, PcmLayout::Stereo}), "native CW PCM producer starts"); + DecoderPcmAdapter adapter; + adapter.selectRoute(DecoderPcmAdapter::RouteLane::NativeSlice, 0); + std::array, kChunk> iq; + for (std::size_t first = 0; first < envelope.size(); first += kChunk) { + for (std::size_t index = 0; index < iq.size(); ++index) { + const double phase = 2 * std::numbers::pi * kOffsets[2] * (first + index) / kRate; + iq[index] = {envelope[first + index] * float(std::cos(phase)), + envelope[first + index] * float(std::sin(phase))}; + } + check(pipeline.process(first, iq), "keyed RF capture processes through real WDSP"); + Pipeline::Packet packet; + while (pipeline.takePacket(packet)) { + if (packet.slot != 0) { continue; } + QVector samples(qsizetype(packet.frames * 2)); + std::copy_n(packet.samples.begin(), samples.size(), samples.begin()); + const auto frame = producer.produce(std::move(samples)); + if (!frame) { check(false, "demodulated CW frame is valid"); continue; } + const auto block = adapter.accept(*frame); + if (block) { decoder.feedFixed24(*block); } + } + QCoreApplication::processEvents(); + std::this_thread::sleep_for(6ms); + } + const auto deadline = std::chrono::steady_clock::now() + 500ms; + while (std::chrono::steady_clock::now() < deadline) { + QCoreApplication::processEvents(); std::this_thread::sleep_for(2ms); + } + std::printf("CW generated V train decoded=\"%s\" pitch=%.1f speed=%.1f\n", + decoded.toUtf8().constData(), decoder.estimatedPitch(), decoder.estimatedSpeed()); + check(decoded.count('V') >= 3, "actual RTL IQ to WDSP to 48/24k adapter to ggmorse decodes at least three expected Vs"); + check(decoder.estimatedPitch() > 550 && decoder.estimatedPitch() < 650, + "ggmorse estimates the receiver's real CW BFO tone"); + check(!pipeline.needsRepair(), "Morse decode has no DSP withdrawal despite muted speaker"); + decoder.stop(); pipeline.stop(); +} + +void refusals() +{ + const auto pipelineOwner = std::make_unique(4); + Pipeline& pipeline = *pipelineOwner; + const auto good = state(); + auto badNotch = good; + badNotch.trackingNotches.entries = {{1, kCenter, 199, true}}; + check(!pipeline.prepare(badNotch), "tracking width below the real WDSP resolution is refused"); + badNotch.trackingNotches.entries = {{1, kCenter, 200, true}, {1, kCenter + 2000, 200, true}}; + check(!pipeline.prepare(badNotch), "duplicate tracking IDs are refused before bank preparation"); + for (int invalid = 0; invalid < 8; ++invalid) { + auto bad = good; + auto& receiver = bad.receivers[0]; + switch (invalid) { + case 0: receiver.agcMode = 9; break; + case 1: receiver.agcThreshold = 101; break; + case 2: receiver.cwPitchHz = 0; break; + case 3: receiver.passband.filterLowHz = -10; break; + case 4: receiver.ritOffsetHz = 100000; break; + case 5: receiver.nbLevel = -1; break; + case 6: receiver.apfLevel = 101; break; + case 7: receiver.mode = T::Mode::Am; break; + } + check(!pipeline.prepare(bad), "invalid controls and carrier-excluding AM filters are rejected before preparation"); + } +} +// Optional integration fixture: interleaved little-endian float32 analytic IQ +// at 240 kHz, USB carrier +40 kHz from capture center. Produces mono float32 +// 48 kHz PCM from the real independent receiver tap. The caller supplies speech +// as analytic upper-sideband IQ and may feed this file to the existing ASR test. +int externalFixture(const char* inputPath, const char* outputPath) +{ + std::ifstream input(inputPath, std::ios::binary); + std::ofstream output(outputPath, std::ios::binary); + if (!input || !output) { return 2; } + auto recipe = state(); + recipe.receivers.resize(1); recipe.receivingIds = {0}; + recipe.receivers[0].passband.carrierHz = kCenter + 40000; + const auto pipelineOwner = std::make_unique(); + Pipeline& pipeline = *pipelineOwner; + if (!pipeline.prepare(recipe, true) || !ready(pipeline) || !pipeline.adopt()) { return 3; } + std::array raw{}; + std::array, kChunk> iq{}; + std::uint64_t first = 0, frames = 0; + while (first < std::uint64_t(kRate) * 300 && input.read(reinterpret_cast(raw.data()), sizeof(raw))) { + for (std::size_t index = 0; index < iq.size(); ++index) { + if (!std::isfinite(raw[2 * index]) || !std::isfinite(raw[2 * index + 1])) { return 4; } + iq[index] = {raw[2 * index], raw[2 * index + 1]}; + } + if (!pipeline.process(first, iq)) { return 5; } + Pipeline::Packet packet; + while (pipeline.takePacket(packet)) { + if (packet.slot != 0) { continue; } + for (std::size_t index = 0; index < packet.frames; ++index) { + const float sample = packet.samples[index * 2]; + output.write(reinterpret_cast(&sample), sizeof(sample)); + ++frames; + } + } + first += iq.size(); + std::this_thread::sleep_for(6ms); + } + const bool success = !pipeline.needsRepair() && frames > 24000 && output.good(); + pipeline.stop(); + std::printf("external IQ capture_frames=%llu mono_pcm_frames=%llu sample_rate=48000\n", + static_cast(first), static_cast(frames)); + return success ? 0 : 6; +} +} // namespace +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + const char* iq = std::getenv("AETHER_RTL_SSB_INPUT_IQ"); + const char* pcm = std::getenv("AETHER_RTL_SSB_OUTPUT_PCM"); + if (iq && pcm) { return externalFixture(iq, pcm); } + sidebandsAndCw(); + hardwareCaptureRate(); + pitchRitAndFilter(); + gainAndPeaking(); + agcResponse(); + noiseAndNotches(); + trackingNotches(); + morseThroughRadioDsp(); + refusals(); + QThreadPool::globalInstance()->waitForDone(); + std::printf("rtl_ssb_cw_pipeline_test: %d failures\n", failures); + return failures ? 1 : 0; +} diff --git a/tests/rtl_wfm_pipeline_test.cpp b/tests/rtl_wfm_pipeline_test.cpp index 96b7c7d6b9..6840f83060 100644 --- a/tests/rtl_wfm_pipeline_test.cpp +++ b/tests/rtl_wfm_pipeline_test.cpp @@ -1,4 +1,5 @@ #include "core/backends/rtl/RtlReceivePipeline.h" +#include "core/backends/rtl/RtlReceiveRecipe.h" #include "core/backends/rtl/RtlRfExtractor.h" #ifdef AETHER_BACKEND_RTL #include "core/backends/rtl/RtlSdrDdc.h" @@ -949,18 +950,31 @@ void admission() native->stop(); auto multiple = std::make_unique(4, true); - for (const Transaction::Mode mode : {Transaction::Mode::Am, Transaction::Mode::Sam, - Transaction::Mode::Usb, Transaction::Mode::Lsb, Transaction::Mode::Cw, - Transaction::Mode::Cwr}) { + for (const Transaction::Mode mode : {Transaction::Mode::Am, Transaction::Mode::Sam}) { invalid = current; invalid.receivers.push_back({{6, 103'000'000, -3000, 3000, 0, 3000, 3000}, mode}); - check(!multiple->prepare(invalid), - "parked legacy recipe cannot bypass configured-set singleton admission"); + check(multiple->prepare(invalid) && ready(*multiple) && multiple->adopt(), + "parked AM/SAM recipe shares configured native bank with WFM"); std::reverse(invalid.receivers.begin(), invalid.receivers.end()); - check(!multiple->prepare(invalid), - "configured legacy singleton refusal is independent of receiver order"); + ++invalid.token.revision; + check(multiple->prepare(invalid) && ready(*multiple) && multiple->adopt(), + "native AM/SAM admission is independent of receiver order"); } multiple->stop(); + multiple.reset(); native.reset(); legacy.reset(); + check(QThreadPool::globalInstance()->waitForDone(15000), "legacy admission fixtures release registry reservations"); + for (const Transaction::Mode mode : {Transaction::Mode::Usb, Transaction::Mode::Lsb, + Transaction::Mode::Cw, Transaction::Mode::Cwr}) { + auto mixed = current; + const auto [low, high] = AetherSDR::rtl::RtlReceiveRecipe::defaultFilter(mode); + mixed.receivers.push_back({{6, 100'300'000, double(low), double(high), 0, 3000, 3000}, mode}); + mixed.receivingIds.push_back(6); + auto bank = std::make_unique(4, true); + check(bank->prepare(mixed, true) && ready(*bank) && bank->adopt() && !bank->legacy(), + "native SSB/CW and WFM share the qualified analog bank"); + bank->stop(); bank.reset(); + check(QThreadPool::globalInstance()->waitForDone(15000), "mixed admission case releases its registry before the next case"); + } } } int main(int argc, char** argv) @@ -968,7 +982,9 @@ int main(int argc, char** argv) QCoreApplication app(argc, argv); outputClockAlignment(); admission(); + check(QThreadPool::globalInstance()->waitForDone(15000), "admission fixtures release all prepared receiver banks"); nativeRouting(); + check(QThreadPool::globalInstance()->waitForDone(15000), "routing fixture drains before independent analog timing workload"); analogMultiReceiver(); workerUnderrunTrace(); if (qEnvironmentVariableIntValue("AETHER_WFM_BENCHMARK") == 1) { diff --git a/tests/tests.cmake b/tests/tests.cmake index ace67df82b..7ddc0e2f0e 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -279,6 +279,7 @@ add_executable(rtl_capture_transaction_test src/core/SharedCapturePolicy.cpp ) target_include_directories(rtl_capture_transaction_test PRIVATE src) +target_link_libraries(rtl_capture_transaction_test PRIVATE Qt6::Core) set_target_properties(rtl_capture_transaction_test PROPERTIES AUTOMOC OFF) add_test(NAME rtl_capture_transaction_test COMMAND rtl_capture_transaction_test) @@ -1076,6 +1077,10 @@ set_tests_properties(wdsp_wbfm_test PROPERTIES TIMEOUT 120) add_executable(wdsp_channel_test tests/wdsp_channel_test.cpp) target_link_libraries(wdsp_channel_test PRIVATE aethercore) add_test(NAME wdsp_channel_test COMMAND wdsp_channel_test) +if(WIN32) + # The Windows timer tick lengthens WDSP's bounded channel-stop waits. + set_tests_properties(wdsp_channel_test PROPERTIES TIMEOUT 600) +endif() # Releasing the noise-blanker hold must not flush the stage: the blanker still # blanks impulses in the first 200 ms after the edge, unlike a re-enabled one. @@ -1823,6 +1828,30 @@ target_include_directories(rtl_receive_pipeline_test PRIVATE src) target_link_libraries(rtl_receive_pipeline_test PRIVATE aethercore Qt6::Core) add_test(NAME rtl_receive_pipeline_test COMMAND rtl_receive_pipeline_test) set_tests_properties(rtl_receive_pipeline_test PROPERTIES TIMEOUT 45) + +# Analytic modulated IQ through production extraction/WDSP; no USB or peer. +add_executable(rtl_am_sam_pipeline_test tests/rtl_am_sam_pipeline_test.cpp) +target_include_directories(rtl_am_sam_pipeline_test PRIVATE src) +target_link_libraries(rtl_am_sam_pipeline_test PRIVATE aethercore Qt6::Core) +add_test(NAME rtl_am_sam_pipeline_test COMMAND rtl_am_sam_pipeline_test) +set_tests_properties(rtl_am_sam_pipeline_test PROPERTIES TIMEOUT 180) + +add_executable(rtl_ssb_cw_pipeline_test tests/rtl_ssb_cw_pipeline_test.cpp) +target_include_directories(rtl_ssb_cw_pipeline_test PRIVATE src) +target_link_libraries(rtl_ssb_cw_pipeline_test PRIVATE aethercore Qt6::Core) +add_test(NAME rtl_ssb_cw_pipeline_test COMMAND rtl_ssb_cw_pipeline_test) +set_tests_properties(rtl_ssb_cw_pipeline_test PROPERTIES TIMEOUT 180) +if(WIN32) + # Paced IQ and bounded channel-stop waits use the coarser Windows timer tick. + set_tests_properties(rtl_am_sam_pipeline_test rtl_ssb_cw_pipeline_test + PROPERTIES TIMEOUT 600) +endif() + +add_executable(cw_mode_gate_test tests/cw_mode_gate_test.cpp) +target_include_directories(cw_mode_gate_test PRIVATE src) +target_link_libraries(cw_mode_gate_test PRIVATE Qt6::Core) +add_test(NAME cw_mode_gate_test COMMAND cw_mode_gate_test) + add_executable(flex_slice_mode_intent_test tests/flex_slice_mode_intent_test.cpp) target_include_directories(flex_slice_mode_intent_test PRIVATE src tests) target_link_libraries(flex_slice_mode_intent_test PRIVATE aethercore Qt6::Core Qt6::Test) @@ -4998,6 +5027,16 @@ if (ENABLE_ASR) add_test(NAME asr_audio_tap_test COMMAND asr_audio_tap_test) set_tests_properties(asr_audio_tap_test PROPERTIES TIMEOUT 120) + add_executable(asr_native_receiver_test + tests/asr_native_receiver_test.cpp + src/gui/AsrAudioTap.cpp + ) + target_include_directories(asr_native_receiver_test PRIVATE src tests) + target_link_libraries(asr_native_receiver_test PRIVATE aethercore aetherasr Qt6::Core) + set_target_properties(asr_native_receiver_test PROPERTIES AUTOMOC ON) + add_test(NAME asr_native_receiver_test COMMAND asr_native_receiver_test) + set_tests_properties(asr_native_receiver_test PROPERTIES TIMEOUT 120) + # Silero VAD (ONNX) smoke test — only when ONNX Runtime is available; env-gated # on a model + WAV at run time (see the test's header), so it SKIPs otherwise. if(ORT_FOUND) @@ -9005,6 +9044,7 @@ endif() set(AETHER_SETTINGS_CONSUMERS cw_rx_model_test + asr_native_receiver_test dax_receive_applet_test graphic_eq_persistence_test hl2_discovery_logging_test diff --git a/tests/unified_title_bar_test.cpp b/tests/unified_title_bar_test.cpp index 14362359d1..9e715c7151 100644 --- a/tests/unified_title_bar_test.cpp +++ b/tests/unified_title_bar_test.cpp @@ -889,12 +889,52 @@ int main(int argc, char** argv) check(tabWithId(*bar, inUse.id)->isLinkCarrier(), "link status stays on a visible tab after removal"); tabs->setCompactMode(false); + // A confirmed USB connection does not need a network heartbeat to + // dismiss discovery; real link alarms still take precedence. + bar->setRadioTabs({nowAvailable}); + bar->setActiveRadio(nowAvailable.id); + bar->setDiscovering(true); + const QString warning = ThemeManager::instance().color( + bar, QStringLiteral("color.accent.warning")).name(); + check(tabs->state().value(QStringLiteral("linkOverrideColor")).toString() == warning, + "discovery is amber before a connection is confirmed"); + bar->setRadioTabs({connected}); + check(tabs->state().value(QStringLiteral("linkOverrideColor")).toString().isEmpty(), + "a connected radio clears discovery without a heartbeat"); + check(tabs->state().value(QStringLiteral("linkPulse")).toDouble() == 0.0, + "connecting does not invent a heartbeat pulse"); + bar->setRadioTabs({nowAvailable}); + bar->setDiscovering(true); + bar->setRadioTabs({connected}); + check(tabs->state().value(QStringLiteral("linkOverrideColor")).toString().isEmpty(), + "reconnecting clears discovery without a heartbeat"); + + // Rescanning while connected must survive repeated status snapshots. + bar->setDiscovering(true); + bar->setRadioTabs({connected}); + check(tabs->state().value(QStringLiteral("linkOverrideColor")).toString() == warning, + "an unchanged connected snapshot preserves a newly requested scan"); + RadioTabEntry renamed = connected; + renamed.name = QStringLiteral("Renamed connected radio"); + bar->setRadioTabs({renamed}); + check(tabs->state().value(QStringLiteral("linkOverrideColor")).toString() == warning, + "a connected radio metadata refresh does not end discovery"); + RadioTabEntry secondConnection = connected; + secondConnection.id = QStringLiteral("SECOND-CONNECTION"); + bar->setRadioTabs({renamed, secondConnection}); + check(tabs->state().value(QStringLiteral("linkOverrideColor")).toString().isEmpty(), + "a newly confirmed connection ends discovery with another radio already connected"); + // A lost link raises the alarm; an operator disconnect clears it. for (int miss = 0; miss < 3; ++miss) { bar->onHeartbeatLost(); } check(tabs->state().value(QStringLiteral("linkAlarm")).toBool(), "three missed beats raise the link alarm"); + bar->setDiscovering(true); + bar->setRadioTabs({connected}); + check(tabs->state().value(QStringLiteral("linkAlarm")).toBool(), + "a connected snapshot does not suppress a real link alarm"); bar->clearLinkAlarm(); check(!tabs->state().value(QStringLiteral("linkAlarm")).toBool(), "an operator disconnect clears the link alarm"); diff --git a/third_party/wdsp/include/aether_wdsp.h b/third_party/wdsp/include/aether_wdsp.h index 7f4e78de11..10be36141c 100644 --- a/third_party/wdsp/include/aether_wdsp.h +++ b/third_party/wdsp/include/aether_wdsp.h @@ -52,6 +52,13 @@ void fexchange2(int channel, float* inputI, float* inputQ, // must pace a burst of input blocks against the worker. int GetChannelOutputReady(int channel); void SetRXAMode(int channel, int mode); +// Existing amd.c controls; mode 0 combines both SAM sidebands. +void SetRXAAMDSBMode(int channel, int sbmode); +void SetRXAAMDFadeLevel(int channel, int levelfade); +void SetRXAANRRun(int channel, int run); +void SetRXAANRVals(int channel, int taps, int delay, double gain, double leakage); +void SetRXAANFRun(int channel, int run); +void SetRXAANFVals(int channel, int taps, int delay, double gain, double leakage); // Broadcast decoder controls, WDSP Guide section 5.3.10. The local patch // applies deemphasis to both live filters; indicator readback is an atomic // latest-completed-block observation. The caller must own channel lifetime.