From 4b1816ac552b20730db15c86fcb568dec468bba3 Mon Sep 17 00:00:00 2001 From: Ozy311 Date: Thu, 1 Oct 2026 19:14:14 -0700 Subject: [PATCH] Separate analog RTL delivery from optional digital decoding. Principle X. --- .github/workflows/appimage.yml | 15 +- .github/workflows/macos-dmg.yml | 7 +- .github/workflows/static-checks.yml | 3 + .github/workflows/windows-installer.yml | 21 +- .gitignore | 5 + CMakeLists.txt | 56 +- cmake/AetherRequireRtl.cmake | 6 + .../architecture/aetherd-touchpoint-tags.json | 12 + docs/architecture/aetherd-touchpoints.md | 7 +- docs/architecture/radio-capabilities-map.md | 13 + docs/decoder-pcm-contract.md | 37 +- docs/native-dax-receive.md | 56 ++ docs/rtl-m1-runtime.md | 45 ++ docs/rtl-multirx-analog.md | 156 +++++ docs/rtl-release-packaging.md | 31 + docs/rtl-wfm.md | 10 +- hal-plugin/AetherSDRDAX.cpp | 7 +- packaging/macos/build-installer.sh | 12 +- scripts/build-windows-local.ps1 | 9 +- scripts/setup/rtl-dependencies.json | 37 ++ scripts/setup/setup-fftw.ps1 | 22 +- scripts/setup/setup-macos-deps.sh | 12 +- scripts/setup/setup-rtl-deps.ps1 | 46 ++ scripts/setup/setup-rtl-deps.sh | 62 ++ src/core/AutomationServer.cpp | 2 + src/core/PipeWireAudioBridge.cpp | 82 ++- src/core/PipeWireAudioBridge.h | 13 +- src/core/PipeWireNativeRxSource.cpp | 25 +- src/core/PipeWireNativeRxSource.h | 21 +- src/core/RtlDeviceSettings.cpp | 4 + src/core/RtlDeviceSettings.h | 1 + src/core/RtlSliceSettings.cpp | 12 + src/core/RtlSliceSettings.h | 2 + src/core/TciServer.cpp | 47 +- src/core/TciServer.h | 5 + src/core/VirtualAudioBridge.cpp | 102 ++-- src/core/VirtualAudioBridge.h | 8 +- src/core/backends/IRadioBackend.h | 2 + src/core/backends/MeterDef.h | 1 + src/core/backends/RadioCapabilities.h | 12 + src/core/backends/SliceDelta.h | 2 + src/core/backends/anan/AnanBackend.cpp | 1 + src/core/backends/flex/FlexBackend.cpp | 1 + src/core/backends/flex/PanadapterStream.cpp | 3 +- src/core/backends/flex/PanadapterStream.h | 1 + src/core/backends/hl2/Hl2Backend.cpp | 1 + src/core/backends/icom/IcomCivBackend.cpp | 1 + .../backends/rtl/RtlCaptureTransaction.cpp | 2 + src/core/backends/rtl/RtlCaptureTransaction.h | 2 + src/core/backends/rtl/RtlReceivePipeline.cpp | 159 ++++- src/core/backends/rtl/RtlReceivePipeline.h | 60 +- src/core/backends/rtl/RtlSdrBackend.cpp | 342 ++++++++++- src/core/backends/rtl/RtlSdrBackend.h | 17 + src/core/backends/rtl/RtlSdrWorker.cpp | 120 +++- src/core/backends/rtl/RtlSdrWorker.h | 32 +- src/core/backends/rtl/RtlSquelchGate.h | 55 +- src/core/backends/rtl/RtlStartupTrace.h | 122 ++++ src/core/backends/sim/SimBackend.cpp | 1 + src/core/dsp/SpectrumTemporalAverage.h | 22 +- src/gui/AppletPanel.cpp | 9 +- src/gui/AppletPanel.h | 2 +- src/gui/Ax25HfPacketDecodeDialog.cpp | 238 +++++--- src/gui/Ax25HfPacketDecodeDialog.h | 19 +- src/gui/DaxApplet.cpp | 26 + src/gui/DaxApplet.h | 3 + src/gui/MainWindow.cpp | 20 +- src/gui/MainWindow_DigitalModes.cpp | 175 +++--- src/gui/MainWindow_Menus.cpp | 13 +- src/gui/MainWindow_Wiring.cpp | 32 +- src/gui/RtlReceiverSettingsWidget.cpp | 41 +- src/gui/RtlReceiverSettingsWidget.h | 2 + src/gui/RxApplet.cpp | 50 +- src/gui/RxApplet.h | 2 + src/gui/SMeterWidget.cpp | 64 +- src/gui/SMeterWidget.h | 12 +- src/gui/SmartMtrConfig.cpp | 12 + src/gui/SmartMtrConfig.h | 4 +- src/gui/SmartMtrWidget.cpp | 25 +- src/gui/SmartMtrWidget.h | 3 +- src/gui/SpectrumWidget.cpp | 8 +- src/gui/SpectrumWidget.h | 3 +- src/gui/VfoWidget.cpp | 71 ++- src/gui/VfoWidget.h | 6 + src/gui/WfmApplet.cpp | 150 ++++- src/gui/WfmApplet.h | 5 + src/main.cpp | 8 +- src/models/Ax25ReceiveModel.cpp | 396 ++++++++++++ src/models/Ax25ReceiveModel.h | 75 +++ src/models/DaxReceiveModel.cpp | 146 +++++ src/models/DaxReceiveModel.h | 34 ++ src/models/DecoderAudioModel.cpp | 22 +- src/models/DecoderAudioModel.h | 2 +- src/models/MeterModel.cpp | 19 +- src/models/MeterModel.h | 2 + src/models/RadioModel.cpp | 14 +- src/models/SliceModel.cpp | 28 +- src/models/SliceModel.h | 6 + tests/Ax25ReceiveFixture.h | 116 ++++ tests/ax25_libmodem_shim_test.cpp | 22 +- tests/ax25_receive_model_test.cpp | 562 ++++++++++++++++++ tests/control_receive_test.cpp | 20 +- tests/dax_receive_applet_test.cpp | 75 +++ tests/meter_model_test.cpp | 36 ++ tests/pipewire_rx_bridge_test.cpp | 65 ++ tests/rtl_aprs_receive_test.cpp | 313 ++++++++++ tests/rtl_backend_test.cpp | 28 + tests/rtl_capture_worker_test.cpp | 257 +++++++- tests/rtl_device_settings_test.cpp | 7 + tests/rtl_model_acceptance_test.cpp | 242 ++++++++ tests/rtl_receive_pipeline_test.cpp | 315 +++++++++- tests/rtl_receiver_settings_widget_test.cpp | 20 +- tests/rtl_runtime_settings_test.cpp | 32 + tests/rtl_spectrum_averaging_test.cpp | 37 ++ tests/rtl_squelch_test.cpp | 43 ++ tests/rtl_wfm_pipeline_test.cpp | 359 ++++++++++- .../rx_applet_squelch_reconciliation_test.cpp | 71 +++ tests/s_meter_geometry_test.cpp | 25 + tests/spectrum_confirmed_geometry_test.cpp | 1 + tests/tci_rx_audio_test.cpp | 250 +++++++- tests/tests.cmake | 67 +++ tests/vfo_meter_overlay_invalidation_test.cpp | 189 ++++++ tests/wfm_controls_test.cpp | 198 ++++++ tools/check_rtl_package.py | 74 +++ tools/test_require_rtl.py | 17 + 124 files changed, 6655 insertions(+), 503 deletions(-) create mode 100644 cmake/AetherRequireRtl.cmake create mode 100644 docs/native-dax-receive.md create mode 100644 docs/rtl-multirx-analog.md create mode 100644 docs/rtl-release-packaging.md create mode 100644 scripts/setup/rtl-dependencies.json create mode 100644 scripts/setup/setup-rtl-deps.ps1 create mode 100644 scripts/setup/setup-rtl-deps.sh create mode 100644 src/core/backends/rtl/RtlStartupTrace.h create mode 100644 src/models/Ax25ReceiveModel.cpp create mode 100644 src/models/Ax25ReceiveModel.h create mode 100644 src/models/DaxReceiveModel.cpp create mode 100644 src/models/DaxReceiveModel.h create mode 100644 tests/Ax25ReceiveFixture.h create mode 100644 tests/ax25_receive_model_test.cpp create mode 100644 tests/dax_receive_applet_test.cpp create mode 100644 tests/pipewire_rx_bridge_test.cpp create mode 100644 tests/rtl_aprs_receive_test.cpp create mode 100644 tests/vfo_meter_overlay_invalidation_test.cpp create mode 100644 tools/check_rtl_package.py create mode 100644 tools/test_require_rtl.py diff --git a/.github/workflows/appimage.yml b/.github/workflows/appimage.yml index d5f511ad58..267a899aff 100644 --- a/.github/workflows/appimage.yml +++ b/.github/workflows/appimage.yml @@ -112,7 +112,7 @@ jobs: libgstreamer-plugins-bad1.0-0 \ libfuse2 file desktop-file-utils imagemagick \ python3-pip autoconf automake libtool libasound2-dev \ - libfftw3-dev libhidapi-dev portaudio19-dev + libfftw3-dev libudev-dev libhidapi-dev portaudio19-dev # glslc — the SPIR-V shader compiler whisper's Vulkan backend needs at # BUILD time (CMake resolves it via find_package(Vulkan COMPONENTS glslc), @@ -249,6 +249,9 @@ jobs: # pure Qt-D-Bus backend talks to KDE Wallet / GNOME Keyring at runtime. run: bash scripts/setup/setup-qtkeychain.sh + - name: Build pinned RTL dependencies (including float FFTW) + run: bash scripts/setup/setup-rtl-deps.sh + - name: Configure run: | # Without pipefail the step's exit status is tee's, so a cmake failure @@ -256,8 +259,11 @@ jobs: # (which also implies -eo pipefail) so the guard is self-contained and # survives anyone editing the step's shell. set -o pipefail + export PKG_CONFIG_PATH="$PWD/third_party/rtl-deps/lib/pkgconfig:${PKG_CONFIG_PATH:-}" cmake -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ + -DENABLE_HD_FM=OFF \ + -DENABLE_RTL=ON -DREQUIRE_RTL=ON \ -DCMAKE_INSTALL_PREFIX=/usr \ -DREQUIRE_SERIALPORT=ON \ -DREQUIRE_KEYCHAIN=ON \ @@ -267,7 +273,7 @@ jobs: -DAETHER_GPU_SPECTRUM=${{ matrix.gpu_spectrum }} \ -DMQTT_TLS=OFF \ -DENABLE_NVIDIA_AFX=ON \ - -DCMAKE_PREFIX_PATH="$QT_ROOT" 2>&1 | tee configure.log + -DCMAKE_PREFIX_PATH="$PWD/third_party/rtl-deps;$QT_ROOT" 2>&1 | tee configure.log # AETHER_GPU_SPECTRUM=ON is a request, not a guarantee: CMake silently # flips it back OFF if Qt6GuiPrivate can't be located, and CMakeLists.txt @@ -304,6 +310,8 @@ jobs: convert AppDir/usr/share/icons/hicolor/256x256/apps/aethersdr.png \ -resize 256x256 AppDir/usr/share/icons/hicolor/256x256/apps/aethersdr.png + mkdir -p AppDir/usr/share/aethersdr + cp -R third_party/rtl-deps/share/aethersdr-rtl-sources AppDir/usr/share/aethersdr/ # Bundle DeepFilterNet3 model for DFNR if [ -f third_party/deepfilter/models/DeepFilterNet3_onnx.tar.gz ]; then cp third_party/deepfilter/models/DeepFilterNet3_onnx.tar.gz \ @@ -408,13 +416,14 @@ jobs: # libsherpa-onnx-c-api.so, and libonnxruntime.so (the app links sherpa's # copy). Qt6 DBus, which the keychain backend needs at runtime, is pulled # in transitively. - export LD_LIBRARY_PATH="${{ github.workspace }}/third_party/qtkeychain/lib:${{ github.workspace }}/third_party/onnxruntime/lib:${{ github.workspace }}/third_party/sherpa-onnx/lib:${LD_LIBRARY_PATH:-}" + export LD_LIBRARY_PATH="${{ github.workspace }}/third_party/rtl-deps/lib:${{ github.workspace }}/third_party/qtkeychain/lib:${{ github.workspace }}/third_party/onnxruntime/lib:${{ github.workspace }}/third_party/sherpa-onnx/lib:${LD_LIBRARY_PATH:-}" ./linuxdeploy-${{ matrix.arch }}.AppImage \ --appdir AppDir \ --plugin qt \ --desktop-file AppDir/usr/share/applications/AetherSDR.desktop \ --icon-file AppDir/usr/share/icons/hicolor/256x256/apps/aethersdr.png \ --output appimage + python3 tools/check_rtl_package.py AppDir --platform linux # Nothing else in the build would notice these plugins going missing: Qt # dlopen()s them at runtime rather than linking them, so no dependency walk diff --git a/.github/workflows/macos-dmg.yml b/.github/workflows/macos-dmg.yml index 68121d1f91..74d4990153 100644 --- a/.github/workflows/macos-dmg.yml +++ b/.github/workflows/macos-dmg.yml @@ -190,7 +190,7 @@ jobs: # bakes in that nothing downstream can correct, so a 14.0 tree must # never restore into a 12.0 build — that would silently recreate the # bug this change exists to remove. - key: macos-deps-${{ matrix.label }}-deploy${{ matrix.deploy_target }}-${{ hashFiles('scripts/setup/setup-macos-deps.sh') }} + key: macos-deps-${{ matrix.label }}-deploy${{ matrix.deploy_target }}-${{ hashFiles('scripts/setup/setup-macos-deps.sh', 'scripts/setup/setup-rtl-deps.sh', 'scripts/setup/rtl-dependencies.json') }} # ~8 min cold, then cached until the script or the target changes — the # same deal third_party/qtkeychain already has. That cost buys the DMG a @@ -362,6 +362,8 @@ jobs: KEYCHAIN_FLAG="-DREQUIRE_KEYCHAIN=ON" cmake -B build -G "Unix Makefiles" \ -DCMAKE_BUILD_TYPE=Release \ + -DENABLE_HD_FM=OFF \ + -DENABLE_RTL=ON -DREQUIRE_RTL=ON \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -DCMAKE_OSX_DEPLOYMENT_TARGET="${{ matrix.deploy_target }}" \ @@ -466,6 +468,9 @@ jobs: "$DEPLOYQT" build/AetherSDR.app \ -verbose=1 \ -always-overwrite + mkdir -p build/AetherSDR.app/Contents/Resources + cp -R third_party/macos-deps/share/aethersdr-rtl-sources build/AetherSDR.app/Contents/Resources/ + python3 tools/check_rtl_package.py build/AetherSDR.app --platform macos # The artifact-side half of the pin. Everything upstream of here asserts # what we *asked* for; this asserts what the DMG actually carries, which diff --git a/.github/workflows/static-checks.yml b/.github/workflows/static-checks.yml index bf83c5c5ae..e2c8be1c9a 100644 --- a/.github/workflows/static-checks.yml +++ b/.github/workflows/static-checks.yml @@ -158,6 +158,9 @@ jobs: with: python-version: "3.12" + - name: RTL release requirement combinations + run: python tools/test_require_rtl.py + # ── aetherd-migration ratchet (docs/aetherd-headless-engine-design.md §10) # EB1/EB2 — the engine (src/core/ + src/models/) must never depend on # the UI: no gui/ header includes, no QtWidgets. diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index 699323f308..ffe7f92b14 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -128,12 +128,23 @@ jobs: id: cache-fftw with: path: third_party/fftw3 - key: fftw-${{ runner.os }}-${{ hashFiles('scripts/setup/setup-fftw.ps1') }} + key: fftw-${{ runner.os }}-${{ hashFiles('scripts/setup/setup-fftw.ps1', 'scripts/setup/rtl-dependencies.json') }} - name: Setup FFTW3 float (NR4 dependency) if: steps.cache-fftw.outputs.cache-hit != 'true' run: .\scripts\setup\setup-fftw.ps1 + - name: Cache RTL-SDR and libusb + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + id: cache-rtl + with: + path: third_party/rtl-deps + key: rtl-${{ runner.os }}-${{ hashFiles('scripts/setup/setup-rtl-deps.ps1', 'scripts/setup/rtl-dependencies.json') }} + + - name: Setup RTL-SDR and libusb + if: steps.cache-rtl.outputs.cache-hit != 'true' + run: .\scripts\setup\setup-rtl-deps.ps1 + - name: Cache hidapi uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 id: cache-hidapi @@ -248,7 +259,7 @@ jobs: # native command), so a configure failure still fails this step — # the reason the bash legs need `set -o pipefail` and this one does # not. - cmake -B build -G "Ninja" -DCMAKE_BUILD_TYPE=RelWithDebInfo -DMQTT_TLS=OFF -DREQUIRE_KEYCHAIN=ON -DREQUIRE_SERIALPORT=ON -DREQUIRE_ASR_ONNX=ON -DREQUIRE_ASR_SHERPA=ON -DREQUIRE_ASR_GPU=ON -DAETHER_GPU_SPECTRUM=ON -DCMAKE_PREFIX_PATH="$env:VULKAN_SDK" -DENABLE_NVIDIA_AFX=ON -DCMAKE_TOOLCHAIN_FILE="$env:CMAKE_TOOLCHAIN_FILE" -DVCPKG_TARGET_TRIPLET="$env:VCPKG_TARGET_TRIPLET" | Tee-Object -FilePath configure.log + cmake -B build -G "Ninja" -DCMAKE_BUILD_TYPE=RelWithDebInfo -DENABLE_HD_FM=OFF -DENABLE_RTL=ON -DREQUIRE_RTL=ON -DMQTT_TLS=OFF -DREQUIRE_KEYCHAIN=ON -DREQUIRE_SERIALPORT=ON -DREQUIRE_ASR_ONNX=ON -DREQUIRE_ASR_SHERPA=ON -DREQUIRE_ASR_GPU=ON -DAETHER_GPU_SPECTRUM=ON -DCMAKE_PREFIX_PATH="$env:VULKAN_SDK" -DENABLE_NVIDIA_AFX=ON -DCMAKE_TOOLCHAIN_FILE="$env:CMAKE_TOOLCHAIN_FILE" -DVCPKG_TARGET_TRIPLET="$env:VCPKG_TARGET_TRIPLET" | Tee-Object -FilePath configure.log # AETHER_GPU_SPECTRUM=ON is a request, not a guarantee: CMake flips it # back OFF without failing when Qt6GuiPrivate can't be located, and @@ -399,6 +410,12 @@ jobs: Write-Host "Bundling ASR runtime DLL: $($_.Name)" Copy-Item $_.FullName deploy\ -Force } + Copy-Item third_party\rtl-deps\bin\*.dll deploy\ + New-Item -ItemType Directory -Force deploy\licenses\rtl | Out-Null + Copy-Item third_party\rtl-deps\share\aethersdr-rtl-sources deploy\licenses\rtl\ -Recurse + Copy-Item third_party\fftw3\share\aethersdr-rtl-sources deploy\licenses\fftw -Recurse + python tools/check_rtl_package.py deploy --platform windows + if ($LASTEXITCODE -ne 0) { throw "RTL payload validation failed" } # Vulkan loader for the whisper GPU backend. ggml-vulkan hard-links # vulkan-1, so the DLL must ship or the exe fails to start even on # CPU-only machines (the loader then enumerates zero devices and ggml diff --git a/.gitignore b/.gitignore index 4b538bf7ce..54abc26c18 100644 --- a/.gitignore +++ b/.gitignore @@ -64,5 +64,10 @@ third_party/qtkeychain-build/ # never be tracked or show up as untracked noise in the parent checkout. .worktrees/ +# Built pinned RTL dependency prefix and intermediate files +third_party/rtl-deps/ +third_party/macos-deps/ +.rtl-deps-build/ + # macOS Finder metadata .DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c1ac214fa..a53f2a0c18 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -144,6 +144,7 @@ if(NOT AETHERSDR_SANITIZER STREQUAL "none") unset(_aether_san_list) endif() option(ENABLE_RTL "Enable the experimental receive-only RTL-SDR backend" ON) +option(REQUIRE_RTL "Fail configuration unless the RTL backend and float FFTW are available" OFF) # Which SSB modulator the Hermes-Lite 2 transmit path is BUILT with. There is # no runtime switch and there must not be one: two silent transmit paths are # worse than one, and Hl2TxDsp.cpp records that the prior TXA attempt failed @@ -405,6 +406,10 @@ else() find_package(PkgConfig QUIET) if(PkgConfig_FOUND) pkg_check_modules(PORTAUDIO portaudio-2.0) + if(PORTAUDIO_FOUND) + # Preserve the resolved prefix; bare -l names can select Homebrew first. + set(PORTAUDIO_LIBRARIES ${PORTAUDIO_LINK_LIBRARIES}) + endif() endif() endif() @@ -423,6 +428,10 @@ if(WIN32) else() if(PkgConfig_FOUND) pkg_check_modules(FFTW3 fftw3) + if(FFTW3_FOUND) + # Preserve the resolved prefix; bare -l names can select Homebrew first. + set(FFTW3_LIBRARIES ${FFTW3_LINK_LIBRARIES}) + endif() endif() if(NOT FFTW3_FOUND) find_library(FFTW3_LIB fftw3) @@ -973,6 +982,7 @@ set(CORE_SOURCES src/core/TciIoWorker.cpp src/core/TciClient.h src/core/TciServer.cpp + src/models/DaxReceiveModel.cpp src/core/TciRxConverter.cpp src/core/TciPeerProcess.cpp src/core/TciProtocol.cpp @@ -1094,6 +1104,7 @@ endif() set(MODEL_SOURCES src/models/DecoderAudioModel.cpp + src/models/Ax25ReceiveModel.cpp src/models/AprsDigipeaterModel.cpp src/models/Nr2SettingsModel.cpp src/models/Rn2SettingsModel.cpp @@ -2644,6 +2655,9 @@ else() if(NOT HIDAPI_FOUND) pkg_check_modules(HIDAPI hidapi) endif() + if(HIDAPI_FOUND) + set(HIDAPI_LIBRARIES ${HIDAPI_LINK_LIBRARIES}) + endif() endif() endif() @@ -2657,8 +2671,8 @@ endif() # plain header+library probe so the switch below is reachable on every # platform, not only the pkg-config ones. if(ENABLE_RTL AND NOT RTLSDR_FOUND) - find_path(RTLSDR_INCLUDE_DIR rtl-sdr.h) - find_library(RTLSDR_LIBRARY NAMES rtlsdr librtlsdr) + find_path(RTLSDR_INCLUDE_DIR rtl-sdr.h HINTS "${CMAKE_SOURCE_DIR}/third_party/rtl-deps/include") + find_library(RTLSDR_LIBRARY NAMES rtlsdr librtlsdr HINTS "${CMAKE_SOURCE_DIR}/third_party/rtl-deps/lib") if(RTLSDR_INCLUDE_DIR AND RTLSDR_LIBRARY) add_library(RtlSdr::RtlSdr UNKNOWN IMPORTED) set_target_properties(RtlSdr::RtlSdr PROPERTIES @@ -2672,8 +2686,8 @@ else() endif() if(ENABLE_RTL AND NOT RTL_FFTW3F_FOUND) - find_path(RTL_FFTW3F_INCLUDE_DIR fftw3.h) - find_library(RTL_FFTW3F_LIBRARY NAMES fftw3f libfftw3f libfftw3f-3) + find_path(RTL_FFTW3F_INCLUDE_DIR fftw3.h HINTS "${CMAKE_SOURCE_DIR}/third_party/fftw3/include") + find_library(RTL_FFTW3F_LIBRARY NAMES fftw3f libfftw3f libfftw3f-3 HINTS "${CMAKE_SOURCE_DIR}/third_party/fftw3/lib") if(RTL_FFTW3F_INCLUDE_DIR AND RTL_FFTW3F_LIBRARY) add_library(RtlFftw3f::RtlFftw3f UNKNOWN IMPORTED) set_target_properties(RtlFftw3f::RtlFftw3f PROPERTIES @@ -2706,6 +2720,33 @@ else() message(STATUS "RTL-SDR ... disabled — librtlsdr not found") endif() +# The optional decoder is a separate feature proposal (RFC #6079). +option(ENABLE_HD_FM "Build experimental embedded digital FM" OFF) +if(ENABLE_HD_FM) + message(FATAL_ERROR "Digital FM is not included in the analog RTL submission. See RFC #6079; configure with -DENABLE_HD_FM=OFF.") +endif() + +include(cmake/AetherRequireRtl.cmake) +aether_require_rtl() + +# Stage RTL independently of the optional NR4 and Digital FM features. +if(WIN32 AND AETHER_BACKEND_RTL) + foreach(_rtl_dll + "${CMAKE_SOURCE_DIR}/third_party/rtl-deps/bin/rtlsdr.dll" + "${CMAKE_SOURCE_DIR}/third_party/rtl-deps/bin/libusb-1.0.dll" + "${CMAKE_SOURCE_DIR}/third_party/fftw3/bin/libfftw3f-3.dll") + if(EXISTS "${_rtl_dll}") + foreach(_rtl_app AetherSDR aetherd) + add_custom_command(TARGET ${_rtl_app} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_rtl_dll}" "$") + endforeach() + elseif(REQUIRE_RTL) + message(FATAL_ERROR "Required RTL runtime missing: ${_rtl_dll}; run scripts/setup/setup-rtl-deps.ps1 and setup-fftw.ps1") + endif() + endforeach() +endif() + # Ulanzi Dial backend — one per platform. All three implementations # expose the same Qt signal contract (see UlanziDialBackend.h); the # mapper dialog and MainWindow dispatcher are unchanged across @@ -3070,7 +3111,12 @@ if(ENABLE_SPECBLEACH) endif() endif() # libspecbleach uses fftwf (float precision FFTW3) - find_library(FFTW3F_LIB fftw3f HINTS /opt/homebrew/lib /usr/local/lib ${CMAKE_SOURCE_DIR}/third_party/fftw3/lib) + if(RTL_FFTW3F_TARGET AND TARGET "${RTL_FFTW3F_TARGET}") + # NR4 and RTL must use the same float FFTW, including its prefix. + set(FFTW3F_LIB "${RTL_FFTW3F_TARGET}") + else() + find_library(FFTW3F_LIB fftw3f HINTS /opt/homebrew/lib /usr/local/lib ${CMAKE_SOURCE_DIR}/third_party/fftw3/lib) + endif() if(FFTW3F_LIB) target_link_libraries(aethercore PRIVATE ${FFTW3F_LIB}) else() diff --git a/cmake/AetherRequireRtl.cmake b/cmake/AetherRequireRtl.cmake new file mode 100644 index 0000000000..8a90ed983b --- /dev/null +++ b/cmake/AetherRequireRtl.cmake @@ -0,0 +1,6 @@ +# Release builds must not silently lose hardware support. Developer OFF is valid. +function(aether_require_rtl) + if(REQUIRE_RTL AND (NOT ENABLE_RTL OR NOT RTLSDR_FOUND OR NOT RTL_FFTW3F_FOUND)) + message(FATAL_ERROR "REQUIRE_RTL needs ENABLE_RTL=ON, librtlsdr and float FFTW (fftw3f). Run the platform dependency setup; use REQUIRE_RTL=OFF only for intentional developer builds.") + endif() +endfunction() diff --git a/docs/architecture/aetherd-touchpoint-tags.json b/docs/architecture/aetherd-touchpoint-tags.json index b14df4fd04..f196b01b74 100644 --- a/docs/architecture/aetherd-touchpoint-tags.json +++ b/docs/architecture/aetherd-touchpoint-tags.json @@ -893,6 +893,12 @@ "split": "", "confidence": "high" }, + "models/DaxReceiveModel.h": { + "tag": "universal", + "note": "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.", + "split": "", + "confidence": "high" + }, "models/MeterModel.h": { "tag": "mixed(flex)", "note": "Meter registry+values are core-profile; VITA-49 raw scaling, TGXL/PGXL amp routing, COMPPEAK quirks are Flex", @@ -1438,5 +1444,11 @@ "note": "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.", "split": "Core: receiver selection, epochs, bounded backlog, rate conversion and decoder resets. Flex compatibility: DAX channel selection and central consumer holds; no wire commands are generated here.", "confidence": "high" + }, + "models/Ax25ReceiveModel.h": { + "tag": "universal", + "note": "Selected AX.25 receive model over the existing typed decoder route; continuous mono24 conversion and bounded modem worker results retain source leases and generation retirement. Existing transmit ownership is separate.", + "split": "", + "confidence": "high" } } diff --git a/docs/architecture/aetherd-touchpoints.md b/docs/architecture/aetherd-touchpoints.md index 41b10a5e15..d87460350b 100644 --- a/docs/architecture/aetherd-touchpoints.md +++ b/docs/architecture/aetherd-touchpoints.md @@ -4,7 +4,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine-design.md) §2, §10). One row per engine header the UI includes; converting a touchpoint means the UI reaches that surface through the versioned protocol instead of the header. -**Totals:** 231 touchpoint headers (194 core, 37 models) — 231/231 tagged, 0/231 converted. +**Totals:** 232 touchpoint headers (193 core, 39 models) — 232/232 tagged, 0/232 converted. | Header | Includers | Tag | Status | |---|---:|---|---| @@ -144,7 +144,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `core/SystemInventory.h` | 1 | ui-support — Startup and support-bundle inventory of host CPU, SIMD and RAM capabilities. Process diagnostics, not radio state. | unconverted | | `core/TciServer.h` | 3 | mixed(flex) — TCI WebSocket server for WSJT-X et al: protocol surface is canonical radio state, but audio/IQ rides Flex DAX | unconverted | | `core/TgxlConnection.h` | 2 | 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` | 158 | 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` | 159 | 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` | 13 | ui-support — QWidget property marker guarding TX-keying controls from the automation bridge; GUI-shell plumbing, no radio state. | unconverted | @@ -193,7 +193,6 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `core/backends/sim/NoiseMixer.h` | 1 | vendor(sim) — Demo-mode synthetic AUDIO engine (RFC #4288 Phase 2b) — additive HF-noise and signal channels feeding SimBackend's audioFrameReady. Backend-internal generator reached from DemoApplet.cpp; family-specific below-seam code, frozen into the EB3 baseline. Demo-mode controls should reach it through an extension namespace, not the concrete type. | unconverted | | `core/backends/sim/SimBackend.h` | 3 | vendor(sim) — The synthetic demo backend (#4473) — a concrete IRadioBackend implementor that generates its own audio and spectrum, RX-only by construction (Principle VI). Reached today from ConnectionPanel.cpp, MainWindow.cpp and MainWindow_Session.cpp; pre-existing coupling, frozen into the EB3 baseline. The UI should select it through RadioModel::makeBackend()'s family string, never the concrete type. | unconverted | | `core/pms/PmsMailbox.h` | 1 | universal — Packet personal-message-system mailbox store/logic; radio-agnostic operating feature. | unconverted | -| `core/tnc/AetherAx25LibmodemShim.h` | 1 | universal — AX.25 modem shim bridging the client AFSK/libmodem demod to the TNC; radio-agnostic DSP glue. | unconverted | | `core/tnc/Ax25.h` | 1 | universal — AX.25 frame data types/constants; radio-agnostic protocol layer. | unconverted | | `core/tnc/Ax25AudioCapture.h` | 1 | universal — AX.25 Capture 3m diagnostics: capture-id/path naming and the shared float32 WAV writer for the RX, generated-TX and post-resample stages; radio-agnostic diagnostic I/O (#5311). | unconverted | | `core/tnc/Ax25Connection.h` | 1 | universal — Single-peer AX.25 v2.0 connected-mode (LAPB) data-link state machine, mod-8 sequence space, backing the Personal Mailbox System. Radio-agnostic packet layer above whatever modem or backend carries the audio. | unconverted | @@ -206,6 +205,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `models/AmpModel.h` | 1 | mixed(flex) — Power-amplifier state model (PGXL / any non-TGXL amp the radio proxies), extracted from RadioModel (#4094). Like TunerModel: universal amp state (presence/operate/telemetry) fused with a Flex relay — the radio-proxied 'amplifier set … operate=' command (the only path that works remote/SmartLink; the direct PgxlConnection is telemetry-only). | unconverted | | `models/AntennaGeniusModel.h` | 4 | peripheral(4o3a) — 4O3A Antenna Genius switch client — standalone accessory with its own UDP-broadcast discovery (port 9007) + direct TCP; connects by device IP/port independent of the radio, works with any radio. Not radio-family wire; a peripheral accessory, NOT behind the IRadioBackend radio seam (reclassified from vendor(flex), #4087 follow-up). | unconverted | | `models/AprsDigipeaterModel.h` | 1 | universal — Session-local APRS fill-in policy and bounded modem queue; no backend-specific commands or transport ownership. | unconverted | +| `models/Ax25ReceiveModel.h` | 1 | universal — Selected AX.25 receive model over the existing typed decoder route; continuous mono24 conversion and bounded modem worker results retain source leases and generation retirement. Existing transmit ownership is separate. | unconverted | | `models/BandDefs.h` | 5 | universal — Static ARRL band plan table (edges, default freq/mode, GEN/WWV); canonical band-plan data, no vendor ties. | unconverted | | `models/BandPlanManager.h` | 9 | universal — Band-plan overlay data (segments/spots/license classes, region merge) from JSON; radio-agnostic canon | unconverted | | `models/BandSettings.h` | 6 | universal — Per-band save/restore of canonical state (freq/mode/filter/AGC/WNB/display range) — band memories, no vendor fields | unconverted | @@ -214,6 +214,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `models/CwxModel.h` | 1 | universal — CW keyer intent: WPM/delay/QSK, 12 macros, send/erase, sent-index progress. Generic despite Flex 'CWX' name. | unconverted | | `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/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/DvkModel.h` | 1 | mixed(flex) — Voice keyer slots/commands are core-profile; status parsing + FlexLib SsdrErrors mapping are flex. | unconverted | diff --git a/docs/architecture/radio-capabilities-map.md b/docs/architecture/radio-capabilities-map.md index 6ba9e1e173..bb884a9c95 100644 --- a/docs/architecture/radio-capabilities-map.md +++ b/docs/architecture/radio-capabilities-map.md @@ -68,6 +68,19 @@ are in [local receive control](../aetherd-local-receive-control.md#qualified-bac `control_receive_test` pins declarations and action-time admission; the optional RTL declaration check runs only when the RTL backend is built. +### Native receive audio export + +`receiveAudioExport` describes the typed pre-monitor `sliceAudioFrameReady` +formats and independent receiver capacity. RTL declares 24/48 kHz and, when its +Digital decoder is built, 44.1 kHz. The other backends explicitly leave it absent. +`DaxReceiveModel` admits only declared rates and live TCI receiver bindings; +MainWindow offers the existing DAX audio bridge separately from radio-side DAX +selectors and IQ. This record grants no TX or headless-control permission. +Native DAX currently requires the optional WebSockets build component for the +shared receiver map, but neither a listening TCI server nor an attached TCI +client. Builds without it retain their existing Flex bridge and do not offer +native DAX. See [native DAX receive routing](../native-dax-receive.md). + ### Native squelch `receiveSquelchModel` describes native desktop squelch modes and the threshold's diff --git a/docs/decoder-pcm-contract.md b/docs/decoder-pcm-contract.md index 406772ac40..c0e1a2e24c 100644 --- a/docs/decoder-pcm-contract.md +++ b/docs/decoder-pcm-contract.md @@ -41,7 +41,7 @@ slice alone: the central hold requests the radio stream, and speaker mix, gain and mute do not reach it. It does not require the operating-system DAX audio bridge or PC-audio monitoring. -With no channel assigned, CW (both GGMorse and DeepFist) and RTTY fall back to +With no channel assigned, CW (both GGMorse and DeepFist), RTTY and AX.25 fall back to the radio's shared receive stream — the pre-A5 decoder input. That stream mixes every audible slice and follows speaker gain and mute, so other slices can interfere and muting stops decoding; the panel discloses it as `RX: shared @@ -85,6 +85,41 @@ TX sidetone remains a separate fixed24 GGMorse instance. Both decoders' legacy byte inputs retain the existing trim-oldest backlog behavior on overflow; typed RX overflow retires detector state instead. +## AX.25 receive worker + +`Ax25ReceiveModel` binds the same selected-source policy with its own AX.25 DAX +hold and owns the existing modem worker previously hosted by the dialog. Native +RTL FM at 48 kHz is converted continuously to mono24 before the unchanged modem +rate guard. HL2/Icom mono24 conversion and Flex DAX/shared fallback retain their +existing producer contracts. No sound device or PC-audio monitoring is required +for a native or assigned-DAX route. Receive squelch remains upstream of the tap. +A producer-declared gap or discontinuity still resets decoding, including one +associated with a capture revision during monitor-control changes; this does not +promise uninterrupted packet recovery across such a boundary. + +The worker inbox is bounded to 65,536 mono samples and 256 blocks; the owner +outbox is bounded to 256 output blocks, with one pending drain event for each queue. +Overflow retires the generation, drops the backlog and resets detector state. +Input/configuration/reset operations and output publication share generation +retirement. Results retain the original source lease and generation through +worker callbacks; they cannot acquire a newer context after decoding. + +`Ax25ReceiveContext::current()` is checked before every result and each dialog +fanout operation, including display, MQTT, KISS and existing packet consumers. +A synchronous listener that disables, rebinds or destroys the model makes the +remaining consumers reject that result. Identical configuration and diagnostics +logging changes preserve receive continuity. Changed configuration, reset, +selection, disconnect and revoked sources retire queued PCM and decoded results. +The receive model owns no transmit API or external service; existing packet/TX +policy is unchanged. Closing it releases only its own DAX hold and joins its worker. + +The focused model test covers exact independent AX.25/FCS recovery from 24/48 kHz +mono/stereo, awkward chunk boundaries, route isolation, DAX coexistence and +retirement after worker decode but before owner delivery. The RTL test injects +independently generated CU8 FM into real device callbacks and traverses the +production RTL worker, native 48 kHz selected-slice seam, converter and modem. +Both are offline receive proofs, not hardware, antenna or on-air qualification. + ## Clock time mapping Decoder sample indices always count local 24 kHz mono frames. Let `O` be the diff --git a/docs/native-dax-receive.md b/docs/native-dax-receive.md new file mode 100644 index 0000000000..ff4dcf2025 --- /dev/null +++ b/docs/native-dax-receive.md @@ -0,0 +1,56 @@ +# Native receive audio through the existing DAX bridge + +On Linux and macOS builds with WebSockets support, a backend declaring +`receiveAudioExport` can feed the existing DAX audio inputs. RTL supplies this +record for its actual typed slice formats. Its configured receiver capacity +still applies; the private eight-receiver evaluation does not alter ordinary +release capacity. Other backends retain their existing behavior. + +Enable the DAX audio applet. Input 1 follows TCI receiver 0, input 2 follows +receiver 1, and so on through input 8/receiver 7. The live TCI receiver map supplies +both paths; sparse internal slice IDs are never interpreted as channel numbers. +Closing/recreating a slice preserves surviving assignments. The applet shows +the slice attached to each input. The TCI listener can be disabled or stopped; +DAX does not require a network client. Builds without the optional WebSockets +component do not offer native DAX because they lack that shared map. + +The adapter accepts owning pre-monitor slice frames, so speaker mute and +speaker gain do not change exported audio. Per-receiver squelch still gates the +slice tap. DAX and TCI gains and consumer lifetimes are independent. DAX stop +never stops TCI or tears down a channel held by another consumer. + +The existing macOS shared-memory ABI is float32 stereo at 24 kHz. Native 24, 44.1 +and 48 kHz are converted continuously to that format; their sample rates are +never relabeled. Linux's existing bridge deliberately downmixes that stereo +signal and emits float32 mono at 48 kHz. Linux DAX therefore does not provide +stereo WFM, while macOS DAX and stereo TCI retain separate left/right samples. +44.1 kHz is an internal Digital decoder rate; TCI negotiation remains 8/12/24/48 kHz. + +Each route has independent converter history, an epoch witness, a replay +cursor, and a revocable receiver binding. The adapter has eight channel slots, +a 32-producer bound, output blocks at most 1024 stereo frames, and no PCM queue. +A live old producer cannot seize a newly created receiver's route. Epoch/rate +changes, discontinuities, forward gaps, removal, stop and backend replacement +reset conversion and retained bridge output. The Linux RX-only bridge buffers +at most 65536 bytes (~341 ms at mono 48 kHz) to accommodate decoder bursts; the +legacy packet bridge retains its smaller capacity. Writes stay nonblocking. +The macOS bridge retains its existing bounded live-edge clamp. + +Receive-only startup creates only RX endpoints: no TX sink/shared-memory +segment or TX poller, no TX producer, and no mic-selection/DAX-TX command. +The TX control is dimmed with an accessible explanation. Native audio export +does not advertise radio-side DAX stream selectors or DAX-IQ support. + +A reset can discard samples retained by AetherSDR or its source FIFO/ring. +Samples already accepted by an external audio client cannot be recalled. +The updated macOS HAL reader uses a cursor compare/exchange so a simultaneous +route reset cannot be overwritten by an older callback. Older installed +four-input drivers must be updated separately before all-eight device testing; +application compilation alone does not validate that installed driver. + +Socket-free checks exercise eight sparse receivers with eight concurrent TCI +clients, independent rates and stereo signatures, mute, stop/restart, listener +stop, epoch changes, live-producer refusal, removal/recreate and callback +revocation. The Linux bridge test injects anonymous output pipes and exercises +all eight downmix/gain paths plus queue/interpolator reset. Hardware, operating +system devices, timing and installed-driver qualification are separate runs. diff --git a/docs/rtl-m1-runtime.md b/docs/rtl-m1-runtime.md index 32874b46da..fa61e0a8d7 100644 --- a/docs/rtl-m1-runtime.md +++ b/docs/rtl-m1-runtime.md @@ -630,3 +630,48 @@ two close peaks, asymmetric/outside capture limits, queued stale deliveries, reentrant disconnect, history movement/wrapping, and per-retune RF frames. Native GPU presentation, bounded performance observations and receive liveness are recorded separately for the exact delivered revision in the stage report. + +### Relative receive metering + +RTL publishes one `SLC:LEVEL` meter per configured receiver, with +unit `dBFS`. It reports the strongest windowed FFT bin intersecting that +receiver's accepted passband, using the existing unaveraged 2048-bin ADC +spectrum detector (about 30 observations/second). This is an uncalibrated +relative RF measurement, not dBm, S-units, integrated channel power, or audio +peak/RMS. Tuner gain and DC content affect it; no guessed RF calibration offset +is applied. Display FFT averaging, speaker mute/gain and SQL do not change its +measurement path. + +The acquisition mailbox is bounded and carries the accepted session/revision; +observations older than 500 ms are discarded at delivery. Every accepted +capture revision withdraws the prior samples before defining current meters, +including parked receivers. Parked/removed/disconnected or unfed receivers +therefore cannot retain a live reading. The UI checks the model's existing +freshness window at 100 ms intervals and displays unavailable when unfed or +stale. Relative readings never enter the calibrated `sLevelChanged` signal or +`sLevelForSlice` accessor. Slice bars, SmartMTR and the selected analog meter +use a labeled -120..0 dBFS scale; other radios retain their calibrated scale. + + +### Private evaluation: optional receive meters + +RTL Receiver settings exposes **Enable receive meters**, default enabled. +Its accepted boolean is stored as `receiveMetersEnabled` in the same per-serial +`RtlDevice` schema-1 document; missing fields in older documents mean enabled. +The `rtl/receive_meters.set` extension requires a boolean and a connected, +accepted receiver state. `receiveMetersControl` advertises that verb. + +Disabling stops the independent RF-passband measurement/publication, clears +cached readings, and stops relative-meter animation. The existing typed meter +catalogue carries an unavailable reason so both the VFO and selected analog +meter identify the disabled state instead of freezing a reading. A generation +stamp rejects queued/in-flight readings across disable/re-enable. The shared +spectrum needed for squelch and the receiver/audio paths remain active. This +control does not retune or rebuild the capture graph. Enabling requires a fresh +observation before any meter becomes live. Settings are unchanged on other +radio families, and anonymous or mismatched serials remain session-only. + +This is an interim operator choice for the private eight-receiver evaluation. +The enabled display still has measurable CPU overhead; bounded redraw fixes +alone did not recover the earlier pre-meter baseline. Exact performance and +hardware-validation evidence belongs to the sealed evaluation package. diff --git a/docs/rtl-multirx-analog.md b/docs/rtl-multirx-analog.md new file mode 100644 index 0000000000..eb6e79109d --- /dev/null +++ b/docs/rtl-multirx-analog.md @@ -0,0 +1,156 @@ +# RTL analog multi-receiver evaluation + +This checkpoint implements the analog multi-receiver path from RFC #5468. +It does not raise the qualified default capacity, enable multiple digital decoders, +or establish release qualification. The free-pan amendment remains subject to maintainer review. Optional +Digital FM is separate RFC #6079 and is absent from this analog branch. Native qualification results belong to the exact +tested revision; component or injected-USB tests do not establish live RF proof. + +## Evaluation admission + +On Linux x86_64, Windows x64 and macOS arm64, start an explicitly isolated evaluation process with +`AETHER_AUTOMATION=1` and `AETHER_RTL_EVALUATION_RECEIVERS=2`, `4` or `8`. +This chooses the same capacity for the transaction and private worker at +construction. It cannot resize a running session, is never persisted, and +does not change `kQualifiedReceiverCapacity` (one). Every other value or +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. + +## Selection and presentation + +RTL owns the selected stable ID separately from receiving membership. Focus +does not prepare DSP, retune capture or change any audio setting. An accepted +capture update retains a surviving selected receiver, including when parked. +Removal chooses the first surviving configured receiver; slot reuse does not +inherit the retired object's selection. Confirmed-control models do not emit +optimistic focus before the owner accepts their live-object identity. + +One WFM applet shows the selected receiver's letter and accepted frequency. +Queued status callbacks and controls retain their originating model binding; +an old popup or held button cannot command a new selection or reused slot. +Parking clears reception observations while retaining configured controls. +Digital station/program metadata and overlays are deferred with RFC #6079. + +## Measurements and interpretation + +The existing health snapshot exposes the qualified default and evaluation +capacities separately. Bounded lifetime counters add USB read starts/cancels, +valid/malformed callback counts, IQ sample counts, callback duration histogram +and exact maximum, queue occupancy/high-water, and per-slot processing-fault +withdrawals. Duration covers callback adoption/DDC/FFT/DSP work, excluding its +own counter update and USB transit time. A callback exceeding its actual +sample duration increments a separate deadline counter. Histogram percentile +bounds are conservative; the final bucket is unbounded. + +`rtlReceiverRecipes` reports each last-published accepted receiver recipe, +including its `stableId`, current `selected` identity, `receiving`/`parked` +membership, `carrierHz`, `mode`, `filterLowHz`/`filterHighHz`, `wfmForceMono`, +`wfmDeemphasisUs`, and `audioGain`/`audioPan`/`audioMute`. +An observer can freeze every receiver's accepted settings without moving +selection to inspect an applet. These are accepted control recipes, not an +independent measurement of the demodulated signal or observed stereo lock. + +`rtlPcmStreams` contains eight stable receiver slots followed by the speaker +mix. Frames, nonzero samples, nonfinite input samples, delivered discontinuities, +left/right energy, peak, queue residence and delivery gaps are cumulative +owner-thread observations. Queue residence uses a separate enqueue clock. Counts and +energies can be differenced across a frozen workload. Lifetime maxima and +delivery gaps can include intentional parking/reconfiguration; they must not +be called steady-state dropouts without checking that interval. Finite, +nonzero PCM establishes processing, not intelligibility or acoustic fidelity. + +Counters are sampled independently, not one atomic instant. An empty +application audio queue is not a hardware XRUN counter. The RTL driver supplies +no sequence counter proving that every RF sample reached the host. Distinct +FM/WFM paths align their sample labels for mixing but do not claim phase-coherent +inter-station acoustic group delay. + +## Qualification boundary + +Use socket-free injected device/model tests plus real generated-IQ demodulation +for selection, stable-ID reuse, restore, stereo/deemphasis/filter independence, +monitor isolation, parking, shared capture refusal/rollback and stale delivery. +Retain the existing mixer deadlines and bounded queues. Before claiming live +capacity, freeze representative one-, two- and four-receiver workloads and +measure actual USB, FFT/waterfall and nonzero audio for at least 30 minutes +each, including CPU/thermal headroom, callback latency, queue growth, saturated +history RSS, planning and teardown. Report failures without changing their +thresholds after observing the result. Linux x86_64 evidence does not qualify +Linux aarch64, macOS, Windows, mixed legacy/native rates or multiple digital workers. + +## Startup delivery headroom + +The packet ring is allocated once from the immutable process receiver capacity. +Its usable slots are 127, 170, 254 and 424 for one, two, four and eight receivers; +two, four and eight are exposed by the process evaluation setting. Native WFM +produces one 256-frame tap per receiver plus a shared 128-frame speaker stream +at 48 kHz. Scaling by that packet rate preserves at least the original one-RX +nominal 225.8 ms buffering time. The qualified one-RX queue stays at 128 allocated +slots, including its empty sentinel. No allocation occurs during acquisition. + +A four-WFM startup trace showed 151 ms spent synchronously publishing the bank, +with all 53 observed packet drops inside that span. The former shared 127-slot +usable ring covered only 112.9 ms at four WFM receivers. The scaled ring retains +publication order and the existing 128-packet per-service drain bound; it does +not shorten UI work or tolerate sustained overload. A process admitted for four +receivers retains its larger ring even with fewer receivers currently active, +so it can hold more queued latency in that state. `rtlPacketQueueCapacity` +reports actual usable slots. Occupancy is sampled current backlog; drops and +high-water remain lifetime observations. +Old 127-packet high-water screening results remain historical comparisons and +must not be described as the current ring's full condition. + +## Eight-receiver requirement and SQL correction + +The operator requested eight on Nobara after observing approximately 3% laptop +CPU with four. That observation is not an eight-receiver capacity result. +This is an isolated evaluation amendment for maintainer review under RFC #5468, +not approval to raise the qualified one-receiver default or other radio limits. +The existing eight slots, PCM routes, mixer membership, queue sizing and stable +IDs are reused; no thread or dependency is added. Evaluate the representative +operator workload and report actual continuity faults and timing/thermal data +separately; a historical zero-deadline-miss screen is not an evaluation admission +rule. Public release qualification remains separate. + +RTL FM/FM-N manual SQL retains its absolute -120 + 1.2 * level scale, in dBFS +per 2048-point Blackman-Harris detector bin. For example, levels 51 and 54 mean +-58.8 and -55.2 dBFS/detector-bin. It is neither calibrated antenna dBm nor a +noise-relative percentage. Tuner gain, bandwidth, in-band interference and +peak/hang behavior influence the threshold required to close audio. There is +no guessed RF offset or changed default/manual threshold in this correction. +The temporary SQL overlay is suppressed when detector/display bandwidths differ; +the visible SQL controls describe the detector units instead of plotting a +misleading absolute threshold on a different scale. + +The display changed to 65536 bins while SQL detection stayed at 2048. Both +normalize by FFT length; white-noise bin power therefore differs by about +15.05 dB, so display Auto thresholds are not detector thresholds. Additionally, +a trimmed logarithmic floor is biased low relative to noise peaks. RTL Auto now +runs inside each acquisition-owned gate on the same detector spectrum as its +peak measurement. It excludes the selected receiver passband plus window leakage, +uses the median of neighboring bin powers (with the exponential-noise median to +mean correction), and smooths locally. It retains the existing ramp, hang and +stale-data closure. It remains a coarse signal-level gate, not a discriminator +noise squelch or calibrated channel-power measurement. + +The backend accepts a typed Auto intent and publishes its adopted per-slice +state. The engine owns Auto/margin alongside the manual threshold in RtlSlices; +manual/Off intent exits Auto. Selection, display zoom, FFT averaging and a hidden +applet do not control adaptation. WFM retains its separate decoder behavior; +the FM/FM-N manual/Auto gate is unavailable there. Schema-1 SQL objects now have +optional `automatic` (default false) and `marginDb` (default 10, range 5–20). +Legacy display-Auto intent rows are preserved but do not silently enable the +new engine policy; explicitly choose Auto after upgrading. The prior manual +threshold is never replaced with a computed Auto threshold by the new engine. + +TCI already consumes the typed per-slice 48 kHz PCM route for RTL. This is a +source-level route audit, not eight-client throughput or RadioReference service +qualification. RTL does not advertise Flex DAX streams; its virtual DAX and +DAX-IQ paths are not supplied by this change. RadioReference/Broadcastify +publishing still requires a separately validated external streaming workflow. diff --git a/docs/rtl-release-packaging.md b/docs/rtl-release-packaging.md new file mode 100644 index 0000000000..2ff1d87cf8 --- /dev/null +++ b/docs/rtl-release-packaging.md @@ -0,0 +1,31 @@ +# RTL release dependency contract + +Official AppImage (both architectures), macOS DMG (both architectures), and +Windows installer/portable/MSIX pipelines configure `ENABLE_RTL=ON` and +`REQUIRE_RTL=ON`. Missing librtlsdr or float FFTW is a configure error. An +intentional developer-only build can still set `ENABLE_RTL=OFF` with the +requirement left OFF. `ENABLE_HD_FM` remains OFF by default and in official +release workflows. This analog-only branch rejects ON explicitly; the separate +Digital evaluation branch and RFC #6079 own opt-in decoder builds. + +`scripts/setup/rtl-dependencies.json` pins RTL-SDR Blog (the existing qualified +Blog V4 driver variant), libusb, and FFTW sources by URL and SHA256. Unix setup +builds shared RTL/libusb; AppImage also builds float FFTW. macOS builds double +and float FFTW at its declared deployment floor. Windows builds RTL/libusb +with MSVC and prepares the official double/float FFTW DLLs and import libs. +The pthreads source/build is a Windows upstream configure prerequisite; it is +not an imported runtime of the library-only RTL target. + +Setup retains corresponding archives, copyright notices and the actual build +recipes in `share/aethersdr-rtl-sources`. Those travel inside each payload. +Source archives include their original license/copyright files. Separate Digital evaluation packages carry their own decoder source and notices; +those dependencies are not part of this analog delivery. +No setup script installs a USB driver, changes udev rules, or opens a radio. + +`tools/check_rtl_package.py` checks that the app imports RTL and float FFTW, +that RTL imports libusb, and that those libraries exist in the payload. Mac +checks reject external non-system load paths; Windows requires these DLLs +beside the executable and also runs the existing complete deployment audit. +This is packaging evidence, not physical USB or RF qualification. CI builds +and tests that only upload logs are not release producers and keep optional +RTL behavior. Dependency-only build workflows do not build AetherSDR. diff --git a/docs/rtl-wfm.md b/docs/rtl-wfm.md index 98cf5beb51..eb726434bd 100644 --- a/docs/rtl-wfm.md +++ b/docs/rtl-wfm.md @@ -97,11 +97,11 @@ second plus actual status changes. Unknown or stale measurements are unavailable no quality percentage is invented. Configuration and processing-health readouts are distinct from reception measurements. -Native NRSC-5/HD FM is a separately requested next checkpoint and RFC amendment, +Native Digital FM is a separate proposal in RFC #6079, not covered by the existing analog architecture approval or the qualification -below. The analog checkpoint exposes no ineffective HD state or empty metadata -controls. Future HD program/ID3 metadata and analog RDS have different decoder -sources; RDS remains separate from the requested native HD implementation. +below. The analog checkpoint exposes no ineffective digital state or empty metadata +controls. Future digital program/ID3 metadata and analog RDS have different decoder +sources; RDS remains separate from the requested native digital implementation. The stereo indicator reports the last completed decoder block carried by a current-session/current-revision independent audio packet. It is unavailable @@ -210,7 +210,7 @@ architecture-specific and operator acceptance work remains separate. ## Future digital reception -HD Radio/nrsc5 implementation is deferred until the current RTL sprint finishes. +Digital FM implementation is deferred to standalone RFC #6079. Its future branch needs wide capture IQ before this analog filter, its own station-centered extraction and 744187.5 Hz conversion, and explicit paired 44.1→48 kHz PCM conversion. RADE's demodulated 24 kHz speech input is unsuitable. diff --git a/hal-plugin/AetherSDRDAX.cpp b/hal-plugin/AetherSDRDAX.cpp index d8a148d8d1..4df762a275 100644 --- a/hal-plugin/AetherSDRDAX.cpp +++ b/hal-plugin/AetherSDRDAX.cpp @@ -75,6 +75,7 @@ class DaxRxHandler : public aspl::IORequestHandler { } uint32_t rp = block->readPos.load(std::memory_order_acquire); + uint32_t originalRp = rp; uint32_t wp = block->writePos.load(std::memory_order_acquire); uint32_t available = wp - rp; @@ -89,7 +90,11 @@ class DaxRxHandler : public aspl::IORequestHandler { std::memset(dst + toRead, 0, (totalSamples - toRead) * sizeof(float)); } - block->readPos.store(rp, std::memory_order_release); + // The app can retire a route or trim backlog while this callback copies. + // Do not overwrite its newer cursor or return an old owner's samples. + if (!block->readPos.compare_exchange_strong(originalRp,rp,std::memory_order_acq_rel)) { + std::memset(dst,0,bytesCount); + } } private: diff --git a/packaging/macos/build-installer.sh b/packaging/macos/build-installer.sh index dc617db672..0f51183d78 100755 --- a/packaging/macos/build-installer.sh +++ b/packaging/macos/build-installer.sh @@ -11,8 +11,11 @@ VERSION=$(grep 'project(AetherSDR' CMakeLists.txt | grep -oE '[0-9]+\.[0-9]+\.[0 echo "=== Building AetherSDR macOS installer v${VERSION} ===" -# 1. Build app -cmake -B "${BUILD_DIR}" -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo +# 1. Build app with the same pinned native dependencies as the release DMG. +export MACOS_DEPLOYMENT_TARGET="${MACOS_DEPLOYMENT_TARGET:-14.0}" +bash scripts/setup/setup-macos-deps.sh +export PKG_CONFIG_PATH="$PWD/third_party/macos-deps/lib/pkgconfig:${PKG_CONFIG_PATH:-}" +cmake -B "${BUILD_DIR}" -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DENABLE_HD_FM=OFF -DENABLE_RTL=ON -DREQUIRE_RTL=ON -DCMAKE_OSX_DEPLOYMENT_TARGET="$MACOS_DEPLOYMENT_TARGET" -DCMAKE_PREFIX_PATH="$PWD/third_party/macos-deps;${CMAKE_PREFIX_PATH:-}" cmake --build "${BUILD_DIR}" -j$(sysctl -n hw.ncpu) # 1b. Build HAL plugin (separate build because libASPL FetchContent conflicts with main Ninja build) @@ -29,6 +32,11 @@ mkdir -p "${PKG_DIR}/app" "${PKG_DIR}/hal" "${PKG_DIR}/scripts" # Copy app bundle cp -R "${BUILD_DIR}/AetherSDR.app" "${PKG_DIR}/app/" +QT_DEPLOY="$(sed -n 's|^Qt6_DIR:PATH=\(.*\)/lib/cmake/Qt6$|\1/bin/macdeployqt|p' "${BUILD_DIR}/CMakeCache.txt")" +[ -x "$QT_DEPLOY" ] || { echo "Cannot locate matching macdeployqt" >&2; exit 1; } +"$QT_DEPLOY" "${PKG_DIR}/app/AetherSDR.app" -always-overwrite +cp -R third_party/macos-deps/share/aethersdr-rtl-sources "${PKG_DIR}/app/AetherSDR.app/Contents/Resources/" +python3 tools/check_rtl_package.py "${PKG_DIR}/app/AetherSDR.app" --platform macos # Copy HAL plugin (check both possible build locations) if [ -d "${HAL_BUILD}/AetherSDRDAX.driver" ]; then diff --git a/scripts/build-windows-local.ps1 b/scripts/build-windows-local.ps1 index 4186c35e5c..7dfec50c40 100644 --- a/scripts/build-windows-local.ps1 +++ b/scripts/build-windows-local.ps1 @@ -89,7 +89,7 @@ Write-Host " MSVC + CMake + Ninja OK; Qt = $QtDir; jobs = $Jobs" -ForegroundCol # --------------------------------------------------------------------------- Write-Host "== Staging third_party deps ==" -ForegroundColor Cyan foreach ($s in @( - "setup-opus.ps1", "setup-fftw.ps1", "setup-hidapi.ps1", + "setup-opus.ps1", "setup-fftw.ps1", "setup-rtl-deps.ps1", "setup-hidapi.ps1", "setup-deepfilter.ps1", "setup-qtkeychain.ps1", "setup-onnxruntime.ps1", "setup-sherpa-onnx.ps1", "setup-vulkan-sdk.ps1")) { Write-Host " -> $s" -ForegroundColor DarkCyan @@ -113,6 +113,7 @@ Write-Host " VULKAN_SDK = $env:VULKAN_SDK" -ForegroundColor Green Write-Host "== Configure ==" -ForegroundColor Cyan cmake -B build -G "Ninja" ` -DCMAKE_BUILD_TYPE=RelWithDebInfo ` + -DENABLE_HD_FM=OFF -DENABLE_RTL=ON -DREQUIRE_RTL=ON ` -DMQTT_TLS=OFF -DREQUIRE_KEYCHAIN=ON -DREQUIRE_SERIALPORT=ON ` -DREQUIRE_ASR_ONNX=ON -DREQUIRE_ASR_SHERPA=ON -DREQUIRE_ASR_GPU=ON ` -DCMAKE_PREFIX_PATH="$QtDir;$env:VULKAN_SDK" ` @@ -159,6 +160,12 @@ Get-ChildItem build\*.dll -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " bundling ASR runtime DLL: $($_.Name)" -ForegroundColor DarkCyan Copy-Item $_.FullName $deploy\ -Force } +Copy-Item third_party\rtl-deps\bin\*.dll $deploy\ -Force +New-Item -ItemType Directory -Force "$deploy\licenses" | Out-Null +Copy-Item third_party\rtl-deps\share\aethersdr-rtl-sources "$deploy\licenses\rtl" -Recurse -Force +Copy-Item third_party\fftw3\share\aethersdr-rtl-sources "$deploy\licenses\fftw" -Recurse -Force +python tools/check_rtl_package.py $deploy --platform windows +if ($LASTEXITCODE -ne 0) { throw "RTL payload validation failed" } # Vulkan loader (whisper GPU): the exe hard-links it, so it must ship. Its # location varies by SDK layout (runtime\x64 on newer SDKs, Bin on older) with a # driver copy in System32 -- take the first that exists. diff --git a/scripts/setup/rtl-dependencies.json b/scripts/setup/rtl-dependencies.json new file mode 100644 index 0000000000..56bb834cdc --- /dev/null +++ b/scripts/setup/rtl-dependencies.json @@ -0,0 +1,37 @@ +{ + "rtl": { + "version": "aed0ea19f3a273370a13c9009b96313c75d54c7b", + "url": "https://github.com/rtlsdrblog/rtl-sdr-blog/archive/aed0ea19f3a273370a13c9009b96313c75d54c7b.tar.gz", + "sha256": "53d070b234507bd0eaa25ee2ec7b4e69d93a038ccf970c1390ad84162d3da5e1", + "archive": "rtl-sdr-blog.tar.gz", + "directory": "rtl-sdr-blog-aed0ea19f3a273370a13c9009b96313c75d54c7b" + }, + "libusb": { + "version": "1.0.30", + "url": "https://github.com/libusb/libusb/releases/download/v1.0.30/libusb-1.0.30.tar.bz2", + "sha256": "fea36f34f9156400209595e300840767ab1a385ede1dc7ee893015aea9c6dbaf", + "archive": "libusb-1.0.30.tar.bz2", + "directory": "libusb-1.0.30" + }, + "pthreads": { + "version": "2.9.1", + "url": "https://sourceware.org/pub/pthreads-win32/pthreads-w32-2-9-1-release.tar.gz", + "sha256": "e6aca7aea8de33d9c8580bcb3a0ea3ec0a7ace4ba3f4e263ac7c7b66bc95fb4d", + "archive": "pthreads-w32-2-9-1-release.tar.gz", + "directory": "pthreads-w32-2-9-1-release" + }, + "fftw": { + "version": "3.3.11", + "url": "https://fftw.org/fftw-3.3.11.tar.gz", + "sha256": "5630c24cdeb33b131612f7eb4b1a9934234754f9f388ff8617458d0be6f239a1", + "archive": "fftw-3.3.11.tar.gz", + "directory": "fftw-3.3.11" + }, + "fftwWindowsSource": { + "version": "3.3.5", + "url": "https://fftw.org/fftw-3.3.5.tar.gz", + "sha256": "8ecfe1b04732ec3f5b7d279fdb8efcad536d555f9d1e8fabd027037d45ea8bcf", + "archive": "fftw-3.3.5.tar.gz", + "directory": "fftw-3.3.5" + } +} diff --git a/scripts/setup/setup-fftw.ps1 b/scripts/setup/setup-fftw.ps1 index d56000ebf4..1dafaf271d 100644 --- a/scripts/setup/setup-fftw.ps1 +++ b/scripts/setup/setup-fftw.ps1 @@ -49,8 +49,18 @@ if (-not $VsVars) { } } +# Matching FFTW source travels with packages, including cache hits. +$pins = Get-Content "$PSScriptRoot\rtl-dependencies.json" -Raw | ConvertFrom-Json +$sourcePin = $pins.fftwWindowsSource +$sourceDir = "$OutDir\share\aethersdr-rtl-sources" +New-Item -ItemType Directory -Force $sourceDir | Out-Null +$sourceArchive = Join-Path $sourceDir $sourcePin.archive +if (-not (Test-Path $sourceArchive)) { Invoke-WebRequest $sourcePin.url -OutFile $sourceArchive } +Confirm-Sha256 $sourceArchive $sourcePin.sha256 +Copy-Item "$PSScriptRoot\setup-fftw.ps1","$PSScriptRoot\rtl-dependencies.json","$PSScriptRoot\_verify_sha256.ps1" "$sourceDir\" + # ── Check if already set up ────────────────────────────────────────────── -if ((Test-Path "$OutDir\lib\fftw3.lib") -and (Test-Path "$OutDir\lib\fftw3f.lib")) { +if ((Test-Path "$OutDir\lib\fftw3.lib") -and (Test-Path "$OutDir\lib\fftw3f.lib") -and (Test-Path "$OutDir\bin\libfftw3f-3.dll") -and (Test-Path "$OutDir\bin\libfftw3-3.dll") -and (Test-Path "$OutDir\include\fftw3.h")) { Write-Host "FFTW3 already set up in $OutDir (double + float)" -ForegroundColor Green exit 0 } @@ -66,8 +76,8 @@ New-Item -ItemType Directory -Force -Path "$OutDir\bin" | Out-Null if (-not (Test-Path $ZipFile)) { Write-Host "Downloading FFTW3 prebuilt DLLs..." -ForegroundColor Cyan Invoke-WebRequest -Uri $FftwUrl -OutFile $ZipFile - Confirm-Sha256 -Path $ZipFile -Expected $FftwSha256 } +Confirm-Sha256 -Path $ZipFile -Expected $FftwSha256 # ── Extract ────────────────────────────────────────────────────────────── Write-Host "Extracting..." -ForegroundColor Cyan @@ -80,8 +90,8 @@ Copy-Item "$tempDir\fftw3.h" "$OutDir\include\" Copy-Item "$tempDir\libfftw3-3.dll" "$OutDir\bin\" Copy-Item "$tempDir\libfftw3-3.def" "$OutDir\lib\" # Float precision (fftw3f) — needed by libspecbleach (NR4) -Copy-Item "$tempDir\libfftw3f-3.dll" "$OutDir\bin\" -ErrorAction SilentlyContinue -Copy-Item "$tempDir\libfftw3f-3.def" "$OutDir\lib\" -ErrorAction SilentlyContinue +Copy-Item "$tempDir\libfftw3f-3.dll" "$OutDir\bin\" +Copy-Item "$tempDir\libfftw3f-3.def" "$OutDir\lib\" # ── Ensure the MSVC environment, then generate .lib ────────────────────── Write-Host "Generating MSVC import library..." -ForegroundColor Cyan @@ -105,12 +115,14 @@ if (-not (Get-Command lib.exe -ErrorAction SilentlyContinue)) { # Generate .lib from .def (double and float precision) Push-Location "$OutDir\lib" & lib.exe /machine:x64 /def:libfftw3-3.def /out:fftw3.lib 2>&1 | Out-Null +if ($LASTEXITCODE -ne 0) { throw "FFTW double import library failed" } if (Test-Path "libfftw3f-3.def") { & lib.exe /machine:x64 /def:libfftw3f-3.def /out:fftw3f.lib 2>&1 | Out-Null } +if ($LASTEXITCODE -ne 0) { throw "FFTW float import library failed" } Pop-Location -if (-not (Test-Path "$OutDir\lib\fftw3.lib")) { +if (-not (Test-Path "$OutDir\lib\fftw3.lib") -or -not (Test-Path "$OutDir\lib\fftw3f.lib")) { Write-Error "Failed to generate fftw3.lib" exit 1 } diff --git a/scripts/setup/setup-macos-deps.sh b/scripts/setup/setup-macos-deps.sh index 5341718d9d..24e339bba1 100755 --- a/scripts/setup/setup-macos-deps.sh +++ b/scripts/setup/setup-macos-deps.sh @@ -81,7 +81,7 @@ OUT_DIR="third_party/macos-deps" PREFIX="$(pwd)/$OUT_DIR" WORK_DIR="$(pwd)/.macos-deps-build" STAMP="$OUT_DIR/.build-stamp" -STAMP_CONTENT="target=$TARGET fftw=$FFTW_VERSION portaudio=$PORTAUDIO_VERSION hidapi=$HIDAPI_VERSION arch=$(uname -m)" +STAMP_CONTENT="target=$TARGET fftw=$FFTW_VERSION portaudio=$PORTAUDIO_VERSION hidapi=$HIDAPI_VERSION arch=$(uname -m) rtl=$(shasum -a 256 "$SCRIPT_DIR/rtl-dependencies.json" "$SCRIPT_DIR/setup-rtl-deps.sh" | shasum -a 256 | cut -d' ' -f1)" # ── Already set up? (lets CI cache third_party/macos-deps) ─────────────── # The stamp carries the deployment target, so a local rebuild at a different @@ -154,7 +154,9 @@ done # target we just exported. Homebrew disables it for the same reason. tar xzf "$WORK_DIR/portaudio.tgz" -C "$WORK_DIR" echo "Building PortAudio $PORTAUDIO_VERSION for macOS $TARGET..." -( cd "$WORK_DIR/portaudio" && ./configure \ +# AppleClang 17 diagnoses the pinned upstream float scaling constants. Keep +# the warning visible without treating that dependency-only warning as fatal. +( cd "$WORK_DIR/portaudio" && CFLAGS="${CFLAGS:-} -Wno-error=implicit-const-int-float-conversion" ./configure \ --prefix="$PREFIX" \ --enable-shared --disable-static \ --disable-mac-universal >/dev/null ) @@ -179,6 +181,12 @@ cmake -B "$WORK_DIR/hidapi-build" -S "$WORK_DIR/hidapi-hidapi-$HIDAPI_VERSION" - cmake --build "$WORK_DIR/hidapi-build" -j"$JOBS" >/dev/null cmake --install "$WORK_DIR/hidapi-build" >/dev/null +RTL_DEPS_PREFIX="$PREFIX" bash "$SCRIPT_DIR/setup-rtl-deps.sh" +SOURCES="$PREFIX/share/aethersdr-rtl-sources" +cp "$WORK_DIR/fftw.tar.gz" "$SOURCES/fftw-$FFTW_VERSION.tar.gz" +cp "$WORK_DIR/fftw-$FFTW_VERSION/COPYING" "$SOURCES/FFTW-COPYING" +cp "$SCRIPT_DIR/setup-macos-deps.sh" "$SOURCES/" + # ── Verify the whole point of the exercise ────────────────────────────── # A build that silently ignored MACOSX_DEPLOYMENT_TARGET would look exactly like # a successful one until a user on the older OS tried to launch the DMG, which is diff --git a/scripts/setup/setup-rtl-deps.ps1 b/scripts/setup/setup-rtl-deps.ps1 new file mode 100644 index 0000000000..499bba380c --- /dev/null +++ b/scripts/setup/setup-rtl-deps.ps1 @@ -0,0 +1,46 @@ +<# Pinned x64 MSVC RTL-SDR Blog build. Run from a VS developer environment. #> +param([string]$OutDir = "third_party\rtl-deps") +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot\_verify_sha256.ps1" +$pins = Get-Content "$PSScriptRoot\rtl-dependencies.json" -Raw | ConvertFrom-Json +$prefix = [IO.Path]::GetFullPath($OutDir) +$work = Join-Path (Get-Location) '.rtl-deps-build' +$sources = Join-Path $prefix 'share\aethersdr-rtl-sources' +New-Item -ItemType Directory -Force $work,$sources,"$prefix\bin","$prefix\lib","$prefix\include" | Out-Null +function Check-Native([string]$step) { if ($LASTEXITCODE -ne 0) { throw "$step failed: $LASTEXITCODE" } } +foreach ($name in @('libusb','rtl','pthreads')) { + $pin = $pins.$name + $archive = Join-Path $sources $pin.archive + if (-not (Test-Path $archive)) { Invoke-WebRequest $pin.url -OutFile $archive } + Confirm-Sha256 $archive $pin.sha256 + tar -xf $archive -C $work + Check-Native "extract $name" +} +$usb = Join-Path $work $pins.libusb.directory +$rtl = Join-Path $work $pins.rtl.directory +$pthreads = Join-Path $work $pins.pthreads.directory +msbuild "$usb\msvc\libusb_dll.vcxproj" /m:4 /p:Configuration=Release /p:Platform=x64 /p:PlatformToolset=v143 +Check-Native 'libusb build' +Push-Location $pthreads +try { + $savedCl = $env:CL + $env:CL = "$savedCl /FS /D_TIMESPEC_DEFINED" + nmake /nologo VC + Check-Native 'pthreads build' +} finally { $env:CL = $savedCl; Pop-Location } +$usbOutput = "$usb\build\v143\x64\Release\dll" +cmake -S $rtl -B "$work\rtl-build" -G Ninja -DCMAKE_BUILD_TYPE=Release ` + -DCMAKE_DISABLE_FIND_PACKAGE_PkgConfig=ON -DCMAKE_DISABLE_FIND_PACKAGE_Git=ON ` + "-DLIBUSB_LIBRARIES=$usbOutput/libusb-1.0.lib" "-DLIBUSB_INCLUDE_DIRS=$usb/libusb" ` + "-DTHREADS_PTHREADS_LIBRARY=$pthreads/pthreadVC2.lib" "-DTHREADS_PTHREADS_INCLUDE_DIR=$pthreads" ` + -DCMAKE_C_FLAGS=/D_TIMESPEC_DEFINED +Check-Native 'RTL configure' +cmake --build "$work\rtl-build" --target rtlsdr -j4 +Check-Native 'RTL build' +Copy-Item "$work\rtl-build\src\rtlsdr.dll","$usbOutput\libusb-1.0.dll" "$prefix\bin\" +Copy-Item "$work\rtl-build\src\rtlsdr.lib" "$prefix\lib\" +Copy-Item "$rtl\include\rtl-sdr.h","$rtl\include\rtl-sdr_export.h" "$prefix\include\" +Copy-Item "$rtl\COPYING" "$sources\RTL-SDR-COPYING" +Copy-Item "$usb\COPYING" "$sources\libusb-COPYING" +Copy-Item "$PSScriptRoot\rtl-dependencies.json","$PSScriptRoot\setup-rtl-deps.ps1","$PSScriptRoot\_verify_sha256.ps1" "$sources\" +Write-Host "RTL libraries and corresponding sources ready at $prefix" diff --git a/scripts/setup/setup-rtl-deps.sh b/scripts/setup/setup-rtl-deps.sh new file mode 100644 index 0000000000..fee794f73d --- /dev/null +++ b/scripts/setup/setup-rtl-deps.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Pinned RTL-SDR Blog + libusb, with float FFTW on Linux. No device access. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$SCRIPT_DIR/_verify_sha256.sh" +PREFIX="${RTL_DEPS_PREFIX:-$(pwd)/third_party/rtl-deps}" +WORK="$(pwd)/.rtl-deps-build" +SOURCES="$PREFIX/share/aethersdr-rtl-sources" +mkdir -p "$WORK" "$SOURCES" +JOBS="${CMAKE_BUILD_PARALLEL_LEVEL:-4}" +field() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]][sys.argv[3]])' "$SCRIPT_DIR/rtl-dependencies.json" "$1" "$2"; } +fetch() { + local name="$1" archive + archive="$(field "$name" archive)" + if [ ! -f "$SOURCES/$archive" ]; then + curl -fL --retry 3 --connect-timeout 30 "$(field "$name" url)" -o "$SOURCES/$archive" + fi + verify_sha256 "$SOURCES/$archive" "$(field "$name" sha256)" + tar xf "$SOURCES/$archive" -C "$WORK" +} +fetch libusb +fetch rtl +USB="$WORK/$(field libusb directory)" +RTL="$WORK/$(field rtl directory)" +cmake_args=() +if [ "$(uname -s)" = Darwin ]; then + : "${MACOS_DEPLOYMENT_TARGET:?Set the deployment target explicitly}" + export MACOSX_DEPLOYMENT_TARGET="$MACOS_DEPLOYMENT_TARGET" + # New Apple SDKs can link pipe2 as a weak import even when the target + # macOS lacks it. Autoconf's link-only probe then selects a null call. + # libusb's pipe + fcntl fallback works across our supported macOS floor. + export ac_cv_func_pipe2=no + cmake_args=(-DCMAKE_OSX_DEPLOYMENT_TARGET="$MACOS_DEPLOYMENT_TARGET" -DCMAKE_INSTALL_NAME_DIR="$PREFIX/lib") +fi +(cd "$USB" && ./configure --prefix="$PREFIX" --libdir="$PREFIX/lib" --enable-shared --disable-static --disable-examples-build --disable-tests-build) +make -C "$USB" -j"$JOBS" +make -C "$USB" install +export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig:${PKG_CONFIG_PATH:-}" +USB_LIBRARY="$PREFIX/lib/libusb-1.0.so" +[ "$(uname -s)" != Darwin ] || USB_LIBRARY="$PREFIX/lib/libusb-1.0.dylib" +cmake -S "$RTL" -B "$WORK/rtl-build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$PREFIX" -DCMAKE_INSTALL_LIBDIR=lib \ + -DCMAKE_DISABLE_FIND_PACKAGE_Git=ON -DCMAKE_DISABLE_FIND_PACKAGE_PkgConfig=ON \ + -DLIBUSB_LIBRARIES="$USB_LIBRARY" -DLIBUSB_INCLUDE_DIRS="$PREFIX/include/libusb-1.0" \ + -DCMAKE_BUILD_WITH_INSTALL_NAME_DIR=ON -DINSTALL_UDEV_RULES=OFF -DDETACH_KERNEL_DRIVER=ON \ + ${cmake_args[@]+"${cmake_args[@]}"} +cmake --build "$WORK/rtl-build" -j"$JOBS" +cmake --install "$WORK/rtl-build" +# macOS setup already builds both FFTW precisions at the pinned floor. +if [ "$(uname -s)" = Linux ]; then + fetch fftw + mkdir -p "$WORK/fftw-build" + (cd "$WORK/fftw-build" && "$WORK/$(field fftw directory)/configure" \ + --prefix="$PREFIX" --enable-float --enable-shared --disable-static --disable-fortran --disable-doc) + make -C "$WORK/fftw-build" -j"$JOBS" + make -C "$WORK/fftw-build" install + cp "$WORK/$(field fftw directory)/COPYING" "$SOURCES/FFTW-COPYING" +fi +cp "$RTL/COPYING" "$SOURCES/RTL-SDR-COPYING" +cp "$USB/COPYING" "$SOURCES/libusb-COPYING" +cp "$SCRIPT_DIR/rtl-dependencies.json" "$SCRIPT_DIR/setup-rtl-deps.sh" "$SCRIPT_DIR/_verify_sha256.sh" "$SOURCES/" +echo "RTL dependency sources and recipes: $SOURCES" diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 480f037d99..a6cc9b1783 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -1682,6 +1682,8 @@ QJsonObject sliceSnapshot(const SliceModel* s, int linkedTo, s->externalReceiveReplacementActive()}, {QStringLiteral("squelch"), s->squelchOn()}, {QStringLiteral("squelchLevel"), s->squelchLevel()}, + {QStringLiteral("automaticSquelch"), s->automaticSquelch()}, + {QStringLiteral("automaticSquelchMarginDb"), s->automaticSquelchMarginDb()}, {QStringLiteral("receiveSquelch"), s->receiveSquelchOn()}, {QStringLiteral("receiveSquelchLevel"), s->receiveSquelchLevel()}, {QStringLiteral("flexSquelch"), s->flexSquelchOn()}, diff --git a/src/core/PipeWireAudioBridge.cpp b/src/core/PipeWireAudioBridge.cpp index f32b8f9803..47f117bbf8 100644 --- a/src/core/PipeWireAudioBridge.cpp +++ b/src/core/PipeWireAudioBridge.cpp @@ -111,10 +111,11 @@ static void cleanupStaleModules() } } -bool PipeWireAudioBridge::open(int activeChannels) +bool PipeWireAudioBridge::open(int activeChannels, bool receiveOnly) { if (m_open) return true; + m_receiveOnly = receiveOnly; cleanupStaleModules(); // Open only as many RX sources as the radio has slices (the device list @@ -134,7 +135,7 @@ bool PipeWireAudioBridge::open(int activeChannels) // fails (e.g. PipeWire not running, only PulseAudio). for (int i = 0; i < m_activeChannels; ++i) { #ifdef HAVE_PIPEWIRE_NATIVE - auto native = std::make_unique(i + 1); + auto native = std::make_unique(i + 1, receiveOnly); if (native->open()) { m_nativeRx[i] = std::move(native); continue; @@ -149,22 +150,24 @@ bool PipeWireAudioBridge::open(int activeChannels) } } - // Create TX pipe sink (apps → radio) - if (!loadPipeSink()) { - qCWarning(lcDax) << "PipeWireAudioBridge: failed to create TX pipe"; - close(); - return false; - } + if (!receiveOnly) { + // Create TX pipe sink (apps → radio) + if (!loadPipeSink()) { + qCWarning(lcDax) << "PipeWireAudioBridge: failed to create TX pipe"; + close(); + return false; + } - // Poll TX pipe for incoming audio from apps - m_txReadTimer = new QTimer(this); - m_txReadTimer->setInterval(5); - m_txReadTimer->setTimerType(Qt::PreciseTimer); - connect(m_txReadTimer, &QTimer::timeout, this, &PipeWireAudioBridge::readTxPipe); - m_txReadTimer->start(); + // Poll TX pipe for incoming audio from apps + m_txReadTimer = new QTimer(this); + m_txReadTimer->setInterval(5); + m_txReadTimer->setTimerType(Qt::PreciseTimer); + connect(m_txReadTimer, &QTimer::timeout, this, &PipeWireAudioBridge::readTxPipe); + m_txReadTimer->start(); + } m_open.store(true, std::memory_order_release); - qCInfo(lcDax) << "PipeWireAudioBridge: opened —" << m_activeChannels << "RX sources + 1 TX sink"; + qCInfo(lcDax) << "PipeWireAudioBridge: opened —" << m_activeChannels << "RX sources, TX sink:" << !receiveOnly; return true; } @@ -198,6 +201,7 @@ void PipeWireAudioBridge::close() // Close pipe file descriptors for (auto& rx : m_rx) { if (rx.fd >= 0) { ::close(rx.fd); rx.fd = -1; } + if (rx.drainFd >= 0) { ::close(rx.drainFd); rx.drainFd = -1; } } if (m_tx.fd >= 0) { ::close(m_tx.fd); m_tx.fd = -1; } @@ -288,12 +292,31 @@ bool PipeWireAudioBridge::loadPipeSource(int index) return false; } - // Cap kernel FIFO capacity so back-pressure surfaces quickly instead of - // accumulating ~1.3 s of audio in the default 64 KB pipe buffer. - if (::fcntl(fd, F_SETPIPE_SZ, PIPE_KERNEL_BUF) < 0) { + // Native decoded PCM can arrive in bursts (Digital prebuffer is ~186 ms). + // Bound that RX-only backlog to ~341 ms rather than truncating a valid burst + // at the legacy Flex packet-sized 21 ms capacity. No blocking writes. + const int capacity = m_receiveOnly ? NATIVE_RX_KERNEL_BUF : PIPE_KERNEL_BUF; + const int actual = ::fcntl(fd,F_SETPIPE_SZ,capacity); + if (actual < capacity && m_receiveOnly) { + qCWarning(lcDax) << "PipeWireAudioBridge: insufficient native RX FIFO capacity"; + ::close(fd); + runPactl({"unload-module",QString::number(modIdx)}); + ::unlink(pipePath.toUtf8().constData()); + return false; + } + if (actual < 0) { qCDebug(lcDax) << "PipeWireAudioBridge: F_SETPIPE_SZ failed (non-fatal):" << strerror(errno); } - + // An idle read descriptor lets a route boundary discard our own retained + // FIFO bytes. It never reads during normal delivery. + const int drainFd = ::open(pipePath.toUtf8().constData(),O_RDONLY|O_NONBLOCK|O_CLOEXEC); + if (drainFd < 0) { + ::close(fd); + runPactl({"unload-module",QString::number(modIdx)}); + ::unlink(pipePath.toUtf8().constData()); + return false; + } + m_rx[index].drainFd = drainFd; m_rx[index].fd = fd; m_rx[index].moduleIndex = modIdx; m_rx[index].pipePath = pipePath; @@ -448,8 +471,29 @@ void PipeWireAudioBridge::feedDaxAudio(int channel, const QByteArray& pcm) } } +void PipeWireAudioBridge::resetRxChannel(int channel) +{ + if (channel < 1 || channel > NUM_CHANNELS) { return; } + m_rxLastSample[channel-1] = 0; +#ifdef HAVE_PIPEWIRE_NATIVE + if (m_nativeRx[channel-1]) { m_nativeRx[channel-1]->reset(); } +#endif + const int fd = m_rx[channel-1].drainFd; + if (fd >= 0) { + std::array discarded{}; + int total = 0; + while (total < NATIVE_RX_KERNEL_BUF) { + const ssize_t count = ::read(fd,discarded.data(),discarded.size()); + if (count <= 0) { break; } + total += int(count); + } + } + emit daxRxLevel(channel,0); +} + void PipeWireAudioBridge::setTransmitting(bool tx) { + if (m_receiveOnly) { return; } m_transmitting.store(tx, std::memory_order_release); if (tx) { diff --git a/src/core/PipeWireAudioBridge.h b/src/core/PipeWireAudioBridge.h index 72db3c3be1..444b1379c0 100644 --- a/src/core/PipeWireAudioBridge.h +++ b/src/core/PipeWireAudioBridge.h @@ -25,6 +25,7 @@ class PipeWireNativeRxSource; // Works with both PulseAudio and PipeWire (via pipewire-pulse). class PipeWireAudioBridge : public QObject { Q_OBJECT + friend class PipeWireRxBridgeTest; public: static constexpr int NUM_CHANNELS = 8; @@ -32,6 +33,7 @@ class PipeWireAudioBridge : public QObject { static constexpr int PIPE_RATE = 48000; // matches PipeWire graph rate — avoids in-graph resampler static constexpr int PIPE_CHANNELS = 1; // mono — ham radio DAX is single-channel static constexpr int PIPE_KERNEL_BUF = 4096; // ~21 ms at 48 kHz mono float32 — bounds kernel FIFO buffering + static constexpr int NATIVE_RX_KERNEL_BUF = 65536; // ~341 ms: bounded decoded-audio bursts static constexpr int TX_RATE = 24000; // TX sink stays at radio-native rate (s16le mono) static constexpr int TX_CHANNELS = 1; @@ -43,12 +45,14 @@ class PipeWireAudioBridge : public QObject { // NUM_CHANNELS stays the compile-time array bound; this only bounds the // open loop so the device list follows the radio (#4854). Dynamic resize // after connect is a follow-up (issue #4935). - bool open(int activeChannels = NUM_CHANNELS); + // receiveOnly opens no TX endpoint and never polls application TX audio. + bool open(int activeChannels = NUM_CHANNELS, bool receiveOnly = false); + bool isReceiveOnly() const { return m_receiveOnly; } void close(); bool isOpen() const { return m_open; } void setGain(float g); // global gain (all RX channels) - void setChannelGain(int channel, float g); // per-channel RX gain (1-4) + void setChannelGain(int channel, float g); // per-channel RX gain (1-8) void setTxGain(float g); // TX gain float gain() const { return m_gain; } void setTxContext(const TxCoordinator::Context& context); @@ -56,6 +60,7 @@ class PipeWireAudioBridge : public QObject { public slots: void feedDaxAudio(int channel, const QByteArray& pcm); void setTransmitting(bool tx); + void resetRxChannel(int channel); signals: void txAudioReady(const QByteArray& pcm, const AetherSDR::TxCoordinator::Context& context); @@ -67,9 +72,10 @@ public slots: bool loadPipeSink(); void unloadModules(); - // RX: we write int16 mono PCM to these pipes → PulseAudio reads them + // RX: we write float32 mono PCM to these pipes → PulseAudio reads them struct RxPipe { int fd{-1}; + int drainFd{-1}; uint32_t moduleIndex{0}; QString pipePath; }; @@ -98,6 +104,7 @@ public slots: // Teardown loops still walk the full NUM_CHANNELS bound — closing an // unopened slot is a no-op. (#4854) int m_activeChannels{NUM_CHANNELS}; + bool m_receiveOnly{false}; float m_gain{0.5f}; // m_channelGain is read on PanadapterStream's network thread (DirectConnection // fast path) and written from the main thread (DaxApplet slider). Float diff --git a/src/core/PipeWireNativeRxSource.cpp b/src/core/PipeWireNativeRxSource.cpp index 3f08aebd42..d380b362be 100644 --- a/src/core/PipeWireNativeRxSource.cpp +++ b/src/core/PipeWireNativeRxSource.cpp @@ -55,8 +55,8 @@ const pw_stream_events kStreamEvents = { } // namespace -PipeWireNativeRxSource::PipeWireNativeRxSource(int channel) - : m_channel(channel) +PipeWireNativeRxSource::PipeWireNativeRxSource(int channel, bool nativePcm) + : m_ringSize(nativePcm ? kMaximumRingSize : 2048), m_ringMask(m_ringSize-1), m_channel(channel) {} PipeWireNativeRxSource::~PipeWireNativeRxSource() @@ -176,6 +176,17 @@ void PipeWireNativeRxSource::close() } } +void PipeWireNativeRxSource::reset() +{ + if (!m_stream) { return; } + auto& context = PipeWireNativeContext::instance(); + context.lock(); + // The RT callback is excluded here; no lock is acquired inside it. + m_readIdx.store(m_writeIdx.load(std::memory_order_acquire),std::memory_order_release); + pw_stream_flush(m_stream,false); + context.unlock(); +} + void PipeWireNativeRxSource::feedAudio(const float* samples, uint32_t count) { if (!m_stream || count == 0) { @@ -185,7 +196,7 @@ void PipeWireNativeRxSource::feedAudio(const float* samples, uint32_t count) const uint32_t writeIdx = m_writeIdx.load(std::memory_order_relaxed); const uint32_t readIdx = m_readIdx.load(std::memory_order_acquire); const uint32_t pending = writeIdx - readIdx; - const uint32_t space = RING_SIZE - pending; + const uint32_t space = m_ringSize - pending; // SPSC invariant: only the producer touches m_writeIdx, only the consumer // touches m_readIdx. On overflow, drop the *newest* incoming samples that @@ -201,8 +212,8 @@ void PipeWireNativeRxSource::feedAudio(const float* samples, uint32_t count) } // Copy in up to two contiguous spans (handles wraparound). - const uint32_t startMasked = writeIdx & RING_MASK; - const uint32_t firstSpan = std::min(toCopy, RING_SIZE - startMasked); + const uint32_t startMasked = writeIdx & m_ringMask; + const uint32_t firstSpan = std::min(toCopy, m_ringSize - startMasked); std::memcpy(&m_ring[startMasked], samples, firstSpan * sizeof(float)); if (toCopy > firstSpan) { std::memcpy(&m_ring[0], samples + firstSpan, (toCopy - firstSpan) * sizeof(float)); @@ -248,8 +259,8 @@ void PipeWireNativeRxSource::onProcess(void* userdata) const uint32_t toRead = std::min(frames, pending); if (toRead > 0) { - const uint32_t startMasked = readIdx & RING_MASK; - const uint32_t firstSpan = std::min(toRead, RING_SIZE - startMasked); + const uint32_t startMasked = readIdx & self->m_ringMask; + const uint32_t firstSpan = std::min(toRead, self->m_ringSize - startMasked); std::memcpy(dst, &self->m_ring[startMasked], firstSpan * sizeof(float)); if (toRead > firstSpan) { std::memcpy(dst + firstSpan, &self->m_ring[0], (toRead - firstSpan) * sizeof(float)); diff --git a/src/core/PipeWireNativeRxSource.h b/src/core/PipeWireNativeRxSource.h index 2cdc4ab7e8..2ee74e4e13 100644 --- a/src/core/PipeWireNativeRxSource.h +++ b/src/core/PipeWireNativeRxSource.h @@ -15,7 +15,7 @@ namespace AetherSDR { // allocation-free SPSC float ring with atomic head/tail. class PipeWireNativeRxSource { public: - explicit PipeWireNativeRxSource(int channel); + explicit PipeWireNativeRxSource(int channel, bool nativePcm = false); ~PipeWireNativeRxSource(); PipeWireNativeRxSource(const PipeWireNativeRxSource&) = delete; @@ -28,10 +28,12 @@ class PipeWireNativeRxSource { // Disconnects and destroys the stream, releases the shared context. void close(); + // Owner-thread boundary: discard retained samples under the existing loop lock. + void reset(); // Push 48 kHz mono float32 samples into the ring buffer. Drops the // newest incoming samples that wouldn't fit if the consumer (PipeWire) - // is too slow — this caps backlog at the ring size (~42 ms) so latency + // is too slow — this caps backlog at the configured ring size (42 or 341 ms) so latency // cannot grow unboundedly even under stalls, and it keeps the SPSC // invariant intact (only the producer touches m_writeIdx, only the // consumer touches m_readIdx). @@ -44,14 +46,11 @@ class PipeWireNativeRxSource { private: - // Ring buffer is a power of two so wraparound is a mask op. - // 2048 samples = ~42 ms @ 48 kHz mono float32. In steady state the ring - // is near-empty; the cap matters only on transient stalls — at which - // point we *want* to drop oldest samples rather than let latency grow. - // Earlier 8192 sizing absorbed up to 170 ms of stalls, which directly - // showed up as DT growth under load. - static constexpr uint32_t RING_SIZE = 2048; - static constexpr uint32_t RING_MASK = RING_SIZE - 1; + // Legacy packet stream: 2048 samples (~42 ms). Native decoded bursts: + // 16384 samples (~341 ms), matching the bounded RX-only FIFO path. + static constexpr uint32_t kMaximumRingSize = 16384; + const uint32_t m_ringSize; + const uint32_t m_ringMask; int m_channel; pw_stream* m_stream{nullptr}; @@ -65,7 +64,7 @@ class PipeWireNativeRxSource { // SPSC ring. Producer (Qt thread) writes m_writeIdx; consumer // (PipeWire thread) writes m_readIdx. Indices are free-running and // masked at access time. - float m_ring[RING_SIZE]{}; + float m_ring[kMaximumRingSize]{}; std::atomic m_writeIdx{0}; std::atomic m_readIdx{0}; }; diff --git a/src/core/RtlDeviceSettings.cpp b/src/core/RtlDeviceSettings.cpp index 9ad3a3e782..034443e537 100644 --- a/src/core/RtlDeviceSettings.cpp +++ b/src/core/RtlDeviceSettings.cpp @@ -30,6 +30,9 @@ bool RtlDeviceSettings::decode(const QJsonObject& object, Values& values) } parsed.ppm = static_cast(number); parsed.dcSuppression = dc.toBool(); + const QJsonValue meters = object.value(QStringLiteral("receiveMetersEnabled")); + if (!meters.isUndefined() && !meters.isBool()) { return false; } + parsed.receiveMetersEnabled = meters.toBool(true); values = parsed; return true; } @@ -85,6 +88,7 @@ bool RtlDeviceSettings::saveAccepted(const Values& values, QString& reason) cons } object.insert(QStringLiteral("ppm"), values.ppm); object.insert(QStringLiteral("dcSuppression"), values.dcSuppression); + object.insert(QStringLiteral("receiveMetersEnabled"), values.receiveMetersEnabled); if (!m_scope.setFeature(featureName(), kSchemaVersion, object)) { reason = QStringLiteral("Applied for this session; saving device settings failed."); qCWarning(lcRtlDeviceSettings) << reason; diff --git a/src/core/RtlDeviceSettings.h b/src/core/RtlDeviceSettings.h index 3e3ca5e643..a530f318ed 100644 --- a/src/core/RtlDeviceSettings.h +++ b/src/core/RtlDeviceSettings.h @@ -16,6 +16,7 @@ class RtlDeviceSettings final { struct Values { int ppm = 0; bool dcSuppression = false; + bool receiveMetersEnabled = true; bool operator==(const Values&) const = default; }; enum class ReadStatus { Missing, Ready, Refused }; diff --git a/src/core/RtlSliceSettings.cpp b/src/core/RtlSliceSettings.cpp index 416b4ea822..939008efd7 100644 --- a/src/core/RtlSliceSettings.cpp +++ b/src/core/RtlSliceSettings.cpp @@ -49,6 +49,8 @@ QJsonObject encodeSlice(const RtlSliceSettings::Slice& slice, QJsonObject previo QJsonObject squelch = previous.value(QStringLiteral("squelch")).toObject(); squelch.insert(QStringLiteral("enabled"), slice.squelchEnabled); squelch.insert(QStringLiteral("level"), slice.squelchLevel); + squelch.insert(QStringLiteral("automatic"), slice.automaticSquelch); + squelch.insert(QStringLiteral("marginDb"), slice.automaticSquelchMarginDb); previous.insert(QStringLiteral("squelch"), squelch); previous.insert(QStringLiteral("audioGain"), slice.audioGain); previous.insert(QStringLiteral("audioMute"), slice.audioMute); @@ -130,6 +132,16 @@ bool RtlSliceSettings::decode(const QJsonObject& object, Document& output, QStri return false; } slice.squelchEnabled = squelch.value(QStringLiteral("enabled")).toBool(); + if (squelch.contains(QStringLiteral("automatic"))) { + if (!squelch.value(QStringLiteral("automatic")).isBool()) { return false; } + slice.automaticSquelch = squelch.value(QStringLiteral("automatic")).toBool(); + } + if (squelch.contains(QStringLiteral("marginDb"))) { + double margin = 0; + if (!number(squelch, QStringLiteral("marginDb"), 5, 20, margin, true)) { return false; } + slice.automaticSquelchMarginDb = static_cast(margin); + } + if (slice.automaticSquelch && !slice.squelchEnabled) { return false; } slice.audioMute = entry.value(QStringLiteral("audioMute")).toBool(); parsed.slices.insert(slice.id, slice); } diff --git a/src/core/RtlSliceSettings.h b/src/core/RtlSliceSettings.h index f8a5d3049a..206e1ee422 100644 --- a/src/core/RtlSliceSettings.h +++ b/src/core/RtlSliceSettings.h @@ -34,6 +34,8 @@ class RtlSliceSettings { // Optional in schema 1; old documents retain the legacy 75 us default. int wfmDeemphasisUs = 75; bool wfmForceMono = false; + bool automaticSquelch = false; + int automaticSquelchMarginDb = 10; }; struct Document { double captureCenterHz = 0; diff --git a/src/core/TciServer.cpp b/src/core/TciServer.cpp index ed75503f7d..8635046f15 100644 --- a/src/core/TciServer.cpp +++ b/src/core/TciServer.cpp @@ -540,6 +540,12 @@ TciServer::TciServer(RadioModel* model, QObject* parent) connect(m_model, &RadioModel::sliceAdded, this, bindSlice); connect(m_model, &RadioModel::sliceRemoved, this, [this] { refreshRxBindings(); }); + // A session can create TCI after the backend restored its slices. + // Seed the sticky map before publishing any audio bindings; positional + // fallback would silently renumber survivors on the first removal. + for (SliceModel* slice : m_model->slices()) { + m_trxMap.acquire(slice->sliceId()); + } for (SliceModel* slice : m_model->slices()) { bindSlice(slice); } } for (int ch = 1; ch <= 8; ++ch) { m_io->setRxGain(ch, m_rxChannelGain[ch - 1]); } @@ -1123,14 +1129,12 @@ QJsonObject TciServer::routingSnapshot() const QJsonArray endpoints; if (m_model) { - const auto slices = m_model->slices(); - for (int trx = 0; trx < slices.size(); ++trx) { - const SliceModel* slice = slices.at(trx); + for (const SliceModel* slice : m_model->slices()) { if (!slice) { continue; } endpoints.append(QJsonObject{ - {QStringLiteral("trx"), trx}, + {QStringLiteral("trx"), m_trxMap.trxForSlice(m_model, slice)}, {QStringLiteral("sliceId"), slice->sliceId()}, {QStringLiteral("panId"), slice->panId()}, {QStringLiteral("frequencyHz"), @@ -2670,6 +2674,15 @@ void TciServer::resetClientRx(ClientState& client) syncClient(client); } +TciRxBinding TciServer::sliceRxBinding(int sliceId) const +{ + const quint64 key = TciIoWorker::rxRouteKey(false,sliceId); + const TciRxBinding binding = m_rxBindings.value(key); + const QPointer owner = m_rxBindingOwners.value(key); + if (!m_model || !owner || m_model->slice(sliceId) != owner || !binding.current()) { return {}; } + return binding; +} + void TciServer::refreshRxBindings() { QHash next; @@ -2712,6 +2725,7 @@ void TciServer::refreshRxBindings() m_rxBindings = next; m_rxBindingOwners = owners; m_io->post([io = m_io.get(), next] { io->setRxBindings(next); }); + emit rxBindingsChanged(); } void TciServer::retireAllRxRoutes() @@ -3100,18 +3114,31 @@ void TciServer::sendInitBurst(TciClient* client) << "TCI: receiver map" << (receiverMap.isEmpty() ? QStringLiteral("(none)") : receiverMap.join(QLatin1Char(' '))); - // TCI protocol requires one command per WebSocket message. - // Split the concatenated burst into individual messages. - QString burst = protocol->generateInitBurst(); - const auto commands = burst.split(';', Qt::SkipEmptyParts); - for (const auto& cmd : commands) { + // Preserve one command per WebSocket message, but admit the bounded init + // snapshot as one worker job. Eight receivers times eight simultaneous + // clients otherwise exceed the 256-job mailbox during their handshakes. + const QString burst = protocol->generateInitBurst(); + const QStringList commands = burst.split(';', Qt::SkipEmptyParts); + QStringList messages; + qsizetype bytes = 0; + for (const QString& cmd : commands) { // DIAG: log each init-burst command — the startup vfo:/dds: here is what // WSJT-X reconciles against on connect; a wrong/late one explains the // "TCI failed set rxfreq" some users hit right at WSJT-X startup. qCDebug(lcCat).noquote() << "TCI tx→init:" << (cmd + QLatin1Char(';')); const QString message = cmd + QLatin1Char(';'); - sendClientText(client, message); + noteClientTextTx(client, message); + messages.append(message); + bytes += message.size() * sizeof(QChar); } + TciIoWorker* io = m_io.get(); + const quint64 id = client->id(); + io->post([io, id, messages = std::move(messages)] { + for (const QString& message : messages) { + // sendText rechecks client lifetime and socket backlog each time. + io->sendText(id, message); + } + }, bytes); qCDebug(lcCat) << "TCI: sent init burst," << commands.size() << "commands"; } diff --git a/src/core/TciServer.h b/src/core/TciServer.h index 892f788602..c494058cbe 100644 --- a/src/core/TciServer.h +++ b/src/core/TciServer.h @@ -91,6 +91,10 @@ class TciServer : public QObject { // local automation bridge's `tci routes` action. QJsonObject routingSnapshot() const; + // Owner-thread projection of the live native receive route. Valid while + // its revocation token is current, independently of listener state. + TciRxBinding sliceRxBinding(int sliceId) const; + // (ownsDaxChannel() and the cross-consumer peeking it existed for were // replaced by per-consumer holds in PanadapterStream's centralized DAX // channel manager — see acquireDaxChannel/releaseDaxChannel. #3305) @@ -161,6 +165,7 @@ public slots: signals: void clientCountChanged(int count); + void rxBindingsChanged(); // Fired whenever the client list or any client's subscriptions change // (connect, disconnect, audio start/stop). The TCI tab repopulates on // this signal. diff --git a/src/core/VirtualAudioBridge.cpp b/src/core/VirtualAudioBridge.cpp index d2bb6f9a0a..bfbbe25b0b 100644 --- a/src/core/VirtualAudioBridge.cpp +++ b/src/core/VirtualAudioBridge.cpp @@ -86,10 +86,12 @@ static bool openShmSegment(const char* name, int& fd, DaxShmBlock*& block) return true; } -bool VirtualAudioBridge::open(int activeChannels) +bool VirtualAudioBridge::open(int activeChannels, bool receiveOnly) { if (m_open.load(std::memory_order_acquire)) return true; + m_receiveOnly = receiveOnly; + // Open only as many RX shm segments as the radio has slices (the device // list follows the radio — product decision, #4854). open() is reached // from MainWindow::startDax(), which runs on a 3 s post-connect timer, so @@ -109,52 +111,54 @@ bool VirtualAudioBridge::open(int activeChannels) m_blocks[i]->active = 1; } - // Open TX shared memory segment - if (!openShmSegment("/aethersdr-dax-tx", m_txShmFd, m_txBlock)) { - close(); - return false; - } - m_txBlock->active = 0; // HAL plugin sets this to 1 when apps write - - // Poll TX shared memory for incoming audio. - // Use a short interval and fixed-size reads so TX stays close to real-time. - m_txPollTimer = new QTimer(this); - m_txPollTimer->setInterval(2); - connect(m_txPollTimer, &QTimer::timeout, this, [this]() { - static int pollNum = 0; - ++pollNum; - - // Diagnostic: log shm state every second regardless of data - if (m_txBlock && pollNum % 500 == 0) { - uint32_t wp = m_txBlock->writePos.load(std::memory_order_relaxed); - uint32_t rp = m_txBlock->readPos.load(std::memory_order_relaxed); - qCDebug(lcDax) << "TX shm poll#" << pollNum - << "wp=" << wp << "rp=" << rp - << "avail=" << (wp - rp) << "active=" << m_txBlock->active; + if (!receiveOnly) { + // Open TX shared memory segment + if (!openShmSegment("/aethersdr-dax-tx", m_txShmFd, m_txBlock)) { + close(); + return false; } + m_txBlock->active = 0; // HAL plugin sets this to 1 when apps write + + // Poll TX shared memory for incoming audio. + // Use a short interval and fixed-size reads so TX stays close to real-time. + m_txPollTimer = new QTimer(this); + m_txPollTimer->setInterval(2); + connect(m_txPollTimer, &QTimer::timeout, this, [this]() { + static int pollNum = 0; + ++pollNum; + + // Diagnostic: log shm state every second regardless of data + if (m_txBlock && pollNum % 500 == 0) { + uint32_t wp = m_txBlock->writePos.load(std::memory_order_relaxed); + uint32_t rp = m_txBlock->readPos.load(std::memory_order_relaxed); + qCDebug(lcDax) << "TX shm poll#" << pollNum + << "wp=" << wp << "rp=" << rp + << "avail=" << (wp - rp) << "active=" << m_txBlock->active; + } - // Drain multiple small chunks each tick to avoid stale backlog. - constexpr int FRAMES_PER_READ = 128; // ~5.3ms @ 24kHz - constexpr int MAX_CHUNKS_PER_TICK = 8; // keep UI responsive under load - for (int i = 0; i < MAX_CHUNKS_PER_TICK; ++i) { - const TxCoordinator::Context context = m_txContext; - QByteArray audio = readTxAudio(FRAMES_PER_READ); - if (audio.isEmpty()) break; - - static int txPollCount = 0; - ++txPollCount; - if (txPollCount <= 10 || txPollCount % 200 == 0) - qCDebug(lcDax) << "VirtualAudioBridge: TX audio from shm, bytes=" << audio.size() - << "(chunk #" << txPollCount << ")" - << "wp=" << m_txBlock->writePos.load(std::memory_order_relaxed) - << "active=" << m_txBlock->active; - emit txAudioReady(audio, context); - } - }); - m_txPollTimer->start(); + // Drain multiple small chunks each tick to avoid stale backlog. + constexpr int FRAMES_PER_READ = 128; // ~5.3ms @ 24kHz + constexpr int MAX_CHUNKS_PER_TICK = 8; // keep UI responsive under load + for (int i = 0; i < MAX_CHUNKS_PER_TICK; ++i) { + const TxCoordinator::Context context = m_txContext; + QByteArray audio = readTxAudio(FRAMES_PER_READ); + if (audio.isEmpty()) break; + + static int txPollCount = 0; + ++txPollCount; + if (txPollCount <= 10 || txPollCount % 200 == 0) + qCDebug(lcDax) << "VirtualAudioBridge: TX audio from shm, bytes=" << audio.size() + << "(chunk #" << txPollCount << ")" + << "wp=" << m_txBlock->writePos.load(std::memory_order_relaxed) + << "active=" << m_txBlock->active; + emit txAudioReady(audio, context); + } + }); + m_txPollTimer->start(); + } m_open.store(true, std::memory_order_release); - qCInfo(lcDax) << "VirtualAudioBridge: opened" << NUM_CHANNELS << "RX + 1 TX shared memory segments"; + qCInfo(lcDax) << "VirtualAudioBridge: opened" << m_activeChannels << "RX shared memory segments, TX:" << !receiveOnly; return true; } @@ -214,6 +218,7 @@ void VirtualAudioBridge::close() void VirtualAudioBridge::setTransmitting(bool tx) { + if (m_receiveOnly) { return; } m_transmitting.store(tx, std::memory_order_release); if (tx) { @@ -287,8 +292,19 @@ void VirtualAudioBridge::feedSilenceToAllChannels() } } +void VirtualAudioBridge::resetRxChannel(int channel) +{ + if (channel < 1 || channel > NUM_CHANNELS) { return; } + DaxShmBlock* block = m_blocks[channel-1]; + if (block) { + block->readPos.store(block->writePos.load(std::memory_order_acquire),std::memory_order_release); + } + emit daxRxLevel(channel,0); +} + void VirtualAudioBridge::feedDaxAudio(int channel, const QByteArray& pcm) { + if (!m_open.load(std::memory_order_acquire)) { return; } // This slot may run on PanadapterStream's network thread via // Qt::DirectConnection — keep it lock-free and free of Qt-thread-affine // calls. The silence-timer stop and ring-buffer readPos snap that used diff --git a/src/core/VirtualAudioBridge.h b/src/core/VirtualAudioBridge.h index 86e0ca108a..7261a97e7b 100644 --- a/src/core/VirtualAudioBridge.h +++ b/src/core/VirtualAudioBridge.h @@ -45,7 +45,9 @@ class VirtualAudioBridge : public QObject { // NUM_CHANNELS stays the compile-time array bound; this only bounds the // open loop so the device list follows the radio (#4854). Dynamic resize // after connect is a follow-up (issue #4935). - bool open(int activeChannels = NUM_CHANNELS); + // receiveOnly opens no TX endpoint and never polls application TX audio. + bool open(int activeChannels = NUM_CHANNELS, bool receiveOnly = false); + bool isReceiveOnly() const { return m_receiveOnly; } void close(); bool isOpen() const { return m_open.load(std::memory_order_acquire); } @@ -67,11 +69,12 @@ class VirtualAudioBridge : public QObject { public slots: // Feed decoded DAX audio for a channel (1-8). - // pcm format: int16 stereo, 24 kHz, little-endian. + // pcm format: native float32 stereo, 24 kHz. void feedDaxAudio(int channel, const QByteArray& pcm); // When transmitting, feed silence to all RX channels so apps don't starve. void setTransmitting(bool tx); + void resetRxChannel(int channel); signals: void txAudioReady(const QByteArray& pcm, const AetherSDR::TxCoordinator::Context& context); @@ -98,6 +101,7 @@ public slots: // Teardown loops still walk the full NUM_CHANNELS bound — closing an // unopened slot is a no-op. (#4854) int m_activeChannels{NUM_CHANNELS}; + bool m_receiveOnly{false}; float m_gain{0.5f}; // -6 dB default — GUI-only std::atomic m_channelGain[NUM_CHANNELS]{0.5f, 0.5f, 0.5f, 0.5f, 0.5f, 0.5f, 0.5f, 0.5f}; float m_txGain{0.5f}; diff --git a/src/core/backends/IRadioBackend.h b/src/core/backends/IRadioBackend.h index 52c1afa29a..3390e46cbe 100644 --- a/src/core/backends/IRadioBackend.h +++ b/src/core/backends/IRadioBackend.h @@ -626,6 +626,8 @@ class IRadioBackend : public QObject { { Q_UNUSED(sliceId); Q_UNUSED(on); Q_UNUSED(level); } + virtual void setSliceAutoSquelch(int sliceId, bool enabled, int marginDb) + { Q_UNUSED(sliceId); Q_UNUSED(enabled); Q_UNUSED(marginDb); } // BroadcastFmReceive declares supported values; accepted state returns in // SliceDelta only after the receiver adopts the requested configuration. virtual void setSliceWfmForceMono(int sliceId, bool forceMono) diff --git a/src/core/backends/MeterDef.h b/src/core/backends/MeterDef.h index af80e81164..471bb94d3f 100644 --- a/src/core/backends/MeterDef.h +++ b/src/core/backends/MeterDef.h @@ -23,6 +23,7 @@ struct MeterDef { double low{0.0}; double high{0.0}; QString description; + QString unavailableReason; // Empty unless measurement is explicitly unavailable. }; } // namespace AetherSDR diff --git a/src/core/backends/RadioCapabilities.h b/src/core/backends/RadioCapabilities.h index 88d26fb9ef..72d31df896 100644 --- a/src/core/backends/RadioCapabilities.h +++ b/src/core/backends/RadioCapabilities.h @@ -61,6 +61,13 @@ struct ReceiveAudioControl { SliceFrequencyControl::Authority authority{SliceFrequencyControl::Authority::Unknown}; // Both gain (0..100) and mute must act on the shared slice's RX audio. }; +// Typed pre-monitor sliceAudioFrameReady export; it grants no TX, IQ or +// headless-control verb. Absence means this consumer path is not qualified. +struct ReceiveAudioExport { + QVector sampleRatesHz; + int maximumReceivers = 0; // independent taps, never a speaker mix +}; + // Native slice squelch semantics. Absence preserves the legacy desktop shape; // this record alone grants no headless-control verb. Explicit modes make an // unsupported demodulator distinguishable from a supported control set to Off. @@ -69,6 +76,10 @@ struct ReceiveSquelchModel { double referenceDb = -160.0; double stepDb = 1.0; QString unit = QStringLiteral("dBm"); + // setSliceAutoSquelch + observed per-slice Auto state are implemented. + bool automaticInEngine = false; + // False when detector and display use different measurement bandwidths. + bool spectrumComparable = true; }; // Broadcast FM receiver controls, distinct from narrow-FM/repeater features. // Engaged only when setSliceWfmDeemphasis and observed stereo status work. @@ -302,6 +313,7 @@ struct RadioCapabilities { std::optional receiveModeControl; std::optional receiveFilterControl; std::optional receiveAudioControl; + std::optional receiveAudioExport; std::optional receiveSquelchModel; std::optional broadcastFmReceive; std::optional receivePanCenterControl; diff --git a/src/core/backends/SliceDelta.h b/src/core/backends/SliceDelta.h index 9f8d32afee..0d2088a9a1 100644 --- a/src/core/backends/SliceDelta.h +++ b/src/core/backends/SliceDelta.h @@ -98,6 +98,8 @@ struct SliceDelta { std::optional agcOffLevel; std::optional squelchOn; std::optional squelchLevel; + std::optional automaticSquelch; + std::optional automaticSquelchMarginDb; std::optional wfmDeemphasisUs; std::optional wfmForceMono; std::optional wfmReceptionDiagnostics; diff --git a/src/core/backends/anan/AnanBackend.cpp b/src/core/backends/anan/AnanBackend.cpp index e824a085b1..8cdc8559de 100644 --- a/src/core/backends/anan/AnanBackend.cpp +++ b/src/core/backends/anan/AnanBackend.cpp @@ -414,6 +414,7 @@ RadioCapabilities AnanBackend::capabilities() const // backend, which is what the record promises. Engine authority: the state is // ours, the radio echoes nothing back, and there is no register to read. c.receiveAudioControl = ReceiveAudioControl{SliceFrequencyControl::Authority::Engine}; + c.receiveAudioExport = std::nullopt; // native DAX export not qualified for this backend c.receivePanCenterControl = std::nullopt; // center also retunes the slice c.receivePanBandwidthControl = ReceivePanRangeControl{SliceFrequencyControl::Authority::Engine, 48'000, 1'536'000}; diff --git a/src/core/backends/flex/FlexBackend.cpp b/src/core/backends/flex/FlexBackend.cpp index 9025751f83..5d5ab1d5b7 100644 --- a/src/core/backends/flex/FlexBackend.cpp +++ b/src/core/backends/flex/FlexBackend.cpp @@ -213,6 +213,7 @@ RadioCapabilities FlexBackend::capabilities() const {QStringLiteral("SAM"), -12000, -10, 10, 12000, 20, 24000}, {QStringLiteral("DSB"), -12000, -10, 10, 12000, 20, 24000}}}; caps.receiveAudioControl = std::nullopt; // legacy wire mixer path has not migrated + caps.receiveAudioExport = std::nullopt; // native DAX export not qualified for this backend caps.receivePanCenterControl = std::nullopt; // unknown coverage including transverters caps.receivePanBandwidthControl = std::nullopt; // legacy coupled geometry path caps.txPowerBands = {}; diff --git a/src/core/backends/flex/PanadapterStream.cpp b/src/core/backends/flex/PanadapterStream.cpp index 7864ccb9ec..5f15a4cb99 100644 --- a/src/core/backends/flex/PanadapterStream.cpp +++ b/src/core/backends/flex/PanadapterStream.cpp @@ -1577,6 +1577,7 @@ const char* PanadapterStream::daxConsumerName(DaxConsumer who) case DaxConsumer::Clock: return "clock"; case DaxConsumer::CwDecoder: return "cw-decoder"; case DaxConsumer::RttyDecoder: return "rtty-decoder"; + case DaxConsumer::Ax25Decoder: return "ax25-decoder"; } return "?"; } @@ -1712,7 +1713,7 @@ QVector PanadapterStream::daxChannelSnapsh s.createPending = it->createPending; for (DaxConsumer who : {DaxConsumer::Bridge, DaxConsumer::Tci, DaxConsumer::Rade, DaxConsumer::Clock, DaxConsumer::CwDecoder, - DaxConsumer::RttyDecoder}) { + DaxConsumer::RttyDecoder, DaxConsumer::Ax25Decoder}) { if (it->holders & daxHolderBit(who)) s.holders << QString::fromLatin1(daxConsumerName(who)); } diff --git a/src/core/backends/flex/PanadapterStream.h b/src/core/backends/flex/PanadapterStream.h index 3257f3939b..e6fd00f22d 100644 --- a/src/core/backends/flex/PanadapterStream.h +++ b/src/core/backends/flex/PanadapterStream.h @@ -124,6 +124,7 @@ class PanadapterStream : public QObject { Clock = 3, // AetherClock time-signal decode engine CwDecoder = 4, // selected receiver CW decoder (pre-monitor DAX) RttyDecoder = 5, // selected receiver RTTY decoder (pre-monitor DAX) + Ax25Decoder = 6, // selected receiver AX.25/APRS decoder (pre-monitor DAX) }; static const char* daxConsumerName(DaxConsumer who); diff --git a/src/core/backends/hl2/Hl2Backend.cpp b/src/core/backends/hl2/Hl2Backend.cpp index 4d776e244a..1534a34d79 100644 --- a/src/core/backends/hl2/Hl2Backend.cpp +++ b/src/core/backends/hl2/Hl2Backend.cpp @@ -1662,6 +1662,7 @@ RadioCapabilities Hl2Backend::capabilities() const {QStringLiteral("AM"), -12000, -10, 10, 12000, 20, 24000}, {QStringLiteral("SAM"), -12000, -10, 10, 12000, 20, 24000}}}; c.receiveAudioControl = ReceiveAudioControl{SliceFrequencyControl::Authority::Engine}; + c.receiveAudioExport = std::nullopt; // native DAX export not qualified for this backend c.receivePanCenterControl = ReceivePanRangeControl{SliceFrequencyControl::Authority::Engine, 100'000, 38'400'000}; c.receivePanBandwidthControl = std::nullopt; // radio-wide rate can retire other receivers diff --git a/src/core/backends/icom/IcomCivBackend.cpp b/src/core/backends/icom/IcomCivBackend.cpp index 67142d8797..3fce4b2627 100644 --- a/src/core/backends/icom/IcomCivBackend.cpp +++ b/src/core/backends/icom/IcomCivBackend.cpp @@ -299,6 +299,7 @@ RadioCapabilities IcomCivBackend::capabilities() const c.receiveModeControl = std::nullopt; c.receiveFilterControl = std::nullopt; c.receiveAudioControl = std::nullopt; + c.receiveAudioExport = std::nullopt; // native DAX export not qualified for this backend c.receivePanCenterControl = std::nullopt; c.receivePanBandwidthControl = std::nullopt; diff --git a/src/core/backends/rtl/RtlCaptureTransaction.cpp b/src/core/backends/rtl/RtlCaptureTransaction.cpp index 70a83339ee..760658e0ce 100644 --- a/src/core/backends/rtl/RtlCaptureTransaction.cpp +++ b/src/core/backends/rtl/RtlCaptureTransaction.cpp @@ -242,6 +242,8 @@ RtlCaptureTransaction::Submission RtlCaptureTransaction::submit(const Desired& d || receiver.audioGain < 0 || receiver.audioGain > 100 || receiver.audioPan < 0 || receiver.audioPan > 100 || receiver.squelchLevel < 0 || receiver.squelchLevel > 100 + || receiver.automaticSquelchMarginDb < 5 || receiver.automaticSquelchMarginDb > 20 + || (receiver.automaticSquelch && !receiver.squelchEnabled) || (receiver.wfmDeemphasisUs != 50 && receiver.wfmDeemphasisUs != 75) || (receiver.squelchEnabled && receiver.mode != Mode::Fm && receiver.mode != Mode::Fmn)) { return {{}, Policy::Error::InvalidNumber}; diff --git a/src/core/backends/rtl/RtlCaptureTransaction.h b/src/core/backends/rtl/RtlCaptureTransaction.h index 81b6697480..79d1ccf6f9 100644 --- a/src/core/backends/rtl/RtlCaptureTransaction.h +++ b/src/core/backends/rtl/RtlCaptureTransaction.h @@ -33,6 +33,8 @@ class RtlCaptureTransaction final { int squelchLevel = 20; int wfmDeemphasisUs = 75; // broadcast standard; validated 50 or 75 only bool wfmForceMono = false; // selected decoder matrix policy, not observed pilot + bool automaticSquelch = false; + int automaticSquelchMarginDb = 10; bool operator==(const Receiver&) const = default; }; struct Desired { diff --git a/src/core/backends/rtl/RtlReceivePipeline.cpp b/src/core/backends/rtl/RtlReceivePipeline.cpp index c9096a0693..9bb3c63005 100644 --- a/src/core/backends/rtl/RtlReceivePipeline.cpp +++ b/src/core/backends/rtl/RtlReceivePipeline.cpp @@ -1,11 +1,25 @@ #include "RtlReceivePipeline.h" #include +#include #include #include #include namespace AetherSDR::rtl { namespace { +unsigned packetSlots(std::size_t capacity) +{ + // Native WFM is the highest analog packet rate: one 256-frame tap per + // receiver plus two 128-frame speaker packets, all at 48 kHz. The old + // 127 usable packets cover ~225 ms for one receiver but only ~113 ms for + // four. Startup UI publication measured 151 ms with no owner drain. + // Scale the same nominal headroom, rounded up, once before acquisition; + // the qualified singleton remains exactly 128 allocated slots. This is + // finite jitter tolerance, not permission to block the owner indefinitely. + constexpr unsigned kSingleReceiverUsablePackets = 127; + const unsigned receivers = static_cast(std::clamp(capacity, 1, 8)); + return 1 + (kSingleReceiverUsablePackets * (receivers + 2) + 2) / 3; +} std::optional mixerOrigin(std::uint64_t firstSample, double rateHz) noexcept { // The extractor accepts only integral, bounded achieved rates. Validate @@ -40,7 +54,8 @@ WdspChannel::Mode dspMode(RtlCaptureTransaction::Mode mode) } } RtlReceivePipeline::RtlReceivePipeline(std::size_t capacity, bool enableWfm) - : m_enableWfm(enableWfm), m_registry({8, capacity, std::min(32, capacity * 2)}) + : m_enableWfm(enableWfm), m_registry({8, capacity, std::min(32, capacity * 2)}), + m_packetSlots(packetSlots(capacity)), m_packets(std::make_unique(m_packetSlots)) { for (auto& monitor : m_monitor) { monitor.store(100 | (50 << 8)); } } @@ -59,8 +74,17 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( || receiver.audioGain < 0 || receiver.audioGain > 100 || receiver.audioPan < 0 || receiver.audioPan > 100 || receiver.squelchLevel < 0 || receiver.squelchLevel > 100 + || receiver.automaticSquelchMarginDb < 5 || receiver.automaticSquelchMarginDb > 20 + || (receiver.automaticSquelch && !receiver.squelchEnabled) || (receiver.wfmDeemphasisUs != 50 && receiver.wfmDeemphasisUs != 75) || (receiver.mode == Transaction::Mode::Wfm && receiver.squelchEnabled)) { return Submission::Failed; } + // 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); + if (!native && state.receivers.size() != 1) { return Submission::Failed; } validated[id] = true; } const unsigned faults = m_faults.load(std::memory_order_acquire); @@ -167,7 +191,9 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( m_specs = specs; m_prepared = m_registry.service().requested; for (const auto& receiver : state.receivers) { - m_nextSquelch[receiver.passband.stableId] = {receiver.squelchEnabled, receiver.squelchLevel}; + m_nextMeterPassbands[receiver.passband.stableId] = receiver.passband; + m_nextSquelch[receiver.passband.stableId] = {receiver.squelchEnabled, receiver.squelchLevel, + receiver.automaticSquelch, receiver.automaticSquelchMarginDb}; m_nextMonitor[receiver.passband.stableId] = static_cast(receiver.audioGain | (receiver.audioPan << 8) | (receiver.audioMute ? 1 << 16 : 0)); } @@ -192,12 +218,14 @@ bool RtlReceivePipeline::adopt() noexcept for (std::size_t slot = 0; slot < m_monitor.size(); ++slot) { m_monitor[slot].store(m_nextMonitor[slot], std::memory_order_relaxed); m_squelchConfig[slot] = m_nextSquelch[slot]; - m_squelch[slot].configure(m_nextSquelch[slot].enabled, m_nextSquelch[slot].level, resetSquelch); + m_squelch[slot].configure(m_nextSquelch[slot].enabled, m_nextSquelch[slot].level, resetSquelch, + m_nextSquelch[slot].automatic, m_nextSquelch[slot].marginDb); } if (resetSquelch) { m_spectrumFresh = false; m_squelchEpoch.fill(0); } m_token = m_nextToken; m_capture = m_nextCapture; m_captureEpoch = m_nextEpoch; m_legacy = m_nextLegacy; m_receivingMask = m_nextReceivingMask; + m_meterPassbands = m_nextMeterPassbands; m_faults.store(0, std::memory_order_release); // old bank faults cannot request a second reset return true; } @@ -213,6 +241,54 @@ void RtlReceivePipeline::observeSpectrum(std::span bins, std::uint6 if (bins.size() != m_spectrum.size()) { return; } std::copy(bins.begin(), bins.end(), m_spectrum.begin()); m_spectrumFirstSample = firstSample; m_spectrumFresh = true; + const std::uint64_t generation = m_rfGeneration.load(std::memory_order_acquire); + if (!(generation & 1)) { return; } // Keep the independent SQL spectrum above. + if (!m_token.session || m_capture.achievedSampleRateHz <= 0) { return; } + RfObservation observation; + observation.token = m_token; + observation.generation = generation; + observation.producedMs = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + const double binHz = m_capture.achievedSampleRateHz / bins.size(); + 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 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; + bool valid = true; + for (int bin = low; bin <= high; ++bin) { + if (!std::isfinite(bins[bin])) { valid = false; break; } + peak = std::max(peak, bins[bin]); + } + if (valid) { observation.mask |= 1u << id; observation.dbfs[id] = peak; } + } + const unsigned write = m_rfWrite.load(std::memory_order_relaxed); + const unsigned next = (write + 1) % m_rfObservations.size(); + if (next != m_rfRead.load(std::memory_order_acquire)) { + m_rfObservations[write] = observation; + m_rfWrite.store(next, std::memory_order_release); + } +} +bool RtlReceivePipeline::takeRfObservation(RfObservation& output) noexcept +{ + for (unsigned count = 0; count < m_rfObservations.size(); ++count) { + const unsigned read = m_rfRead.load(std::memory_order_relaxed); + if (read == m_rfWrite.load(std::memory_order_acquire)) { return false; } + output = m_rfObservations[read]; + m_rfRead.store((read + 1) % m_rfObservations.size(), std::memory_order_release); + const std::uint64_t generation = m_rfGeneration.load(std::memory_order_acquire); + if ((generation & 1) && output.generation == generation) { return true; } + } + return false; +} +void RtlReceivePipeline::setReceiveMetersEnabled(bool enabled) noexcept +{ + const std::uint64_t generation = m_rfGeneration.load(std::memory_order_relaxed); + if (bool(generation & 1) != enabled) { + m_rfGeneration.store(generation + 1, std::memory_order_release); + } } void RtlReceivePipeline::setMonitor(int slot, int gain, int pan, bool mute) noexcept { @@ -224,6 +300,10 @@ void RtlReceivePipeline::process(const RtlReceiverRegistry::SampleBlock& block, std::span views) noexcept { if (m_legacy) { return; } + if (m_startupTrace && !m_startupTrace->nativeFirstNs) { + m_startupTrace->markProducer(RtlStartupTrace::FirstNativeBlock, m_token.session, + m_token.revision, block.firstSample, -1, 0, block.samples.size()); + } std::array inputs; for (std::size_t i = 0; i < views.size(); ++i) { const auto& spec = *views[i].spec; @@ -255,7 +335,8 @@ void RtlReceivePipeline::process(const RtlReceiverRegistry::SampleBlock& block, const int slot = view.spec->handle.slot; auto& gate = m_squelch[slot]; if (m_squelchEpoch[slot] != view.spec->epoch) { - gate.configure(m_squelchConfig[slot].enabled, m_squelchConfig[slot].level, true); + gate.configure(m_squelchConfig[slot].enabled, m_squelchConfig[slot].level, true, + m_squelchConfig[slot].automatic, m_squelchConfig[slot].marginDb); m_squelchEpoch[slot] = view.spec->epoch; } if (m_spectrumFresh && !view.spec->dsp.wbfmReceive) { @@ -268,16 +349,12 @@ void RtlReceivePipeline::process(const RtlReceiverRegistry::SampleBlock& block, (offset + passband.filterLowHz) / binHz + 1024)), 0, 2047); const int high = std::clamp(static_cast(std::ceil( (offset + passband.filterHighHz) / binHz + 1024)), low, 2047); - float peak = -120.0f; - for (int bin = low; bin <= high; ++bin) { - if (!std::isfinite(m_spectrum[bin])) { peak = std::numeric_limits::quiet_NaN(); break; } - peak = std::max(peak, m_spectrum[bin]); - } - gate.observe(peak, clock(m_spectrumFirstSample)); + gate.observeSpectrum(m_spectrum, low, high, clock(m_spectrumFirstSample)); } if (!view.receiver->processCapture(block, *this)) { const unsigned bit = 1u << slot; if (!(m_faults.fetch_or(bit, std::memory_order_release) & bit)) { + m_receiverWithdrawals[slot].fetch_add(1, std::memory_order_relaxed); TraceEvent event = traceContext(); event.slot = slot; event.stableId = view.spec->passband.stableId; event.instance = view.spec->handle.instance; @@ -295,11 +372,19 @@ void RtlReceivePipeline::process(const RtlReceiverRegistry::SampleBlock& block, } RtlReceivePipeline::Diagnostics RtlReceivePipeline::diagnostics() const noexcept { - return {m_observed.load(std::memory_order_acquire), + Diagnostics result{m_observed.load(std::memory_order_acquire), m_drops.load(std::memory_order_relaxed), m_mixerLate.load(std::memory_order_relaxed), m_mixerRejected.load(std::memory_order_relaxed), m_mixerConfigurationFailures.load(std::memory_order_relaxed), m_traceDrops.load(std::memory_order_relaxed)}; + result.queuedPackets = (m_write.load(std::memory_order_acquire) + m_packetSlots + - m_read.load(std::memory_order_acquire)) % m_packetSlots; + result.packetQueueHighWater = m_packetQueueHighWater.load(std::memory_order_relaxed); + result.packetQueueCapacity = m_packetSlots - 1; + for (std::size_t slot = 0; slot < m_receiverWithdrawals.size(); ++slot) { + result.receiverWithdrawals[slot] = m_receiverWithdrawals[slot].load(std::memory_order_relaxed); + } + return result; } void RtlReceivePipeline::audioBlock(const RtlReceiverRegistry::ReceiverSpec& spec, std::uint64_t first, std::span left, std::span right, bool discontinuity) noexcept @@ -330,6 +415,18 @@ void RtlReceivePipeline::audioBlockWithStatus(const RtlReceiverRegistry::Receive packet.samples[2 * i] = gatedLeft[i]; packet.samples[2 * i + 1] = gatedRight[i]; } enqueue(packet); // independent tap, before gain/mute/pan + // A retained receiver can finish buffered PCM from before a new speaker + // epoch. Keep its tap continuous, but do not offer that retired prefix to + // the new mix. Use the fixed origin, never the advancing playback cursor: + // genuinely late current-epoch blocks must still fail mixer validation. + if (m_mixerOrigin && first < *m_mixerOrigin && !left.empty() + && left.size() <= *m_mixerOrigin - first + && std::ranges::all_of(std::span(gatedLeft).first(left.size()), + [](float value) { return std::isfinite(value); }) + && std::ranges::all_of(std::span(gatedRight).first(right.size()), + [](float value) { return std::isfinite(value); })) { + return; + } m_mixer.push(packet.slot, packet.instance, packet.receiverEpoch, first, std::span(gatedLeft).first(left.size()), std::span(gatedRight).first(right.size())); } @@ -382,9 +479,43 @@ bool RtlReceivePipeline::takeTraceEvent(TraceEvent& output) noexcept bool RtlReceivePipeline::enqueue(const Packet& packet) noexcept { const unsigned write = m_write.load(std::memory_order_relaxed); - const unsigned next = (write + 1) % kPackets; - if (next == m_read.load(std::memory_order_acquire)) { m_drops.fetch_add(1, std::memory_order_relaxed); return false; } + const unsigned next = (write + 1) % m_packetSlots; + const unsigned read = m_read.load(std::memory_order_acquire); + if (m_startupTrace) { ++m_startupTrace->packetAttempts; } + if (next == read) { + m_drops.fetch_add(1, std::memory_order_relaxed); + if (m_startupTrace) { + ++m_startupTrace->packetDrops; + if (!m_startupTrace->firstDrop) { + m_startupTrace->firstDrop = true; + m_startupTrace->markProducer(RtlStartupTrace::FirstDrop, packet.token.session, + packet.token.revision, m_traceCaptureFirst, packet.slot, packet.firstSample, + packet.frames, m_packetSlots - 1); + } + } + return false; + } + if (m_startupTrace && m_startupTrace->firstDrop && !m_startupTrace->firstRecovery) { + m_startupTrace->firstRecovery = true; + m_startupTrace->markProducer(RtlStartupTrace::FirstRecovery, packet.token.session, + packet.token.revision, m_traceCaptureFirst, packet.slot, packet.firstSample, + packet.frames, (write + m_packetSlots - read) % m_packetSlots); + } m_packets[write] = packet; + m_packets[write].enqueuedMonotonicNs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + const std::uint64_t queued = (next + m_packetSlots - read) % m_packetSlots; + if (m_startupTrace && queued == m_packetSlots - 1 && !m_startupTrace->firstFull) { + m_startupTrace->firstFull = true; + m_startupTrace->markProducer(RtlStartupTrace::FirstQueueFull, packet.token.session, + packet.token.revision, m_traceCaptureFirst, packet.slot, packet.firstSample, + packet.frames, queued); + } + // Sole producer: no compare/exchange loop or callback contention needed. + if (queued > m_packetQueueHighWater.load(std::memory_order_relaxed)) { + m_packetQueueHighWater.store(queued, std::memory_order_relaxed); + } m_write.store(next, std::memory_order_release); return true; } @@ -393,7 +524,7 @@ bool RtlReceivePipeline::takePacket(Packet& output) noexcept const unsigned read = m_read.load(std::memory_order_relaxed); if (read == m_write.load(std::memory_order_acquire)) { return false; } output = m_packets[read]; - m_read.store((read + 1) % kPackets, std::memory_order_release); + m_read.store((read + 1) % m_packetSlots, std::memory_order_release); return true; } } // namespace AetherSDR::rtl diff --git a/src/core/backends/rtl/RtlReceivePipeline.h b/src/core/backends/rtl/RtlReceivePipeline.h index 976f9173c2..bee15d03b9 100644 --- a/src/core/backends/rtl/RtlReceivePipeline.h +++ b/src/core/backends/rtl/RtlReceivePipeline.h @@ -3,10 +3,12 @@ #include "RtlCaptureTransaction.h" #include "RtlReceiverRegistry.h" #include "RtlSquelchGate.h" +#include "RtlStartupTrace.h" #include #include #include #include +#include namespace AetherSDR::rtl { // One acquisition context and one serialized control owner. Preparation, @@ -24,6 +26,21 @@ class RtlReceivePipeline final : private RtlReceiverRegistry::BlockProcessor, std::uint64_t mixerRejectedBlocks = 0; std::uint64_t mixerConfigurationFailures = 0; std::uint64_t droppedTraceEvents = 0; + std::uint64_t queuedPackets = 0; + std::uint64_t packetQueueHighWater = 0; + std::uint64_t packetQueueCapacity = 0; // usable packets, excluding the SPSC sentinel + std::array receiverWithdrawals{}; + // The worker fills these acquisition-lifetime observations in its + // snapshot. A pipeline-only fixture truthfully leaves them at zero. + std::uint64_t callbackCount = 0; + std::uint64_t callbackIqSamples = 0; + std::uint64_t malformedCallbacks = 0; + std::uint64_t callbackTotalNs = 0; + std::uint64_t callbackMaxNs = 0; + std::uint64_t callbackDeadlineMisses = 0; + std::uint64_t usbReadStarts = 0; + std::uint64_t usbCancelRequests = 0; + std::array callbackDurationBuckets{}; }; // Independently sampled lifetime counters, not one atomic point-in-time // transaction. The control owner caches these for healthSnapshot(). @@ -33,6 +50,7 @@ class RtlReceivePipeline final : private RtlReceiverRegistry::BlockProcessor, std::uint64_t captureEpoch = 0; std::uint64_t instance = 0; std::uint64_t receiverEpoch = 0; + std::uint64_t enqueuedMonotonicNs = 0; // mailbox residence instrumentation std::uint64_t firstSample = 0; int slot = -1; std::size_t frames = 0; @@ -45,6 +63,22 @@ class RtlReceivePipeline final : private RtlReceiverRegistry::BlockProcessor, std::optional wfmReception; std::array samples{}; }; + // Windowed peak FFT bin in each accepted passband, relative to ADC full + // scale. This is neither calibrated dBm nor integrated channel power. + struct RfObservation { + Transaction::Token token; + std::uint64_t producedMs = 0; + std::uint64_t generation = 0; + std::uint8_t mask = 0; + std::array dbfs{}; + }; + bool takeRfObservation(RfObservation& output) noexcept; + void setReceiveMetersEnabled(bool enabled) noexcept; + bool rfObservationIsCurrent(const RfObservation& observation) const noexcept + { + const auto generation = m_rfGeneration.load(std::memory_order_acquire); + return (generation & 1) && observation.generation == generation; + } // Bounded acquisition observations; no formatting, clock reads, allocation, // or logging on the sample callback. The owner drains across bank adoption // so repair cannot erase the fault that requested it. @@ -83,7 +117,7 @@ class RtlReceivePipeline final : private RtlReceiverRegistry::BlockProcessor, bool adopt() noexcept; bool process(std::uint64_t firstSample, std::span> samples) noexcept; // Acquisition only, before process() for this capture block. Shares the - // display's existing FFT; the caller supplies capture-sample identity. + // fixed 2048-bin detector FFT; caller supplies capture-sample identity. void observeSpectrum(std::span bins, std::uint64_t firstSample) noexcept; void stop(); // after acquisition joined bool takePacket(Packet& output) noexcept; @@ -91,6 +125,9 @@ class RtlReceivePipeline final : private RtlReceiverRegistry::BlockProcessor, bool needsRepair() const noexcept { return m_faults.load(std::memory_order_acquire) != 0; } std::uint64_t droppedPackets() const noexcept { return m_drops.load(std::memory_order_relaxed); } bool legacy() const noexcept { return m_legacy; } + void setStartupTrace(RtlStartupTrace* trace) noexcept { m_startupTrace = trace; } + std::uint64_t startupQueuedPackets() const noexcept + { return (m_write.load(std::memory_order_acquire) + m_packetSlots - m_read.load(std::memory_order_acquire)) % m_packetSlots; } private: friend struct RtlReceivePipelineTestAccess; void process(const RtlReceiverRegistry::SampleBlock&, std::span) noexcept override; @@ -104,6 +141,7 @@ class RtlReceivePipeline final : private RtlReceiverRegistry::BlockProcessor, const RtlAudioMixer::MissingMask&) noexcept override; void enqueueTrace(const TraceEvent&) noexcept; TraceEvent traceContext() const noexcept; + RtlStartupTrace* m_startupTrace = nullptr; // fixed before acquisition; worker retains ownership const bool m_enableWfm; RtlReceiverRegistry m_registry; RtlReceiverRegistry::SampleReader m_reader; @@ -139,21 +177,35 @@ class RtlReceivePipeline final : private RtlReceiverRegistry::BlockProcessor, std::atomic m_faults{0}; std::array, 8> m_monitor; std::array m_nextMonitor{}; - struct SquelchConfig { bool enabled = false; int level = 20; }; + struct SquelchConfig { + bool enabled = false; int level = 20; + bool automatic = false; int marginDb = 10; + }; std::array m_nextSquelch{}; std::array m_squelchConfig{}; std::array m_squelch; std::array m_squelchEpoch{}; + std::array m_nextMeterPassbands{}, m_meterPassbands{}; + std::array m_rfObservations{}; + // Main-thread writer; callback snapshots the epoch. Odd means enabled. + // Epoch tagging rejects queued/in-flight samples across disable/re-enable. + std::atomic m_rfGeneration{1}; + alignas(64) std::atomic m_rfWrite{0}; + alignas(64) std::atomic m_rfRead{0}; std::array m_spectrum{}; std::uint64_t m_spectrumFirstSample = 0; bool m_spectrumFresh = false; // SPSC queue. Overflow drops the new packet; the consumer observes the // sample-position gap and marks its next typed frame discontinuous. - static constexpr unsigned kPackets = 128; - std::array m_packets; + // Fixed before acquisition. Preserve the singleton's nominal buffering + // time as independent analog taps share the existing speaker queue. + const unsigned m_packetSlots; + std::unique_ptr m_packets; alignas(64) std::atomic m_write{0}; alignas(64) std::atomic m_read{0}; std::atomic m_drops{0}; + std::atomic m_packetQueueHighWater{0}; + std::array, 8> m_receiverWithdrawals{}; std::atomic m_mixerLate{0}; std::atomic m_mixerRejected{0}; std::atomic m_mixerConfigurationFailures{0}; diff --git a/src/core/backends/rtl/RtlSdrBackend.cpp b/src/core/backends/rtl/RtlSdrBackend.cpp index 807354f61d..97a14db333 100644 --- a/src/core/backends/rtl/RtlSdrBackend.cpp +++ b/src/core/backends/rtl/RtlSdrBackend.cpp @@ -15,6 +15,7 @@ #include #include +#include #include namespace AetherSDR::rtl { @@ -26,6 +27,14 @@ Q_LOGGING_CATEGORY(lcRtlReceive, "aether.rtl.receive", QtWarningMsg) 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); +} + bool hasReceivingNarrowFm(const RtlCaptureTransaction::State& state) { return std::ranges::any_of(state.receivers, [&state](const auto& receiver) { @@ -114,6 +123,18 @@ uint32_t RtlSdrBackend::clampSampleRate(uint32_t requestedHz) RtlSdrBackend::RtlSdrBackend(QObject* parent) : IRadioBackend(parent) { + // Process-only, opt-in qualification profile. This does not raise the + // shipped capacity or persist an unqualified limit in a radio profile. +#if (defined(Q_OS_LINUX) && defined(Q_PROCESSOR_X86_64)) \ + || (defined(Q_OS_MAC) && defined(Q_PROCESSOR_ARM_64)) \ + || (defined(Q_OS_WIN) && defined(Q_PROCESSOR_X86_64)) + const QByteArray evaluation = qgetenv("AETHER_RTL_EVALUATION_RECEIVERS"); + if (qgetenv("AETHER_AUTOMATION") == "1" && (evaluation == "2" || evaluation == "4" || evaluation == "8")) { + m_receiverCapacity = evaluation.toInt(); + m_capture = RtlCaptureTransaction({8, static_cast(m_receiverCapacity)}); + qCWarning(lcRtlReceive) << "Unqualified RTL analog evaluation capacity:" << m_receiverCapacity; + } +#endif m_captureTimer.setInterval(10); connect(&m_captureTimer, &QTimer::timeout, this, &RtlSdrBackend::serviceCapture); } @@ -209,9 +230,10 @@ RadioCapabilities RtlSdrBackend::capabilities() const c.broadcastFmReceive = BroadcastFmReceive{{50, 75}, true, true}; } c.receiveAudioControl = ReceiveAudioControl{SliceFrequencyControl::Authority::Engine}; + c.receiveAudioExport = ReceiveAudioExport{{24000,48000},m_receiverCapacity}; c.receiveSquelchModel = ReceiveSquelchModel{ {QStringLiteral("FM"), QStringLiteral("FMN")}, - RtlSquelchGate::kReferenceDb, RtlSquelchGate::kStepDb, QStringLiteral("dBFS/bin")}; + RtlSquelchGate::kReferenceDb, RtlSquelchGate::kStepDb, QStringLiteral("dBFS/2048-bin"), true, false}; c.panSpanModel = PanSpanModel{false, false}; if (m_lastPublished && hasReceivingNarrowFm(*m_lastPublished)) { c.receiveCapturePlacement = ReceiveCapturePlacement{ @@ -247,7 +269,7 @@ RadioCapabilities RtlSdrBackend::capabilities() const | RadioCapabilities::ClientSettingsDomain::RfGain | RadioCapabilities::ClientSettingsDomain::Memories; } // Vendor extensions - c.extensions["rtl"] = QVariantMap{{"serial", m_serial}, {"settingsVersion", 1}}; + c.extensions["rtl"] = QVariantMap{{"serial", m_serial}, {"settingsVersion", 1}, {"receiveMetersControl", true}}; c.extensionNamespaces = {"rtl"}; return c; @@ -403,13 +425,19 @@ RtlCaptureTransaction::Receiver RtlSdrBackend::initialReceiver() const void RtlSdrBackend::startCapture(std::unique_ptr worker) { m_worker = std::move(worker); + m_worker->setReceiveMetersEnabled(m_receiveMetersEnabled); + m_startupTrace = qgetenv("AETHER_AUTOMATION") == "1" && qgetenv("AETHER_RTL_STARTUP_TRACE") == "1" + ? std::make_shared() : nullptr; + m_worker->setStartupTrace(m_startupTrace); m_diagnostics = {}; + m_pcmDiagnostics.fill({}); m_wfmStatus.fill(WfmStereoStatus::Unavailable); m_wfmReception.fill({}); m_wfmLastObservation.fill(std::nullopt); for (auto& age : m_wfmPublicationAge) { age.invalidate(); } for (auto& age : m_wfmObservationAge) { age.invalidate(); } m_capture.beginSession(); + m_activeSliceId = -1; m_published = {}; m_lastPublished.reset(); m_viewport.reset(); @@ -481,6 +509,7 @@ void RtlSdrBackend::disconnectRadio() for (auto& age : m_wfmObservationAge) { age.invalidate(); } m_captureTimer.stop(); m_capture.endSession(); + m_activeSliceId = -1; m_published = {}; m_pendingViewport = {}; m_pendingDrag = false; @@ -509,6 +538,9 @@ void RtlSdrBackend::disconnectRadio() connect(worker.get(), &QThread::finished, worker.get(), &QObject::deleteLater); if (!worker->stopReading()) { worker.release(); + } else if (m_startupTrace) { + qCWarning(lcRtlReceive).noquote().nospace() << "RtlStartupTrace " + << QString::fromUtf8(m_startupTrace->jsonAfterJoin()); } } worker.reset(); @@ -528,6 +560,9 @@ IRadioBackend::HealthSnapshot RtlSdrBackend::healthSnapshot() const { if (!m_connected) { return {}; } HealthSnapshot snapshot; + snapshot.values.insert(QStringLiteral("rtlReceiverCapacity"), m_receiverCapacity); + snapshot.values.insert(QStringLiteral("rtlQualifiedReceiverCapacity"), kQualifiedReceiverCapacity); + snapshot.values.insert(QStringLiteral("rtlEvaluationProfile"), m_receiverCapacity > kQualifiedReceiverCapacity); snapshot.order = {QStringLiteral("rtlQueueDrops"), QStringLiteral("rtlMixerLateFrames"), QStringLiteral("rtlMixerRejectedBlocks"), QStringLiteral("rtlMixerConfigurationFailures"), QStringLiteral("rtlReceiveTraceDrops")}; @@ -546,6 +581,101 @@ IRadioBackend::HealthSnapshot RtlSdrBackend::healthSnapshot() const for (int i = 0; i < snapshot.order.size(); ++i) { snapshot.values.insert(snapshot.order[i], QVariant::fromValue(counters[i])); } + const auto value = [&](const char* key, std::uint64_t count) { + snapshot.values.insert(QString::fromLatin1(key), QVariant::fromValue(count)); + }; + value("rtlQueuedPackets", m_diagnostics.queuedPackets); + value("rtlPacketQueueHighWater", m_diagnostics.packetQueueHighWater); + value("rtlPacketQueueCapacity", m_diagnostics.packetQueueCapacity); + value("rtlCallbackCount", m_diagnostics.callbackCount); + value("rtlCallbackIqSamples", m_diagnostics.callbackIqSamples); + value("rtlMalformedCallbacks", m_diagnostics.malformedCallbacks); + value("rtlCallbackTotalNs", m_diagnostics.callbackTotalNs); + value("rtlCallbackMaxNs", m_diagnostics.callbackMaxNs); + value("rtlCallbackDeadlineMisses", m_diagnostics.callbackDeadlineMisses); + value("rtlUsbReadStarts", m_diagnostics.usbReadStarts); + value("rtlUsbCancelRequests", m_diagnostics.usbCancelRequests); + QVariantList histogram, bounds, withdrawals, pcm; + for (std::size_t i = 0; i < m_diagnostics.callbackDurationBuckets.size(); ++i) { + histogram.append(QVariant::fromValue(m_diagnostics.callbackDurationBuckets[i])); + bounds.append(QVariant::fromValue(RtlSdrWorker::kCallbackDurationUpperBoundsNs[i])); + } + for (const auto count : m_diagnostics.receiverWithdrawals) { + withdrawals.append(QVariant::fromValue(count)); + } + for (const auto& stream : m_pcmDiagnostics) { + QVariantMap row; + row.insert(QStringLiteral("packets"), QVariant::fromValue(stream.packets)); + row.insert(QStringLiteral("frames"), QVariant::fromValue(stream.frames)); + row.insert(QStringLiteral("nonzeroSamples"), QVariant::fromValue(stream.nonzeroSamples)); + row.insert(QStringLiteral("nonfiniteSamples"), QVariant::fromValue(stream.nonfiniteSamples)); + row.insert(QStringLiteral("discontinuities"), QVariant::fromValue(stream.discontinuities)); + row.insert(QStringLiteral("queueAgeTotalNs"), QVariant::fromValue(stream.queueAgeTotalNs)); + row.insert(QStringLiteral("queueAgeMaxNs"), QVariant::fromValue(stream.queueAgeMaxNs)); + row.insert(QStringLiteral("maxDeliveryGapMs"), QVariant::fromValue(stream.maxDeliveryGapMs)); + row.insert(QStringLiteral("lastDeliveryMs"), QVariant::fromValue(stream.lastDeliveryMs)); + row.insert(QStringLiteral("peak"), stream.peak); + row.insert(QStringLiteral("sumSquaresLeft"), stream.sumSquaresLeft); + row.insert(QStringLiteral("sumSquaresRight"), stream.sumSquaresRight); + pcm.append(row); + } + snapshot.values.insert(QStringLiteral("rtlCallbackDurationBuckets"), histogram); + snapshot.values.insert(QStringLiteral("rtlCallbackDurationUpperBoundsNs"), bounds); + snapshot.values.insert(QStringLiteral("rtlReceiverWithdrawals"), withdrawals); + snapshot.values.insert(QStringLiteral("rtlPcmStreams"), pcm); + } + if (m_lastPublished) { + QVariantList recipes; + for (const auto& receiver : m_lastPublished->receivers) { + const int stableId = receiver.passband.stableId; + const bool receiving = std::ranges::find(m_lastPublished->receivingIds, stableId) + != m_lastPublished->receivingIds.end(); + QVariantMap recipe; + recipe.insert(QStringLiteral("stableId"), stableId); + recipe.insert(QStringLiteral("selected"), stableId == m_activeSliceId); + recipe.insert(QStringLiteral("receiving"), receiving); + recipe.insert(QStringLiteral("parked"), !receiving); + recipe.insert(QStringLiteral("carrierHz"), receiver.passband.carrierHz); + recipe.insert(QStringLiteral("mode"), modeName(receiver.mode)); + recipe.insert(QStringLiteral("filterLowHz"), receiver.passband.filterLowHz); + recipe.insert(QStringLiteral("filterHighHz"), receiver.passband.filterHighHz); + recipe.insert(QStringLiteral("wfmForceMono"), receiver.wfmForceMono); + recipe.insert(QStringLiteral("wfmDeemphasisUs"), receiver.wfmDeemphasisUs); + recipe.insert(QStringLiteral("audioGain"), receiver.audioGain); + recipe.insert(QStringLiteral("audioPan"), receiver.audioPan); + recipe.insert(QStringLiteral("audioMute"), receiver.audioMute); + recipe.insert(QStringLiteral("squelchEnabled"), receiver.squelchEnabled); + recipe.insert(QStringLiteral("squelchLevel"), receiver.squelchLevel); + recipe.insert(QStringLiteral("automaticSquelch"), receiver.automaticSquelch); + recipe.insert(QStringLiteral("automaticSquelchMarginDb"), receiver.automaticSquelchMarginDb); + recipes.append(recipe); + } + snapshot.values.insert(QStringLiteral("rtlReceiverRecipes"), recipes); + } + const QList> diagnosticRows{ + {QStringLiteral("rtlReceiverCapacity"), tr("Configured receiver capacity")}, + {QStringLiteral("rtlQualifiedReceiverCapacity"), tr("Qualified default receiver capacity")}, + {QStringLiteral("rtlEvaluationProfile"), tr("Unqualified evaluation profile enabled")}, + {QStringLiteral("rtlQueuedPackets"), tr("Audio packets currently queued")}, + {QStringLiteral("rtlPacketQueueHighWater"), tr("Audio packet queue high-water mark")}, + {QStringLiteral("rtlPacketQueueCapacity"), tr("Audio packet queue usable capacity")}, + {QStringLiteral("rtlCallbackCount"), tr("USB callbacks processed")}, + {QStringLiteral("rtlCallbackIqSamples"), tr("IQ samples delivered by USB callbacks")}, + {QStringLiteral("rtlMalformedCallbacks"), tr("Malformed USB callbacks")}, + {QStringLiteral("rtlCallbackTotalNs"), tr("Total callback processing time (ns)")}, + {QStringLiteral("rtlCallbackMaxNs"), tr("Maximum callback processing time (ns)")}, + {QStringLiteral("rtlCallbackDeadlineMisses"), tr("Callbacks exceeding their sample duration")}, + {QStringLiteral("rtlUsbReadStarts"), tr("USB asynchronous read starts")}, + {QStringLiteral("rtlUsbCancelRequests"), tr("USB asynchronous read cancellations")}, + {QStringLiteral("rtlCallbackDurationBuckets"), tr("Callback duration histogram")}, + {QStringLiteral("rtlCallbackDurationUpperBoundsNs"), tr("Callback histogram upper bounds (ns)")}, + {QStringLiteral("rtlReceiverWithdrawals"), tr("Processing-fault withdrawals by stable receiver")}, + {QStringLiteral("rtlPcmStreams"), tr("PCM observations for receivers A-H, then speaker")}, + {QStringLiteral("rtlReceiverRecipes"), tr("Accepted recipes by stable receiver")}}; + snapshot.sections.insert(diagnosticRows.front().first, tr("RTL evaluation measurements (since connect)")); + for (const auto& row : diagnosticRows) { + snapshot.order.append(row.first); + snapshot.labels.insert(row.first, row.second); } if (m_lastPublished) { const auto& capture = m_lastPublished->capture; @@ -590,8 +720,11 @@ bool RtlSdrBackend::createSlice(const QString& panId, double frequencyHz) { if (!m_connected || !m_capture.confirmed() || !std::isfinite(frequencyHz) || m_capture.busy() || (panId != QStringLiteral("0xe1000000") && !panId.isEmpty()) - || m_requested.receivers.size() >= static_cast(m_receiverCapacity) - || std::ranges::any_of(m_requested.receivers, [](const auto& receiver) { return receiver.mode != RtlCaptureTransaction::Mode::Fm && receiver.mode != RtlCaptureTransaction::Mode::Fmn; })) { return false; } + || 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.")); + 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; } @@ -623,6 +756,27 @@ bool RtlSdrBackend::hasAcceptedSlice(int sliceId) const }); } +void RtlSdrBackend::setActiveSlice(int sliceId) +{ + if (!hasAcceptedSlice(sliceId)) { return; } + m_activeSliceId = sliceId; + const auto token = m_published; + const auto receivers = m_lastPublished->receivers; + // Copy membership before signalling: a receiver may be removed or a new + // selection made reentrantly by a model/UI observer. + for (const auto& receiver : receivers) { + if (!acceptsFrame(token.session, token.revision) || m_activeSliceId != sliceId) { return; } + if (receiver.passband.stableId == sliceId) { continue; } + SliceDelta delta; + delta.active = false; + emit sliceChanged(receiver.passband.stableId, delta); + } + if (!acceptsFrame(token.session, token.revision) || m_activeSliceId != sliceId) { return; } + SliceDelta delta; + delta.active = true; + emit sliceChanged(sliceId, delta); +} + void RtlSdrBackend::setSliceFrequency(int sliceId, double hz) { requestReceiveTune(sliceId, hz, ReceiveTuneView::Preserve); @@ -688,7 +842,12 @@ void RtlSdrBackend::setSliceMode(int sliceId, const QString& mode) if (receiver == desired.receivers.end()) { return; } const auto modeValue = captureMode(canonical); const bool narrowFm = modeValue == RtlCaptureTransaction::Mode::Fm || modeValue == RtlCaptureTransaction::Mode::Fmn; - if (!narrowFm && desired.receivers.size() != 1) { return; } + 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.")); + 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. @@ -708,7 +867,7 @@ void RtlSdrBackend::setSliceMode(int sliceId, const QString& mode) receiver->mode = modeValue; desired.followReceiverId = sliceId; desired.avoidDc = narrowFm; - if (!narrowFm) { receiver->squelchEnabled = false; } + if (!narrowFm) { receiver->squelchEnabled = false; receiver->automaticSquelch = false; } receiver->passband.guardLowHz = (narrowFm || nativeWfm) ? 3000 : 0; receiver->passband.guardHighHz = (narrowFm || nativeWfm) ? 3000 : 0; requestCapture(desired); @@ -776,11 +935,29 @@ void RtlSdrBackend::setSliceSquelch(int sliceId, bool enabled, int level) if (receiver == desired.receivers.end() || (receiver->mode != RtlCaptureTransaction::Mode::Fm && receiver->mode != RtlCaptureTransaction::Mode::Fmn)) { return; } - if (receiver->squelchEnabled == enabled && receiver->squelchLevel == level) { return; } + if (receiver->squelchEnabled == enabled && receiver->squelchLevel == level + && !receiver->automaticSquelch) { return; } + receiver->automaticSquelch = false; receiver->squelchEnabled = enabled; receiver->squelchLevel = level; requestCapture(desired); } +void RtlSdrBackend::setSliceAutoSquelch(int sliceId, bool enabled, int marginDb) +{ + if (!hasAcceptedSlice(sliceId) || marginDb < 5 || marginDb > 20) { return; } + auto desired = m_requested; + const auto receiver = std::ranges::find_if(desired.receivers, + [sliceId](const auto& value) { return value.passband.stableId == sliceId; }); + if (receiver == desired.receivers.end() + || (receiver->mode != RtlCaptureTransaction::Mode::Fm + && receiver->mode != RtlCaptureTransaction::Mode::Fmn)) { return; } + if (receiver->automaticSquelch == enabled && receiver->automaticSquelchMarginDb == marginDb) { return; } + receiver->automaticSquelch = enabled; + receiver->automaticSquelchMarginDb = marginDb; + if (enabled) { receiver->squelchEnabled = true; } + requestCapture(desired); +} + // ────────────────────────────────────────────────────────────────────────────── // IRadioBackend — pan control // ────────────────────────────────────────────────────────────────────────────── @@ -970,6 +1147,22 @@ void RtlSdrBackend::invokeExtension(const QString& ns, const QString& verb, if (verb == QLatin1String("settings.get")) { emit extensionResult(requestId, deviceSettingsStatus()); return; } + if (verb == QLatin1String("receive_meters.set")) { + if (arg.metaType().id() != QMetaType::Bool || !m_lastPublished) { + emit extensionError(requestId, tr("Receive meters require a boolean and an accepted receiver state")); + return; + } + const QPointer producer(m_worker.get()); + m_receiveMetersEnabled = arg.toBool(); + m_worker->setReceiveMetersEnabled(m_receiveMetersEnabled); + saveAcceptedDeviceSettings(); + publishRfMeterDefinitions(true); + if (!producer || producer.data() != m_worker.get() || !m_connected) { return; } + emit extensionStatus(QStringLiteral("rtl"), QStringLiteral("settings"), deviceSettingsStatus()); + if (!producer || producer.data() != m_worker.get() || !m_connected) { return; } + emit extensionResult(requestId, m_receiveMetersEnabled); + return; + } bool ok = false; const qint64 value = arg.toLongLong(&ok); const int type = arg.metaType().id(); @@ -1033,6 +1226,8 @@ QVector RtlSdrBackend::acceptedSettings() const } slice.audioGain = m_monitors[id].gain; slice.audioMute = m_monitors[id].mute; slice.audioPan = m_monitors[id].pan; slice.squelchEnabled = receiver.squelchEnabled; slice.squelchLevel = receiver.squelchLevel; + slice.automaticSquelch = receiver.automaticSquelch; + slice.automaticSquelchMarginDb = receiver.automaticSquelchMarginDb; slice.wfmDeemphasisUs = receiver.wfmDeemphasisUs; slice.wfmForceMono = receiver.wfmForceMono; output.append(slice); @@ -1098,13 +1293,14 @@ void RtlSdrBackend::restoreAcceptedSlices() unsupportedWfmFilter = true; continue; } + const bool bankCompatible = !wide || nativeWfm; if ((!wide && (slice.filterLowHz < -21600 || slice.filterHighHz > 21600 || slice.filterLowHz >= 0 || slice.filterHighHz <= 0)) - || (wide && !desired.receivers.empty()) - || (!desired.receivers.empty() && desired.receivers.front().mode != RtlCaptureTransaction::Mode::Fm - && desired.receivers.front().mode != RtlCaptureTransaction::Mode::Fmn)) { continue; } + || (!desired.receivers.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}); + slice.audioMute, !wide && slice.squelchEnabled, slice.squelchLevel, slice.wfmDeemphasisUs, slice.wfmForceMono, + !wide && slice.automaticSquelch, slice.automaticSquelchMarginDb}); } // Preserve a valid configured receiver even when it is parked. The // transaction derives DSP membership from the confirmed capture; a @@ -1138,6 +1334,7 @@ void RtlSdrBackend::applyRestoredState(const RestoredRadioState& state) m_panRfGainDb = kDefaultRfGainDb; m_ppmCorrection = m_savedDeviceSettings.values.ppm; m_dcSuppression = m_savedDeviceSettings.values.dcSuppression; + m_receiveMetersEnabled = m_savedDeviceSettings.values.receiveMetersEnabled; m_directSampling = 0; if (state.rfFrequencyHz > 0) { @@ -1285,13 +1482,24 @@ bool RtlSdrBackend::acceptsFrame(quint64 session, quint64 revision) const return m_connected && m_published == RtlCaptureTransaction::Token{session, revision}; } +void RtlSdrBackend::markStartup(RtlStartupTrace::Kind kind, int slot) +{ + if (!m_startupTrace || m_startupTrace->ownerExpired) { return; } + m_startupTrace->markOwner(kind, m_published.session, m_published.revision, + m_capture.requested().revision, m_worker ? m_worker->startupDroppedPackets() : 0, + m_worker ? m_worker->startupQueuedPackets() : 0, slot); +} + void RtlSdrBackend::serviceCapture() { if (!m_worker) { return; } + markStartup(RtlStartupTrace::ServiceBegin); const QPointer producer(m_worker.get()); m_worker->serviceCancellation(); + markStartup(RtlStartupTrace::ControlDone); const std::uint64_t previousTraceDrops = m_diagnostics.droppedTraceEvents; m_diagnostics = m_worker->diagnostics(); + markStartup(RtlStartupTrace::SnapshotDone); if (m_diagnostics.droppedTraceEvents != previousTraceDrops) { qCDebug(lcRtlReceive).nospace() << "RtlReceive ms=" << QDateTime::currentMSecsSinceEpoch() << " kind=trace_overflow trace_drops=" << m_diagnostics.droppedTraceEvents; @@ -1332,7 +1540,11 @@ void RtlSdrBackend::serviceCapture() return; } if (completion == RtlCaptureTransaction::Completion::Published) { + markStartup(RtlStartupTrace::PublishBegin); publishCapture(); + if (producer && producer.data() == m_worker.get()) { + markStartup(RtlStartupTrace::PublishEnd); + } finishExtensions(true, result->token); } else if (completion == RtlCaptureTransaction::Completion::Failed && !m_capture.busy()) { const auto& state = *m_capture.confirmed(); @@ -1358,8 +1570,12 @@ void RtlSdrBackend::serviceCapture() } } if (m_worker) { + if (producer && producer.data() == m_worker.get()) { + markStartup(RtlStartupTrace::DrainBegin); + } drainAudio(); if (!producer || producer.data() != m_worker.get()) { return; } + markStartup(RtlStartupTrace::DrainEnd); expireWfmObservations(); if (!producer || producer.data() != m_worker.get()) { return; } if (!m_capture.busy() && m_worker->needsRepair()) { requestCapture(m_requested); } @@ -1371,6 +1587,9 @@ void RtlSdrBackend::serviceCapture() requestViewport(true); } } + if (producer && producer.data() == m_worker.get()) { + markStartup(RtlStartupTrace::ServiceEnd); + } } void RtlSdrBackend::retireNativeAudio() @@ -1408,6 +1627,21 @@ void RtlSdrBackend::publishLegacyPcm(const QByteArray& pcm, const QByteArray& pr void RtlSdrBackend::drainAudio() { const QPointer worker(m_worker.get()); + RtlReceivePipeline::RfObservation rf; + while (worker && worker.data() == m_worker.get() && worker->takeRfObservation(rf)) { + const auto now = std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); + if (!m_receiveMetersEnabled || !acceptsFrame(rf.token.session, rf.token.revision) || !rf.producedMs + || rf.producedMs > now || now - rf.producedMs > 500) { continue; } + for (int id = 0; id < 8; ++id) { + if (rf.mask & (1u << id)) { + emit meterUpdate(QStringLiteral("SLC%1:LEVEL").arg(id), rf.dbfs[id]); + if (!worker || worker.data() != m_worker.get() + || !acceptsFrame(rf.token.session, rf.token.revision) + || !worker->rfObservationIsCurrent(rf)) { return; } + } + } + } + if (!worker || worker.data() != m_worker.get()) { return; } RtlReceivePipeline::Packet packet; for (int count = 0; count < 128 && worker && worker.data() == m_worker.get() && worker->takeAudio(packet); ++count) { @@ -1430,6 +1664,10 @@ void RtlSdrBackend::drainAudio() } QVector samples(static_cast(2 * packet.frames)); std::copy_n(packet.samples.begin(), samples.size(), samples.begin()); + auto& statistics = m_pcmDiagnostics[packet.slot < 0 ? 8 : packet.slot]; + for (const float sample : samples) { + statistics.nonfiniteSamples += !std::isfinite(sample); + } const auto frame = output.producer->produce(std::move(samples), packet.firstSample, packet.discontinuity || packet.firstSample != output.nextSample); output.nextSample = packet.firstSample + packet.frames; @@ -1437,6 +1675,29 @@ void RtlSdrBackend::drainAudio() observeWfm(packet); if (!worker || worker.data() != m_worker.get() || !acceptsFrame(packet.token.session, packet.token.revision)) { return; } + ++statistics.packets; + statistics.frames += packet.frames; + statistics.discontinuities += frame->discontinuity(); + const auto deliveredNs = static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + if (packet.enqueuedMonotonicNs && packet.enqueuedMonotonicNs <= deliveredNs) { + const auto age = deliveredNs - packet.enqueuedMonotonicNs; + statistics.queueAgeTotalNs += age; + statistics.queueAgeMaxNs = std::max(statistics.queueAgeMaxNs, age); + } + const quint64 deliveredMs = deliveredNs / 1'000'000; + if (statistics.lastDeliveryMs) { + statistics.maxDeliveryGapMs = std::max(statistics.maxDeliveryGapMs, + deliveredMs - statistics.lastDeliveryMs); + } + statistics.lastDeliveryMs = deliveredMs; + for (std::size_t sample = 0; sample < 2 * packet.frames; ++sample) { + const double amplitude = packet.samples[sample]; + statistics.nonzeroSamples += std::abs(amplitude) > 1e-9; + statistics.peak = std::max(statistics.peak, std::abs(amplitude)); + if (sample % 2 == 0) { statistics.sumSquaresLeft += amplitude * amplitude; } + else { statistics.sumSquaresRight += amplitude * amplitude; } + } if (packet.slot < 0) { emit audioFrameReady(*frame); } else { emit sliceAudioFrameReady(packet.slot, *frame); } } @@ -1513,7 +1774,9 @@ void RtlSdrBackend::emitSliceState(const RtlCaptureTransaction::Receiver& receiv delta.filterHigh = static_cast(receiver.passband.filterHighHz); delta.audioGain = m_monitors[id].gain; delta.audioMute = m_monitors[id].mute; delta.audioPan = m_monitors[id].pan; delta.squelchOn = receiver.squelchEnabled; delta.squelchLevel = receiver.squelchLevel; - delta.panId = QStringLiteral("0xe1000000"); delta.active = id == m_requested.receivers.front().passband.stableId; + delta.automaticSquelch = receiver.automaticSquelch; + delta.automaticSquelchMarginDb = receiver.automaticSquelchMarginDb; + delta.panId = QStringLiteral("0xe1000000"); delta.active = id == m_activeSliceId; delta.inCapture = m_lastPublished && std::ranges::find(m_lastPublished->receivingIds, id) != m_lastPublished->receivingIds.end(); delta.wfmDeemphasisUs = receiver.wfmDeemphasisUs; @@ -1521,7 +1784,14 @@ void RtlSdrBackend::emitSliceState(const RtlCaptureTransaction::Receiver& receiv delta.wfmReceptionDiagnostics = m_wfmReception[id]; delta.wfmStereoStatus = m_wfmStatus[id]; delta.modeList = capabilities().receiveModeControl->modes; + const QPointer traceWorker(m_worker.get()); + markStartup(RtlStartupTrace::SliceBegin, id); emit sliceChanged(id, delta); + // A synchronous observer may replace the session. Its trace cannot own + // an end marker from this retired publication. + if (traceWorker && traceWorker.data() == m_worker.get()) { + markStartup(RtlStartupTrace::SliceEnd, id); + } } void RtlSdrBackend::publishCapture() @@ -1560,6 +1830,11 @@ void RtlSdrBackend::publishCapture() else { retirePcmStreams(); } } const auto prior = m_lastPublished; + if (std::ranges::none_of(state.receivers, [this](const auto& receiver) { + return receiver.passband.stableId == m_activeSliceId; + })) { + m_activeSliceId = state.receivers.empty() ? -1 : state.receivers.front().passband.stableId; + } const bool restoring = m_restoreToken == state.token; if (restoring) { for (const auto& receiver : state.receivers) { @@ -1603,7 +1878,17 @@ void RtlSdrBackend::publishCapture() m_wfmPublicationAge[id].invalidate(); m_wfmObservationAge[id].invalidate(); } + // Retire samples across every accepted capture revision, including parking, + // removal, retune and ID reuse. A definition alone is never a live sample. + if (prior) { + for (const auto& receiver : prior->receivers) { + emit meterRemoved(700 + receiver.passband.stableId); + if (!m_capture.confirmed() || m_capture.confirmed()->token != state.token) { return; } + } + } m_lastPublished = state; + publishRfMeterDefinitions(); + if (!m_capture.confirmed() || m_capture.confirmed()->token != state.token) { return; } for (const auto& receiver : state.receivers) { m_monitors[receiver.passband.stableId] = {receiver.audioGain, receiver.audioPan, receiver.audioMute}; updateMonitor(receiver.passband.stableId); @@ -1741,6 +2026,7 @@ void RtlSdrBackend::verifyDeviceSettingsIdentity(const QString& serial) m_deviceSettingsReason = tr("Session only: opened device serial does not match the settings identity."); m_ppmCorrection = 0; m_dcSuppression = false; + m_receiveMetersEnabled = true; } } @@ -1749,7 +2035,36 @@ void RtlSdrBackend::saveAcceptedDeviceSettings() m_deviceSettingsSaved = false; if (m_deviceSettingsAllowed) { m_deviceSettingsSaved = RtlDeviceSettings(m_settingsScope).saveAccepted( - {m_ppmCorrection, m_dcSuppression}, m_deviceSettingsReason); + {m_ppmCorrection, m_dcSuppression, m_receiveMetersEnabled}, m_deviceSettingsReason); + } +} + +void RtlSdrBackend::publishRfMeterDefinitions(bool retireValues) +{ + if (!m_lastPublished) { return; } + const auto state = *m_lastPublished; + const bool enabled = m_receiveMetersEnabled; + const auto current = [this, &state, enabled] { + return m_lastPublished && m_lastPublished->token == state.token + && m_capture.confirmed() && m_capture.confirmed()->token == state.token + && m_receiveMetersEnabled == enabled; + }; + for (const auto& receiver : state.receivers) { + MeterDef def; + def.index = 700 + receiver.passband.stableId; + def.source = QStringLiteral("SLC"); def.sourceIndex = receiver.passband.stableId; + def.name = QStringLiteral("LEVEL"); def.unit = QStringLiteral("dBFS"); + def.low = -120; def.high = 0; + def.description = tr("Peak windowed FFT bin in the receive passband, relative to ADC full scale; uncalibrated, not dBm or channel power"); + if (!enabled) { def.unavailableReason = tr("Receive meters disabled"); } + // Retire cached values before either enabling or disabling. A definition + // never stands in for a fresh observation after re-enabling. + if (retireValues) { + emit meterRemoved(def.index); + if (!current()) { return; } + } + emit meterDefined(def); + if (!current()) { return; } } } @@ -1759,6 +2074,7 @@ QVariantMap RtlSdrBackend::deviceSettingsStatus() const {QStringLiteral("applied"), m_connected && m_lastPublished.has_value()}, {QStringLiteral("ppm"), m_ppmCorrection}, {QStringLiteral("dcSuppression"), m_dcSuppression}, + {QStringLiteral("receiveMetersEnabled"), m_receiveMetersEnabled}, {QStringLiteral("pending"), m_capture.busy()}, {QStringLiteral("requestedPpm"), m_requested.hardware.ppm}, {QStringLiteral("requestedDcSuppression"), m_requested.dcSuppression}, diff --git a/src/core/backends/rtl/RtlSdrBackend.h b/src/core/backends/rtl/RtlSdrBackend.h index 9b774bab0c..b6bbbccd8b 100644 --- a/src/core/backends/rtl/RtlSdrBackend.h +++ b/src/core/backends/rtl/RtlSdrBackend.h @@ -61,6 +61,7 @@ class RtlSdrBackend : public IRadioBackend { bool createSlice(const QString& panId, double frequencyHz) override; bool removeSlice(int sliceId) override; + void setActiveSlice(int sliceId) override; void setSliceFrequency(int sliceId, double hz) override; bool requestReceiveTune(int sliceId, double hz, ReceiveTuneView view) override; bool recenterReceiveCapture(const QString& panId) override; @@ -77,6 +78,7 @@ class RtlSdrBackend : public IRadioBackend { void setSliceAudioGain(int sliceId, int gainPercent) override; void setSliceAudioPan(int sliceId, int panPercent) override; void setSliceSquelch(int sliceId, bool enabled, int level) override; + void setSliceAutoSquelch(int sliceId, bool enabled, int marginDb) override; void setSliceWfmDeemphasis(int sliceId, int microseconds) override; void setSliceWfmForceMono(int sliceId, bool forceMono) override; void setKeying(bool key, const AetherSDR::TxCoordinator::Operation& operation, const AetherSDR::TxCoordinator::Completion& completion = {}) override; @@ -131,6 +133,7 @@ class RtlSdrBackend : public IRadioBackend { QVector acceptedSettings() const; void finishExtensions(bool success, RtlCaptureTransaction::Token token = {}); QVariantMap deviceSettingsStatus() const; + void publishRfMeterDefinitions(bool retireValues = false); void saveAcceptedDeviceSettings(); void verifyDeviceSettingsIdentity(const QString& serial); bool acceptsFrame(quint64 session, quint64 revision) const; @@ -172,6 +175,7 @@ class RtlSdrBackend : public IRadioBackend { int m_panRfGainDb{kDefaultRfGainDb}; int m_ppmCorrection{0}; bool m_dcSuppression = false; + bool m_receiveMetersEnabled = true; RtlDeviceSettings::ReadResult m_savedDeviceSettings; bool m_deviceSettingsAllowed = false; bool m_deviceSettingsSaved = false; @@ -188,6 +192,9 @@ class RtlSdrBackend : public IRadioBackend { int m_receiverCapacity = kQualifiedReceiverCapacity; RtlCaptureTransaction m_capture{{8, kQualifiedReceiverCapacity}}; std::optional m_lastPublished; + // Focus belongs to a configured stable identity, including parked receivers. + // It does not change the capture or demodulator bank. + int m_activeSliceId = -1; struct Monitor { int gain = 100; int pan = 50; bool mute = false; }; std::array m_monitors; struct NativeAudio { @@ -219,6 +226,8 @@ class RtlSdrBackend : public IRadioBackend { // Worker thread (owns async USB reader & RtlSdrDdc engine) std::unique_ptr m_worker; + std::shared_ptr m_startupTrace; + void markStartup(RtlStartupTrace::Kind kind, int slot = -1); std::array m_wfmStatus{}; std::array m_wfmObservationAge; std::array m_wfmPublicationAge; @@ -231,6 +240,14 @@ class RtlSdrBackend : public IRadioBackend { }; std::array, 8> m_wfmLastObservation; RtlReceivePipeline::Diagnostics m_diagnostics; // owner-thread health cache + struct PcmDiagnostics { + quint64 packets = 0, frames = 0, nonzeroSamples = 0, nonfiniteSamples = 0; + quint64 discontinuities = 0, queueAgeTotalNs = 0, queueAgeMaxNs = 0; + quint64 lastDeliveryMs = 0, maxDeliveryGapMs = 0; + double peak = 0, sumSquaresLeft = 0, sumSquaresRight = 0; + }; + // Slots 0..7 are independent pre-monitor taps; slot 8 is the speaker mix. + std::array m_pcmDiagnostics{}; RtlSdrDdc* ddc(); }; diff --git a/src/core/backends/rtl/RtlSdrWorker.cpp b/src/core/backends/rtl/RtlSdrWorker.cpp index ee7109a3bb..79c13901ff 100644 --- a/src/core/backends/rtl/RtlSdrWorker.cpp +++ b/src/core/backends/rtl/RtlSdrWorker.cpp @@ -6,12 +6,31 @@ #include "core/LogManager.h" #include "core/backends/rtl/RtlSdrUsbDevice.h" #include +#include namespace AetherSDR::rtl { namespace { constexpr std::uint32_t kRtlBufLength = 16384; using T = RtlCaptureTransaction; +bool onlyMonitorDiffers(const T::State& before, const T::State& after) +{ + if (before.token.session != after.token.session || before.hardware != after.hardware + || before.capture != after.capture || before.dcSuppression != after.dcSuppression + || before.automaticDirectSampling != after.automaticDirectSampling + || before.receivingIds != after.receivingIds + || 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]; + const T::Receiver& next = after.receivers[i]; + previous.audioGain = next.audioGain; + previous.audioPan = next.audioPan; + previous.audioMute = next.audioMute; + if (previous != next) { return false; } + } + return true; +} + } // namespace RtlSdrWorker::RtlSdrWorker(struct rtlsdr_dev* dev, QObject* parent, std::size_t capacity) @@ -51,7 +70,7 @@ bool RtlSdrWorker::stopReading() { m_stopRequested = true; for (int attempt = 0; isRunning() && attempt < 50; ++attempt) { - if (m_device) { m_device->cancelAsync(); } + cancelReading(); if (wait(100)) { break; } } if (isRunning()) { @@ -62,6 +81,54 @@ bool RtlSdrWorker::stopReading() return true; } +void RtlSdrWorker::cancelReading() +{ + if (m_device) { + // Count API requests, including retries; this is not a USB restart or + // a claim that the driver acknowledged cancellation. + m_usbCancelRequests.fetch_add(1, std::memory_order_relaxed); + m_device->cancelAsync(); + } +} + +RtlReceivePipeline::Diagnostics RtlSdrWorker::diagnostics() const +{ + RtlReceivePipeline::Diagnostics result = m_pipeline->diagnostics(); + result.callbackCount = m_callbackCount.load(std::memory_order_relaxed); + result.callbackIqSamples = m_callbackIqSamples.load(std::memory_order_relaxed); + result.malformedCallbacks = m_malformedCallbacks.load(std::memory_order_relaxed); + result.callbackTotalNs = m_callbackTotalNs.load(std::memory_order_relaxed); + result.callbackMaxNs = m_callbackMaxNs.load(std::memory_order_relaxed); + result.callbackDeadlineMisses = m_callbackDeadlineMisses.load(std::memory_order_relaxed); + result.usbReadStarts = m_usbReadStarts.load(std::memory_order_relaxed); + result.usbCancelRequests = m_usbCancelRequests.load(std::memory_order_relaxed); + for (std::size_t i = 0; i < m_callbackDurationBuckets.size(); ++i) { + result.callbackDurationBuckets[i] = m_callbackDurationBuckets[i].load(std::memory_order_relaxed); + } + return result; +} + +void RtlSdrWorker::recordCallback(std::uint64_t durationNs, std::uint32_t iqSamples) noexcept +{ + m_callbackCount.fetch_add(1, std::memory_order_relaxed); + m_callbackIqSamples.fetch_add(iqSamples, std::memory_order_relaxed); + m_callbackTotalNs.fetch_add(durationNs, std::memory_order_relaxed); + // Only the acquisition context writes this maximum; readers use the mirror. + if (durationNs > m_callbackMaxNs.load(std::memory_order_relaxed)) { + m_callbackMaxNs.store(durationNs, std::memory_order_relaxed); + } + if (m_captureRateHz != 0 + && durationNs > std::uint64_t(iqSamples) * 1'000'000'000 / m_captureRateHz) { + m_callbackDeadlineMisses.fetch_add(1, std::memory_order_relaxed); + } + for (std::size_t i = 0; i < kCallbackDurationUpperBoundsNs.size(); ++i) { + if (durationNs <= kCallbackDurationUpperBoundsNs[i]) { + m_callbackDurationBuckets[i].fetch_add(1, std::memory_order_relaxed); + break; + } + } +} + bool RtlSdrWorker::submit(const Transaction::Work& work) { if (work.target.receivers.empty() @@ -121,12 +188,28 @@ void RtlSdrWorker::serviceCancellation() // race, including a device that never delivers its first callback. if (m_command.load(std::memory_order_acquire) == Command::Hardware && m_readerRunning.load(std::memory_order_acquire) && m_device) { - m_device->cancelAsync(); + cancelReading(); } } void RtlSdrWorker::applyDdc(const Transaction::State& state) { + if (m_work->before && m_applied == m_work->before->token && m_applied.revision != 0 + && !m_work->hardwareChanged && m_result + && m_result->code == Transaction::ResultCode::Applied + && onlyMonitorDiffers(*m_work->before, state)) { + // Includes compensation for a superseded audio-only request. The RF + // observation never changed, so retain the partial FFT, cadence and + // estimator. Still stamp every new frame with the adopted token; the + // backend's publication fences continue rejecting obsolete revisions. + const Transaction::Receiver& receiver = state.receivers.front(); + m_ddc.applyMonitor(receiver.audioGain, receiver.audioPan, receiver.audioMute); + m_applied = state.token; + qCDebug(lcPerf).nospace() << "RtlCapture phase=monitor_adopt ms=" << QDateTime::currentMSecsSinceEpoch() + << " session=" << state.token.session << " revision=" << state.token.revision + << " generation=" << state.capture.generation << " compensation=" << m_work->compensation; + return; + } bool compatible = m_work->before && m_applied.session == state.token.session && m_applied.revision != 0 && !m_work->compensation && m_result && m_result->code == Transaction::ResultCode::Applied @@ -164,6 +247,7 @@ void RtlSdrWorker::applyDdc(const Transaction::State& state) m_legacyFilterHighHz = highHz; m_ddc.applyMonitor(receiver.audioGain, receiver.audioPan, receiver.audioMute); m_applied = state.token; + m_captureRateHz = state.hardware.sampleRateHz; qCDebug(lcPerf).nospace() << "RtlCapture phase=adopt ms=" << QDateTime::currentMSecsSinceEpoch() << " session=" << state.token.session << " revision=" << state.token.revision << " centerHz=" << state.hardware.centerHz << " generation=" << state.capture.generation; @@ -247,6 +331,7 @@ void RtlSdrWorker::run() } m_readerRunning.store(true, std::memory_order_release); if (m_stopRequested.load(std::memory_order_acquire)) { break; } + m_usbReadStarts.fetch_add(1, std::memory_order_relaxed); const int rc = m_device->readAsync(&RtlSdrWorker::rtlsdrCallback, this); m_readerRunning.store(false, std::memory_order_release); if (m_stopRequested.load(std::memory_order_acquire)) { break; } @@ -263,20 +348,47 @@ void RtlSdrWorker::rtlsdrCallback(unsigned char* buf, std::uint32_t len, void* c if (!worker) { return; } const Command command = worker->m_command.load(std::memory_order_acquire); if (worker->m_stopRequested.load(std::memory_order_acquire) || command == Command::Hardware) { - worker->m_device->cancelAsync(); + worker->cancelReading(); return; } if (!buf || len == 0 || len % 2 != 0 || len > kRtlBufLength) { + worker->m_malformedCallbacks.fetch_add(1, std::memory_order_relaxed); worker->m_ddc.resetSpectrum(); worker->m_dcBlocker.reset(); return; } + const std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now(); + RtlStartupTrace* const trace = worker->m_startupTrace.get(); + const std::uint64_t attemptsBefore = trace ? trace->packetAttempts : 0; + const std::uint64_t dropsBefore = trace ? trace->packetDrops : 0; + if (trace) { + trace->callbackStartNs = static_cast(std::chrono::duration_cast(started.time_since_epoch()).count()); + trace->callbackIndex = worker->m_callbackCount.load(std::memory_order_relaxed) + 1; + if (command == Command::Receiver) { + trace->markProducer(RtlStartupTrace::WorkerAdoptBegin, worker->m_work->token.session, + worker->m_work->token.revision, worker->m_firstSample); + } + } const bool adopting = command == Command::Receiver && worker->m_pipeline->adopt(); - if (adopting) { worker->applyDdc(worker->m_work->target); } + if (adopting) { + worker->applyDdc(worker->m_work->target); + if (trace) { + trace->markProducer(RtlStartupTrace::WorkerAdoptEnd, worker->m_applied.session, + worker->m_applied.revision, worker->m_firstSample); + } + } worker->handleCallback(buf, len); // Complete is the release of ALL references into m_work, including DSP // output during this block. The control side may destroy it immediately. if (adopting) { worker->m_command.store(Command::Complete, std::memory_order_release); } + const auto ended = std::chrono::steady_clock::now(); + const std::uint64_t durationNs = static_cast( + std::chrono::duration_cast(ended - started).count()); + worker->recordCallback(durationNs, len / 2); + if (trace) { + trace->finishCallback(static_cast(std::chrono::duration_cast( + ended.time_since_epoch()).count()), len / 2, attemptsBefore, dropsBefore); + } } void RtlSdrWorker::handleCallback(unsigned char* buf, std::uint32_t len) diff --git a/src/core/backends/rtl/RtlSdrWorker.h b/src/core/backends/rtl/RtlSdrWorker.h index d7dbad3e91..d9d39ae641 100644 --- a/src/core/backends/rtl/RtlSdrWorker.h +++ b/src/core/backends/rtl/RtlSdrWorker.h @@ -7,10 +7,13 @@ #include #include +#include #include #include +#include #include #include +#include struct rtlsdr_dev; @@ -44,10 +47,24 @@ class RtlSdrWorker : public QThread { bool submit(const Transaction::Work& work); std::optional takeResult(); void serviceCancellation(); + bool takeRfObservation(RtlReceivePipeline::RfObservation& value) { return m_pipeline->takeRfObservation(value); } + bool rfObservationIsCurrent(const RtlReceivePipeline::RfObservation& value) const { return m_pipeline->rfObservationIsCurrent(value); } + void setReceiveMetersEnabled(bool enabled) { m_pipeline->setReceiveMetersEnabled(enabled); } bool takeAudio(RtlReceivePipeline::Packet& packet) { return m_pipeline->takePacket(packet); } - RtlReceivePipeline::Diagnostics diagnostics() const { return m_pipeline->diagnostics(); } + // Independently sampled counters since construction. Timing covers each + // valid callback's adoption and processing, not USB transport latency. + // The final bucket is unbounded; finite percentile bounds are conservative. + static constexpr std::array kCallbackDurationUpperBoundsNs{ + 50'000, 100'000, 200'000, 400'000, 800'000, 1'200'000, 1'600'000, 2'000'000, + 2'400'000, 2'800'000, 3'400'000, 5'000'000, 10'000'000, 20'000'000, + 50'000'000, std::numeric_limits::max()}; + RtlReceivePipeline::Diagnostics diagnostics() const; bool takeTraceEvent(RtlReceivePipeline::TraceEvent& event) { return m_pipeline->takeTraceEvent(event); } bool needsRepair() const { return m_pipeline->needsRepair(); } + void setStartupTrace(std::shared_ptr trace) + { m_startupTrace = std::move(trace); m_pipeline->setStartupTrace(m_startupTrace.get()); } + std::uint64_t startupDroppedPackets() const { return m_pipeline->droppedPackets(); } + std::uint64_t startupQueuedPackets() const { return m_pipeline->startupQueuedPackets(); } void setMonitor(int slot, int gain, int pan, bool mute) { m_pipeline->setMonitor(slot, gain, pan, mute); } signals: @@ -66,7 +83,10 @@ class RtlSdrWorker : public QThread { void applyDdc(const Transaction::State& state); void applyHardware(); bool prepareHardwareResult(); + void cancelReading(); + void recordCallback(std::uint64_t durationNs, std::uint32_t iqSamples) noexcept; + std::shared_ptr m_startupTrace; std::unique_ptr m_device; std::atomic m_readerRunning{false}; std::atomic m_stopRequested{false}; @@ -83,6 +103,16 @@ class RtlSdrWorker : public QThread { int m_legacyFilterHighHz = 0; std::unique_ptr m_pipeline; std::uint64_t m_firstSample = 0; + std::uint32_t m_captureRateHz = 0; // acquisition-context only + std::atomic m_callbackCount{0}; + std::atomic m_callbackIqSamples{0}; + std::atomic m_malformedCallbacks{0}; + std::atomic m_callbackTotalNs{0}; + std::atomic m_callbackMaxNs{0}; + std::atomic m_callbackDeadlineMisses{0}; + std::array, kCallbackDurationUpperBoundsNs.size()> m_callbackDurationBuckets{}; + std::atomic m_usbReadStarts{0}; + std::atomic m_usbCancelRequests{0}; RtlSdrDdc m_ddc; RtlDcBlocker m_dcBlocker; bool m_dcSuppression = false; diff --git a/src/core/backends/rtl/RtlSquelchGate.h b/src/core/backends/rtl/RtlSquelchGate.h index 47bcc58e2e..656aaac614 100644 --- a/src/core/backends/rtl/RtlSquelchGate.h +++ b/src/core/backends/rtl/RtlSquelchGate.h @@ -1,13 +1,17 @@ #pragma once #include +#include #include #include +#include +#include namespace AetherSDR::rtl { -// Acquisition-owned envelope. Detector and display both consume the existing -// 2048-point Blackman-Harris FFT, normalized by FFT size: dBFS/bin, not dBm -// or integrated channel power. No planner, locks or allocations run here. +// Acquisition-owned envelope using the fixed 2048-point Blackman-Harris FFT. +// Thresholds are dBFS per detector bin, not dBm or integrated channel power. +// The display has a different FFT size and must never drive this estimator. +// No planner, locks or allocations run here. class RtlSquelchGate final { public: static constexpr double kReferenceDb = -120.0; @@ -16,14 +20,52 @@ class RtlSquelchGate final { static constexpr std::uint64_t kStaleFrames = 4800; // missing detector: close static constexpr float kRampStep = 1.0f / 240.0f; // 5 ms - void configure(bool enabled, int level, bool reset = false) noexcept + void configure(bool enabled, int level, bool reset = false, + bool automatic = false, int marginDb = 10) noexcept { if (reset || enabled != m_enabled) { m_open = false; m_observed = false; m_lastAbove = 0; if (reset) { m_gain = enabled ? 0.0f : 1.0f; } } + if (reset || automatic != m_automatic || enabled != m_enabled) { m_floor = std::numeric_limits::quiet_NaN(); } + m_automatic = automatic; + m_marginDb = std::clamp(marginDb, 5, 20); m_enabled = enabled; - m_threshold = kReferenceDb + kStepDb * std::clamp(level, 0, 100); + if (!automatic) { m_threshold = kReferenceDb + kStepDb * std::clamp(level, 0, 100); } + } + void observeSpectrum(std::span bins, int low, int high, + std::uint64_t frame) noexcept + { + if (low < 0 || high < low || high >= static_cast(bins.size())) { + observe(std::numeric_limits::quiet_NaN(), frame); return; + } + double peak = kReferenceDb; + for (int i = low; i <= high; ++i) { + if (!std::isfinite(bins[i])) { observe(std::numeric_limits::quiet_NaN(), frame); return; } + peak = std::max(peak, double(bins[i])); + } + if (m_automatic) { + // Exclude the desired passband and window leakage. A median of + // neighboring bin powers rejects isolated carriers. For complex + // Gaussian noise, power is exponential: median / ln(2) estimates + // mean power. A trimmed mean of log power is systematically low + // and makes a peak detector hold noise open at the normal margin. + const int first = std::max(0, low - 64); + const int end = std::min(static_cast(bins.size()), high + 65); + std::array neighbors{}; + std::size_t count = 0; + for (int i = first; i < end; ++i) { + if (i >= low - 4 && i <= high + 4) { continue; } + if (std::isfinite(bins[i])) { neighbors[count++] = bins[i]; } + } + if (!count) { observe(std::numeric_limits::quiet_NaN(), frame); return; } + std::sort(neighbors.begin(), neighbors.begin() + count); + const double medianDb = (neighbors[(count - 1) / 2] + neighbors[count / 2]) * 0.5; + const double floor = medianDb + 10.0 * std::log10(1.0 / std::log(2.0)); + m_floor = std::isfinite(m_floor) ? 0.9 * m_floor + 0.1 * floor : floor; + m_threshold = m_floor + m_marginDb; + } + observe(peak, frame); } void observe(double peakDb, std::uint64_t frame) noexcept { @@ -46,6 +88,9 @@ class RtlSquelchGate final { return m_gain; } private: + bool m_automatic = false; + int m_marginDb = 10; + double m_floor = std::numeric_limits::quiet_NaN(); bool m_enabled = false; bool m_open = false; bool m_observed = false; diff --git a/src/core/backends/rtl/RtlStartupTrace.h b/src/core/backends/rtl/RtlStartupTrace.h new file mode 100644 index 0000000000..6cca7c6ec1 --- /dev/null +++ b/src/core/backends/rtl/RtlStartupTrace.h @@ -0,0 +1,122 @@ +#pragma once + +// Temporary opt-in startup investigation. Each buffer has one writer; inspect +// only after the acquisition thread has joined. No logging/I/O in capture. +#include +#include +#include +#include +#include +#include +#include + +namespace AetherSDR::rtl { +struct RtlStartupTrace { + enum Kind { ServiceBegin, ControlDone, SnapshotDone, PublishBegin, PublishEnd, + DrainBegin, DrainEnd, ServiceEnd, SliceBegin, SliceEnd, WorkerAdoptBegin, + WorkerAdoptEnd, FirstNativeBlock, FirstQueueFull, FirstDrop, FirstRecovery }; + static std::uint64_t nowNs() noexcept + { + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + } + struct Event { + int kind = 0, slot = -1; + std::uint64_t ns = 0, session = 0, revision = 0, requestedRevision = 0; + std::uint64_t captureFirst = 0, packetFirst = 0, callback = 0; + std::uint64_t drops = 0, queued = 0, frames = 0; + }; + template struct Buffer { + std::array events{}; + std::size_t size = 0, omitted = 0; + void add(const Event& event) noexcept + { + if (size < N) { events[size++] = event; } + else { ++omitted; } + } + }; + struct Bin { + std::uint64_t callbacks = 0, iqFrames = 0, attempts = 0, drops = 0; + std::uint64_t firstNs = 0, lastNs = 0, maxCallbackNs = 0, maxAttempts = 0; + }; + const std::uint64_t startedNs = nowNs(); + Buffer<16384> owner; // first 20 seconds, bounded even under timer reentry + Buffer<64> producer; + std::array bins{}; // 20 ms aggregates: first 2.56 s of native PCM + bool ownerExpired = false; + std::uint64_t nativeFirstNs = 0, callbackStartNs = 0, callbackIndex = 0; + std::uint64_t packetAttempts = 0, packetDrops = 0; + bool firstFull = false, firstDrop = false, firstRecovery = false; + + void markOwner(Kind kind, std::uint64_t session, std::uint64_t revision, + std::uint64_t requested, std::uint64_t drops, std::uint64_t queued, int slot = -1) noexcept + { + if (ownerExpired) { return; } + const std::uint64_t ns = nowNs(); + if (ns - startedNs > 20'000'000'000ULL) { ownerExpired = true; return; } + Event event; event.kind = kind; event.slot = slot; event.ns = ns; + event.session = session; event.revision = revision; event.requestedRevision = requested; + event.drops = drops; event.queued = queued; owner.add(event); + } + void markProducer(Kind kind, std::uint64_t session, std::uint64_t revision, + std::uint64_t captureFirst, int slot = -1, std::uint64_t packetFirst = 0, + std::uint64_t frames = 0, std::uint64_t queued = 0) noexcept + { + Event event; event.kind = kind; event.slot = slot; event.ns = nowNs(); + event.session = session; event.revision = revision; event.captureFirst = captureFirst; + event.packetFirst = packetFirst; event.callback = callbackIndex; + event.drops = packetDrops; event.queued = queued; event.frames = frames; + producer.add(event); + if (kind == FirstNativeBlock) { nativeFirstNs = event.ns; } + } + void finishCallback(std::uint64_t endedNs, std::uint64_t iqFrames, + std::uint64_t attemptsBefore, std::uint64_t dropsBefore) noexcept + { + if (!nativeFirstNs || endedNs < nativeFirstNs) { return; } + const std::uint64_t index = (endedNs - nativeFirstNs) / 20'000'000; + if (index >= bins.size()) { return; } + Bin& bin = bins[index]; + ++bin.callbacks; bin.iqFrames += iqFrames; + const std::uint64_t attempts = packetAttempts - attemptsBefore; + bin.attempts += attempts; bin.drops += packetDrops - dropsBefore; + if (!bin.firstNs) { bin.firstNs = callbackStartNs; } + bin.lastNs = endedNs; + bin.maxCallbackNs = std::max(bin.maxCallbackNs, endedNs - callbackStartNs); + bin.maxAttempts = std::max(bin.maxAttempts, attempts); + } + QByteArray jsonAfterJoin() const + { + const auto number = [](std::uint64_t n) { return QString::number(static_cast(n)); }; + const auto serialize = [&number](const auto& buffer) { + QJsonArray result; + for (std::size_t i = 0; i < buffer.size; ++i) { + const Event& event = buffer.events[i]; + result.append(QJsonObject{{"kind", event.kind}, {"slot", event.slot}, + {"ns", number(event.ns)}, {"session", number(event.session)}, + {"revision", number(event.revision)}, {"requestedRevision", number(event.requestedRevision)}, + {"captureFirst", number(event.captureFirst)}, {"packetFirst", number(event.packetFirst)}, + {"callback", number(event.callback)}, {"drops", number(event.drops)}, + {"queued", number(event.queued)}, {"frames", number(event.frames)}}); + } + return result; + }; + QJsonArray aggregate; + for (std::size_t i = 0; i < bins.size(); ++i) { + const Bin& bin = bins[i]; + if (!bin.callbacks) { continue; } + aggregate.append(QJsonObject{{"bin", int(i)}, {"callbacks", number(bin.callbacks)}, + {"iqFrames", number(bin.iqFrames)}, {"attempts", number(bin.attempts)}, + {"drops", number(bin.drops)}, {"firstNs", number(bin.firstNs)}, + {"lastNs", number(bin.lastNs)}, {"maxCallbackNs", number(bin.maxCallbackNs)}, + {"maxAttempts", number(bin.maxAttempts)}}); + } + const QJsonObject document{{"schema", 1}, {"startedNs", number(startedNs)}, + {"nativeFirstNs", number(nativeFirstNs)}, {"ownerEvents", serialize(owner)}, + {"producer", serialize(producer)}, {"producerBins20ms", aggregate}, + {"ownerOmitted", number(owner.omitted)}, {"producerOmitted", number(producer.omitted)}, + {"ownerExpired", ownerExpired}, {"writtenAfterJoin", true}, + {"packetAttemptsAtJoin", number(packetAttempts)}, {"packetDropsAtJoin", number(packetDrops)}}; + return QJsonDocument(document).toJson(QJsonDocument::Compact); + } +}; +} // namespace AetherSDR::rtl diff --git a/src/core/backends/sim/SimBackend.cpp b/src/core/backends/sim/SimBackend.cpp index 145cda11c6..6bb18baa36 100644 --- a/src/core/backends/sim/SimBackend.cpp +++ b/src/core/backends/sim/SimBackend.cpp @@ -251,6 +251,7 @@ RadioCapabilities SimBackend::capabilities() const {QStringLiteral("USB"), QStringLiteral("LSB")}}; caps.receiveFilterControl = std::nullopt; // demo passband setters only echo state caps.receiveAudioControl = std::nullopt; // no independently controlled RX mixer yet + caps.receiveAudioExport = std::nullopt; // native DAX export not qualified for this backend caps.receivePanCenterControl = std::nullopt; // spectrum remains anchored to the VFO caps.receivePanBandwidthControl = std::nullopt; // fixed synthetic span caps.canReboot = false; diff --git a/src/core/dsp/SpectrumTemporalAverage.h b/src/core/dsp/SpectrumTemporalAverage.h index 2a968bb8c5..bd8bcbea95 100644 --- a/src/core/dsp/SpectrumTemporalAverage.h +++ b/src/core/dsp/SpectrumTemporalAverage.h @@ -73,12 +73,32 @@ class SpectrumTemporalAverage final { m_originHz = originHz; m_stepHz = stepHz; } - std::copy(values.begin(), values.end(), m_observation.begin()); const double exponent = -elapsedSeconds / (average * kMsPerStep / 1000.0); const double retained = std::exp(exponent); const double alpha = -std::expm1(exponent); const double offset = (originHz - m_originHz) / stepHz; std::size_t reused = 0; + if (offset == 0.0) { + // Aligned observations only read their own old RF bin, so display + // and history can advance together without copying or resampling. + // Use old validity for output before retiring newly excluded bins. + for (std::size_t i = 0; i < values.size(); ++i) { + const float observed = values[i]; + const bool previous = m_valid[i]; + const float estimate = previous ? float(retained * m_state[i] + alpha * observed) + : observed; + values[i] = logarithmic ? estimate : decibels(estimate); + const bool usable = i >= firstUsable && i < endUsable; + if (usable) { + m_state[i] = estimate; + if (previous) { ++reused; } + } + m_valid[i] = usable; + } + m_haveAverage = true; + return reused; + } + std::copy(values.begin(), values.end(), m_observation.begin()); for (std::size_t i = 0; i < values.size(); ++i) { const auto previous = sample(m_state, double(i) + offset, 0, values.size(), true); const float estimate = previous ? float(retained * *previous + alpha * m_observation[i]) diff --git a/src/gui/AppletPanel.cpp b/src/gui/AppletPanel.cpp index 8b4b02482b..81d52a9754 100644 --- a/src/gui/AppletPanel.cpp +++ b/src/gui/AppletPanel.cpp @@ -1758,13 +1758,12 @@ void AppletPanel::setProfilesVisible(bool visible) QStringLiteral("Applet_PROF"), visible); } -void AppletPanel::setDaxStreamsVisible(bool visible) +void AppletPanel::setDaxStreamsVisible(bool visible, bool nativeReceive) { - // Both tiles are the same capability: "DAX" is per-slice receive audio, - // "IQ" is per-panadapter IQ, and a radio produces both stream kinds or - // neither. + // A native slice PCM export provides audio only; IQ and radio-side DAX + // selectors retain their separate radio stream capability. applyCapabilityVisibility(QStringLiteral("DAX"), - QStringLiteral("Applet_DAX"), visible); + QStringLiteral("Applet_DAX"), visible || nativeReceive); applyCapabilityVisibility(QStringLiteral("IQ"), QStringLiteral("Applet_IQ"), visible); // The AetherClock applet is NOT a DAX tile — it stays visible on every diff --git a/src/gui/AppletPanel.h b/src/gui/AppletPanel.h index 78835d8987..23dbe2e835 100644 --- a/src/gui/AppletPanel.h +++ b/src/gui/AppletPanel.h @@ -225,7 +225,7 @@ class AppletPanel : public QWidget { // connected radio produces per-slice audio / per-pan IQ streams // (RadioCapabilities::hasDaxStreams). Same not-markHardwareConditional // reasoning as setProfilesVisible above. - void setDaxStreamsVisible(bool visible); + void setDaxStreamsVisible(bool visible, bool nativeReceive = false); // Show/hide the EQ button and applet — the radio's own 8-band hardware // equalizer (RadioCapabilities::hasRadioSideDsp). Deliberately does NOT diff --git a/src/gui/Ax25HfPacketDecodeDialog.cpp b/src/gui/Ax25HfPacketDecodeDialog.cpp index 4f6c8a2a57..952985a8ec 100644 --- a/src/gui/Ax25HfPacketDecodeDialog.cpp +++ b/src/gui/Ax25HfPacketDecodeDialog.cpp @@ -845,10 +845,9 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, theme::setContainer(this, QStringLiteral("dialog/ax25Decode")); setMinimumSize(1080, 680); - m_shim = new AetherAx25LibmodemShim(); - m_shim->moveToThread(&m_shimThread); - connect(&m_shimThread, &QThread::finished, m_shim, &QObject::deleteLater); - m_shimThread.start(); + if (m_radio) { + m_receive = std::make_unique(*m_radio); + } m_kissServer = new KissTncServer(this); configureTncAuthority(true); m_heard = new HeardList(this); @@ -1163,51 +1162,35 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, this, &Ax25HfPacketDecodeDialog::startTransmitFromUi); connect(m_txPaceTimer, &QTimer::timeout, this, &Ax25HfPacketDecodeDialog::paceTransmitAudio); - connect(m_shim, &AetherAx25LibmodemShim::frameDecoded, - this, &Ax25HfPacketDecodeDialog::appendFrame); -#ifdef HAVE_MQTT - connect(m_shim, &AetherAx25LibmodemShim::frameDecoded, - this, &Ax25HfPacketDecodeDialog::publishFrameMqtt); -#endif - // RX -> KISS clients: forward every decoded frame to connected hosts. - connect(m_shim, &AetherAx25LibmodemShim::frameDecoded, this, - [this](const Ax25DecodedFrame& frame) { - if (m_kissServer && m_kissServer->isListening() && !frame.ax25FrameNoFcs.isEmpty()) { - m_kissServer->broadcastAx25Frame(frame.ax25FrameNoFcs); - ++m_kissRxCount; - refreshTncStatus(); - } - }); - // RX -> Mailbox: feed every decoded frame to the PMS (heard list always; - // connected-mode handling only for frames addressed to our PMS callsign). - connect(m_shim, &AetherAx25LibmodemShim::frameDecoded, this, - [this](const Ax25DecodedFrame& frame) { - if (frame.ax25FrameNoFcs.isEmpty()) - return; - // Record into the shared heard log once (drives MHEARD + quick-connect), - // then through the APRS parser into the station roster + messenger. - if (auto decoded = ax25::Frame::decode(frame.ax25FrameNoFcs)) { - if (m_heard) - m_heard->record(*decoded); - if (auto packet = aprs::parseFrame(*decoded)) { - if (m_aprsStations) - m_aprsStations->record(*packet); - if (m_aprsMessenger) - m_aprsMessenger->onPacket(*packet); - } - if (frame.fcsOk && m_enableDecode && m_enableDecode->isChecked()) { - m_digi->receiveFrame(frame.ax25FrameNoFcs); - } - } - if (m_pms) - m_pms->onAirFrame(frame.ax25FrameNoFcs); - if (m_terminal) - m_terminal->onAirFrame(frame.ax25FrameNoFcs); - }); - connect(m_shim, &AetherAx25LibmodemShim::diagnosticsUpdated, - this, &Ax25HfPacketDecodeDialog::updateDiagnostics); - connect(m_shim, &AetherAx25LibmodemShim::statusChanged, - this, &Ax25HfPacketDecodeDialog::refreshStatus); + if (m_receive) { + connect(m_receive.get(), &Ax25ReceiveModel::frameDecoded, + this, &Ax25HfPacketDecodeDialog::handleDecodedFrame); + connect(m_receive.get(), &Ax25ReceiveModel::diagnosticsUpdated, this, + [this](const Ax25DecoderDiagnostics& diagnostics, const Ax25ReceiveContext& context) { + updateDiagnostics(diagnostics, context); + }); + connect(m_receive.get(), &Ax25ReceiveModel::statusChanged, + this, &Ax25HfPacketDecodeDialog::refreshStatus); + connect(m_receive.get(), &Ax25ReceiveModel::routeStatusChanged, + this, &Ax25HfPacketDecodeDialog::refreshStatus); + connect(m_receive.get(), &Ax25ReceiveModel::sourceReset, this, [this] { + const QPointer guard(this); + m_lastDiagnostics = {}; + m_lastDiagnosticsUtc = {}; + m_lastActivityHdlc = 0; + m_lastActivityAccepted = 0; + if (m_captureActive) { finishAudioCapture(false); } + if (!guard) { return; } + refreshStatus(); + }); + connect(m_receive.get(), &Ax25ReceiveModel::pcmReady, this, + [this](const DecoderPcmBlock& block, const Ax25ReceiveContext& context) { + if (!context.current()) { return; } + handleRxAudio(QByteArray(reinterpret_cast(block.samples.constData()), + block.samples.size() * static_cast(sizeof(float))), + DecoderPcmBlock::kSampleRateHz, context); + }); + } connect(m_heartbeatTimer, &QTimer::timeout, this, &Ax25HfPacketDecodeDialog::updateHeartbeat); @@ -1228,12 +1211,6 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, }); } - if (m_audio) { - connect(m_audio, &AudioEngine::tncRxAudioReady, - this, &Ax25HfPacketDecodeDialog::handleRxAudio, - Qt::QueuedConnection); - } - // KISS TNC server wiring. connect(m_kissServer, &KissTncServer::ax25FrameFromClient, this, &Ax25HfPacketDecodeDialog::handleKissFrameFromClient); @@ -1457,14 +1434,14 @@ Ax25HfPacketDecodeDialog::~Ax25HfPacketDecodeDialog() finishAudioCapture(false); if (m_kissServer) m_kissServer->stop(); - if (m_audio) - m_audio->setTncRxTapEnabled(false); - m_shimThread.quit(); - m_shimThread.wait(); + m_receive.reset(); } void Ax25HfPacketDecodeDialog::setAttachedSlice(SliceModel* slice) { + const QPointer guard(this); + if (m_receive) { m_receive->setSlice(slice); } + if (!guard) { return; } if (m_attachedSlice == slice) { logAttachedSliceState(QStringLiteral("slice state refresh")); refreshStatus(); @@ -1505,6 +1482,7 @@ void Ax25HfPacketDecodeDialog::setAttachedSlice(SliceModel* slice) void Ax25HfPacketDecodeDialog::setModemProfile(Ax25ModemProfile profile, bool persist) { + const QPointer guard(this); m_shimConfig = ax25DemodConfigForProfile(profile, Ax25TonePolarity::Normal); if (m_digi) { m_digi->setBaud(m_shimConfig.baud); } if (m_digiEnable) { m_digiEnable->setEnabled(m_shimConfig.baud == 1200); } @@ -1513,9 +1491,8 @@ void Ax25HfPacketDecodeDialog::setModemProfile(Ax25ModemProfile profile, bool pe // mid-sweep — and the sweep would be measuring the wrong preamble. if (m_terminalTxPreamble) m_shimConfig.txPreambleFlags = m_terminalTxPreamble->value(); - QMetaObject::invokeMethod(m_shim, [shim = m_shim, cfg = m_shimConfig]() { - shim->configure(cfg); - }, Qt::QueuedConnection); + if (m_receive) { m_receive->configure(m_shimConfig); } + if (!guard) { return; } m_lastDiagnostics = {}; m_lastDiagnosticsUtc = {}; @@ -2066,6 +2043,10 @@ void Ax25HfPacketDecodeDialog::enableDecodeForProgram(TxProgram kind) void Ax25HfPacketDecodeDialog::applyDecodeEnabled(bool enabled) { + const QPointer guard(this); + // Retire receive work before any callback from shutdown/control cleanup. + if (m_receive) { m_receive->setEnabled(enabled); } + if (!guard) { return; } if (!enabled) { for (std::size_t i = 0; i < m_txPrograms.size(); ++i) { (void)setTxProgram(static_cast(i), false); @@ -2074,17 +2055,14 @@ void Ax25HfPacketDecodeDialog::applyDecodeEnabled(bool enabled) } if (!enabled && m_digi) { m_digi->setEnabled(false); } if (enabled) { - QMetaObject::invokeMethod(m_shim, &AetherAx25LibmodemShim::reset, Qt::QueuedConnection); m_lastDiagnostics = {}; m_enabledUtc = QDateTime::currentDateTimeUtc(); m_lastDiagnosticsUtc = {}; m_lastNoAudioNoticeUtc = {}; m_lastActivityHdlc = 0; m_lastActivityAccepted = 0; - if (m_audio) - m_audio->setTncRxTapEnabled(true); appendSystemLine(QStringLiteral( - "Modem enabled. RX tap requested; waiting for 24 kHz PC RX audio.")); + "Modem enabled. Waiting for selected receive audio converted to 24 kHz mono.")); } else { if (m_captureActive) finishAudioCapture(false); @@ -2115,9 +2093,6 @@ void Ax25HfPacketDecodeDialog::applyDecodeEnabled(bool enabled) if (m_pms) m_pms->dropLink(); - if (m_audio) - m_audio->setTncRxTapEnabled(false); - QMetaObject::invokeMethod(m_shim, &AetherAx25LibmodemShim::reset, Qt::QueuedConnection); m_lastDiagnostics = {}; m_lastDiagnosticsUtc = {}; m_lastActivityHdlc = 0; @@ -2130,8 +2105,51 @@ void Ax25HfPacketDecodeDialog::applyDecodeEnabled(bool enabled) refreshPmsStatus(); } -void Ax25HfPacketDecodeDialog::handleRxAudio(const QByteArray& monoFloat32Pcm, int sampleRate) +void Ax25HfPacketDecodeDialog::handleDecodedFrame( + const Ax25DecodedFrame& frame, const Ax25ReceiveContext& context) { + const QPointer guard(this); + const auto current = [&] { return guard && context.current(); }; + if (!current()) { return; } + appendFrame(frame, context); + if (!current()) { return; } +#ifdef HAVE_MQTT + publishFrameMqtt(frame); + if (!current()) { return; } +#endif + if (m_kissServer && m_kissServer->isListening() && !frame.ax25FrameNoFcs.isEmpty()) { + m_kissServer->broadcastAx25Frame(frame.ax25FrameNoFcs); + if (!current()) { return; } + ++m_kissRxCount; + refreshTncStatus(); + if (!current()) { return; } + } + if (frame.ax25FrameNoFcs.isEmpty()) { return; } + if (auto decoded = ax25::Frame::decode(frame.ax25FrameNoFcs)) { + if (m_heard) { m_heard->record(*decoded); } + if (!current()) { return; } + if (auto packet = aprs::parseFrame(*decoded)) { + if (m_aprsStations) { m_aprsStations->record(*packet); } + if (!current()) { return; } + if (m_aprsMessenger) { m_aprsMessenger->onPacket(*packet); } + if (!current()) { return; } + } + if (frame.fcsOk && m_enableDecode && m_enableDecode->isChecked()) { + m_digi->receiveFrame(frame.ax25FrameNoFcs); + if (!current()) { return; } + } + } + if (m_pms) { m_pms->onAirFrame(frame.ax25FrameNoFcs); } + if (!current()) { return; } + if (m_terminal) { m_terminal->onAirFrame(frame.ax25FrameNoFcs); } +} + +void Ax25HfPacketDecodeDialog::handleRxAudio( + const QByteArray& monoFloat32Pcm, int sampleRate, const Ax25ReceiveContext& context) +{ + const QPointer guard(this); + const auto current = [&] { return guard && context.current(); }; + if (!current()) { return; } if (m_captureActive && !monoFloat32Pcm.isEmpty()) { if (m_captureSampleRate == 0) { m_captureSampleRate = sampleRate; @@ -2141,12 +2159,14 @@ void Ax25HfPacketDecodeDialog::handleRxAudio(const QByteArray& monoFloat32Pcm, i appendSystemLine(QStringLiteral("Audio capture armed: %1 seconds at %2 Hz.") .arg(kAudioCaptureSeconds) .arg(sampleRate)); + if (!current() || !m_captureActive) { return; } } if (sampleRate != m_captureSampleRate) { appendSystemLine(QStringLiteral("Audio capture cancelled: sample rate changed from %1 to %2 Hz.") .arg(m_captureSampleRate) .arg(sampleRate)); + if (!current() || !m_captureActive) { return; } finishAudioCapture(false); } else { const qsizetype remaining = m_captureTargetBytes - m_capturePcm.size(); @@ -2158,9 +2178,6 @@ void Ax25HfPacketDecodeDialog::handleRxAudio(const QByteArray& monoFloat32Pcm, i } } - QMetaObject::invokeMethod(m_shim, [shim = m_shim, pcm = monoFloat32Pcm, sr = sampleRate]() { - shim->feedAudio(pcm, sr); - }, Qt::QueuedConnection); } void Ax25HfPacketDecodeDialog::startAudioCapture() @@ -2170,6 +2187,9 @@ void Ax25HfPacketDecodeDialog::startAudioCapture() return; } + const QPointer guard(this); + if (m_receive) { m_receive->reset(); } + if (!guard) { return; } m_capturePcm.clear(); m_captureId = makeAx25AudioCaptureId(); m_captureSampleRate = 0; @@ -2177,7 +2197,6 @@ void Ax25HfPacketDecodeDialog::startAudioCapture() m_captureTxSequence = 0; m_captureIcomPostResampleActive = false; m_captureActive = true; - QMetaObject::invokeMethod(m_shim, &AetherAx25LibmodemShim::reset, Qt::QueuedConnection); m_lastDiagnostics = {}; m_lastDiagnosticsUtc = {}; m_lastActivityHdlc = 0; @@ -2187,6 +2206,7 @@ void Ax25HfPacketDecodeDialog::startAudioCapture() if (m_captureButton) m_captureButton->setText(QStringLiteral("Cancel Capture")); appendSystemLine(QStringLiteral("Decoder state reset for RX audio capture.")); + if (!guard) { return; } appendSystemLine(QStringLiteral( "Starting %1 second RX/TX audio capture (%2); transmit several packets now.") .arg(kAudioCaptureSeconds) @@ -2195,7 +2215,9 @@ void Ax25HfPacketDecodeDialog::startAudioCapture() void Ax25HfPacketDecodeDialog::finishAudioCapture(bool save) { + const QPointer guard(this); finishIcomPostResampleCapture(); + if (!guard) { return; } const QByteArray capture = m_capturePcm; const QString captureId = m_captureId; const int sampleRate = m_captureSampleRate; @@ -3099,21 +3121,32 @@ void Ax25HfPacketDecodeDialog::finishTransmit(bool aborted, const QString& reaso } } -void Ax25HfPacketDecodeDialog::appendFrame(const Ax25DecodedFrame& frame) +void Ax25HfPacketDecodeDialog::appendFrame( + const Ax25DecodedFrame& frame, const Ax25ReceiveContext& context) { - if (!frame.fcsOk) + const QPointer guard(this); + const auto current = [&] { return guard && context.current(); }; + if (!current() || !frame.fcsOk) return; ++m_frameCount; m_lastDecodeUtc = frame.timestampUtc; m_log->append(formatTerminalLine(frame)); + if (!current()) { return; } m_log->verticalScrollBar()->setValue(m_log->verticalScrollBar()->maximum()); - if (m_packetActivity) + if (!current()) { return; } + if (m_packetActivity) { m_packetActivity->recordFrame(); + } + if (!current()) { return; } refreshStatus(); } -void Ax25HfPacketDecodeDialog::updateDiagnostics(const Ax25DecoderDiagnostics& diagnostics) +void Ax25HfPacketDecodeDialog::updateDiagnostics( + const Ax25DecoderDiagnostics& diagnostics, const Ax25ReceiveContext& context) { + const QPointer guard(this); + const auto current = [&] { return guard && context.current(); }; + if (!current()) { return; } const bool firstAudio = !m_lastDiagnosticsUtc.isValid(); m_lastDiagnostics = diagnostics; m_lastDiagnosticsUtc = QDateTime::currentDateTimeUtc(); @@ -3121,9 +3154,12 @@ void Ax25HfPacketDecodeDialog::updateDiagnostics(const Ax25DecoderDiagnostics& d appendSystemLine(QStringLiteral("RX audio stream detected: %1 Hz, %2 samples/window.") .arg(diagnostics.sampleRate) .arg(diagnostics.audioSamples)); + if (!current()) { return; } + } + if (m_diagnosticsDebugEnabled) { + appendDiagnosticsLine(diagnostics, context); + if (!current()) { return; } } - if (m_diagnosticsDebugEnabled) - appendDiagnosticsLine(diagnostics); refreshStatus(); } @@ -3165,13 +3201,13 @@ void Ax25HfPacketDecodeDialog::updateHeartbeat() if (waited >= 2 && (!m_lastNoAudioNoticeUtc.isValid() || m_lastNoAudioNoticeUtc.secsTo(now) >= 5)) { appendSystemLine(QStringLiteral( - "Waiting for RX audio blocks. Confirm PC Audio is enabled, a slice is active, and AetherSDR is receiving the packet audio stream.")); + "Waiting for RX audio blocks. Check the selected receiver and receive source shown in modem status.")); m_lastNoAudioNoticeUtc = now; } } else if (m_lastDiagnosticsUtc.secsTo(now) >= 4 && (!m_lastNoAudioNoticeUtc.isValid() || m_lastNoAudioNoticeUtc.secsTo(now) >= 5)) { appendSystemLine(QStringLiteral( - "No fresh RX audio diagnostics for %1 s. The tap is enabled, but audio may be paused or PC Audio may be off.") + "No fresh RX audio diagnostics for %1 s. The selected receive source may be paused or unavailable.") .arg(m_lastDiagnosticsUtc.secsTo(now))); m_lastNoAudioNoticeUtc = now; } @@ -3200,7 +3236,31 @@ void Ax25HfPacketDecodeDialog::refreshStatus() status = m_attachedSliceId >= 0 ? QStringLiteral("Standby") : QStringLiteral("No slice attached"); } + QString sourceText = QStringLiteral("RX: unavailable"); + if (m_receive) { + switch (m_receive->routeStatus()) { + case DecoderAudioModel::RouteStatus::Bound: + sourceText = QStringLiteral("RX: selected slice"); + break; + case DecoderAudioModel::RouteStatus::SharedRxAudio: + sourceText = QStringLiteral("RX: shared audio (audible slice mix; gain and mute apply)"); + break; + case DecoderAudioModel::RouteStatus::DaxTransportUnavailable: + sourceText = QStringLiteral("RX: assigned DAX transport unavailable"); + break; + case DecoderAudioModel::RouteStatus::Inactive: + sourceText = QStringLiteral("RX: inactive"); + break; + } + } + status += QStringLiteral(" | ") + sourceText; if (m_modemStatusValue) { + QString sourceDescription = sourceText; + if (m_receive && (m_receive->routeStatus() == DecoderAudioModel::RouteStatus::SharedRxAudio + || m_receive->routeStatus() == DecoderAudioModel::RouteStatus::DaxTransportUnavailable)) { + sourceDescription += QStringLiteral(". Assign a DAX RX channel to isolate a Flex slice; no channel is assigned automatically."); + } + m_modemStatusValue->setAccessibleDescription(sourceDescription); const QString stateText = m_lastDiagnostics.inFrame ? QStringLiteral("frame") : m_lastDiagnostics.inPreamble ? QStringLiteral("preamble") : QStringLiteral("search"); @@ -3288,10 +3348,7 @@ void Ax25HfPacketDecodeDialog::setDiagnosticsDebugEnabled(bool enabled, bool per return; m_diagnosticsDebugEnabled = enabled; - if (m_shim) - QMetaObject::invokeMethod(m_shim, [shim = m_shim, enabled]() { - shim->setDiagnosticsLoggingEnabled(enabled); - }, Qt::QueuedConnection); + if (m_receive) { m_receive->setDiagnosticsLoggingEnabled(enabled); } if (m_terminal) m_terminal->setVerbose(enabled); // echo protocol detail inline in the terminal if (m_packetActivity) @@ -3341,6 +3398,7 @@ void Ax25HfPacketDecodeDialog::logAttachedSliceState(const QString& reason) void Ax25HfPacketDecodeDialog::appendSystemLine(const QString& text) { + const QPointer guard(this); if (!m_log) return; qCDebug(lcAx25).noquote() << text; @@ -3349,6 +3407,7 @@ void Ax25HfPacketDecodeDialog::appendSystemLine(const QString& text) "MODEM  " "%2") .arg(utcClock().toHtmlEscaped(), text.toHtmlEscaped())); + if (!guard) { return; } m_log->verticalScrollBar()->setValue(m_log->verticalScrollBar()->maximum()); } @@ -3375,9 +3434,11 @@ void Ax25HfPacketDecodeDialog::appendTransmitLine(const Ax25TransmitFrame& frame m_log->verticalScrollBar()->setValue(m_log->verticalScrollBar()->maximum()); } -void Ax25HfPacketDecodeDialog::appendDiagnosticsLine(const Ax25DecoderDiagnostics& diagnostics) +void Ax25HfPacketDecodeDialog::appendDiagnosticsLine( + const Ax25DecoderDiagnostics& diagnostics, const Ax25ReceiveContext& context) { - if (!m_log || !m_diagnosticsDebugEnabled) + const QPointer guard(this); + if (!context.current() || !m_log || !m_diagnosticsDebugEnabled) return; const QString state = diagnostics.inFrame @@ -3434,6 +3495,7 @@ void Ax25HfPacketDecodeDialog::appendDiagnosticsLine(const Ax25DecoderDiagnostic "DIAG  " "%2") .arg(utcClock().toHtmlEscaped(), line.toHtmlEscaped())); + if (!guard || !context.current()) { return; } m_log->verticalScrollBar()->setValue(m_log->verticalScrollBar()->maximum()); } diff --git a/src/gui/Ax25HfPacketDecodeDialog.h b/src/gui/Ax25HfPacketDecodeDialog.h index 5c9de04089..d68078c723 100644 --- a/src/gui/Ax25HfPacketDecodeDialog.h +++ b/src/gui/Ax25HfPacketDecodeDialog.h @@ -4,7 +4,7 @@ #include "models/RadioModel.h" #include "models/TxController.h" #include -#include "core/tnc/AetherAx25LibmodemShim.h" +#include "models/Ax25ReceiveModel.h" #include #include @@ -15,7 +15,7 @@ #include #include #include -#include +#include class QAbstractButton; class QCheckBox; @@ -200,7 +200,9 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { // without this a backend that drops the command leaves the TX — and every // frame queued behind it — hung until the window closes. void armTxStreamWaitTimeout(); - void handleRxAudio(const QByteArray& monoFloat32Pcm, int sampleRate); + void handleDecodedFrame(const Ax25DecodedFrame& frame, const Ax25ReceiveContext& context); + void handleRxAudio(const QByteArray& monoFloat32Pcm, int sampleRate, + const Ax25ReceiveContext& context); void startAudioCapture(); void finishAudioCapture(bool save); void captureGeneratedTxAudio(const Ax25TransmitResult& tx); @@ -279,8 +281,9 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { const TxCoordinator::Request& input); void maybeStartNextKissTx(); void refreshTncStatus(); - void appendFrame(const Ax25DecodedFrame& frame); - void updateDiagnostics(const Ax25DecoderDiagnostics& diagnostics); + void appendFrame(const Ax25DecodedFrame& frame, const Ax25ReceiveContext& context); + void updateDiagnostics(const Ax25DecoderDiagnostics& diagnostics, + const Ax25ReceiveContext& context); void updateHeartbeat(); void refreshStatus(); void refreshTransmitControls(); @@ -288,7 +291,8 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { void logAttachedSliceState(const QString& reason); void appendSystemLine(const QString& text); void appendTransmitLine(const Ax25TransmitFrame& frame); - void appendDiagnosticsLine(const Ax25DecoderDiagnostics& diagnostics); + void appendDiagnosticsLine(const Ax25DecoderDiagnostics& diagnostics, + const Ax25ReceiveContext& context); QString formatTerminalLine(const Ax25DecodedFrame& frame) const; QString defaultTransmitSource() const; QString transmitSliceSummary() const; @@ -299,8 +303,7 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { AudioEngine* m_audio{nullptr}; RadioModel* m_radio{nullptr}; - AetherAx25LibmodemShim* m_shim{nullptr}; - QThread m_shimThread; + std::unique_ptr m_receive; Ax25DemodConfig m_shimConfig; QStackedWidget* m_tabStack{nullptr}; QAbstractButton* m_ax25Tab{nullptr}; diff --git a/src/gui/DaxApplet.cpp b/src/gui/DaxApplet.cpp index fc9e6664b8..2283949099 100644 --- a/src/gui/DaxApplet.cpp +++ b/src/gui/DaxApplet.cpp @@ -1,5 +1,6 @@ #include "DaxApplet.h" #include "MeterSlider.h" +#include "ControlAvailabilityRegistry.h" #include "SliceLabel.h" #include "core/AppSettings.h" #include "core/ThemeManager.h" @@ -117,6 +118,7 @@ void DaxApplet::buildUI() row->addWidget(chLabel); m_daxRxStatus[i] = new QLabel(QStringLiteral("\u2014")); + m_daxRxStatus[i]->setObjectName(QStringLiteral("daxRxStatus%1").arg(i+1)); m_daxRxStatus[i]->setStyleSheet(kStatusLabel); m_daxRxStatus[i]->setFixedWidth(40); m_daxRxStatus[i]->setTextFormat(Qt::RichText); // slice letter may be HTML (#2606) @@ -202,9 +204,16 @@ void DaxApplet::setRadioModel(RadioModel* model) return; } + auto* availability = new ControlAvailabilityRegistry(*model,this); + availability->registerWidget(m_daxTxMeter,tr("DAX transmit audio is unavailable on this receive-only radio."), + [](bool connected,const RadioCapabilities& caps) { + return !connected || (caps.canTransmit && caps.hasDaxStreams); + }); + // Wire slice add/remove for DAX channel tracking connect(model, &RadioModel::sliceAdded, this, [this](SliceModel* s) { connect(s, &SliceModel::daxChannelChanged, this, [this]() { + if (m_nativeReceiveRouting) { return; } // Update DAX status labels for (int i = 0; i < kChannels; ++i) { m_daxRxStatus[i]->setText(QStringLiteral("\u2014")); @@ -246,6 +255,23 @@ void DaxApplet::setRadioModel(RadioModel* model) updateTxLabel(); } +void DaxApplet::setNativeReceiveRouting(bool native) +{ + if (m_nativeReceiveRouting == native) { return; } + m_nativeReceiveRouting = native; + for (int channel = 1; channel <= kChannels; ++channel) { + setReceiveChannelSlice(channel,nullptr); + } +} + +void DaxApplet::setReceiveChannelSlice(int channel, SliceModel* slice) +{ + if (channel < 1 || channel > kChannels || !m_daxRxStatus[channel-1]) { return; } + m_daxRxStatus[channel-1]->setText(slice + ? tr("Slice %1").arg(SliceLabel::richText(slice->sliceId(),slice->letter())) + : QStringLiteral("—")); +} + void DaxApplet::setDaxEnabled(bool on) { if (!m_daxEnable) { // note-only on Windows (#4112) diff --git a/src/gui/DaxApplet.h b/src/gui/DaxApplet.h index deb08354e2..934bff9297 100644 --- a/src/gui/DaxApplet.h +++ b/src/gui/DaxApplet.h @@ -22,6 +22,8 @@ class DaxApplet : public QWidget { explicit DaxApplet(QWidget* parent = nullptr); void setRadioModel(RadioModel* model); + void setNativeReceiveRouting(bool native); + void setReceiveChannelSlice(int channel, SliceModel* slice); // Bound the operator-facing RX row count to the radio's slice capacity // (Principle I): a 6300 exposes 2, a 6700 eight. Rows above n are HIDDEN, @@ -43,6 +45,7 @@ class DaxApplet : public QWidget { void buildUI(); RadioModel* m_model{nullptr}; + bool m_nativeReceiveRouting{false}; int m_maxDaxChannels{kChannels}; // radio slice capacity (FlexLib table) QPushButton* m_daxEnable{nullptr}; diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index b4bc3edeb2..d132fe8c44 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -4539,7 +4539,7 @@ void MainWindow::showNetworkDiagnosticsDialog() tciServer()); #else showOrRaisePersistent(m_networkDiagnosticsDialog, - &m_radioModel, m_audio, m_networkDiagnosticsHistory); + &m_radioModel, m_audio, m_networkDiagnosticsHistory, nullptr); #endif } @@ -7524,7 +7524,8 @@ void MainWindow::applyCapabilitiesToUi(bool connected, const RadioCapabilities& spectrum->setPanBinsAbsolute(binsAbsolute); const auto sql = connected ? caps.receiveSquelchModel : std::nullopt; spectrum->setSquelchScale(sql ? sql->referenceDb : -160.0, - sql ? sql->stepDb : 1.0, sql ? sql->unit : QString()); + sql ? sql->stepDb : 1.0, sql ? sql->unit : QString(), + !sql || sql->spectrumComparable); } } @@ -7554,18 +7555,17 @@ void MainWindow::applyCapabilitiesToUi(bool connected, const RadioCapabilities& // ── DAX: the DAX and DAX-IQ applets, and the autostart toggle ────────── // - // VISIBILITY ONLY. startDax()'s null-check on panStream() is a separate - // crash guard and stays exactly where it is — the two are not merged. This - // stops the operator being offered the controls; that stops a session that - // somehow reaches the bridge anyway from segfaulting on a null stream. const bool dax = !connected || caps.hasDaxStreams; + bool nativeDax = false; +#if defined(HAVE_WEBSOCKETS) && (defined(Q_OS_MAC) || defined(HAVE_PIPEWIRE)) + nativeDax = connected && caps.receiveAudioExport.has_value(); +#endif if (m_appletPanel) { - m_appletPanel->setDaxStreamsVisible(dax); + m_appletPanel->setDaxStreamsVisible(dax,nativeDax); + m_appletPanel->daxApplet()->setNativeReceiveRouting(nativeDax); } - for (VfoWidget* vfo : findChildren()) + for (VfoWidget* vfo : findChildren()) { vfo->setDaxVisible(dax); - if (m_autoDaxAction) { - m_autoDaxAction->setVisible(dax); } // Extended DSP (NRS / RNN / NRF): read via hasExtendedDspFilters(), which diff --git a/src/gui/MainWindow_DigitalModes.cpp b/src/gui/MainWindow_DigitalModes.cpp index 0335cb82f6..59b6f49c20 100644 --- a/src/gui/MainWindow_DigitalModes.cpp +++ b/src/gui/MainWindow_DigitalModes.cpp @@ -41,6 +41,7 @@ #include "core/WfmSettings.h" #include "models/BandPlanManager.h" #include "models/RadioModel.h" +#include "models/DaxReceiveModel.h" #include "models/SliceModel.h" #include @@ -1204,14 +1205,12 @@ bool MainWindow::startDax() { if (m_daxBridge) return true; - // DAX rides PanadapterStream's VITA-49 audio, which only a Flex backend - // owns — RadioModel leaves panStream() null for every other family (see - // its makeBackend/Flex-adapter step). Bail before creating the bridge so a - // non-Flex session can't reach the acquireDaxChannel() calls below on a - // null stream. Without this, connecting to an HL2 with AutoStartDAX=True - // segfaults ~3 s later from the auto-start timer in onConnectionStateChanged. - if (!m_radioModel.panStream()) { - qCDebug(lcDax) << "MainWindow: DAX unavailable — backend has no PanadapterStream"; + bool nativeReceive = false; +#ifdef HAVE_WEBSOCKETS + nativeReceive = m_radioModel.backendCapabilities().receiveAudioExport.has_value() && tciServer(); +#endif + if (!nativeReceive && !m_radioModel.panStream()) { + qCDebug(lcDax) << "MainWindow: DAX unavailable for this backend/build"; return false; } @@ -1231,7 +1230,7 @@ bool MainWindow::startDax() // Open only as many DAX RX devices as the radio has slices — the audio // device list follows the radio (#4854). maxSlices() is known here: this // runs on the 3 s post-connect timer, well after the radio reported it. - if (!m_daxBridge->open(m_radioModel.maxSlices())) { + if (!m_daxBridge->open(m_radioModel.maxSlices(), nativeReceive)) { qWarning() << "MainWindow: failed to open DAX audio bridge"; QMessageBox::warning(this, "DAX Audio Bridge Error", "AetherSDR could not open the DAX audio bridge.\n\n" @@ -1245,68 +1244,70 @@ bool MainWindow::startDax() // RadioModel::handleDaxRxStreamRegistry (#3305) — instead of per-consumer // statusReceived hooks with divergent filtering. - // Acquire DAX channels only for slices with a channel assigned. - // FlexLib creates streams on demand, not all 8 unconditionally. - // Creating unused streams causes the radio to round-robin audio - // across all of them, starving the active channels. - m_daxSliceLastCh.clear(); - for (auto* s : m_radioModel.slices()) { - int ch = s->daxChannel(); - m_daxSliceLastCh[s->sliceId()] = ch; - if (ch >= 1 && ch <= 8) { - m_radioModel.acquireDaxChannel( - ch, PanadapterStream::DaxConsumer::Bridge); - } - } - - // #2895: the one-shot loop above only covers slices that ALREADY have a - // DAX channel at bridge startup (typically just slice 0 / DAX 1). When the - // user later assigns DAX 2-4 to another slice via the UI, SliceModel only - // sends `slice set dax=` — it never sends `stream create - // type=dax_rx`, so the radio never registers a DAX client (dax_clients - // stays 0) and sends silence. React to per-slice channel changes here and - // create/remove the DAX RX stream on demand, mirroring the TCI path - // (TciServer::ensureDaxForTci, #1331/#1439) and FlexLib - // RequestDAXRXAudioStream(channel). - for (auto* s : m_radioModel.slices()) { - wireDaxSlice(s); - } - m_daxSliceConns.append(connect(&m_radioModel, &RadioModel::sliceAdded, - this, [this](SliceModel* s) { - if (!m_daxBridge || !s) return; - // Let onDaxChannelChanged() see a 0 -> channel transition for slices - // restored with DAX already assigned. - m_daxSliceLastCh[s->sliceId()] = 0; - wireDaxSlice(s); - // A slice can arrive already carrying a DAX channel (radio profile - // restore); make sure its stream exists too. - if (s->daxChannel() >= 1 && s->daxChannel() <= 8) { - onDaxChannelChanged(s, s->daxChannel()); - } - })); - // A slice removed out from under us (pan close, foreign client, profile - // load) never fires daxChannelChanged — release its channel here or the - // bridge holds it until stopDax (a silent radio-side orphan, #3305). - m_daxSliceConns.append(connect(&m_radioModel, &RadioModel::sliceRemoved, - this, [this](int sliceId) { - if (!m_daxBridge) return; - const int ch = m_daxSliceLastCh.take(sliceId); - if (ch < 1 || ch > 4) return; + if (!nativeReceive) { + // Acquire DAX channels only for slices with a channel assigned. + // FlexLib creates streams on demand, not all 8 unconditionally. + // Creating unused streams causes the radio to round-robin audio + // across all of them, starving the active channels. + m_daxSliceLastCh.clear(); for (auto* s : m_radioModel.slices()) { - if (s && s->daxChannel() == ch) return; // channel hopped slices + int ch = s->daxChannel(); + m_daxSliceLastCh[s->sliceId()] = ch; + if (ch >= 1 && ch <= 8) { + m_radioModel.acquireDaxChannel( + ch, PanadapterStream::DaxConsumer::Bridge); + } } - m_radioModel.releaseDaxChannel( - ch, PanadapterStream::DaxConsumer::Bridge); - })); - // Wire DAX RX: PanadapterStream routes registered DAX streams here - connect(m_radioModel.panStream(), &PanadapterStream::daxPcmReady, - m_daxBridge, [bridge = m_daxBridge](int channel, const PcmFrame& frame) { - const QByteArray pcm = frame.legacyStereo24(); - if (!pcm.isEmpty()) { - bridge->feedDaxAudio(channel, pcm); + // #2895: the one-shot loop above only covers slices that ALREADY have a + // DAX channel at bridge startup (typically just slice 0 / DAX 1). When the + // user later assigns DAX 2-4 to another slice via the UI, SliceModel only + // sends `slice set dax=` — it never sends `stream create + // type=dax_rx`, so the radio never registers a DAX client (dax_clients + // stays 0) and sends silence. React to per-slice channel changes here and + // create/remove the DAX RX stream on demand, mirroring the TCI path + // (TciServer::ensureDaxForTci, #1331/#1439) and FlexLib + // RequestDAXRXAudioStream(channel). + for (auto* s : m_radioModel.slices()) { + wireDaxSlice(s); } - }); + m_daxSliceConns.append(connect(&m_radioModel, &RadioModel::sliceAdded, + this, [this](SliceModel* s) { + if (!m_daxBridge || !s) return; + // Let onDaxChannelChanged() see a 0 -> channel transition for slices + // restored with DAX already assigned. + m_daxSliceLastCh[s->sliceId()] = 0; + wireDaxSlice(s); + // A slice can arrive already carrying a DAX channel (radio profile + // restore); make sure its stream exists too. + if (s->daxChannel() >= 1 && s->daxChannel() <= 8) { + onDaxChannelChanged(s, s->daxChannel()); + } + })); + // A slice removed out from under us (pan close, foreign client, profile + // load) never fires daxChannelChanged — release its channel here or the + // bridge holds it until stopDax (a silent radio-side orphan, #3305). + m_daxSliceConns.append(connect(&m_radioModel, &RadioModel::sliceRemoved, + this, [this](int sliceId) { + if (!m_daxBridge) return; + const int ch = m_daxSliceLastCh.take(sliceId); + if (ch < 1 || ch > DaxBridge::NUM_CHANNELS) { return; } + for (auto* s : m_radioModel.slices()) { + if (s && s->daxChannel() == ch) return; // channel hopped slices + } + m_radioModel.releaseDaxChannel( + ch, PanadapterStream::DaxConsumer::Bridge); + })); + + // Wire DAX RX: PanadapterStream routes registered DAX streams here + connect(m_radioModel.panStream(), &PanadapterStream::daxPcmReady, + m_daxBridge, [bridge = m_daxBridge](int channel, const PcmFrame& frame) { + const QByteArray pcm = frame.legacyStereo24(); + if (!pcm.isEmpty()) { + bridge->feedDaxAudio(channel, pcm); + } + }); + } // DAX-IQ stream-status routing, the VITA-49 IQ feed, level meter, and the // enable/disable/rate connections are wired ONCE at construction (see the @@ -1334,6 +1335,21 @@ bool MainWindow::startDax() m_daxBridge->setChannelGain(i, ss.value(QStringLiteral("DaxRxGain%1").arg(i), "0.5").toString().toFloat()); m_daxBridge->setTxGain(ss.value("DaxTxGain", "0.5").toString().toFloat()); +#ifdef HAVE_WEBSOCKETS + if (nativeReceive) { + auto* receive = new DaxReceiveModel(m_radioModel, *tciServer(), m_daxBridge); + connect(receive, &DaxReceiveModel::audioReady, m_daxBridge, &DaxBridge::feedDaxAudio); + connect(receive, &DaxReceiveModel::channelReset, m_daxBridge, &DaxBridge::resetRxChannel); + connect(receive, &DaxReceiveModel::channelSliceChanged, m_appletPanel->daxApplet(), + [this](int channel, int sliceId) { + m_appletPanel->daxApplet()->setReceiveChannelSlice(channel,m_radioModel.slice(sliceId)); + }); + receive->setEnabled(true); + qCInfo(lcDax) << "MainWindow: starting receive-only DAX audio bridge"; + return true; + } +#endif + // Wire DAX TX: apps → bridge → AudioEngine → VITA-49. // AudioEngine chooses packet format/routing based on DaxTxLowLatency. // This is one configured shared endpoint, not an inferred process identity. @@ -1371,8 +1387,11 @@ void MainWindow::stopDax() { if (!m_daxBridge) return; - m_audio->setDaxTxMode(false); - m_audio->clearTxAccumulators(); // self-marshals + const bool receiveOnly = m_daxBridge->isReceiveOnly(); + if (!receiveOnly) { + m_audio->setDaxTxMode(false); + m_audio->clearTxAccumulators(); // self-marshals + } // #2895: drop the per-slice daxChannelChanged / sliceAdded reactions wired // in startDax() so they don't fire against a torn-down bridge. @@ -1382,8 +1401,9 @@ void MainWindow::stopDax() m_daxSliceConns.clear(); m_daxSliceLastCh.clear(); - disconnect(m_radioModel.panStream(), &PanadapterStream::daxPcmReady, - m_daxBridge, nullptr); + if (m_radioModel.panStream()) { + disconnect(m_radioModel.panStream(), &PanadapterStream::daxPcmReady, m_daxBridge, nullptr); + } disconnect(m_daxBridge, &DaxBridge::txAudioReady, this, nullptr); @@ -1392,13 +1412,20 @@ void MainWindow::stopDax() // or RADE still uses survives a bridge teardown — the #3363/#2886 failure // class, now enforced structurally instead of by cross-consumer peeking // (#3305). - m_radioModel.releaseAllDaxChannels( - PanadapterStream::DaxConsumer::Bridge); + if (!receiveOnly) { + m_radioModel.releaseAllDaxChannels(PanadapterStream::DaxConsumer::Bridge); + } // Restore original mic selection - if (!m_savedMicSelection.isEmpty() && m_savedMicSelection != "PC") + if (!receiveOnly && !m_savedMicSelection.isEmpty() && m_savedMicSelection != "PC") { m_radioModel.sendCommand(QString("transmit set mic_selection=%1").arg(m_savedMicSelection)); + } + if (receiveOnly) { + for (int channel = 1; channel <= DaxBridge::NUM_CHANNELS; ++channel) { + m_appletPanel->daxApplet()->setReceiveChannelSlice(channel,nullptr); + } + } m_daxBridge->close(); delete m_daxBridge; m_daxBridge = nullptr; diff --git a/src/gui/MainWindow_Menus.cpp b/src/gui/MainWindow_Menus.cpp index a4dd46fc5f..928711ba6d 100644 --- a/src/gui/MainWindow_Menus.cpp +++ b/src/gui/MainWindow_Menus.cpp @@ -19,6 +19,7 @@ #include "AetherialAudioStrip.h" #include "AppletPanel.h" #include "DaxApplet.h" +#include "ControlAvailabilityRegistry.h" #include "PanadapterApplet.h" #include "PanadapterStack.h" #include "RadioSetupDialog.h" @@ -939,8 +940,16 @@ void MainWindow::buildMenuBar() } #else auto* autoDaxAction = settingsMenu->addAction("Autostart DAX with AetherSDR"); - m_autoDaxAction = autoDaxAction; // hidden by applyCapabilitiesToUi() on a - // radio that reports no DAX streams + m_autoDaxAction = autoDaxAction; + auto* daxAvailability = new ControlAvailabilityRegistry(m_radioModel, this); + daxAvailability->registerAction(autoDaxAction, tr("DAX audio export is unavailable for this receiver in this build."), + [](bool connected, const RadioCapabilities& caps) { + bool nativeReceive = false; +#ifdef HAVE_WEBSOCKETS + nativeReceive = caps.receiveAudioExport.has_value(); +#endif + return !connected || caps.hasDaxStreams || nativeReceive; + }); autoDaxAction->setCheckable(true); autoDaxAction->setChecked( AppSettings::instance().value("AutoStartDAX", "False").toString() == "True"); diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index 5f243a7d71..0d57a49374 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -1385,7 +1385,8 @@ bool MainWindow::autoSquelchShouldRunOnSpectrum( } const auto sql = m_radioModel.backendCapabilities().receiveSquelchModel; - if (sql && (!sql->modes.contains(s->mode()) || panId != s->panId())) { return false; } + if (sql && (sql->automaticInEngine || !sql->modes.contains(s->mode()) + || panId != s->panId())) { return false; } return kiwiSdrProfileForPan(panId).isEmpty() && (!spectrum || !spectrum->kiwiSdrWaterfallActive()); @@ -1505,6 +1506,16 @@ void MainWindow::wireVfoTelemetry(VfoWidget* vfo, SliceModel* s) return; } + auto* relativeTimer = new QTimer(vfo); + relativeTimer->setInterval(100); + connect(relativeTimer, &QTimer::timeout, vfo, [this, vfo, id = s->sliceId()] { + const auto& meters = m_radioModel.meterModel(); + const MeterDef* def = meters.meterDef(meters.findMeter("SLC", "LEVEL", id)); + if ((def && def->unit == "dBFS") || vfo->relativeSignalLevel()) { + vfo->setRelativeSignalLevel(meters.relativeLevelForSlice(id), def ? def->unavailableReason : QString()); + } + }); + relativeTimer->start(); // Feed S-meter per-slice — only this VFO's slice level const int sid = s->sliceId(); const QPointer vfoPtr(vfo); @@ -1813,6 +1824,7 @@ void MainWindow::onSliceAdded(SliceModel* s) // toggle that flag on slice-mode transitions (e.g. band-stack restore) // because doing so parks DAX2's TX Stream in Busy. (#2315) auto updateDaxTxMode = [this]() { + if (!m_radioModel.backendCapabilities().canTransmit) { return; } bool isDigital = false; int txSliceId = -1; for (auto* sl : m_radioModel.slices()) { @@ -2963,7 +2975,7 @@ void MainWindow::runProfileLoadRecoveryPass(const QString& profileType, #endif #if defined(Q_OS_MAC) || defined(HAVE_PIPEWIRE) - if (m_daxBridge) { + if (m_daxBridge && m_radioModel.backendCapabilities().hasDaxStreams) { auto* panStream = m_radioModel.panStream(); bool txSliceIsDigital = false; @@ -3919,7 +3931,8 @@ void MainWindow::wirePanadapter(PanadapterApplet* applet) const auto sql = m_radioModel.isConnected() ? m_radioModel.backendCapabilities().receiveSquelchModel : std::nullopt; sw->setSquelchScale(sql ? sql->referenceDb : -160.0, - sql ? sql->stepDb : 1.0, sql ? sql->unit : QString()); + sql ? sql->stepDb : 1.0, sql ? sql->unit : QString(), + !sql || sql->spectrumComparable); wirePanDisplayStatus(applet, pan); } @@ -6121,6 +6134,19 @@ void MainWindow::applyAmpTxMeters(float watts, float swr, bool fromRelay) void MainWindow::wireMeters() { + auto* relativeTimer = new QTimer(this); + relativeTimer->setInterval(100); + connect(relativeTimer, &QTimer::timeout, this, [this] { + if (!m_appletPanel) { return; } + auto* meter = m_appletPanel->sMeterWidget(); + const auto& meters = m_radioModel.meterModel(); + const MeterDef* def = meters.meterDef(meters.findMeter("SLC", "LEVEL", m_activeSliceId)); + if ((def && def->unit == "dBFS") || meter->relativeLevel()) { + meter->setRelativeLevel(meters.relativeLevelForSlice(m_activeSliceId), m_activeSliceId, + def ? def->unavailableReason : QString()); + } + }); + relativeTimer->start(); // ── S-Meter: MeterModel → SMeterWidget (active slice only) ───────────── connect(&m_radioModel.meterModel(), &MeterModel::sLevelChanged, this, [this](int sliceIndex, float dbm) { diff --git a/src/gui/RtlReceiverSettingsWidget.cpp b/src/gui/RtlReceiverSettingsWidget.cpp index 8c358cdff0..4075d0e747 100644 --- a/src/gui/RtlReceiverSettingsWidget.cpp +++ b/src/gui/RtlReceiverSettingsWidget.cpp @@ -47,8 +47,9 @@ bool supportsSettings(const RadioCapabilities& caps) RtlReceiverSettingsWidget::RtlReceiverSettingsWidget(RadioModel& model, QWidget* parent) : QWidget(parent), m_model(model), m_availability(new ControlAvailabilityRegistry(model, this)), - m_controls(new QGroupBox(tr("Receiver corrections"), this)), + m_controls(new QGroupBox(tr("Receiver settings"), this)), m_ppm(new RtlPpmSpinBox(m_controls)), m_ppmTimer(new QTimer(this)), m_dc(new QCheckBox(tr("Suppress IQ DC"), m_controls)), + m_meters(new QCheckBox(tr("Enable receive meters"), m_controls)), m_applied(new QLabel(this)), m_status(new QLabel(this)), m_identity(new QLabel(this)) { setObjectName(QStringLiteral("rtlReceiverSettings")); @@ -69,6 +70,9 @@ RtlReceiverSettingsWidget::RtlReceiverSettingsWidget(RadioModel& model, QWidget* form->addRow(apply); m_dc->setObjectName(QStringLiteral("rtlDcSuppression")); form->addRow(m_dc); + m_meters->setObjectName(QStringLiteral("rtlReceiveMeters")); + m_meters->setAccessibleDescription(tr("Turn off to reduce display CPU use. Reception, squelch and audio continue.")); + form->addRow(m_meters); layout->addWidget(m_controls); m_applied->setObjectName(QStringLiteral("rtlCorrectionsApplied")); m_status->setObjectName(QStringLiteral("rtlCorrectionsStatus")); @@ -89,8 +93,9 @@ RtlReceiverSettingsWidget::RtlReceiverSettingsWidget(RadioModel& model, QWidget* "with a 5 Hz high-pass response. Signals at capture center are also affected, especially an AM or CW carrier. " "Keep wanted signals away from capture center. After a capture change or enable, allow about 0.3 seconds to settle. " "The squelch measurement stays unchanged.\n\n" - "Accepted corrections are saved per reported device serial. Dongles reporting the same serial share settings; " - "devices without a serial use corrections for this session only."), this); + "Receive meters can be disabled to reduce CPU use; RF levels then show unavailable. Reception, squelch and audio continue.\n\n" + "Accepted settings are saved per reported device serial. Dongles reporting the same serial share settings; " + "devices without a serial use settings for this session only."), this); explanation->setWordWrap(true); layout->addWidget(explanation); layout->addStretch(); @@ -104,6 +109,17 @@ RtlReceiverSettingsWidget::RtlReceiverSettingsWidget(RadioModel& model, QWidget* m_availability->registerWidget(m_dc, tr("The connected radio does not provide RTL receiver corrections."), available, [this] { return m_haveState && m_confirmed.value(QStringLiteral("dcSuppression")).toBool(); }); + m_availability->registerWidget(m_meters, + tr("The connected radio does not provide a receive-meter switch."), + [available](bool connected, const RadioCapabilities& caps) { + return available(connected, caps) && caps.extensions.value(QStringLiteral("rtl")).toMap() + .value(QStringLiteral("receiveMetersControl")).toBool(); + }, [this] { return m_haveState && m_confirmed.value(QStringLiteral("receiveMetersEnabled"), true).toBool(); }); + connect(m_meters, &QCheckBox::clicked, this, [this](bool requested) { + const QSignalBlocker block(m_meters); + m_meters->setChecked(m_confirmed.value(QStringLiteral("receiveMetersEnabled"), true).toBool()); + submit(QStringLiteral("receive_meters.set"), requested, m_metersRequest); + }); connect(m_ppm, &QSpinBox::valueChanged, this, [this] { if (!isVisible() || !m_haveState) { return; } m_ppmEdited = true; @@ -131,12 +147,12 @@ void RtlReceiverSettingsWidget::bindBackend() disconnect(m_statusConnection); disconnect(m_resultConnection); disconnect(m_errorConnection); m_backend = m_model.backend(); const quint64 generation = ++m_bindingGeneration; - m_queryRequest = m_ppmRequest = m_dcRequest = 0; + m_queryRequest = m_ppmRequest = m_dcRequest = m_metersRequest = 0; m_haveState = false; m_ppmEdited = false; m_confirmed.clear(); m_error.clear(); { - const QSignalBlocker ppmBlock(m_ppm), dcBlock(m_dc); - m_ppm->setValue(0); m_dc->setChecked(false); + const QSignalBlocker ppmBlock(m_ppm), dcBlock(m_dc), metersBlock(m_meters); + m_ppm->setValue(0); m_dc->setChecked(false); m_meters->setChecked(true); } renderStatus(); if (!m_model.isConnected() || !m_backend || !supportsSettings(m_model.backendCapabilities())) { return; } @@ -152,20 +168,21 @@ void RtlReceiverSettingsWidget::bindBackend() if (generation != m_bindingGeneration || !source || source != m_model.backend()) { return; } if (id == m_queryRequest && id) { m_queryRequest = 0; acceptStatus(value.toMap()); - } else if (id && (id == m_ppmRequest || id == m_dcRequest)) { + } else if (id && (id == m_ppmRequest || id == m_dcRequest || id == m_metersRequest)) { if (id == m_ppmRequest) { m_ppmRequest = 0; // Preserve a newer edit that the operator has not submitted. m_ppmEdited = m_ppm->value() != value.toInt(); } if (id == m_dcRequest) { m_dcRequest = 0; } + if (id == m_metersRequest) { m_metersRequest = 0; } queryStatus(); } }); m_errorConnection = connect(source, &IRadioBackend::extensionError, this, [this, source, generation](quint64 id, const QString& reason) { if (generation != m_bindingGeneration || !source || source != m_model.backend() || !id - || (id != m_queryRequest && id != m_ppmRequest && id != m_dcRequest)) { return; } + || (id != m_queryRequest && id != m_ppmRequest && id != m_dcRequest && id != m_metersRequest)) { return; } const bool queryFailed = id == m_queryRequest; if (queryFailed) { m_queryRequest = 0; } if (id == m_ppmRequest) { @@ -175,6 +192,7 @@ void RtlReceiverSettingsWidget::bindBackend() if (m_ppm->value() == m_submittedPpm) { m_ppmTimer->stop(); } } if (id == m_dcRequest) { m_dcRequest = 0; } + if (id == m_metersRequest) { m_metersRequest = 0; } m_error = tr("Request refused: %1").arg(reason); if (!queryFailed) { queryStatus(); } renderStatus(); @@ -195,9 +213,10 @@ void RtlReceiverSettingsWidget::acceptStatus(const QVariantMap& status) if (!status.value(QStringLiteral("applied")).toBool()) { return; } m_haveState = true; m_confirmed = status; - const QSignalBlocker ppmBlock(m_ppm), dcBlock(m_dc); + const QSignalBlocker ppmBlock(m_ppm), dcBlock(m_dc), metersBlock(m_meters); if (!m_ppmEdited) { m_ppm->setValue(status.value(QStringLiteral("ppm")).toInt()); } m_dc->setChecked(status.value(QStringLiteral("dcSuppression")).toBool()); + m_meters->setChecked(status.value(QStringLiteral("receiveMetersEnabled"), true).toBool()); m_availability->refreshEngaged(); renderStatus(); } @@ -256,11 +275,11 @@ void RtlReceiverSettingsWidget::renderStatus() if (!usable) { lines << tr("Waiting for a connected receiver's confirmed settings."); } else { if (m_ppmTimer->isActive()) { lines << tr("Frequency correction will apply after this adjustment."); } - if (m_ppmRequest || m_dcRequest || m_confirmed.value(QStringLiteral("pending")).toBool()) { + if (m_ppmRequest || m_dcRequest || m_metersRequest || m_confirmed.value(QStringLiteral("pending")).toBool()) { lines << tr("Applying request; the applied values above remain in use until confirmation."); } lines << (m_confirmed.value(QStringLiteral("saved")).toBool() - ? tr("Applied corrections saved for this device.") + ? tr("Applied settings saved for this device.") : m_confirmed.value(QStringLiteral("saveReason"), tr("Applied for this session; not saved.")).toString()); } if (!m_error.isEmpty()) { lines << m_error; } diff --git a/src/gui/RtlReceiverSettingsWidget.h b/src/gui/RtlReceiverSettingsWidget.h index f2dfe6dc7a..d2e2f793f9 100644 --- a/src/gui/RtlReceiverSettingsWidget.h +++ b/src/gui/RtlReceiverSettingsWidget.h @@ -44,6 +44,7 @@ class RtlReceiverSettingsWidget final : public QWidget { QSpinBox* m_ppm; QTimer* m_ppmTimer; QCheckBox* m_dc; + QCheckBox* m_meters; QLabel* m_applied; QLabel* m_status; QLabel* m_identity; @@ -56,5 +57,6 @@ class RtlReceiverSettingsWidget final : public QWidget { quint64 m_queryRequest = 0; quint64 m_ppmRequest = 0; quint64 m_dcRequest = 0; + quint64 m_metersRequest = 0; }; } // namespace AetherSDR diff --git a/src/gui/RxApplet.cpp b/src/gui/RxApplet.cpp index 88b3af8816..f98913edf9 100644 --- a/src/gui/RxApplet.cpp +++ b/src/gui/RxApplet.cpp @@ -1036,9 +1036,13 @@ void RxApplet::buildUI() // pan's auto-squelch algorithm picks up the new margin // and the suggested level recomputes on the next FFT. const int margin = std::clamp(v, 5, 20); - auto& s = AppSettings::instance(); - s.setValue("AutoSqlMarginDb", QString::number(margin)); - s.save(); + if (usingEngineAutoSquelch() && m_slice) { + m_slice->setAutomaticSquelch(true, margin); + } else { + auto& s = AppSettings::instance(); + s.setValue("AutoSqlMarginDb", QString::number(margin)); + s.save(); + } emit autoSqlMarginDbChanged(margin); } }); @@ -1393,8 +1397,7 @@ void RxApplet::applySqlModeVisuals() case SqlMode::Auto: { m_sqlSlider->setRange(5, 20); const int margin = std::clamp( - AppSettings::instance().value("AutoSqlMarginDb", "10").toInt(), - 5, 20); + autoSqlMarginDb(), 5, 20); m_sqlSlider->setValue(margin); m_sqlSlider->setEnabled(m_sqlBtn->isEnabled()); m_sqlSlider->setToolTip( @@ -1442,10 +1445,17 @@ void RxApplet::cycleSqlModeExternal() int RxApplet::autoSqlMarginDb() const { + if (usingEngineAutoSquelch() && m_slice) { return m_slice->automaticSquelchMarginDb(); } return std::clamp( AppSettings::instance().value("AutoSqlMarginDb", "10").toInt(), 5, 20); } +bool RxApplet::usingEngineAutoSquelch() const +{ + return m_receiveSquelchModel && m_receiveSquelchModel->automaticInEngine + && !usingExternalReceiveSquelch(); +} + bool RxApplet::usingExternalReceiveSquelch() const { return m_slice && m_slice->externalReceiveReplacementActive(); @@ -1514,9 +1524,13 @@ void RxApplet::setSqlSliderValueExternal(int v) } } else if (m_sqlMode == SqlMode::Auto) { const int margin = std::clamp(v, 5, 20); - auto& s = AppSettings::instance(); - s.setValue("AutoSqlMarginDb", QString::number(margin)); - s.save(); + if (usingEngineAutoSquelch() && m_slice) { + m_slice->setAutomaticSquelch(true, margin); + } else { + auto& s = AppSettings::instance(); + s.setValue("AutoSqlMarginDb", QString::number(margin)); + s.save(); + } emit autoSqlMarginDbChanged(margin); if (m_sqlSlider) { QSignalBlocker b(m_sqlSlider); @@ -1533,7 +1547,7 @@ void RxApplet::loadClientSquelchIntent() m_clientManualSqlLevel.reset(); m_restoreAutoSql = false; m_clientSqlAwaitingReport = false; - if (!m_slice || !m_radioModel || usingExternalReceiveSquelch()) { + if (!m_slice || !m_radioModel || usingExternalReceiveSquelch() || usingEngineAutoSquelch()) { return; } const RadioSettingsScope scope = m_radioModel->settingsScope(); @@ -1570,7 +1584,7 @@ void RxApplet::loadClientSquelchIntent() void RxApplet::saveClientSquelchIntent() { if (!m_clientSquelchScope.hasRadioIdentity() || !m_slice - || usingExternalReceiveSquelch()) { + || usingExternalReceiveSquelch() || usingEngineAutoSquelch()) { return; } const RadioSettingsScope scope = m_clientSquelchScope; @@ -1656,7 +1670,11 @@ void RxApplet::setSqlMode(SqlMode m, bool propagateToRadio) // For an absolute detector Auto starts from the accepted/manual // threshold. A dB margin is not an absolute threshold; the next FFT // supplies that through the spectrum's declared scale. - m_slice->setSquelch(sqOn, level); + if (m == SqlMode::Auto && usingEngineAutoSquelch()) { + m_slice->setAutomaticSquelch(true, autoSqlMarginDb()); + } else { + m_slice->setSquelch(sqOn, level); + } } } @@ -2668,6 +2686,14 @@ void RxApplet::connectSlice(SliceModel* s) }); auto applySquelchState = [this](bool on, int level, bool externalReceive) { + if (!externalReceive && usingEngineAutoSquelch()) { + if (on && !m_slice->automaticSquelch()) { setManualSqlLevelForCurrentSurface(level); } + const SqlMode mode = !on ? SqlMode::Off + : (m_slice->automaticSquelch() ? SqlMode::Auto : SqlMode::Manual); + setSqlMode(mode, /*propagateToRadio=*/false); + emit squelchStateChanged(on, level); + return; + } if (!externalReceive && m_clientSqlAwaitingReport) { if (!m_slice->squelchStateKnown()) { return; @@ -2749,6 +2775,8 @@ void RxApplet::connectSlice(SliceModel* s) if (s->externalReceiveAutoSquelchOn()) { mode = SqlMode::Auto; } + } else if (usingEngineAutoSquelch()) { + mode = s->squelchOn() && s->automaticSquelch() ? SqlMode::Auto : mode; } else if (m_clientSqlAwaitingReport) { // A previous radio/slice's Auto mode is not this radio's intent. // Wait for its first SQL report instead of starting on defaults. diff --git a/src/gui/RxApplet.h b/src/gui/RxApplet.h index e73b23509f..0dd88ff50b 100644 --- a/src/gui/RxApplet.h +++ b/src/gui/RxApplet.h @@ -35,6 +35,7 @@ class KiwiSdrManager; // RX Applet: controls for one receive slice (antenna, filter presets, AGC, // AF/RF gain, squelch, NB/NR/ANF, RIT/XIT). class RxApplet : public QWidget { + friend struct RxAppletSquelchTestAccess; Q_OBJECT public: @@ -333,6 +334,7 @@ class RxApplet : public QWidget { void cycleSqlMode(); void setSqlMode(SqlMode m, bool propagateToRadio); bool usingExternalReceiveSquelch() const; + bool usingEngineAutoSquelch() const; int clampManualSqlLevelForCurrentSurface(int level) const; void setManualSqlLevelForCurrentSurface(int level); int agcThresholdMinimum() const; diff --git a/src/gui/SMeterWidget.cpp b/src/gui/SMeterWidget.cpp index ab53faafe5..1fd6086673 100644 --- a/src/gui/SMeterWidget.cpp +++ b/src/gui/SMeterWidget.cpp @@ -161,6 +161,7 @@ QString SMeterWidget::accessibleValueText() const } const float displayDbm = m_rxMode == RxMode::SMeterPeak ? m_peakDbm : m_levelDbm; + if (m_relativeLevel) { return tr("Relative RF, %1 dBFS, uncalibrated").arg(displayDbm, 0, 'f', 0); } return tr("%1, %2 dBm") .arg(sUnitsTextFor(displayDbm)) .arg(static_cast(displayDbm)); @@ -179,7 +180,17 @@ void SMeterWidget::setFaceTheme(AnalogMeterFaceTheme theme) void SMeterWidget::setLevel(float dbm) // a11y-check: skip -- settled update is timer-throttled { + if (m_relativeLevel) { + setAccessibleDescription(tr("Signal strength meter, shows S-units or TX power")); + } + m_relativeLevel = false; + if (!m_peakReset.isActive()) { m_peakReset.start(); } m_receiveMeterReadingActive = false; + updateReceiveLevel(dbm); +} + +void SMeterWidget::updateReceiveLevel(float dbm) +{ m_levelDbm = dbm; // Peak hold (existing needle/triangle behavior) @@ -207,9 +218,38 @@ void SMeterWidget::setLevel(float dbm) // a11y-check: skip -- settled update is } } +void SMeterWidget::setRelativeLevel(std::optional dbfs, int receiverId, const QString& unavailableReason) +{ + const bool valid = unavailableReason.isEmpty() && dbfs && std::isfinite(*dbfs); + if (m_relativeLevel && !m_relativeValid && !valid && receiverId == m_relativeReceiver + && unavailableReason == m_relativeUnavailableReason) { return; } + m_relativeUnavailableReason = unavailableReason; + const bool reset = !m_relativeLevel || receiverId != m_relativeReceiver || !valid; + const float value = valid ? *dbfs : -120.0f; + if (reset) { m_peakDbm = value; m_peakHoldDbm = value; m_peakHoldTimerRunning = false; } + setAccessibleDescription(unavailableReason.isEmpty() + ? tr("Relative RF peak FFT bin in dBFS; uncalibrated, not dBm or audio level") : unavailableReason); + m_relativeLevel = true; m_relativeValid = valid; m_relativeReceiver = receiverId; + m_receiveMeterReadingActive = false; + if (!valid) { + m_levelDbm = value; + m_needleFraction = m_targetNeedleFraction = 0; + m_needleAnimation.stop(); m_peakDecay.stop(); m_peakReset.stop(); + update(); scheduleAccessibleValue(); + return; + } + if (!m_peakReset.isActive()) { m_peakReset.start(); } + updateReceiveLevel(value); +} + void SMeterWidget::setReceiveMeterReading( const KiwiSdrProtocol::MeterReading& reading) { + if (m_relativeLevel) { + setAccessibleDescription(tr("Signal strength meter, shows S-units or TX power")); + } + m_relativeLevel = false; + if (!m_peakReset.isActive()) { m_peakReset.start(); } m_receiveMeterReading = reading; m_receiveMeterReadingActive = true; @@ -436,6 +476,7 @@ void SMeterWidget::animateNeedle() bool SMeterWidget::usesUnavailableRxMeter() const { + if (m_relativeLevel) { return !m_relativeValid; } return m_receiveMeterReadingActive && !(m_receiveMeterReading.valid && (m_receiveMeterReading.capability @@ -447,6 +488,8 @@ bool SMeterWidget::usesUnavailableRxMeter() const QString SMeterWidget::unavailableRxMeterLabel() const { + if (m_relativeLevel) { return m_relativeUnavailableReason.isEmpty() + ? tr("Relative RF — unavailable") : m_relativeUnavailableReason; } if (!m_receiveMeterReadingActive) { return QStringLiteral("Meter unavailable"); } @@ -509,6 +552,7 @@ QString SMeterWidget::sUnitsText() const QString SMeterWidget::sUnitsTextFor(float dbm) const { + if (m_relativeLevel) { return tr("Relative RF"); } const SMeterGeometry::RxScale& scale = m_geometry.rxScale; if (dbm <= scale.minimumDbm) { return QStringLiteral("S0"); @@ -524,6 +568,7 @@ QString SMeterWidget::sUnitsTextFor(float dbm) const float SMeterWidget::dbmToFraction(float dbm) const { + if (m_relativeLevel) { return std::clamp((dbm + 120.0f) / 120.0f, 0.0f, 1.0f); } return static_cast(m_geometry.rxFraction(dbm)); } @@ -843,9 +888,15 @@ void SMeterWidget::paintEvent(QPaintEvent*) const QColor labelColor = physicalTheme ? physicalPalette->text : whiteColor; // -- Outside ticks (RX) --------------------------------------------------- - for (const SMeterGeometry::Tick& tick : m_geometry.rxScale.ticks) { - const QColor& color = (tick.value > m_geometry.rxScale.s9Dbm) ? redColor : whiteColor; - drawOutsideTick(dbmToFraction(static_cast(tick.value)), tick.label, color, true); + if (m_relativeLevel) { + for (int db = -120; db <= 0; db += 20) { + drawOutsideTick((db + 120.0f) / 120.0f, QString::number(db), whiteColor, true); + } + } else { + for (const SMeterGeometry::Tick& tick : m_geometry.rxScale.ticks) { + const QColor& color = (tick.value > m_geometry.rxScale.s9Dbm) ? redColor : whiteColor; + drawOutsideTick(dbmToFraction(static_cast(tick.value)), tick.label, color, true); + } } // -- Inside ticks (TX): scale depends on TX mode -------------------------- @@ -1105,8 +1156,9 @@ void SMeterWidget::paintEvent(QPaintEvent*) p.setFont(srcFont); p.setPen(sourceColor); - p.drawText(qRound(faceCenterX - sfm.horizontalAdvance(m_source) / 2.0), - sourceBaseline, m_source); + const QString source = m_relativeLevel ? tr("RF peak bin") : m_source; + p.drawText(qRound(faceCenterX - sfm.horizontalAdvance(source) / 2.0), + sourceBaseline, source); const float displayDbm = (m_rxMode == RxMode::SMeterPeak) ? m_peakDbm : m_levelDbm; @@ -1117,7 +1169,7 @@ void SMeterWidget::paintEvent(QPaintEvent*) p.drawText(qRound(faceLeft) + m_geometry.readout.sideMarginPixels, valueBaseline, sText); - const QString dbmText = QString("%1 dBm").arg(displayDbm, 0, 'f', 0); + const QString dbmText = QString(m_relativeLevel ? "%1 dBFS" : "%1 dBm").arg(displayDbm, 0, 'f', 0); p.setPen(valueColor); p.drawText(qRound(faceRight) - vfm.horizontalAdvance(dbmText) - m_geometry.readout.sideMarginPixels, diff --git a/src/gui/SMeterWidget.h b/src/gui/SMeterWidget.h index fdc097c339..877723c53b 100644 --- a/src/gui/SMeterWidget.h +++ b/src/gui/SMeterWidget.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -31,8 +32,8 @@ class SMeterWidget : public QWidget { QSize sizeHint() const override { return m_geometry.sizing.preferred; } QSize minimumSizeHint() const override { return m_geometry.sizing.minimum; } - // Current reading in dBm. - float levelDbm() const { return m_levelDbm; } + // Calibrated reading only; a relative RF reading has no dBm equivalent. + float levelDbm() const { return m_relativeLevel ? std::numeric_limits::quiet_NaN() : m_levelDbm; } // Reading as S-units string (e.g. "S7", "S9+20"). QString sUnitsText() const; @@ -54,6 +55,8 @@ class SMeterWidget : public QWidget { public slots: // Update the displayed RX level (S-meter dBm). void setLevel(float dbm); + void setRelativeLevel(std::optional dbfs, int receiverId, const QString& unavailableReason = {}); + bool relativeLevel() const { return m_relativeLevel; } void setReceiveMeterReading( const AetherSDR::KiwiSdrProtocol::MeterReading& reading); @@ -87,6 +90,7 @@ public slots: void paintEvent(QPaintEvent* event) override; private: + void updateReceiveLevel(float value); void updateNeedleTarget(); void animateNeedle(); void updatePeakHoldValue(); @@ -125,6 +129,10 @@ public slots: bool m_backgroundCacheValid{false}; QTimer m_accessibilityTimer; QString m_lastAccessibleValue; + bool m_relativeLevel = false; + bool m_relativeValid = false; + QString m_relativeUnavailableReason; + int m_relativeReceiver = -1; float m_levelDbm{0.0f}; // current RX reading; initialized from geometry float m_peakDbm{0.0f}; // RX peak hold; initialized from geometry QString m_source{"S-Meter Peak"}; diff --git a/src/gui/SmartMtrConfig.cpp b/src/gui/SmartMtrConfig.cpp index 643d92241f..6c92e5e762 100644 --- a/src/gui/SmartMtrConfig.cpp +++ b/src/gui/SmartMtrConfig.cpp @@ -251,6 +251,16 @@ QString formatWatts(double w) const MeterConfig& meterConfig(MeterKind kind) { + static const MeterConfig relativeCfg = [] { + MeterConfig cfg; cfg.valueToPosition = mapLinear; + for (int db = -120; db <= 0; db += 20) { + ScaleMarker marker; + marker.position = mapLinear(db, -120, 0); + marker.size = MarkerSize::Large; marker.label = QString::number(db); + cfg.markers.push_back(marker); + } + return cfg; + }(); static const MeterConfig signalCfg = buildSignalConfig(); static const MeterConfig micCfg = buildMicConfig(); static const MeterConfig swrCfg = buildSwrConfig(); @@ -260,6 +270,8 @@ const MeterConfig& meterConfig(MeterKind kind) // valueToPosition for indicatorPosition(). A barefoot default scale is fine. static const MeterConfig powerCfg = buildPowerConfig(120.0); switch (kind) { + case MeterKind::RelativeSignal: + return relativeCfg; case MeterKind::MicLevel: return micCfg; case MeterKind::SWR: diff --git a/src/gui/SmartMtrConfig.h b/src/gui/SmartMtrConfig.h index 90dbba1443..33c401a6ca 100644 --- a/src/gui/SmartMtrConfig.h +++ b/src/gui/SmartMtrConfig.h @@ -13,8 +13,8 @@ namespace AetherSDR { // value plus a registry entry in SmartMtrConfig.cpp; no widget changes. // The measurement the control currently shows. Extend here for new kinds. -// Signal is the RX scale; the rest are TX scales (see DisplaySettings::TxMeter). -enum class MeterKind { Signal, MicLevel, SWR, Power, Compression }; +// Signal (dBm) and RelativeSignal (dBFS) are RX scales; the rest are TX scales. +enum class MeterKind { RelativeSignal, Signal, MicLevel, SWR, Power, Compression }; // Forward-power scale headroom: the scale top sits this far above the radio's // rated power so a rig pushing slightly past rated doesn't peg the bar, and the diff --git a/src/gui/SmartMtrWidget.cpp b/src/gui/SmartMtrWidget.cpp index 35ee833259..f4b6faf2cd 100644 --- a/src/gui/SmartMtrWidget.cpp +++ b/src/gui/SmartMtrWidget.cpp @@ -1,6 +1,7 @@ #include "SmartMtrWidget.h" #include "SmartMtrStyle.h" +#include "core/ThemeManager.h" #include #include @@ -111,8 +112,10 @@ void SmartMtrWidget::applyBallistics(MeterKind kind) m_smooth.setBallistics(b); } -void SmartMtrWidget::setMeterInput(const MeterInput& input) +void SmartMtrWidget::setMeterInput(const MeterInput& input, const QString& unavailableReason) { + m_unavailableReason = unavailableReason; + setAccessibleDescription(unavailableReason); // A non-finite value (NaN/Inf) would survive std::clamp (both comparisons // false), stick in the bar smoother so needsAnimation() never clears, and // poison the extremes running sum permanently. Drop it; the last good frame @@ -168,6 +171,13 @@ void SmartMtrWidget::setMeterInput(const MeterInput& input) span > 0.0 ? float((posUnits - kScaleMin) / span) : 0.0f; m_smooth.setTarget(targetFrac); + if (!input.hasValue && input.kind == MeterKind::RelativeSignal) { + m_animTimer.stop(); + m_extremes.reset(); + m_smooth.snapToTarget(); + update(); + return; + } if (kindChanged) m_smooth.snapToTarget(); // snap across the discontinuity, don't glide @@ -254,6 +264,13 @@ void SmartMtrWidget::paintEvent(QPaintEvent*) drawIndicator(p, g); p.drawPixmap(0, 0, m_aboveBar); drawExtremes(p, g); + if (!m_unavailableReason.isEmpty()) { + p.setPen(ThemeManager::instance().color(this, "color.text.secondary")); + QFont labelFont = font(); + labelFont.setPixelSize(10); + p.setFont(labelFont); + p.drawText(rect(), Qt::AlignCenter, tr("Meters off")); + } // Let the parent's value-label overlay repaint in lockstep with the markers. emit repainted(); @@ -502,7 +519,7 @@ void SmartMtrWidget::drawMarkers(QPainter& p, const SmartMtrGeometry& g) const void SmartMtrWidget::drawTypeLabel(QPainter& p, const SmartMtrGeometry& g) const { // TX meters only — the RX signal meter shows no type label. - if (!m_showTypeLabel || m_kind == MeterKind::Signal) + if ((!m_showTypeLabel && m_kind != MeterKind::RelativeSignal) || m_kind == MeterKind::Signal) return; QString text; @@ -511,6 +528,7 @@ void SmartMtrWidget::drawTypeLabel(QPainter& p, const SmartMtrGeometry& g) const case MeterKind::SWR: text = QStringLiteral("SWR"); break; case MeterKind::Power: text = QStringLiteral("PWR"); break; case MeterKind::Compression: text = QStringLiteral("COMP"); break; + case MeterKind::RelativeSignal: text = QStringLiteral("dBFS"); break; case MeterKind::Signal: return; } @@ -641,6 +659,7 @@ QString SmartMtrWidget::extremeSUnit(double raw) const QString SmartMtrWidget::extremeDbm(double raw) const { const int v = qRound(raw); + if (m_kind == MeterKind::RelativeSignal) { return QStringLiteral("%1dBFS").arg(v); } return m_kind == MeterKind::MicLevel ? QStringLiteral("%1dB").arg(v) : QStringLiteral("%1dBm").arg(v); } @@ -693,7 +712,7 @@ QVector SmartMtrWidget::extremeLabels() const // Numeric value labels are a signal-meter feature only; other kinds (mic/TX) // show no value text (the peak marker itself still draws). - if (m_kind != MeterKind::Signal) + if (m_kind != MeterKind::Signal && m_kind != MeterKind::RelativeSignal) return out; // Two lines for signal (S-unit over dBm); a single top line for mic (no diff --git a/src/gui/SmartMtrWidget.h b/src/gui/SmartMtrWidget.h index b3b1fe5a56..b9e879c609 100644 --- a/src/gui/SmartMtrWidget.h +++ b/src/gui/SmartMtrWidget.h @@ -27,7 +27,7 @@ class SmartMtrWidget : public QWidget { // Push what to display. The parent owns the data (kind + value + range); // the widget just renders it. Triggers a repaint. - void setMeterInput(const MeterInput& input); + void setMeterInput(const MeterInput& input, const QString& unavailableReason = {}); // Extremes (min/max peak-hold markers) options. Widget-local mirrors of the // DisplaySettings enums so this widget stays free of the settings/AppSettings @@ -77,6 +77,7 @@ class SmartMtrWidget : public QWidget { void changeEvent(QEvent*) override; private: + QString m_unavailableReason; // One element per method; all draw in UNITS via SmartMtrGeometry. Called // from paintEvent in back-to-front order. void drawControl(QPainter& p, const SmartMtrGeometry& g) const; diff --git a/src/gui/SpectrumWidget.cpp b/src/gui/SpectrumWidget.cpp index a9c02f9d6a..fc1aa98853 100644 --- a/src/gui/SpectrumWidget.cpp +++ b/src/gui/SpectrumWidget.cpp @@ -4378,6 +4378,7 @@ void SpectrumWidget::setWfLineDuration(int ms) { void SpectrumWidget::setSquelchLine(bool visible, int level) { + visible = visible && m_squelchSpectrumComparable; m_flexSquelchLineVisible = visible; m_flexSquelchLevel = std::clamp(level, 0, 100); markOverlayDirty(); @@ -4602,10 +4603,13 @@ void SpectrumWidget::setAutoSquelchEnable(bool on) } } -void SpectrumWidget::setSquelchScale(double referenceDb, double stepDb, const QString& unit) +void SpectrumWidget::setSquelchScale(double referenceDb, double stepDb, const QString& unit, bool spectrumComparable) { if (!std::isfinite(referenceDb) || !std::isfinite(stepDb) || stepDb <= 0) { return; } - if (m_squelchReferenceDb == referenceDb && m_squelchStepDb == stepDb && m_squelchUnit == unit) { return; } + if (m_squelchReferenceDb == referenceDb && m_squelchStepDb == stepDb && m_squelchUnit == unit + && m_squelchSpectrumComparable == spectrumComparable) { return; } + m_squelchSpectrumComparable = spectrumComparable; + if (!spectrumComparable) { m_flexSquelchLineVisible = false; } m_squelchReferenceDb = referenceDb; m_squelchStepDb = stepDb; m_squelchUnit = unit; m_sqlNoiseFloorDbm = -999.0f; m_lastAutoSquelchLevel = -1; markOverlayDirty(); diff --git a/src/gui/SpectrumWidget.h b/src/gui/SpectrumWidget.h index c6df5381a7..e95054d065 100644 --- a/src/gui/SpectrumWidget.h +++ b/src/gui/SpectrumWidget.h @@ -317,7 +317,7 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { // (0-100), mapped to absolute dBm via the radio's fixed scale: // dBm = -160 + level. (Empirically verified on FLEX-8600 fw 4.1.5.) void setSquelchLine(bool visible, int level); - void setSquelchScale(double referenceDb, double stepDb, const QString& unit); + void setSquelchScale(double referenceDb, double stepDb, const QString& unit, bool spectrumComparable = true); // KiwiSDR SQL is a dB margin above Kiwi's median noise-floor estimate. // marginDb is the server margin, not the UI slider value. void setKiwiSdrSquelchLine(bool visible, int marginDb, bool floorRelative); @@ -1578,6 +1578,7 @@ class SpectrumWidget : public SPECTRUM_BASE_CLASS { double m_squelchReferenceDb{-160.0}; double m_squelchStepDb{1.0}; QString m_squelchUnit; + bool m_squelchSpectrumComparable = true; bool m_kiwiSdrSquelchLineVisible{false}; int m_kiwiSdrSquelchLevel{0}; bool m_kiwiSdrSquelchLineFloorRelative{false}; diff --git a/src/gui/VfoWidget.cpp b/src/gui/VfoWidget.cpp index ebd12807fe..2719157f62 100644 --- a/src/gui/VfoWidget.cpp +++ b/src/gui/VfoWidget.cpp @@ -3894,6 +3894,17 @@ void VfoWidget::paintEvent(QPaintEvent* event) const int scaleY = barY + barH + 2; const int tickH = 3; + if (m_relativeSignal) { + p.setPen(ThemeManager::instance().color(this, "color.text.secondary")); + p.drawLine(barX, scaleY, barX + barW, scaleY); + QFont font = p.font(); font.setPixelSize(7); p.setFont(font); + for (int db = -120; db <= 0; db += 30) { + const int x = barX + (db + 120) * barW / 120; + p.drawLine(x, scaleY, x, scaleY + tickH); + p.drawText(x - (db == 0 ? 4 : 8), scaleY + tickH + 7, QString::number(db)); + } + return; + } // Horizontal line: blue from start to S9, red from S9 to end. p.setPen(QColor(0x30, 0x80, 0xff)); p.drawLine(barX, scaleY, s9X, scaleY); @@ -3970,6 +3981,7 @@ QRect VfoWidget::meterBarRect() const void VfoWidget::applyMeterView(bool smartMtr) { m_smartMtr = smartMtr; + updateSignalMeterTarget(); if (m_meterStack) { m_meterStack->setCurrentIndex(smartMtr ? 1 : 0); } @@ -4119,6 +4131,8 @@ void VfoWidget::syncSmartMtrSettingsControls() void VfoWidget::setSignalLevel(float dbm) { + if (m_relativeSignal) { m_dbmLabel->setAccessibleDescription({}); } + m_relativeSignal = false; m_receiveMeterReadingActive = false; m_signalDbm = dbm; m_signalHasDbm = true; // FLEX always delivers a calibrated dBm reading @@ -4128,6 +4142,26 @@ void VfoWidget::setSignalLevel(float dbm) pushSmartMtrInput(); } +void VfoWidget::setRelativeSignalLevel(std::optional dbfs, const QString& unavailableReason) +{ + const bool valid = unavailableReason.isEmpty() && dbfs && std::isfinite(*dbfs); + if (m_relativeSignal && !m_relativeSignalValid && !valid + && m_relativeUnavailableReason == unavailableReason) { return; } + m_relativeUnavailableReason = unavailableReason; + m_relativeSignal = true; + m_relativeSignalValid = valid; + m_receiveMeterReadingActive = false; + m_signalHasDbm = false; + m_signalDbm = m_relativeSignalValid ? *dbfs : -120.0f; + m_dbmLabel->setText(m_relativeSignalValid + ? tr("%1 dBFS").arg(m_signalDbm, 0, 'f', 0) : (unavailableReason.isEmpty() ? tr("— dBFS") : tr("Meters off"))); + m_dbmLabel->setAccessibleName(tr("Relative RF level dBFS")); + m_dbmLabel->setAccessibleDescription(unavailableReason.isEmpty() + ? tr("Peak FFT bin in the receive passband, uncalibrated; not dBm or audio level") : unavailableReason); + updateSignalMeterTarget(); + pushSmartMtrInput(); +} + // SmartMTR feed: choose signal vs mic by TX state and push the input. The // canonical ranges match the per-kind configs in SmartMtrConfig.cpp. void VfoWidget::pushSmartMtrInput() @@ -4195,7 +4229,7 @@ void VfoWidget::pushSmartMtrInput() } in.hasValue = true; } else { - in.kind = MeterKind::Signal; + in.kind = m_relativeSignal ? MeterKind::RelativeSignal : MeterKind::Signal; in.value = m_signalDbm; in.min = -127.0; // dBm: S0 in.max = -13.0; // dBm: S9+60 @@ -4204,8 +4238,11 @@ void VfoWidget::pushSmartMtrInput() // The widget parks/fades the indicator and suppresses the value labels // when hasValue is false, matching the "Meter ---" dBm label. in.hasValue = m_signalHasDbm; + if (m_relativeSignal) { + in.min = -120; in.max = 0; in.hasValue = m_relativeSignalValid; + } } - m_smartMtrWidget->setMeterInput(in); + m_smartMtrWidget->setMeterInput(in, m_relativeSignal ? m_relativeUnavailableReason : QString()); } void VfoWidget::pushSmartMtrOptions() @@ -4246,6 +4283,19 @@ void VfoWidget::pushSmartMtrOptions() void VfoWidget::onSmartMtrRepainted() { + // The animated bar is painted inside the flag, independently of the + // spectrum's optional value-label overlay. An empty overlay needs no + // rebuild on each bar frame. Do erase its last labels immediately when a + // reading disappears, even inside the normal animation throttle window. + if (m_smartMtrWidget->extremeLabels().isEmpty()) { + if (m_smartMtrLabelsVisible) { + m_smartMtrLabelsVisible = false; + m_lastLabelDirtyMs = -1; + emit smartMtrLabelsChanged(); + } + return; + } + // The meter repaints up to ~120 Hz while markers move; the spectrum's // static-overlay redraw (which draws the value labels) is comparatively // costly, so throttle the refresh requests to ~20 Hz. @@ -4253,6 +4303,7 @@ void VfoWidget::onSmartMtrRepainted() if (m_lastLabelDirtyMs >= 0 && now - m_lastLabelDirtyMs < 50) return; m_lastLabelDirtyMs = now; + m_smartMtrLabelsVisible = true; emit smartMtrLabelsChanged(); } @@ -4423,6 +4474,8 @@ void VfoWidget::setTransmitting(bool tx) void VfoWidget::setReceiveMeterReading( const KiwiSdrProtocol::MeterReading& reading) { + if (m_relativeSignal) { m_dbmLabel->setAccessibleDescription({}); } + m_relativeSignal = false; m_receiveMeterReading = reading; m_receiveMeterReadingActive = true; const bool hasDisplayDbm = @@ -4468,12 +4521,24 @@ float VfoWidget::signalDbmToMeterFraction(float dbm) void VfoWidget::updateSignalMeterTarget() { - if (usesUnavailableSignalMeter()) { + if (m_relativeSignal) { + m_targetSignalMeterFraction = m_relativeSignalValid + ? std::clamp((m_signalDbm + 120.0f) / 120.0f, 0.0f, 1.0f) : 0.0f; + } else if (usesUnavailableSignalMeter()) { m_targetSignalMeterFraction = 0.0f; } else { m_targetSignalMeterFraction = signalDbmToMeterFraction(m_signalDbm); } + // Only the selected meter needs animation. Keep the standard bar seeded + // for switching back, without repainting the whole flag behind SmartMTR. + if (m_smartMtr || (m_relativeSignal && !m_relativeSignalValid)) { + m_signalMeterAnimation.stop(); + m_signalMeterFraction = m_targetSignalMeterFraction; + if (!m_smartMtr) { update(); } + return; + } + if (qAbs(m_targetSignalMeterFraction - m_signalMeterFraction) <= kSignalMeterSnapEpsilon) { m_signalMeterFraction = m_targetSignalMeterFraction; if (m_signalMeterAnimation.isActive()) { diff --git a/src/gui/VfoWidget.h b/src/gui/VfoWidget.h index 7d9e29b07c..00fc1be0f1 100644 --- a/src/gui/VfoWidget.h +++ b/src/gui/VfoWidget.h @@ -70,6 +70,8 @@ class VfoWidget : public QWidget { // 2-state on/off mode against the slice's squelchLevel only. void setRxApplet(RxApplet* rx); void setSignalLevel(float dbm); + void setRelativeSignalLevel(std::optional dbfs, const QString& unavailableReason = {}); + bool relativeSignalLevel() const { return m_relativeSignal; } void setReceiveMeterReading( const AetherSDR::KiwiSdrProtocol::MeterReading& reading); // SmartMTR feeds: live mic level + separately-measured mic peak (both dBFS) @@ -438,6 +440,9 @@ class VfoWidget : public QWidget { QStringList m_antList; bool m_updatingFromModel{false}; bool m_lastOnLeft{true}; + bool m_relativeSignal = false; + bool m_relativeSignalValid = false; + QString m_relativeUnavailableReason; float m_signalDbm{-130.0f}; // Whether m_signalDbm is a real calibrated reading. FLEX always is; a // KiwiSDR slice without a calibrated meter is not, in which case the @@ -497,6 +502,7 @@ class VfoWidget : public QWidget { // meter's repaint asks the spectrum overlay to refresh. QElapsedTimer m_labelDirtyClock; qint64 m_lastLabelDirtyMs{-1}; + bool m_smartMtrLabelsVisible{false}; float m_micDbfs{-40.0f}; // latest mic level (dBFS); SmartMTR TX scale float m_micPeakDbfs{-40.0f}; // latest mic peak (dBFS, radio MICPEAK stat) // Latest TX-meter values, cached for the SmartMTR TX scales (see the setters). diff --git a/src/gui/WfmApplet.cpp b/src/gui/WfmApplet.cpp index e29e6c6aa7..ff3f3499c1 100644 --- a/src/gui/WfmApplet.cpp +++ b/src/gui/WfmApplet.cpp @@ -11,6 +11,7 @@ #include "models/SliceModel.h" #include +#include #include #include #include @@ -20,6 +21,9 @@ #include #include #include +#include +#include +#include #include #include @@ -31,6 +35,55 @@ QJsonObject preferences() .value(QStringLiteral("WfmApplet"), QString{}).toString().toUtf8()).object(); } +// A popup may outlive its receiver binding. Preserve the binding witness +// after hidePopup(): Qt can deliver activated later. +class WfmReceiverComboBox final : public GuardedComboBox { +public: + using GuardedComboBox::GuardedComboBox; + void setBinding(quint64 binding) + { + m_binding = binding; + invalidateInteraction(); + } + void invalidateInteraction() + { + ++m_generation; + hidePopup(); + } + quint64 takeActivationBinding() + { + const Origin origin = m_origin.value_or(Origin{m_binding, m_generation}); + m_origin.reset(); + // Bound controls use nonzero slice generations. Zero cannot authorize + // an activation whose receiver binding was retired. + return origin.generation == m_generation ? origin.binding : 0; + } + void showPopup() override + { + m_origin = Origin{m_binding, m_generation}; + GuardedComboBox::showPopup(); + } +protected: + void keyPressEvent(QKeyEvent* event) override + { + if (!view()->isVisible()) { m_origin = Origin{m_binding, m_generation}; } + GuardedComboBox::keyPressEvent(event); + } + void wheelEvent(QWheelEvent* event) override + { + if (!view()->isVisible()) { m_origin = Origin{m_binding, m_generation}; } + GuardedComboBox::wheelEvent(event); + } +private: + struct Origin { + quint64 binding; + quint64 generation; + }; + quint64 m_binding{0}; + quint64 m_generation{0}; + std::optional m_origin; +}; + void setReadout(QLabel* label, const QString& text, const QString& name) { if (label->text() == text && label->accessibleName() == name) { return; } @@ -67,6 +120,13 @@ WfmApplet::WfmApplet(QWidget* parent) : QWidget(parent) auto* root = new QVBoxLayout(this); root->setContentsMargins(2, 2, 2, 2); root->setSpacing(3); + m_identity = new QLabel(this); + m_identity->setObjectName(QStringLiteral("wfmReceiverIdentity")); + m_identity->setTextFormat(Qt::PlainText); + m_identity->setWordWrap(true); + ThemeManager::instance().applyStyleSheet(m_identity, + "QLabel { color: {{color.text.primary}}; font-size: 11px; }"); + root->addWidget(m_identity); m_scope = new WfmLockScope(this); m_scope->setObjectName(QStringLiteral("wfmLockScope")); root->addWidget(m_scope); @@ -90,13 +150,13 @@ WfmApplet::WfmApplet(QWidget* parent) : QWidget(parent) auto* settings = new QGridLayout; settings->setHorizontalSpacing(5); settings->setVerticalSpacing(3); - m_deemphasis = new GuardedComboBox(this); + m_deemphasis = new WfmReceiverComboBox(this); m_deemphasis->setObjectName(QStringLiteral("wfmDeemphasis")); m_deemphasis->setAccessibleName(tr("Broadcast FM deemphasis")); m_deemphasis->setPlaceholderText(tr("Unknown")); m_deemphasis->setFocusPolicy(Qt::StrongFocus); applyComboStyle(m_deemphasis); - m_bandwidth = new GuardedComboBox(this); + m_bandwidth = new WfmReceiverComboBox(this); m_bandwidth->setObjectName(QStringLiteral("wfmBandwidth")); m_bandwidth->setAccessibleName(tr("Broadcast FM bandwidth")); m_bandwidth->setPlaceholderText(tr("Unavailable")); @@ -151,20 +211,25 @@ WfmApplet::WfmApplet(QWidget* parent) : QWidget(parent) " border-radius: 3px; padding: 4px; }"); root->addWidget(m_diagnostics); + connect(m_audioMode, &QPushButton::pressed, this, [this] { + m_audioModeBinding = m_sliceBinding; + }); connect(m_audioMode, &QPushButton::clicked, this, [this] { - if (m_available && m_slice && m_audioMode->isEnabled()) { + if (acceptsControl(m_audioMode, m_audioModeBinding)) { m_slice->setWfmForceMono(!m_slice->wfmForceMono()); } - refresh(); + refresh(); // The displayed selection remains backend-confirmed. }); connect(m_deemphasis, &QComboBox::activated, this, [this](int index) { - if (m_available && m_slice && m_deemphasis->isEnabled()) { + const quint64 binding = static_cast(m_deemphasis)->takeActivationBinding(); + if (acceptsControl(m_deemphasis, binding) && index >= 0 && index < m_deemphasis->count()) { m_slice->setWfmDeemphasis(m_deemphasis->itemData(index).toInt()); } refresh(); }); connect(m_bandwidth, &QComboBox::activated, this, [this](int index) { - if (m_available && m_slice && m_bandwidth->isEnabled()) { + const quint64 binding = static_cast(m_bandwidth)->takeActivationBinding(); + if (acceptsControl(m_bandwidth, binding) && index >= 0 && index < m_bandwidth->count()) { const int width = m_bandwidth->itemData(index).toInt(); const ModeFilters::Edges edges = ModeFilters::edgesForWidth(QStringLiteral("WFM"), width, {}); const RadioCapabilities caps = m_model->backendCapabilities(); @@ -197,8 +262,15 @@ bool WfmApplet::ownsWfmSlice() const && m_slice->mode() == QLatin1String("WFM"); } +bool WfmApplet::acceptsControl(const QWidget* control, quint64 binding) const +{ + return binding == m_sliceBinding && m_connected && m_available && ownsWfmSlice() + && !m_slice->externalReceiveReplacementActive() && control->isEnabled(); +} + void WfmApplet::setRadioModel(RadioModel* model) { + const quint64 binding = ++m_modelBinding; for (const auto& connection : m_modelConnections) { disconnect(connection); } m_modelConnections.clear(); m_availability.reset(); @@ -208,58 +280,81 @@ void WfmApplet::setRadioModel(RadioModel* model) m_caps = model ? model->backendCapabilities() : RadioCapabilities{}; if (model) { m_modelConnections.append(connect(model, &RadioModel::capabilitiesChanged, this, - [this](bool connected, const RadioCapabilities& caps) { + [this, binding](bool connected, const RadioCapabilities& caps) { + if (binding != m_modelBinding) { return; } + const bool connectionChanged = connected != m_connected; m_connected = connected; m_caps = caps; + if (connectionChanged) { setSlice(m_slice); } refresh(); })); m_modelConnections.append(connect(model, &RadioModel::connectionStateChanged, this, - [this](bool connected) { + [this, binding](bool connected) { + if (binding != m_modelBinding) { return; } + const bool connectionChanged = connected != m_connected; m_connected = connected; m_caps = m_model ? m_model->backendCapabilities() : RadioCapabilities{}; + if (connectionChanged) { setSlice(m_slice); } refresh(); })); m_modelConnections.append(connect(model, &RadioModel::sliceRemoved, this, - [this](int id) { - if (m_slice && m_slice->sliceId() == id) { setSlice(nullptr); } + [this, binding](int id) { + if (binding != m_modelBinding) { return; } + if (m_slice && m_slice->sliceId() == id + && (!m_model || m_model->slice(id) != m_slice)) { setSlice(nullptr); } })); - m_modelConnections.append(connect(model, &QObject::destroyed, this, [this] { + m_modelConnections.append(connect(model, &QObject::destroyed, this, [this, binding] { + if (binding != m_modelBinding) { return; } m_availability.reset(); m_model = nullptr; m_connected = false; m_caps = {}; - refresh(); + setSlice(nullptr); })); registerControls(); } - refresh(); + setSlice(m_slice); } void WfmApplet::setSlice(SliceModel* slice) { + const quint64 binding = ++m_sliceBinding; + for (QComboBox* combo : {m_deemphasis, m_bandwidth}) { + static_cast(combo)->setBinding(binding); + } + m_audioMode->setDown(false); for (const auto& connection : m_sliceConnections) { disconnect(connection); } m_sliceConnections.clear(); m_scope->clear(); m_slice = slice; if (slice) { - m_sliceConnections.append(connect(slice, &SliceModel::modeChanged, this, &WfmApplet::refresh)); - m_sliceConnections.append(connect(slice, &SliceModel::filterChanged, this, &WfmApplet::refresh)); - m_sliceConnections.append(connect(slice, &SliceModel::wfmDeemphasisChanged, this, &WfmApplet::refresh)); - m_sliceConnections.append(connect(slice, &SliceModel::wfmForceMonoChanged, this, &WfmApplet::refresh)); - m_sliceConnections.append(connect(slice, &SliceModel::wfmStereoStatusChanged, this, &WfmApplet::refresh)); - m_sliceConnections.append(connect(slice, &SliceModel::wfmReceptionDiagnosticsChanged, this, [this] { + const auto current = [this, binding] { + return binding == m_sliceBinding && m_slice && m_model + && m_model->slice(m_slice->sliceId()) == m_slice; + }; + const auto update = [this, current] { if (current()) { refresh(); } }; + m_sliceConnections.append(connect(slice, &SliceModel::modeChanged, this, update)); + m_sliceConnections.append(connect(slice, &SliceModel::filterChanged, this, update)); + m_sliceConnections.append(connect(slice, &SliceModel::letterChanged, this, update)); + m_sliceConnections.append(connect(slice, &SliceModel::frequencyReported, this, update)); + m_sliceConnections.append(connect(slice, &SliceModel::wfmDeemphasisChanged, this, update)); + m_sliceConnections.append(connect(slice, &SliceModel::wfmForceMonoChanged, this, update)); + m_sliceConnections.append(connect(slice, &SliceModel::wfmStereoStatusChanged, this, update)); + m_sliceConnections.append(connect(slice, &SliceModel::wfmReceptionDiagnosticsChanged, this, [this, current] { + if (!current()) { return; } refreshDiagnostics(); appendScopeSample(); })); - m_sliceConnections.append(connect(slice, &SliceModel::frequencyChanged, this, [this] { + m_sliceConnections.append(connect(slice, &SliceModel::frequencyChanged, this, [this, current] { + if (!current()) { return; } m_scope->clear(); refreshDiagnostics(); })); - m_sliceConnections.append(connect(slice, &SliceModel::inCaptureChanged, this, &WfmApplet::refresh)); - m_sliceConnections.append(connect(slice, &SliceModel::externalReceiveReplacementChanged, this, &WfmApplet::refresh)); - m_sliceConnections.append(connect(slice, &QObject::destroyed, this, [this] { - m_slice = nullptr; - refresh(); + m_sliceConnections.append(connect(slice, &SliceModel::inCaptureChanged, this, update)); + m_sliceConnections.append(connect(slice, &SliceModel::externalReceiveReplacementChanged, this, update)); + m_sliceConnections.append(connect(slice, &QObject::destroyed, this, [this, binding] { + if (binding != m_sliceBinding) { return; } + setSlice(nullptr); })); } refresh(); @@ -305,6 +400,11 @@ void WfmApplet::refresh() if (!available) { m_scope->clear(); } emit availabilityChanged(available); } + const QString identity = m_available + ? tr("Slice %1 · %2").arg(m_slice->letter(), m_slice->frequencyReportedKnown() + ? tr("%1 MHz").arg(m_slice->reportedFrequency(), 0, 'f', 6) : tr("Frequency unavailable")) + : tr("No selected WFM receiver"); + setReadout(m_identity, identity, tr("Broadcast FM receiver: %1").arg(identity)); if (m_availability) { m_availability->refreshEngaged(); } else { diff --git a/src/gui/WfmApplet.h b/src/gui/WfmApplet.h index 71a3910062..98a5f5c598 100644 --- a/src/gui/WfmApplet.h +++ b/src/gui/WfmApplet.h @@ -34,6 +34,7 @@ class WfmApplet : public QWidget { void availabilityChanged(bool available); private: bool ownsWfmSlice() const; + bool acceptsControl(const QWidget* control, quint64 binding) const; void refresh(); void refreshDiagnostics(); void appendScopeSample(); @@ -44,12 +45,16 @@ class WfmApplet : public QWidget { QPointer m_model; QPointer m_slice; + quint64 m_modelBinding{0}; + quint64 m_sliceBinding{0}; + quint64 m_audioModeBinding{0}; QVector m_modelConnections; QVector m_sliceConnections; std::unique_ptr m_availability; RadioCapabilities m_caps; bool m_connected{false}; bool m_available{false}; + QLabel* m_identity{nullptr}; WfmLockScope* m_scope{nullptr}; QPushButton* m_audioMode{nullptr}; QLabel* m_status{nullptr}; diff --git a/src/main.cpp b/src/main.cpp index 604a2fbd41..46cc3529bd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -484,7 +484,8 @@ int main(int argc, char* argv[]) // (~/.config/AetherSDR/AetherSDR/, because org and app name are both // "AetherSDR") to GenericConfigLocation + "/AetherSDR", where AppSettings lives. // Idempotent: skips existing targets and removes the old dir once empty. - { + // An isolated profile must not migrate the operator's ordinary profile. + if (qEnvironmentVariableIsEmpty("AETHER_SETTINGS_DIR")) { const QString newDir = QStandardPaths::writableLocation( QStandardPaths::GenericConfigLocation) + QStringLiteral("/AetherSDR"); @@ -535,8 +536,9 @@ int main(int argc, char* argv[]) // listing (which was getting crowded with 50+ aethersdr-*.log files // sitting alongside the actual settings). GenericConfigLocation + // app name still avoids the AppConfigLocation double-nest. - const QString configRoot = QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) - + "/AetherSDR"; + // Honour the same explicit profile as the settings store. An isolated + // qualification launch must not migrate or prune the operator's logs. + const QString configRoot = AetherSDR::SettingsPaths::configDir(); const QString logDir = configRoot + "/logs"; QDir().mkpath(logDir); // Source-feed logs (dxcluster.log, rbn.log, wsjtx.log, …) live in diff --git a/src/models/Ax25ReceiveModel.cpp b/src/models/Ax25ReceiveModel.cpp new file mode 100644 index 0000000000..182f3e02b9 --- /dev/null +++ b/src/models/Ax25ReceiveModel.cpp @@ -0,0 +1,396 @@ +#include "Ax25ReceiveModel.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace AetherSDR { + +struct Ax25ReceiveModel::Impl { + struct Input { + DecoderPcmBlock block; + quint64 generation = 0; + }; + struct Output { + PcmEpochLease source; + quint64 generation = 0; + QVector frames; + std::optional diagnostics; + }; + struct State { + QMutex mutex; + std::shared_ptr> generation = + std::make_shared>(1); + Ax25ReceiveModel* owner = nullptr; + QObject* worker = nullptr; + std::deque input; + std::deque output; + qsizetype frames = 0; + bool workerScheduled = false; + bool outputScheduled = false; + bool resetNotification = false; + bool diagnosticsLogging = false; + Ax25DemodConfig config; + quint64 configuration = 1; + static constexpr qsizetype kMaxFrames = 65536; + static constexpr std::size_t kMaxBlocks = 256; + + // Called under mutex. Retire BEFORE any callbacks or worker operation; + // a result already queued to the owner must become stale immediately. + void retire() + { + ++*generation; + input.clear(); + output.clear(); + frames = 0; + } + }; + + class Worker final : public QObject { + public: + explicit Worker(std::shared_ptr state) + : m_state(std::move(state)), m_shim(this) + { + connect(&m_shim, &AetherAx25LibmodemShim::frameDecoded, this, + [this](const Ax25DecodedFrame& frame) { + if (m_active && current(*m_active)) { + m_active->frames.append(frame); + } + }); + connect(&m_shim, &AetherAx25LibmodemShim::diagnosticsUpdated, this, + [this](const Ax25DecoderDiagnostics& diagnostics) { + if (m_active && current(*m_active)) { + m_active->diagnostics = diagnostics; + } + }); + } + + void drain() + { + // Yield regularly to shutdown/configuration work. The owner and + // producer cannot create one queued event per incoming PCM block. + for (int count = 0; count < 8; ++count) { + Input input; + Ax25DemodConfig config; + quint64 generation = 0; + quint64 configuration = 0; + bool logging = false; + { + QMutexLocker lock(&m_state->mutex); + if (!m_state->owner) { + m_state->workerScheduled = false; + return; + } + generation = m_state->generation->load(); + configuration = m_state->configuration; + config = m_state->config; + logging = m_state->diagnosticsLogging; + if (!m_state->input.empty()) { + input = std::move(m_state->input.front()); + m_state->input.pop_front(); + m_state->frames -= input.block.samples.size(); + } + } + // Configure/reset signals have no active PCM context and cannot + // borrow a new generation for an older result. + m_active.reset(); + if (configuration != m_configuration) { + m_shim.configure(config); + m_configuration = configuration; + m_generation = generation; + } else if (generation != m_generation) { + m_shim.reset(); + m_generation = generation; + } + m_shim.setDiagnosticsLoggingEnabled(logging); + if (input.generation && input.generation == generation + && input.block.current() + && generation == m_state->generation->load()) { + // Preserve the feed's original context throughout synchronous + // shim callbacks, even if the owner resets concurrently. + m_active = Output{input.block.source, input.generation, {}, {}}; + m_shim.feedAudio(QByteArray( + reinterpret_cast(input.block.samples.constData()), + input.block.samples.size() * static_cast(sizeof(float))), + DecoderPcmBlock::kSampleRateHz); + if (current(*m_active) + && (!m_active->frames.isEmpty() || m_active->diagnostics)) { + QMutexLocker lock(&m_state->mutex); + if (m_state->owner && current(*m_active)) { + if (m_state->output.size() == State::kMaxBlocks) { + m_state->retire(); + m_state->resetNotification = true; + } else { + m_state->output.push_back(std::move(*m_active)); + } + scheduleOutput(m_state); + } + } + m_active.reset(); + } + { + QMutexLocker lock(&m_state->mutex); + if (m_state->input.empty() + && generation == m_state->generation->load() + && configuration == m_state->configuration + && logging == m_state->diagnosticsLogging) { + m_state->workerScheduled = false; + return; + } + } + } + QMetaObject::invokeMethod(this, [this] { drain(); }, Qt::QueuedConnection); + } + + private: + bool current(const Output& output) const + { + return output.generation == m_state->generation->load() + && output.source.current(); + } + std::shared_ptr m_state; + AetherAx25LibmodemShim m_shim; + quint64 m_generation = 0; + quint64 m_configuration = 0; + std::optional m_active; + }; + + Ax25ReceiveModel* owner; + std::unique_ptr audio; + std::shared_ptr state = std::make_shared(); + QThread thread; + bool enabled = false; + + // Caller holds State::mutex, fencing invocation against owner destruction. + static void scheduleOutput(const std::shared_ptr& state) + { + if (!state->owner || state->outputScheduled) { + return; + } + state->outputScheduled = true; + Ax25ReceiveModel* target = state->owner; + QMetaObject::invokeMethod(target, [target, state] { + target->m_impl->publish(state); + }, Qt::QueuedConnection); + } + + void scheduleWorker() + { + if (!state->worker || state->workerScheduled) { + return; + } + state->workerScheduled = true; + auto* worker = static_cast(state->worker); + QMetaObject::invokeMethod(worker, [worker] { worker->drain(); }, Qt::QueuedConnection); + } + + void retire() + { + { + QMutexLocker lock(&state->mutex); + state->retire(); + state->resetNotification = false; + scheduleWorker(); + } + const QPointer guard(owner); + emit owner->sourceReset(); + if (guard) { + emit owner->statusChanged(); + } + } + + void enqueue(const DecoderPcmBlock& block) + { + if (!enabled || !block.current()) { + return; + } + const QPointer guard(owner); + bool overflow = false; + quint64 generation = 0; + { + QMutexLocker lock(&state->mutex); + if (block.samples.size() > State::kMaxFrames) { + state->retire(); + overflow = true; + } else { + if (state->input.size() == State::kMaxBlocks + || state->frames + block.samples.size() > State::kMaxFrames) { + state->retire(); + overflow = true; + } + generation = state->generation->load(); + state->input.push_back({block, generation}); + state->frames += block.samples.size(); + } + scheduleWorker(); + } + if (overflow) { + emit owner->sourceReset(); + if (!guard) { + return; + } + } + if (generation && enabled && block.current() + && generation == state->generation->load()) { + emit owner->pcmReady(block, {block.source, generation, state->generation}); + } + } + + void publish(const std::shared_ptr& box) + { + std::deque output; + bool reset = false; + { + QMutexLocker lock(&box->mutex); + output.swap(box->output); + box->outputScheduled = false; + reset = std::exchange(box->resetNotification, false); + } + const QPointer guard(owner); + if (reset) { + emit owner->sourceReset(); + if (!guard) { + return; + } + } + for (const Output& item : output) { + const auto current = [&] { + return enabled && item.source.current() + && item.generation == box->generation->load(); + }; + for (const Ax25DecodedFrame& frame : item.frames) { + if (!current()) { + break; + } + emit owner->frameDecoded(frame, {item.source, item.generation, box->generation}); + if (!guard) { + return; + } + } + if (item.diagnostics && current()) { + emit owner->diagnosticsUpdated(*item.diagnostics, + {item.source, item.generation, box->generation}); + if (!guard) { + return; + } + } + } + } +}; + +Ax25ReceiveModel::Ax25ReceiveModel(RadioModel& radio, QObject* parent) + : QObject(parent), m_impl(std::make_unique()) +{ + Impl& d = *m_impl; + d.owner = this; + d.state->owner = this; + auto* worker = new Impl::Worker(d.state); + d.state->worker = worker; + worker->moveToThread(&d.thread); + connect(&d.thread, &QThread::finished, worker, &QObject::deleteLater); + d.thread.setObjectName(QStringLiteral("Ax25Receive")); + d.thread.start(); + d.audio = std::make_unique(radio, DecoderAudioModel::Consumer::Ax25); + connect(d.audio.get(), &DecoderAudioModel::sourceReset, this, [this] { m_impl->retire(); }); + connect(d.audio.get(), &DecoderAudioModel::pcmReady, this, + [this](const DecoderPcmBlock& block) { m_impl->enqueue(block); }); + connect(d.audio.get(), &DecoderAudioModel::routeStatusChanged, + this, &Ax25ReceiveModel::routeStatusChanged); +} + +Ax25ReceiveModel::~Ax25ReceiveModel() +{ + Impl& d = *m_impl; + { + QMutexLocker lock(&d.state->mutex); + d.state->owner = nullptr; + d.state->retire(); + } + d.audio.reset(); + d.thread.quit(); + d.thread.wait(); +} + +void Ax25ReceiveModel::setSlice(SliceModel* slice) +{ + m_impl->audio->setSlice(slice); +} + +void Ax25ReceiveModel::setEnabled(bool enabled) +{ + if (m_impl->enabled == enabled) { + return; + } + m_impl->enabled = enabled; + m_impl->audio->setEnabled(enabled); +} + +void Ax25ReceiveModel::configure(const Ax25DemodConfig& config) +{ + { + QMutexLocker lock(&m_impl->state->mutex); + const Ax25DemodConfig& previous = m_impl->state->config; + if (std::tie(previous.profile, previous.sampleRate, previous.baud, + previous.markHz, previous.spaceHz, previous.polarity, + previous.vhfMode, previous.txPreambleFlags) + == std::tie(config.profile, config.sampleRate, config.baud, + config.markHz, config.spaceHz, config.polarity, + config.vhfMode, config.txPreambleFlags)) { + return; + } + m_impl->state->config = config; + ++m_impl->state->configuration; + m_impl->state->retire(); + m_impl->state->resetNotification = false; + m_impl->scheduleWorker(); + } + const QPointer guard(this); + emit sourceReset(); + if (guard) { + emit statusChanged(); + } +} + +void Ax25ReceiveModel::reset() +{ + m_impl->retire(); +} + +void Ax25ReceiveModel::setDiagnosticsLoggingEnabled(bool enabled) +{ + QMutexLocker lock(&m_impl->state->mutex); + m_impl->state->diagnosticsLogging = enabled; + m_impl->scheduleWorker(); +} + +QObject* Ax25ReceiveModel::workerForTest() const +{ + return m_impl->state->worker; +} + +Ax25ReceiveModel::QueueState Ax25ReceiveModel::queuesForTest() const +{ + QMutexLocker lock(&m_impl->state->mutex); + return {m_impl->state->frames, m_impl->state->input.size(), + m_impl->state->output.size(), m_impl->state->workerScheduled, + m_impl->state->outputScheduled}; +} + +bool Ax25ReceiveModel::isEnabled() const +{ + return m_impl->enabled; +} + +DecoderAudioModel::RouteStatus Ax25ReceiveModel::routeStatus() const +{ + return m_impl->audio->routeStatus(); +} + +} // namespace AetherSDR diff --git a/src/models/Ax25ReceiveModel.h b/src/models/Ax25ReceiveModel.h new file mode 100644 index 0000000000..6e5bc4730a --- /dev/null +++ b/src/models/Ax25ReceiveModel.h @@ -0,0 +1,75 @@ +#pragma once + +#include "DecoderAudioModel.h" +#include "core/tnc/AetherAx25LibmodemShim.h" + +#include +#include +#include + +namespace AetherSDR { + +class RadioModel; +class SliceModel; + +// Retained by queued consumers as well as the model. An earlier listener may +// synchronously change the source, so each outward consumer checks this again. +struct Ax25ReceiveContext { + PcmEpochLease source; + quint64 generation = 0; + std::shared_ptr> activeGeneration; + bool current() const + { + return source.current() && activeGeneration + && activeGeneration->load() == generation; + } +}; + +// Selected receive audio and its existing modem worker. No sound device, +// external service or transmit authority is owned by this model. +class Ax25ReceiveModel final : public QObject { + Q_OBJECT +public: + explicit Ax25ReceiveModel(RadioModel& radio, QObject* parent = nullptr); + ~Ax25ReceiveModel() override; + + void setSlice(SliceModel* slice); + void setEnabled(bool enabled); + void configure(const Ax25DemodConfig& config); + void reset(); + void setDiagnosticsLoggingEnabled(bool enabled); + bool isEnabled() const; + DecoderAudioModel::RouteStatus routeStatus() const; + +signals: + void frameDecoded(const AetherSDR::Ax25DecodedFrame& frame, + const AetherSDR::Ax25ReceiveContext& context); + void diagnosticsUpdated(const AetherSDR::Ax25DecoderDiagnostics& diagnostics, + const AetherSDR::Ax25ReceiveContext& context); + void statusChanged(); + void routeStatusChanged(); + void sourceReset(); + // Capture observes the same admitted continuous mono24 source as the modem. + void pcmReady(const AetherSDR::DecoderPcmBlock& block, + const AetherSDR::Ax25ReceiveContext& context); + +private: + friend struct Ax25ReceiveModelTestAccess; + struct QueueState { + qsizetype inputFrames; + std::size_t inputBlocks; + std::size_t outputBlocks; + bool workerScheduled; + bool outputScheduled; + }; + // Read-only private seam for deterministic bounded-queue tests. Barriers + // live entirely in the test; production scheduling never waits on them. + QObject* workerForTest() const; + QueueState queuesForTest() const; + struct Impl; + std::unique_ptr m_impl; +}; + +} // namespace AetherSDR + +Q_DECLARE_METATYPE(AetherSDR::Ax25ReceiveContext) diff --git a/src/models/DaxReceiveModel.cpp b/src/models/DaxReceiveModel.cpp new file mode 100644 index 0000000000..ac89332019 --- /dev/null +++ b/src/models/DaxReceiveModel.cpp @@ -0,0 +1,146 @@ +#include "DaxReceiveModel.h" +#ifdef HAVE_WEBSOCKETS +#include "RadioModel.h" +#include "SliceModel.h" +#include "core/TciServer.h" +#include "core/TciRxConverter.h" +#include +#include +#include +#include + +namespace AetherSDR { +struct DaxReceiveModel::State { + struct Channel { + TciRxBinding binding; + PcmEpochLease epoch; + quint64 nextSample = 0; + std::shared_ptr converter; + }; + struct Pin { + PcmEpochLease epoch; + std::shared_ptr> owner; + }; + QPointer model; + QPointer routing; + std::array channels; + QHash pins; + PcmFrameGate gate; + bool enabled = false; + bool processing = false; +}; + +DaxReceiveModel::DaxReceiveModel(RadioModel& model, TciServer& routing, QObject* parent) + : QObject(parent), m_state(std::make_unique()) +{ + Q_ASSERT(model.thread() == thread() && routing.thread() == thread()); + m_state->model = &model; + m_state->routing = &routing; + connect(&model, &RadioModel::backendSliceAudioFrameReady, this, &DaxReceiveModel::receive); + connect(&routing, &TciServer::rxBindingsChanged, this, &DaxReceiveModel::refreshBindings); + connect(&model, &RadioModel::capabilitiesChanged, this, &DaxReceiveModel::refreshBindings); + connect(&model, &RadioModel::connectionStateChanged, this, [this](bool connected) { + if (!connected) { setEnabled(false); } + }); + connect(&routing, &QObject::destroyed, this, [this] { setEnabled(false); }); +} + +DaxReceiveModel::~DaxReceiveModel() = default; + +void DaxReceiveModel::setEnabled(bool enabled) +{ + m_state->enabled = enabled; + refreshBindings(); +} + +void DaxReceiveModel::refreshBindings() +{ + QPointer self(this); + std::array next; + if (m_state->enabled && m_state->model && m_state->routing) { + const auto capability = m_state->model->backendCapabilities().receiveAudioExport; + if (capability) { + for (SliceModel* slice : m_state->model->slices()) { + const TciRxBinding binding = m_state->routing->sliceRxBinding(slice->sliceId()); + if (binding.current() && binding.trx >= 0 && binding.trx < 8 + && binding.trx < capability->maximumReceivers) { + next[binding.trx] = binding; + } + } + } + } + for (int i = 0; i < 8; ++i) { + State::Channel& channel = m_state->channels[i]; + if (channel.binding.alive == next[i].alive) { continue; } + channel = {}; + channel.binding = next[i]; + const int sliceId = next[i].current() ? next[i].sliceId : -1; + emit channelReset(i+1); + if (!self) { return; } + emit channelSliceChanged(i+1,sliceId); + if (!self) { return; } + } +} + +void DaxReceiveModel::receive(int sliceId, const PcmFrame& frame) +{ + const PcmFrame input = frame; + if (m_state->processing || !m_state->enabled || !m_state->model || !m_state->routing + || !input.current() || input.stream().purpose != PcmPurpose::Slice + || input.stream().sliceId != sliceId) { return; } + const auto capability = m_state->model->backendCapabilities().receiveAudioExport; + if (!capability || !capability->sampleRatesHz.contains(input.stream().format.sampleRateHz)) { return; } + const TciRxBinding binding = m_state->routing->sliceRxBinding(sliceId); + if (!binding.current() || binding.trx < 0 || binding.trx >= 8 + || binding.trx >= capability->maximumReceivers) { return; } + const int index = binding.trx; + State::Channel& channel = m_state->channels[index]; + if (channel.binding.alive != binding.alive) { return; } + + // A removed/replaced receiver retains a bounded tombstone while its old + // producer is live. Neither a new owner nor a competing source can seize it. + for (auto it = m_state->pins.begin(); it != m_state->pins.end();) { + if (!it->epoch.current()) { it = m_state->pins.erase(it); } + else { ++it; } + } + const auto pin = m_state->pins.constFind(sliceId); + if (pin != m_state->pins.cend()) { + if (pin->epoch.stream() != input.stream() || pin->owner != binding.alive) { return; } + } else if (m_state->pins.size() >= qsizetype(PcmFrameGate::kMaxStreams)) { return; } + if (!m_state->gate.accept(input)) { return; } + m_state->pins.insert(sliceId,{input.epochLease(),binding.alive}); + + m_state->processing = true; + QPointer self(this); + const auto processing = qScopeGuard([self] { if (self) { self->m_state->processing = false; } }); + if (channel.epoch.stream() != input.stream() || channel.nextSample != input.firstSample() + || input.discontinuity() || !channel.converter) { + channel.epoch = input.epochLease(); + channel.converter = std::make_shared(input.stream().format,24000); + emit channelReset(index+1); + if (!self || !m_state->enabled || !binding.current() + || channel.binding.alive != binding.alive || !input.current()) { return; } + } + channel.nextSample = input.firstSample() + input.frameCount(); + const std::shared_ptr converter = channel.converter; + if (!converter || !converter->valid()) { return; } + const auto current = [self,index,binding,input,converter] { + return self && self->m_state->enabled && input.current() && binding.current() + && self->m_state->channels[index].binding.alive == binding.alive + && self->m_state->channels[index].converter == converter; + }; + const bool accepted = converter->process(std::span(input.samples().constData(), input.samples().size()), + [self,index,current](QVector stereo) { + if (!current()) { return false; } + const QByteArray pcm(reinterpret_cast(stereo.constData()), + stereo.size()*qsizetype(sizeof(float))); + emit self->audioReady(index+1,pcm); + return current(); + }); + if (!accepted && self && channel.converter == converter) { + channel.converter.reset(); + emit channelReset(index+1); + } +} +} +#endif diff --git a/src/models/DaxReceiveModel.h b/src/models/DaxReceiveModel.h new file mode 100644 index 0000000000..842e3fe53e --- /dev/null +++ b/src/models/DaxReceiveModel.h @@ -0,0 +1,34 @@ +#pragma once +#ifdef HAVE_WEBSOCKETS +#include +#include +#include + +namespace AetherSDR { +class RadioModel; +class TciServer; +class PcmFrame; + +// Owner-thread receive adapter for the existing virtual audio bridges. Uses +// TCI's live receiver bindings even while its listener is stopped. Owns no +// device, transport, thread or PCM queue; output is float32 stereo at 24 kHz. +class DaxReceiveModel final : public QObject { + Q_OBJECT +public: + DaxReceiveModel(RadioModel& model, TciServer& routing, QObject* parent = nullptr); + ~DaxReceiveModel() override; + void setEnabled(bool enabled); + +signals: + void audioReady(int channel, const QByteArray& stereo24); + void channelReset(int channel); + void channelSliceChanged(int channel, int sliceId); + +private: + void refreshBindings(); + void receive(int sliceId, const PcmFrame& frame); + struct State; + std::unique_ptr m_state; +}; +} +#endif diff --git a/src/models/DecoderAudioModel.cpp b/src/models/DecoderAudioModel.cpp index 6599dd9bca..012a561079 100644 --- a/src/models/DecoderAudioModel.cpp +++ b/src/models/DecoderAudioModel.cpp @@ -41,6 +41,16 @@ struct DecoderAudioModel::Impl { std::shared_ptr inbox; DecoderPcmAdapter adapter; + const char* consumerName() const + { + switch (consumer) { + case Consumer::Cw: return "CW"; + case Consumer::Rtty: return "RTTY"; + case Consumer::Ax25: return "AX.25"; + } + return "Decoder"; + } + void setRouteStatus(RouteStatus status) { if (routeStatus == status) { @@ -58,19 +68,23 @@ struct DecoderAudioModel::Impl { if (status == RouteStatus::SharedRxAudio) { // Not a warning: with no DAX assignment this is the ordinary Flex // path and the decoder still runs, just on the shared mix. - qCInfo(lcDsp) << (consumer == Consumer::Cw ? "CW" : "RTTY") + qCInfo(lcDsp) << consumerName() << "receive decoder: no DAX RX channel assigned to the selected slice;" << "decoding the shared receive audio (speaker gain and mute apply)"; } else if (status == RouteStatus::DaxTransportUnavailable) { - qCWarning(lcDsp) << (consumer == Consumer::Cw ? "CW" : "RTTY") + qCWarning(lcDsp) << consumerName() << "receive decoder: selected DAX transport unavailable; no input route"; } } PanadapterStream::DaxConsumer daxConsumer() const { - return consumer == Consumer::Cw ? PanadapterStream::DaxConsumer::CwDecoder - : PanadapterStream::DaxConsumer::RttyDecoder; + switch (consumer) { + case Consumer::Cw: return PanadapterStream::DaxConsumer::CwDecoder; + case Consumer::Rtty: return PanadapterStream::DaxConsumer::RttyDecoder; + case Consumer::Ax25: return PanadapterStream::DaxConsumer::Ax25Decoder; + } + Q_UNREACHABLE(); } void closeInbox() diff --git a/src/models/DecoderAudioModel.h b/src/models/DecoderAudioModel.h index 8eb85c3880..2ed5f62695 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 }; + enum class Consumer { Cw, Rtty, Ax25 }; // 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/MeterModel.cpp b/src/models/MeterModel.cpp index 3dc2d13faa..49170a1984 100644 --- a/src/models/MeterModel.cpp +++ b/src/models/MeterModel.cpp @@ -55,6 +55,7 @@ QJsonObject meterToJson(const MeterDef& def, bool hasValue, float value, qint64 obj["low"] = def.low; obj["high"] = def.high; obj["description"] = def.description; + obj["unavailable_reason"] = def.unavailableReason; obj["has_value"] = hasValue; obj["value"] = hasValue ? QJsonValue(value) : QJsonValue(); // Milliseconds since this meter's value last updated (-1 = never). Lets a @@ -130,6 +131,11 @@ void MeterModel::defineMeter(const MeterDef& def) removeMeter(def.index); m_manifestSliceContext = sliceContext; } + if (redefinition && def.source == QStringLiteral("SLC") + && def.name == QStringLiteral("LEVEL") && previous->unit != def.unit) { + m_values.remove(def.index); + m_valueUpdatedMs.remove(def.index); + } const bool nativeUnitChanged = redefinition && def.index == m_nativeAlcIndex && previous->unit != def.unit; m_defs[def.index] = def; @@ -839,6 +845,16 @@ std::optional MeterModel::swrIfLive() const : std::nullopt; } +std::optional MeterModel::relativeLevelForSlice(int sliceIndex) const +{ + if (sliceIndex < 0) { return std::nullopt; } + const int index = findMeter(QStringLiteral("SLC"), QStringLiteral("LEVEL"), sliceIndex); + const MeterDef* def = meterDef(index); + if (!def || !def->unavailableReason.isEmpty() || def->unit != QStringLiteral("dBFS") + || !vitalIsFresh(true, valueAgeMs(index))) { return std::nullopt; } + return m_values.value(index); +} + std::optional MeterModel::sLevelForSlice(int sliceIndex) const { const auto it = m_sLevelIdxBySlice.constFind(sliceIndex); @@ -846,6 +862,7 @@ std::optional MeterModel::sLevelForSlice(int sliceIndex) const return std::nullopt; // this receiver declares no LEVEL meter } const int index = it.value(); + if (m_defs.value(index).unit != QStringLiteral("dBm")) { return std::nullopt; } // Declared is not fed, and fed is not current: m_sLevelIdxBySlice is populated // by the definition, so the index alone would publish a default no packet set. // vitalIsFresh is the same window `get radio` uses for PATEMP (#5516); at the @@ -948,7 +965,7 @@ void MeterModel::applyValues(const QVector& ids, const QVector& bool isSliceLevel = false; for (auto sit = m_sLevelIdxBySlice.constBegin(); sit != m_sLevelIdxBySlice.constEnd(); ++sit) { if (sit.value() == idx) { - emit sLevelChanged(sit.key(), v); + if (it->unit == QStringLiteral("dBm")) { emit sLevelChanged(sit.key(), v); } isSliceLevel = true; break; } diff --git a/src/models/MeterModel.h b/src/models/MeterModel.h index 65ce3ccd7e..7cd6257195 100644 --- a/src/models/MeterModel.h +++ b/src/models/MeterModel.h @@ -110,6 +110,8 @@ class MeterModel : public QObject { // (Flex: the manifest's `num`; HL2: the receiver number; Icom: 0), so // RigctlProtocol can pass slice->sliceId(). std::optional sLevelForSlice(int sliceIndex) const; + // ADC-relative RF only. Never usable as calibrated dBm/S units. + std::optional relativeLevelForSlice(int sliceIndex) const; // The radio-wide S-meter reading — what `get meters` publishes as a scalar // — WHEN THERE IS ONE. Exactly one slice declaring a LEVEL meter is the diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index 2b05ca4857..b464c7c130 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -9644,6 +9644,14 @@ void RadioModel::wireSliceAudioIntentsToBackend(SliceModel* s, bool geometryThro m_backend->setSliceSquelch(s->sliceId(), on, level); } }, Qt::DirectConnection); + connect(s, &SliceModel::automaticSquelchRequested, this, + [this, s, canDispatch](bool enabled, int marginDb) { + if (!canDispatch() || !m_backend->isConnected() || slice(s->sliceId()) != s + || s->externalReceiveReplacementActive() || marginDb < 5 || marginDb > 20) { return; } + const auto sql = m_backend->capabilities().receiveSquelchModel; + if (!sql || !sql->automaticInEngine || !sql->modes.contains(s->mode())) { return; } + m_backend->setSliceAutoSquelch(s->sliceId(), enabled, marginDb); + }, Qt::DirectConnection); connect(s, &SliceModel::wfmForceMonoRequested, this, [this, s, canDispatch](bool forceMono) { if (!canDispatch() || !m_backend->isConnected() || slice(s->sliceId()) != s @@ -9696,9 +9704,9 @@ void RadioModel::wireSliceAudioIntentsToBackend(SliceModel* s, bool geometryThro // transmit with it. The backend clears the previously active slice, which on // a Flex arrives as a status echo and here has no other way of happening. connect(s, &SliceModel::activeSliceCommandIssued, this, - [this, s]() { - if (m_backend) m_backend->setActiveSlice(s->sliceId()); - }); + [this, s, canDispatch]() { + if (canDispatch()) { m_backend->setActiveSlice(s->sliceId()); } + }, Qt::DirectConnection); } void RadioModel::setBackendForTest(std::unique_ptr backend, diff --git a/src/models/SliceModel.cpp b/src/models/SliceModel.cpp index 60792a865c..cbcfafb049 100644 --- a/src/models/SliceModel.cpp +++ b/src/models/SliceModel.cpp @@ -717,6 +717,12 @@ void SliceModel::setSquelch(bool on, int level) emit squelchChanged(on, level); } +void SliceModel::setAutomaticSquelch(bool enabled, int marginDb) +{ + if (marginDb < 5 || marginDb > 20 || externalReceiveReplacementActive()) { return; } + emit automaticSquelchRequested(enabled, marginDb); +} + void SliceModel::setManualSquelch(bool on, int level) { setSquelch(on, level); @@ -813,6 +819,13 @@ void SliceModel::setTxSlice(bool on) void SliceModel::setActive(bool on) { + if (confirmsControls()) { + // Selection must follow the same live-object admission as tuning. + // An optimistic activeChanged edge can make the UI select a reused + // numeric slot before the owner refuses this retired object's intent. + if (on && QThread::currentThread() == thread()) { emit activeSliceCommandIssued(); } + return; + } if (on) { // Optimistic (#3854 review): activeSlice() prefers the radio's active // flag, so waiting for the echo leaves a one-round-trip window where @@ -1679,7 +1692,13 @@ void SliceModel::applyChanges(const SliceDelta& d) emit wfmReceptionDiagnosticsChanged(value); } } - if (d.squelchOn.has_value() || d.squelchLevel.has_value()) { + if (d.automaticSquelch) { m_automaticSquelch = *d.automaticSquelch; } + if (d.automaticSquelchMarginDb && *d.automaticSquelchMarginDb >= 5 + && *d.automaticSquelchMarginDb <= 20) { + m_automaticSquelchMarginDb = *d.automaticSquelchMarginDb; + } + if (d.squelchOn.has_value() || d.squelchLevel.has_value() + || d.automaticSquelch || d.automaticSquelchMarginDb) { m_squelchOnKnown |= d.squelchOn.has_value(); m_squelchLevelKnown |= d.squelchLevel.has_value(); if (d.squelchOn.has_value()) @@ -1694,8 +1713,13 @@ void SliceModel::applyChanges(const SliceDelta& d) // External-receive (Kiwi) slices keep their level in // m_externalReceiveSquelchLevel and never read the Flex manual // memory, so exclude them here exactly as setManualSquelch does. - if (m_squelchEchoIsManual && !m_externalReceiveAudioReplacement) + // Engine-owned Auto publishes its retained manual threshold, + // not a computed Auto level. Restore that memory even if the + // attached applet already marked its echo surface Off/Auto. + if ((m_squelchEchoIsManual || d.automaticSquelch.has_value()) + && !m_externalReceiveAudioReplacement) { setManualSquelchLevel(*d.squelchLevel); + } } emit squelchChanged(m_squelchOn, m_squelchLevel); } diff --git a/src/models/SliceModel.h b/src/models/SliceModel.h index 8132427fdb..84dc97987c 100644 --- a/src/models/SliceModel.h +++ b/src/models/SliceModel.h @@ -160,6 +160,9 @@ class SliceModel : public QObject { : m_audioMute; } bool flexAudioMute() const { return m_audioMute; } // Only inbound reports establish current-session truth; local setters do not. + bool automaticSquelch() const { return m_automaticSquelch; } + int automaticSquelchMarginDb() const { return m_automaticSquelchMarginDb; } + void setAutomaticSquelch(bool enabled, int marginDb); bool squelchStateKnown() const { return m_squelchOnKnown && m_squelchLevelKnown; } void invalidateSquelchState() { m_squelchOnKnown = false; m_squelchLevelKnown = false; } bool squelchOn() const { return m_squelchOn; } @@ -414,6 +417,7 @@ class SliceModel : public QObject { // for why turning the notch on without placing it is not enough. void manualNotchCommandIssued(bool on, int position); void squelchCommandIssued(bool on, int level); + void automaticSquelchRequested(bool enabled, int marginDb); // Receive and transmit incremental tuning. void ritCommandIssued(bool on, int hz); void xitCommandIssued(bool on, int hz); @@ -640,6 +644,8 @@ class SliceModel : public QObject { QString m_agcMode{"med"}; int m_agcThreshold{65}; int m_agcOffLevel{10}; + bool m_automaticSquelch{false}; + int m_automaticSquelchMarginDb{10}; bool m_squelchOnKnown{false}; bool m_squelchLevelKnown{false}; bool m_squelchOn{false}; diff --git a/tests/Ax25ReceiveFixture.h b/tests/Ax25ReceiveFixture.h new file mode 100644 index 0000000000..11afe49c6b --- /dev/null +++ b/tests/Ax25ReceiveFixture.h @@ -0,0 +1,116 @@ +#pragma once + +// Independent receive fixture: fixed AX.25 bytes, CRC-16/X25, HDLC bit stuffing +// and NRZI Bell 202. It does not call the application's TX/modulator APIs. +#include +#include + +#include +#include +#include + +namespace AetherSDR::test::ax25rx { + +inline QByteArray frame() +{ + return QByteArray::fromHex( + "82a0a4a64040609c60868298987303f0" + "21343734322e30304e2f31323231372e3030573e4f46464c494e4520415052532052582046495854555245"); +} + +inline unsigned fcs(const QByteArray& bytes) +{ + unsigned crc = 0xffff; + for (unsigned char byte : bytes) { + crc ^= byte; + for (int bit = 0; bit < 8; ++bit) { + crc = (crc >> 1) ^ ((crc & 1) ? 0x8408 : 0); + } + } + return crc ^ 0xffff; +} + +inline QVector hdlcBits(int preambleFlags = 100, int postambleFlags = 32, + const QByteArray& payload = frame()) +{ + QVector bits; + const auto byte = [&](unsigned value) { + for (int bit = 0; bit < 8; ++bit) { + bits.append((value >> bit) & 1); + } + }; + for (int flag = 0; flag < preambleFlags; ++flag) { + byte(0x7e); + } + QByteArray bytes = payload; + const unsigned crc = fcs(bytes); + bytes.append(static_cast(crc & 255)); + bytes.append(static_cast(crc >> 8)); + int ones = 0; + for (unsigned char value : bytes) { + for (int index = 0; index < 8; ++index) { + const int bit = (value >> index) & 1; + bits.append(bit); + if (bit) { + if (++ones == 5) { + bits.append(0); + ones = 0; + } + } else { + ones = 0; + } + } + } + for (int flag = 0; flag < postambleFlags; ++flag) { + byte(0x7e); + } + return bits; +} + +inline QVector afsk(int sampleRate, int channels = 1, int preambleFlags = 100, + const QByteArray& payload = frame()) +{ + const QVector bits = hdlcBits(preambleFlags, 32, payload); + QVector samples; + samples.reserve(bits.size() * (sampleRate / 1200) * channels); + bool mark = true; + double phase = 0; + for (int bit : bits) { + if (!bit) { + mark = !mark; + } + const double step = 2 * std::numbers::pi * (mark ? 1200 : 2200) / sampleRate; + for (int index = 0; index < sampleRate / 1200; ++index) { + const float sample = static_cast(0.5 * std::sin(phase)); + phase = std::fmod(phase + step, 2 * std::numbers::pi); + samples.append(sample); + if (channels == 2) { + samples.append(sample * 0.75f); + } + } + } + return samples; +} + +inline QByteArray cu8FmIq(int sampleRate = 2400000, double deviationHz = 2500, + double carrierOffsetHz = 0, double warmupSeconds = 0.35, + int preambleFlags = 160) +{ + const QVector audio = afsk(sampleRate, 1, preambleFlags); + const qsizetype warmup = static_cast(warmupSeconds * sampleRate); + QByteArray iq(2 * (warmup + audio.size()), Qt::Uninitialized); + double phase = 0; + for (qsizetype index = 0; index < warmup + audio.size(); ++index) { + const double modulation = index < warmup ? 0 : 2 * audio[index - warmup]; + phase = std::remainder(phase + 2 * std::numbers::pi + * (carrierOffsetHz + deviationHz * modulation) / sampleRate, + 2 * std::numbers::pi); + iq[2 * index] = static_cast(std::clamp( + std::lround(127.5 + 38.4 * std::cos(phase)), 0L, 255L)); + iq[2 * index + 1] = static_cast(std::clamp( + std::lround(127.5 + 38.4 * std::sin(phase)), 0L, 255L)); + } + return iq; +} + +} // namespace AetherSDR::test::ax25rx diff --git a/tests/ax25_libmodem_shim_test.cpp b/tests/ax25_libmodem_shim_test.cpp index 1485f5a33a..93b04d6308 100644 --- a/tests/ax25_libmodem_shim_test.cpp +++ b/tests/ax25_libmodem_shim_test.cpp @@ -10,7 +10,7 @@ #include #include #include -#include +#include #include #include @@ -839,14 +839,12 @@ void testReplayWavLoaderFeedsShim() const auto bits = lm::ax25::encode_bitstream(frameBytes, 0, 80, 8); const auto audio = afskPcmFromBits(bits, cfg); - QTemporaryFile file; - file.setFileTemplate(QStringLiteral("aether-ax25-capture-XXXXXX.wav")); - const bool opened = file.open(); - report("temporary replay WAV opened", opened); + QTemporaryDir directory; + const bool opened = directory.isValid(); + report("temporary replay WAV directory created", opened); if (!opened) return; - const QString path = file.fileName(); - file.close(); + const QString path = directory.filePath(QStringLiteral("capture.wav")); const QByteArray pcm(reinterpret_cast(audio.data()), static_cast(audio.size() * sizeof(float))); @@ -887,15 +885,13 @@ void testCaptureWriterSupportsTxStages() ax25AudioCapturePath(Ax25AudioCaptureStage::TxGenerated, captureId, 0).isEmpty()); - QTemporaryFile file; - file.setFileTemplate(QStringLiteral("aether-ax25-tx-capture-XXXXXX.wav")); - const bool opened = file.open(); - report("temporary TX capture WAV opened", opened); + QTemporaryDir directory; + const bool opened = directory.isValid(); + report("temporary TX capture WAV directory created", opened); if (!opened) { return; } - const QString path = file.fileName(); - file.close(); + const QString path = directory.filePath(QStringLiteral("capture.wav")); const float stereoFrames[] = { 0.25f, 0.25f, diff --git a/tests/ax25_receive_model_test.cpp b/tests/ax25_receive_model_test.cpp new file mode 100644 index 0000000000..28cb8065c7 --- /dev/null +++ b/tests/ax25_receive_model_test.cpp @@ -0,0 +1,562 @@ +// Socket-free normalized PCM -> production selected route -> real modem worker. +// The independent fixture does not use the application's transmit encoder. +#include "TestSettingsProfile.h" +#include "Ax25ReceiveFixture.h" +#include "core/backends/IRadioBackend.h" +#include "models/Ax25ReceiveModel.h" +#include "models/RadioModel.h" +#include "models/SliceModel.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace AetherSDR { +struct Ax25ReceiveModelTestAccess { + static QObject* worker(Ax25ReceiveModel& model) { return model.workerForTest(); } + static auto queues(const Ax25ReceiveModel& model) { return model.queuesForTest(); } +}; +} +using namespace AetherSDR; +namespace { +int failures = 0; +int checks = 0; +void check(bool value, const char* message) +{ + ++checks; + if (!value) { + ++failures; + std::fprintf(stderr, "FAIL: %s\n", message); + } +} +template bool waitFor(Predicate predicate, int milliseconds = 4000) +{ + QElapsedTimer timer; + timer.start(); + while (!predicate() && timer.elapsed() < milliseconds) { + QCoreApplication::processEvents(); + QThread::msleep(1); + } + return predicate(); +} +void settle(int milliseconds = 30) +{ + QElapsedTimer timer; + timer.start(); + waitFor([&] { return timer.elapsed() >= milliseconds; }, milliseconds + 100); +} +class Source final : public IRadioBackend { +public: + RadioCapabilities capabilities() const override + { + RadioCapabilities caps; + caps.hasDaxStreams = dax; + caps.maxSlices = 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 {} // no TX transport + void invokeExtension(const QString&, const QString&, quint64, const QVariant&) override {} + bool live = false; + bool dax = false; + std::unique_ptr stream; +}; +struct Fixture { + RadioModel radio; + Source* source = nullptr; + SliceModel* a = nullptr; + SliceModel* b = nullptr; + explicit Fixture(bool withDax = false) + { + auto backend = std::make_unique(); + source = backend.get(); + source->dax = withDax; + if (withDax) { + source->stream = std::make_unique(); + } + radio.setBackendForTest(std::move(backend), QStringLiteral("rtl"), source->stream.get()); + source->connectRadio({}); + a = add(3); + b = add(7); + } + SliceModel* add(int id) + { + SliceDelta delta; + delta.panId = QStringLiteral("aprs-pan"); + delta.frequency = 144.390; + delta.mode = QStringLiteral("FM"); + delta.inUse = true; + emit source->sliceChanged(id, delta); + return radio.slice(id); + } +}; +void bind(Ax25ReceiveModel& model, Fixture& fixture) +{ + model.configure(ax25DemodConfigForProfile(Ax25ModemProfile::Vhf1200)); + model.setSlice(fixture.a); + model.setEnabled(true); +} +void feed(Fixture& fixture, PcmProducer& producer, const QVector& samples, + int channels, const std::function& deliver = {}) +{ + constexpr int chunks[] = {1, 17, 255, 11, 1024, 513, 37}; + qsizetype offset = 0; + int chunk = 0; + while (offset < samples.size()) { + const qsizetype count = std::min(chunks[chunk++ % 7] * channels, + samples.size() - offset); + const auto frame = producer.produce(samples.mid(offset, count)); + check(frame.has_value(), "fixture creates a valid typed frame"); + if (!frame) { + return; + } + if (deliver) { + deliver(*frame); + } else { + emit fixture.source->sliceAudioFrameReady(frame->stream().sliceId, *frame); + } + offset += count; + QCoreApplication::processEvents(); + QThread::msleep(1); + } +} +void packetMatrix(QCoreApplication& app) +{ + check(test::ax25rx::fcs(test::ax25rx::frame()) == 0x1e9b, + "independent fixed AX25 payload has expected X25 FCS 9b1e on wire"); + for (const int rate : {24000, 48000}) { + for (const int channels : {1, 2}) { + Fixture fixture; + Ax25ReceiveModel model(fixture.radio); + bind(model, fixture); + fixture.radio.setPcAudioEnabled(false); + fixture.a->setAudioGain(0); + fixture.a->setAudioMute(true); + int frames = 0; + int pcm = 0; + int resets = 0; + Ax25ReceiveContext retained; + QObject::connect(&model, &Ax25ReceiveModel::sourceReset, &app, [&] { ++resets; }); + QObject::connect(&model, &Ax25ReceiveModel::pcmReady, &app, + [&](const DecoderPcmBlock& block, const Ax25ReceiveContext& context) { + ++pcm; + retained = context; + check(context.current() && block.current() + && block.source.stream().sliceId == 3 + && block.inputSampleRateHz == rate, + "mono24 output retains native selected source identity and rate"); + }); + QObject::connect(&model, &Ax25ReceiveModel::frameDecoded, &app, + [&](const Ax25DecodedFrame& frame, const Ax25ReceiveContext& context) { + ++frames; + check(context.current() && frame.fcsOk + && frame.ax25FrameNoFcs == test::ax25rx::frame(), + "known Bell202 packet survives production conversion with exact FCS"); + }); + PcmProducer producer; + producer.start(PcmPurpose::Slice, 3, + {rate, channels == 1 ? PcmLayout::Mono : PcmLayout::Stereo}); + feed(fixture, producer, test::ax25rx::afsk(rate, channels), channels); + check(waitFor([&] { return frames == 1; }) && pcm > 0, + "24/48 mono/stereo decode with awkward chunk boundaries and monitoring off"); + const int before = resets; + const Ax25ReceiveContext live = retained; + model.configure(ax25DemodConfigForProfile(Ax25ModemProfile::Vhf1200)); + model.setDiagnosticsLoggingEnabled(true); + model.setDiagnosticsLoggingEnabled(false); + fixture.a->setAudioGain(80); + fixture.a->setAudioMute(false); + fixture.radio.setPcAudioEnabled(true); + check(resets == before && live.current(), + "unchanged config, logging and monitor controls preserve receive context"); + model.reset(); + check(!live.current(), "explicit reset immediately retires already delivered contexts"); + settle(); + check(frames == 1, "one fixture yields one accepted packet"); + } + } +} +void routingAndDax(QCoreApplication& app) +{ + Fixture fixture; + Ax25ReceiveModel model(fixture.radio); + bind(model, fixture); + int pcm = 0; + QObject::connect(&model, &Ax25ReceiveModel::pcmReady, &app, + [&](const DecoderPcmBlock&, const Ax25ReceiveContext&) { ++pcm; }); + PcmProducer other; + PcmProducer speaker; + other.start(PcmPurpose::Slice, 7, {24000, PcmLayout::Mono}); + speaker.start(); + emit fixture.source->sliceAudioFrameReady(7, *other.produce({0.5f})); + emit fixture.source->audioFrameReady(*speaker.produce({0.5f, 0.5f})); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + check(pcm == 0, "native selection rejects unrelated slice and speaker mix"); + + Fixture flex(true); + Ax25ReceiveModel ax25(flex.radio); + DecoderAudioModel cw(flex.radio, DecoderAudioModel::Consumer::Cw); + DecoderAudioModel rtty(flex.radio, DecoderAudioModel::Consumer::Rtty); + bind(ax25, flex); + check(ax25.routeStatus() == DecoderAudioModel::RouteStatus::SharedRxAudio + && flex.a->daxChannel() == 0, "Flex retains explicit shared fallback without auto assignment"); + int shared = 0; + QObject::connect(&ax25, &Ax25ReceiveModel::pcmReady, &app, + [&](const DecoderPcmBlock&, const Ax25ReceiveContext&) { ++shared; }); + PcmProducer sharedProducer; + sharedProducer.start(); + emit flex.source->audioFrameReady(*sharedProducer.produce({0.5f, 0.5f})); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + check(shared == 1, "unassigned Flex fallback remains usable"); + int decoded = 0; + QObject::connect(&ax25, &Ax25ReceiveModel::frameDecoded, &app, + [&](const Ax25DecodedFrame& frame, const Ax25ReceiveContext& context) { + if (context.current() && frame.ax25FrameNoFcs == test::ax25rx::frame() && frame.fcsOk) { + ++decoded; + } + }); + feed(flex, sharedProducer, test::ax25rx::afsk(24000, 2), 2, + [&](const PcmFrame& frame) { emit flex.source->audioFrameReady(frame); }); + check(waitFor([&] { return decoded == 1; }), "shared Flex fallback decodes exact packet and FCS"); + flex.a->setDaxChannel(8); + cw.setSlice(flex.a); + rtty.setSlice(flex.a); + cw.setEnabled(true); + rtty.setEnabled(true); + using Holder = PanadapterStream::DaxConsumer; + PanadapterStream& stream = *flex.source->stream; + stream.acquireDaxChannel(8, Holder::Clock); + check(stream.daxChannelHeldBy(8, Holder::Ax25Decoder) + && stream.daxChannelHeldBy(8, Holder::CwDecoder) + && stream.daxChannelHeldBy(8, Holder::RttyDecoder) + && stream.daxChannelHeldBy(8, Holder::Clock) + && stream.daxChannelSnapshot().front().holders.contains(QStringLiteral("ax25-decoder")), + "AX25 has an independent DAX hold alongside CW RTTY and Clock"); + PcmProducer dax; + dax.start(PcmPurpose::Auxiliary, -1, {24000, PcmLayout::Mono}); + feed(flex, dax, test::ax25rx::afsk(24000), 1, + [&](const PcmFrame& frame) { emit stream.daxPcmReady(8, frame); }); + check(waitFor([&] { return decoded == 2; }), "assigned DAX path decodes exact packet"); + ax25.setEnabled(false); + check(!stream.daxChannelHeldBy(8, Holder::Ax25Decoder) + && stream.daxChannelHeldBy(8, Holder::CwDecoder) + && stream.daxChannelHeldBy(8, Holder::RttyDecoder) + && stream.daxChannelHeldBy(8, Holder::Clock), + "closing AX25 releases only its hold"); + Fixture absent; + absent.source->dax = true; + absent.a->setDaxChannel(1); + Ax25ReceiveModel unavailable(absent.radio); + bind(unavailable, absent); + check(unavailable.routeStatus() == DecoderAudioModel::RouteStatus::DaxTransportUnavailable, + "missing assigned DAX transport reports unavailable instead of changing sources"); +} + +// The model has only one queued owner call: worker publication. Intercepting +// it retires the lease after worker decode, immediately before owner delivery. +class RetireBeforePublication final : public QObject { +public: + std::function retire; + bool fired = false; + bool eventFilter(QObject*, QEvent* event) override + { + if (!fired && event->type() == QEvent::MetaCall) { + fired = true; + retire(); + } + return false; + } +}; +void stalePublication(QCoreApplication& app, int scenario) +{ + Fixture fixture; + Ax25ReceiveModel model(fixture.radio); + bind(model, fixture); + int frames = 0; + int diagnostics = 0; + QObject::connect(&model, &Ax25ReceiveModel::frameDecoded, &app, + [&](const Ax25DecodedFrame&, const Ax25ReceiveContext&) { ++frames; }); + QObject::connect(&model, &Ax25ReceiveModel::diagnosticsUpdated, &app, + [&](const Ax25DecoderDiagnostics&, const Ax25ReceiveContext&) { ++diagnostics; }); + PcmProducer producer; + producer.start(PcmPurpose::Slice, 3, {24000, PcmLayout::Mono}); + RetireBeforePublication filter; + filter.retire = [&] { + switch (scenario) { + case 0: model.setEnabled(false); break; + case 1: model.setSlice(fixture.b); break; + case 2: model.configure(ax25DemodConfigForProfile(Ax25ModemProfile::Hf300)); break; + case 3: producer.invalidate(); break; + case 4: + fixture.source->disconnectRadio(); + // setBackendForTest intentionally bypasses production lifecycle wiring. + emit fixture.radio.connectionStateChanged(false); + break; + default: model.reset(); break; + } + }; + model.installEventFilter(&filter); + // Less than one second: the first worker result is the known packet, not + // the modem's one-second diagnostic report. One block avoids input overflow. + const QVector packet = test::ax25rx::afsk(24000, 1, 40); + check(packet.size() < 24000, "publication fixture ends before diagnostics interval"); + emit fixture.source->sliceAudioFrameReady(3, *producer.produce(packet)); + check(waitFor([&] { return filter.fired; }), "worker decoded before owner retirement hook"); + settle(); + check(frames == 0 && diagnostics == 0, + "retired decoded output cannot escape after disable selection config epoch or disconnect"); + model.removeEventFilter(&filter); +} +void reentrantConsumers(QCoreApplication& app) +{ + Fixture fixture; + auto model = std::make_unique(fixture.radio); + bind(*model, fixture); + int first = 0; + int admittedSecond = 0; + Ax25ReceiveContext retained; + QObject::connect(model.get(), &Ax25ReceiveModel::frameDecoded, &app, + [&](const Ax25DecodedFrame&, const Ax25ReceiveContext& context) { + ++first; + retained = context; + model->reset(); + }); + QObject::connect(model.get(), &Ax25ReceiveModel::frameDecoded, &app, + [&](const Ax25DecodedFrame&, const Ax25ReceiveContext& context) { + if (context.current()) { + ++admittedSecond; + } + }); + PcmProducer producer; + producer.start(PcmPurpose::Slice, 3, {24000, PcmLayout::Mono}); + feed(fixture, producer, test::ax25rx::afsk(24000), 1); + check(waitFor([&] { return first == 1; }) && admittedSecond == 0 && !retained.current(), + "later synchronous listeners reject context retired by the first listener"); + model.reset(); + check(!retained.current(), "retained context is safe and stale after model destruction"); + + auto doomed = std::make_unique(fixture.radio); + bind(*doomed, fixture); + QPointer guard(doomed.get()); + QObject::connect(doomed.get(), &Ax25ReceiveModel::frameDecoded, &app, + [&](const Ax25DecodedFrame&, const Ax25ReceiveContext& context) { + retained = context; + doomed.reset(); + }); + PcmProducer fresh; + fresh.start(PcmPurpose::Slice, 3, {24000, PcmLayout::Mono}); + feed(fixture, fresh, test::ax25rx::afsk(24000), 1); + check(waitFor([&] { return !guard; }) && !retained.current(), + "frame callback may destroy model and join its worker without stale continuation"); +} +void multipleResultsOneBlock(QCoreApplication& app, bool resetFirst) +{ + Fixture fixture; + Ax25ReceiveModel model(fixture.radio); + bind(model, fixture); + QByteArray second = test::ax25rx::frame(); + second.back() = '2'; + QVector samples = test::ax25rx::afsk(24000, 1, 40); + samples += test::ax25rx::afsk(24000, 1, 40, second); + int frames = 0; + QObject::connect(&model, &Ax25ReceiveModel::frameDecoded, &app, + [&](const Ax25DecodedFrame& frame, const Ax25ReceiveContext& context) { + ++frames; + check(context.current() && frame.fcsOk + && frame.ax25FrameNoFcs == (frames == 1 ? test::ax25rx::frame() : second), + "single PCM block yields independently framed exact AX25 payloads"); + if (resetFirst) { + model.reset(); + } + }); + PcmProducer producer; + producer.start(PcmPurpose::Slice, 3, {24000, PcmLayout::Mono}); + check(samples.size() <= PcmFrame::kMaxFrames, "two packet fixture fits one bounded PCM block"); + emit fixture.source->sliceAudioFrameReady(3, *producer.produce(samples)); + check(waitFor([&] { return frames >= (resetFirst ? 1 : 2); }), + "two-frame control decodes both, or first callback resets model"); + settle(); + check(frames == (resetFirst ? 1 : 2), + "reset from first result prevents second result in the same worker output batch"); +} +class WorkerBarrier { +public: + struct State { + QSemaphore entered; + QSemaphore release; + std::atomic timedOut{false}; + }; + explicit WorkerBarrier(Ax25ReceiveModel& model) : state(std::make_shared()) + { + QMetaObject::invokeMethod(Ax25ReceiveModelTestAccess::worker(model), [box = state] { + box->entered.release(); + box->timedOut = !box->release.tryAcquire(1, 4000); + }, Qt::QueuedConnection); + check(state->entered.tryAcquire(1, 4000), "bounded worker barrier entered"); + } + ~WorkerBarrier() { state->release.release(); } + std::shared_ptr state; +}; +bool workerIdle(Ax25ReceiveModel& model) +{ + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + return waitFor([&] { + const auto queue = Ax25ReceiveModelTestAccess::queues(model); + return !queue.workerScheduled && queue.inputBlocks == 0; + }); +} +void boundedInput(QCoreApplication& app, bool frameLimit) +{ + Fixture fixture; + Ax25ReceiveModel model(fixture.radio); + bind(model, fixture); + PcmProducer producer; + producer.start(PcmPurpose::Slice, 3, {24000, PcmLayout::Mono}); + // Establish the route's initial discontinuity before counting overflow. + emit fixture.source->sliceAudioFrameReady(3, *producer.produce({0.0f})); + check(workerIdle(model), "initial route input drains"); + int resets = 0; + int decoded = 0; + QObject::connect(&model, &Ax25ReceiveModel::sourceReset, &app, [&] { ++resets; }); + QObject::connect(&model, &Ax25ReceiveModel::frameDecoded, &app, + [&](const Ax25DecodedFrame& frame, const Ax25ReceiveContext& context) { + if (context.current() && frame.fcsOk && frame.ax25FrameNoFcs == test::ax25rx::frame()) { + ++decoded; + } + }); + std::shared_ptr barrier; + { + WorkerBarrier blocked(model); + barrier = blocked.state; + const QVector input(frameLimit ? 65536 : 1, 0.0f); + const int blocks = frameLimit ? 2 : 257; + for (int index = 0; index < blocks; ++index) { + emit fixture.source->sliceAudioFrameReady(3, *producer.produce(input)); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + const auto queue = Ax25ReceiveModelTestAccess::queues(model); + check(queue.inputFrames <= 65536 && queue.inputBlocks <= 256 + && queue.workerScheduled, + "stalled worker retains bounded PCM and one scheduled drain"); + } + const auto queue = Ax25ReceiveModelTestAccess::queues(model); + check(resets == 1 && queue.inputBlocks == 1 + && queue.inputFrames == input.size(), + "input overflow retires backlog once and retains only the latest block"); + } + check(workerIdle(model) && !barrier->timedOut, "worker barrier releases and retained input drains"); + feed(fixture, producer, test::ax25rx::afsk(24000), 1); + check(waitFor([&] { return decoded == 1; }), "exact packet recovers after bounded input overflow"); +} +void boundedOutput(QCoreApplication& app) +{ + Fixture fixture; + Ax25ReceiveModel model(fixture.radio); + // The output queue contract is independent of demodulator lane count. + // Use one supported lane for this 260-second fixture; other scenarios retain A+. + model.configure(ax25DemodConfigForProfile(Ax25ModemProfile::Vhf1200, + Ax25TonePolarity::Normal, VhfMode::A)); + model.setSlice(fixture.a); + model.setEnabled(true); + PcmProducer producer; + producer.start(PcmPurpose::Slice, 3, {24000, PcmLayout::Mono}); + emit fixture.source->sliceAudioFrameReady(3, *producer.produce({0.0f})); + check(workerIdle(model), "initial output route input drains"); + int resets = 0; + int decoded = 0; + int diagnostics = 0; + QObject::connect(&model, &Ax25ReceiveModel::sourceReset, &app, [&] { ++resets; }); + QObject::connect(&model, &Ax25ReceiveModel::diagnosticsUpdated, &app, + [&](const Ax25DecoderDiagnostics&, const Ax25ReceiveContext& context) { + check(context.current(), "only current diagnostics escape output overflow"); + ++diagnostics; + }); + QObject::connect(&model, &Ax25ReceiveModel::frameDecoded, &app, + [&](const Ax25DecodedFrame& frame, const Ax25ReceiveContext& context) { + if (context.current() && frame.fcsOk && frame.ax25FrameNoFcs == test::ax25rx::frame()) { + ++decoded; + } + }); + RetireBeforePublication hold; + hold.retire = [&] { + // Keep the original owner event on the stack, then allow it to run. + // This stalls publication without deleting or replacing a queued call. + const QVector silence(24000, 0.0f); + for (int index = 0; index < 259; ++index) { + emit fixture.source->sliceAudioFrameReady(3, *producer.produce(silence)); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + check(workerIdle(model), "bounded output fixture drains one diagnostic input"); + const auto queue = Ax25ReceiveModelTestAccess::queues(model); + check(queue.outputBlocks <= 256 && queue.outputScheduled, + "stalled owner retains bounded results and its single pending publication"); + } + check(diagnostics == 0 && resets == 0, + "owner stall delays outward diagnostics and reset notification"); + }; + model.installEventFilter(&hold); + emit fixture.source->sliceAudioFrameReady(3, *producer.produce(QVector(24000, 0.0f))); + check(waitFor([&] { return hold.fired; }), "owner publication stall executed"); + model.removeEventFilter(&hold); + check(resets == 1 && diagnostics > 0 && diagnostics < 10, + "output overflow resets once and publishes only post-retirement diagnostics"); + feed(fixture, producer, test::ax25rx::afsk(24000), 1); + check(waitFor([&] { return decoded == 1; }), "exact packet recovers after bounded output overflow"); +} +void inputRetirement(QCoreApplication& app) +{ + Fixture fixture; + Ax25ReceiveModel model(fixture.radio); + bind(model, fixture); + int frames = 0; + QObject::connect(&model, &Ax25ReceiveModel::frameDecoded, &app, + [&](const Ax25DecodedFrame&, const Ax25ReceiveContext&) { ++frames; }); + PcmProducer producer; + producer.start(PcmPurpose::Slice, 3, {24000, PcmLayout::Mono}); + emit fixture.source->sliceAudioFrameReady(3, *producer.produce(test::ax25rx::afsk(24000))); + model.setSlice(fixture.b); + model.setSlice(fixture.a); + settle(); + check(frames == 0, "queued selected input is retired even after reselecting the same slice ID"); + feed(fixture, producer, test::ax25rx::afsk(24000), 1); + check(waitFor([&] { return frames == 1; }), "fresh audio recovers after selection retirement"); +} +} // namespace +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("ax25-receive-model")); + if (!profile.isValid()) { + return 2; + } + QCoreApplication app(argc, argv); + packetMatrix(app); + routingAndDax(app); + for (int scenario = 0; scenario < 6; ++scenario) { + stalePublication(app, scenario); + } + reentrantConsumers(app); + multipleResultsOneBlock(app, false); + multipleResultsOneBlock(app, true); + boundedInput(app, false); + boundedInput(app, true); + boundedOutput(app); + inputRetirement(app); + std::fprintf(stdout, "%d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/tests/control_receive_test.cpp b/tests/control_receive_test.cpp index 591eea4666..5bca475537 100644 --- a/tests/control_receive_test.cpp +++ b/tests/control_receive_test.cpp @@ -13,6 +13,7 @@ #include "core/backends/sim/SimBackend.h" #ifdef AETHER_BACKEND_RTL #include "core/backends/rtl/RtlSdrBackend.h" +#include "core/backends/rtl/RtlReceivePipeline.h" #endif #include @@ -629,17 +630,30 @@ void productionCapabilityContracts() && rtlCaps.receivePanCenterControl && rtlCaps.receivePanBandwidthControl && rtlCaps.panSpanModel && !rtlCaps.panSpanModel->followsSampleRate, "RTL exposes independent display center/span alongside implemented FM filter and mixer controls"); - check(rtlCaps.receiveFilterControl && rtlCaps.receiveFilterControl->modes.size() == 2 + const int expectedFilterModes = rtl::RtlReceivePipeline::kQualifiedWfmEnabled ? 3 : 2; + check(rtlCaps.receiveFilterControl && rtlCaps.receiveFilterControl->modes.size() == expectedFilterModes && rtlCaps.receiveFilterControl->modes[0].mode == QStringLiteral("FM") && rtlCaps.receiveFilterControl->modes[1].mode == QStringLiteral("FMN") && rtlCaps.receiveFilterControl->modes[0].minimumLowHz == -21600 && rtlCaps.receiveFilterControl->modes[1].maximumHighHz == 21600, - "RTL adjustable filter qualification excludes legacy WFM"); + "RTL filter contract retains FM/FM-N and admits qualified WFM only"); + if constexpr (rtl::RtlReceivePipeline::kQualifiedWfmEnabled) { + check(rtlCaps.broadcastFmReceive && rtlCaps.receiveFilterControl + && rtlCaps.receiveFilterControl->modes.size() == 3 + && rtlCaps.receiveFilterControl->modes[2].mode == QStringLiteral("WFM") + && rtlCaps.receiveFilterControl->modes[2].minimumLowHz == -100000 + && rtlCaps.receiveFilterControl->modes[2].maximumHighHz == 100000 + && rtlCaps.receiveFilterControl->modes[2].minimumWidthHz == 30000 + && rtlCaps.receiveFilterControl->modes[2].maximumWidthHz == 200000, + "Qualified WFM declares the implemented 30-200 kHz filter range"); + } else { + check(!rtlCaps.broadcastFmReceive, "Legacy WFM exposes no broadcast stereo contract"); + } check(rtlCaps.receiveSquelchModel && rtlCaps.receiveSquelchModel->modes == QStringList{"FM", "FMN"} && rtlCaps.receiveSquelchModel->referenceDb == -120 && rtlCaps.receiveSquelchModel->stepDb == 1.2 - && rtlCaps.receiveSquelchModel->unit == QStringLiteral("dBFS/bin"), + && rtlCaps.receiveSquelchModel->unit == QStringLiteral("dBFS/2048-bin"), "RTL desktop squelch declares the implemented detector's modes and units"); #endif } diff --git a/tests/dax_receive_applet_test.cpp b/tests/dax_receive_applet_test.cpp new file mode 100644 index 0000000000..503b9d090a --- /dev/null +++ b/tests/dax_receive_applet_test.cpp @@ -0,0 +1,75 @@ +// Socket-free real DAX applet: injected capabilities, no audio device or radio. +#include "TestSettingsProfile.h" +#include "gui/DaxApplet.h" +#include "gui/MeterSlider.h" +#include "models/RadioModel.h" +#include "models/SliceModel.h" +#include +#include +#include +#include + +using namespace AetherSDR; +class ReceiveBackend final : public IRadioBackend { +public: + RadioCapabilities caps; + RadioCapabilities capabilities() const override { return caps; } + bool isConnected() const override { return true; } + void connectRadio(const RadioConnectRequest&) override {} + void disconnectRadio() override {} + 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 {} +}; +int main(int argc,char** argv) +{ + TestSettingsProfile profile("dax-receive-applet"); + QApplication app(argc,argv); + int failures=0; + const auto check=[&](bool ok,const char* name) { + if(!ok) {++failures;std::fprintf(stderr,"FAIL: %s\n",name);} + }; + RadioModel model; + auto backend=std::make_unique(); + ReceiveBackend* source=backend.get(); + source->caps.canTransmit=false; + source->caps.maxSlices=8; + source->caps.receiveAudioExport=ReceiveAudioExport{{24000,48000},8}; + model.setBackendForTest(std::move(backend),QStringLiteral("test")); + DaxApplet widget; + widget.setRadioModel(&model); + widget.setMaxDaxChannels(8); + widget.setNativeReceiveRouting(true); + widget.show(); + MeterSlider* tx=nullptr; + int rx=0; + for(MeterSlider* meter:widget.findChildren()) { + if(meter->accessibleName()=="DAX TX gain") {tx=meter;} + else if(meter->accessibleName().startsWith("DAX RX ")) { + ++rx;check(meter->isEnabled() && meter->isVisible(),"all eight DAX receive gain controls are usable"); + } + } + check(rx==8,"DAX applet exposes eight receive channels"); + check(tx && !tx->isEnabled() && tx->isVisible() && !tx->toolTip().isEmpty() + && tx->accessibleDescription().contains("receive-only"), + "receive-only TX is dimmed with an accessible reason, not hidden"); + for(int i=0;i<8;++i) { + SliceDelta delta;delta.frequency=100.1+i*0.1;delta.mode=QStringLiteral("FM"); + emit source->sliceChanged(10+i,delta); + widget.setReceiveChannelSlice(i+1,model.slice(10+i)); + QLabel* label=widget.findChild(QStringLiteral("daxRxStatus%1").arg(i+1)); + check(label && label->text().contains("Slice"),"native route identifies its assigned slice in every DAX row"); + } + source->caps.receiveAudioExport.reset();source->caps.hasDaxStreams=true;source->caps.canTransmit=true; + emit model.capabilitiesChanged(true,source->caps); + widget.setNativeReceiveRouting(false); + check(tx && tx->isEnabled() && tx->toolTip().isEmpty() + && !tx->accessibleDescription().contains("receive-only"), + "returning to a transmit-capable DAX backend restores its TX control"); + std::printf("DAX applet: %d failures\n",failures); + return failures?1:0; +} diff --git a/tests/meter_model_test.cpp b/tests/meter_model_test.cpp index 3ba2cd5f53..ed3a273a29 100644 --- a/tests/meter_model_test.cpp +++ b/tests/meter_model_test.cpp @@ -1948,5 +1948,41 @@ int main(int argc, char** argv) testAmpPowerFlagTracksOnlyPowerMeters(); testWithdrawingAnAmpMeterAnnouncesItself(); + { + MeterModel model; + int absoluteReadings = 0; + QObject::connect(&model, &MeterModel::sLevelChanged, &model, + [&](int, float) { ++absoluteReadings; }); + MeterDef relative = slcMeter(700, 7); + relative.unit = "dBFS"; + model.defineMeter(relative); + model.updateValueByName("SLC", "LEVEL", -35.0f, 7); + report("relative RF never emits calibrated dBm", absoluteReadings == 0); + report("relative RF never enters absolute lookup", !model.sLevelForSlice(7)); + report("relative RF is available with its own reference", model.relativeLevelForSlice(7) == -35.0f); + model.removeMeter(700); + model.defineMeter(relative); + report("recreated receiver cannot inherit the old reading", !model.relativeLevelForSlice(7)); + for (int id = 0; id < 8; ++id) { + relative.index = 700 + id; relative.sourceIndex = id; + model.defineMeter(relative); + model.updateValueByName("SLC", "LEVEL", -20.0f - id, id); + } + model.removeMeter(703); + for (int id = 0; id < 8; ++id) { + report("relative RF stays associated with stable receiver ID", + id == 3 ? !model.relativeLevelForSlice(id) + : model.relativeLevelForSlice(id) == -20.0f - id); + } + report("unselected receiver cannot fall back to first meter", !model.relativeLevelForSlice(-1)); + relative.unit = "dBm"; + model.defineMeter(relative); + report("unit change cannot reinterpret an old relative sample", !model.sLevelForSlice(7)); + model.updateValueByName("SLC", "LEVEL", -73, 7); + report("calibrated meter still publishes dBm", model.sLevelForSlice(7) == -73 && absoluteReadings == 1); + model.clear(); + report("disconnect clears relative RF", !model.relativeLevelForSlice(7)); + } + return g_failed == 0 ? 0 : 1; } diff --git a/tests/pipewire_rx_bridge_test.cpp b/tests/pipewire_rx_bridge_test.cpp new file mode 100644 index 0000000000..48bc48f564 --- /dev/null +++ b/tests/pipewire_rx_bridge_test.cpp @@ -0,0 +1,65 @@ +// Socket-free Linux bridge test. Own anonymous pipes replace its output FDs; +// no PulseAudio modules, audio devices, radio or helper process are opened. +#include "core/PipeWireAudioBridge.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace AetherSDR { +class PipeWireRxBridgeTest { +public: + static int run() + { + int failures = 0; + const auto check = [&](bool ok,const char* name) { + if (!ok) { ++failures; std::fprintf(stderr,"FAIL: %s\n",name); } + }; + PipeWireAudioBridge bridge; + std::array readers{}; + readers.fill(-1); + const auto closeReaders = qScopeGuard([&] { + for (int fd : readers) { if (fd >= 0) { ::close(fd); } } + }); + bridge.m_open.store(true); + for (int i = 0; i < 8; ++i) { + int fds[2]; + if (::pipe2(fds,O_NONBLOCK|O_CLOEXEC) != 0) { return 1; } + readers[i] = fds[0]; + bridge.m_rx[i].fd = fds[1]; + bridge.m_rx[i].drainFd = ::dup(fds[0]); + check(::fcntl(fds[1],F_SETPIPE_SZ,65536) >= 65536,"bounded native receive pipe capacity available"); + bridge.setChannelGain(i+1,0.5f); + const std::array stereo{float(i+1)/8.0f,0,float(i+1)/8.0f,0}; + bridge.feedDaxAudio(i+1,QByteArray(reinterpret_cast(stereo.data()),sizeof(stereo))); + std::array output{}; + const ssize_t count=::read(readers[i],output.data(),sizeof(output)); + check(count==sizeof(output) && std::abs(output[0]-float(i+1)/64.0f)<1e-6f + && std::abs(output[1]-float(i+1)/32.0f)<1e-6f + && output[2]==output[1] && output[3]==output[1], + "all eight real bridge channels downmix stereo24 and interpolate mono48 with independent gain"); + bridge.feedDaxAudio(i+1,QByteArray(reinterpret_cast(stereo.data()),sizeof(stereo))); + bridge.resetRxChannel(i+1); + check(::read(readers[i],output.data(),sizeof(output)) < 0, + "route reset drains retained output from the old channel owner"); + const std::array silence{}; + bridge.feedDaxAudio(i+1,QByteArray(reinterpret_cast(silence.data()),sizeof(silence))); + output.fill(1); + check(::read(readers[i],output.data(),sizeof(output))==sizeof(output) + && output==silence,"route reset discards interpolation history before the new owner's silence"); + } + bridge.close(); + std::printf("PipeWire bridge: %d failures\n",failures); + return failures ? 1 : 0; + } +}; +} +int main(int argc,char** argv) +{ + QCoreApplication app(argc,argv); + return AetherSDR::PipeWireRxBridgeTest::run(); +} diff --git a/tests/rtl_aprs_receive_test.cpp b/tests/rtl_aprs_receive_test.cpp new file mode 100644 index 0000000000..aa7202005c --- /dev/null +++ b/tests/rtl_aprs_receive_test.cpp @@ -0,0 +1,313 @@ +// Socket-free end-to-end RX regression: independent AX.25/Bell202 fixture -> +// CU8 callbacks -> real RTL worker/backend -> native slice PCM -> modem model. +// Only USB device operations are injected. No radio or sound device is opened. +#include "TestSettingsProfile.h" +#include "Ax25ReceiveFixture.h" +#include "core/backends/rtl/RtlSdrBackend.h" +#include "core/backends/rtl/RtlSdrWorker.h" +#include "models/Ax25ReceiveModel.h" +#include "models/RadioModel.h" +#include "models/SliceModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace AetherSDR; +using Transaction = rtl::RtlCaptureTransaction; +namespace AetherSDR::rtl { +struct RtlCaptureBackendTestAccess { + static void start(RtlSdrBackend& backend, std::unique_ptr device) + { + backend.m_receiverCapacity = 2; + backend.m_capture = RtlCaptureTransaction({8, 2}); + backend.m_requested.hardware = {144290000, 2400000, 0, 0, 0, 240}; + backend.m_requested.receivers = { + {{3, 144390000, -8000, 8000, 0, 3000, 3000}, Transaction::Mode::Fm, 0, 50, true}, + {{7, 144590000, -8000, 8000, 0, 3000, 3000}, Transaction::Mode::Fm, 0, 50, true}}; + backend.startCapture(std::make_unique(std::move(device), nullptr, 2)); + } + static std::optional diagnostics(const RtlSdrBackend& backend) + { + if (!backend.m_worker) { return {}; } + return backend.m_worker->diagnostics(); + } + static quint64 revision(const RtlSdrBackend& backend) { return backend.m_published.revision; } +}; +} +namespace { +using Clock = std::chrono::steady_clock; +constexpr int kSampleRate = 2400000; +constexpr int kCallbackFrames = 8192; +int failures = 0; +void check(bool condition, const char* message) +{ + if (!condition) { ++failures; std::fprintf(stderr, "FAIL: %s\n", message); } +} +template bool waitFor(Predicate predicate, int milliseconds = 15000) +{ + QElapsedTimer elapsed; + elapsed.start(); + while (!predicate() && elapsed.elapsed() < milliseconds) { + QCoreApplication::processEvents(); + QThread::msleep(1); + } + return predicate(); +} +struct DeviceState { + std::mutex mutex; + std::condition_variable changed; + bool playing = false; + bool canceled = false; + std::atomic finished{false}; + std::atomic callbacks{0}; + Transaction::Hardware hardware; + QByteArray iq; +}; +class RecordedDevice final : public rtl::RtlSdrWorker::Device { +public: + explicit RecordedDevice(std::shared_ptr state) : m_state(std::move(state)) {} + bool set(Transaction::Control control, std::int64_t value) override + { + switch (control) { + case Transaction::Control::DirectSampling: m_state->hardware.directSampling = int(value); break; + case Transaction::Control::SampleRate: m_state->hardware.sampleRateHz = std::uint32_t(value); break; + case Transaction::Control::Ppm: m_state->hardware.ppm = int(value); break; + case Transaction::Control::OffsetTuning: m_state->hardware.offsetTuning = int(value); break; + case Transaction::Control::Center: m_state->hardware.centerHz = std::uint32_t(value); break; + case Transaction::Control::Gain: m_state->hardware.gainTenths = int(value); break; + } + return true; + } + std::optional read() override { return m_state->hardware; } + bool resetBuffer() override { return true; } + int readAsync(Callback callback, void* context) override + { + std::unique_lock lock(m_state->mutex); + m_state->changed.wait(lock, [&] { return m_state->playing || m_state->canceled; }); + const auto start = Clock::now(); + for (qsizetype offset = 0; offset < m_state->iq.size() && !m_state->canceled;) { + const qsizetype count = std::min(2 * kCallbackFrames, m_state->iq.size() - offset); + auto* bytes = reinterpret_cast(m_state->iq.data() + offset); + lock.unlock(); + callback(bytes, static_cast(count), context); + ++m_state->callbacks; + lock.lock(); + offset += count; + const auto deadline = start + std::chrono::nanoseconds( + (offset / 2) * 1000000000LL / kSampleRate); + m_state->changed.wait_until(lock, deadline, [&] { return m_state->canceled; }); + } + m_state->finished = true; + m_state->changed.wait(lock, [&] { return m_state->canceled; }); + return 0; + } + void cancelAsync() override + { + std::lock_guard lock(m_state->mutex); + m_state->canceled = true; + m_state->changed.notify_all(); + } +private: + std::shared_ptr m_state; +}; +QByteArray captureFixture(std::uint32_t centerHz) +{ + QByteArray iq = test::ax25rx::cu8FmIq(kSampleRate, 2500, 144390000.0 - centerHz); + double phase = 0; + // A second real RF receiver carries a different continuous audio tone. + // Its PCM must exist without entering the selected receiver's decoder. + for (qsizetype index = 0; index < iq.size() / 2; ++index) { + const double tone = std::sin(2 * std::numbers::pi * 440 * double(index) / kSampleRate); + phase = std::remainder(phase + 2 * std::numbers::pi * (144590000.0 - centerHz + 2500 * tone) / kSampleRate, + 2 * std::numbers::pi); + const double i = (static_cast(iq[2 * index]) - 127.5) / 127.5; + const double q = (static_cast(iq[2 * index + 1]) - 127.5) / 127.5; + iq[2 * index] = char(std::clamp(std::lround(127.5 + 127.5 * (0.65 * i + 0.2 * std::cos(phase))), 0L, 255L)); + iq[2 * index + 1] = char(std::clamp(std::lround(127.5 + 127.5 * (0.65 * q + 0.2 * std::sin(phase))), 0L, 255L)); + } + return iq; +} +} + +int runScenario(QCoreApplication& app, bool changeMonitor) +{ + const int initialFailures = failures; + auto device = std::make_shared(); + check(test::ax25rx::fcs(test::ax25rx::frame()) == 0x1e9b, "independent fixture retains its known FCS"); + RadioModel radio; + auto backend = std::make_unique(); + rtl::RtlSdrBackend* source = backend.get(); + radio.setBackendForTest(std::move(backend), QStringLiteral("rtl")); + QString error; + QObject::connect(source, &IRadioBackend::connectionError, &app, [&](const QString& value) { error = value; }); + quint64 selectedFrames = 0; + quint64 otherFrames = 0; + quint64 convertedSamples = 0; + int wrongInput = 0; + int decoded = 0; + int resets = 0; + int nativeDiscontinuities = 0; + quint64 nativeNextSample = 0; + int sourceIdentityChanges = 0; + QJsonArray nativeBoundaries; + PcmEpochLease selectedSource; + QByteArray nativePcm; + QByteArray decoderPcm; + const QString dumpRoot = qEnvironmentVariable("AETHER_APRS_TEST_PCM_DIR"); + QObject::connect(&radio, &RadioModel::backendSliceAudioFrameReady, &app, + [&](int id, const PcmFrame& frame) { + if (!frame.current() || frame.stream().purpose != PcmPurpose::Slice + || frame.stream().sliceId != id || frame.stream().format.sampleRateHz != 48000) { + ++wrongInput; + } + if (id == 3) { + if (selectedFrames && selectedSource.stream() != frame.stream()) { ++sourceIdentityChanges; } + if (frame.discontinuity() || frame.firstSample() != nativeNextSample) { + ++nativeDiscontinuities; + const auto& stream = frame.stream(); + nativeBoundaries.append(QJsonObject{{"first_sample", qint64(frame.firstSample())}, + {"expected_sample", qint64(nativeNextSample)}, {"explicit_discontinuity", frame.discontinuity()}, + {"source", qint64(stream.source)}, {"session", qint64(stream.session)}, + {"format_generation", qint64(stream.formatGeneration)}, + {"receiver_instance", qint64(stream.receiverInstance)}, + {"capture_revision", qint64(rtl::RtlCaptureBackendTestAccess::revision(*source))}}); + std::fprintf(stderr, "NATIVE discontinuity=%d first=%llu expected=%llu frames=%lld\n", + frame.discontinuity(), frame.firstSample(), nativeNextSample, qint64(frame.frameCount())); + } + nativeNextSample = frame.firstSample() + frame.frameCount(); + selectedFrames += frame.frameCount(); selectedSource = frame.epochLease(); + if (!dumpRoot.isEmpty()) { + nativePcm.append(reinterpret_cast(frame.samples().constData()), + frame.samples().size() * qsizetype(sizeof(float))); + } + } + if (id == 7) { otherFrames += frame.frameCount(); } + }); + rtl::RtlCaptureBackendTestAccess::start(*source, std::make_unique(device)); + if (!waitFor([&] { return source->isConnected() && radio.slice(3) && radio.slice(7); })) { + check(false, "actual backend accepts the injected capture and publishes both sparse receivers"); + source->disconnectRadio(); + return 1; + } + // Startup deliberately places FM away from converter DC. Generate the + // independent RF carriers against actual device readback, not requested RF. + const std::uint32_t captureCenterHz = device->hardware.centerHz; + device->iq = captureFixture(captureCenterHz); + const QByteArray fixtureHash = QCryptographicHash::hash(device->iq, QCryptographicHash::Sha256).toHex(); + Ax25ReceiveModel modem(radio); + modem.configure(ax25DemodConfigForProfile(Ax25ModemProfile::Vhf1200)); + modem.setSlice(radio.slice(3)); + modem.setEnabled(true); + radio.setPcAudioEnabled(false); + check(radio.slice(3)->audioGain() == 0 && radio.slice(3)->audioMute() + && radio.slice(7)->audioGain() == 0 && radio.slice(7)->audioMute(), + "both receiver monitors are confirmed muted at zero gain before playback"); + QObject::connect(&modem, &Ax25ReceiveModel::sourceReset, &app, [&] { + ++resets; + const auto& stream = selectedSource.stream(); + std::fprintf(stderr, "RESET count=%d native=%llu converted=%llu source=%llu session=%llu generation=%llu instance=%llu current=%d\n", + resets, selectedFrames, convertedSamples, stream.source, stream.session, + stream.formatGeneration, stream.receiverInstance, selectedSource.current()); + }); + QObject::connect(&modem, &Ax25ReceiveModel::pcmReady, &app, + [&](const DecoderPcmBlock& block, const Ax25ReceiveContext& context) { + if (!context.current() || !block.current() || block.inputSampleRateHz != 48000 + || block.source.stream().sliceId != 3 || context.source.stream() != block.source.stream()) { + ++wrongInput; + } + convertedSamples += block.samples.size(); + if (!dumpRoot.isEmpty()) { + decoderPcm.append(reinterpret_cast(block.samples.constData()), + block.samples.size() * qsizetype(sizeof(float))); + } + }); + QObject::connect(&modem, &Ax25ReceiveModel::frameDecoded, &app, + [&](const Ax25DecodedFrame& frame, const Ax25ReceiveContext& context) { + ++decoded; + check(context.current() && context.source.stream() == selectedSource.stream() + && frame.fcsOk && frame.ax25FrameNoFcs == test::ax25rx::frame(), + "real RTL FM native48 -> selected mono24 model recovers exact AX25 packet and FCS"); + }); + { + std::lock_guard lock(device->mutex); + device->playing = true; + device->changed.notify_all(); + } + bool monitorChanged = false; + const bool completed = waitFor([&] { + if (changeMonitor && !monitorChanged && selectedFrames > 24000) { + monitorChanged = true; + std::fprintf(stderr, "MONITOR change native=%llu converted=%llu resets=%d\n", selectedFrames, convertedSamples, resets); + radio.slice(3)->setAudioGain(50); + radio.slice(3)->setAudioGain(0); + radio.slice(7)->setAudioPan(0); + radio.slice(7)->setAudioPan(100); + radio.setPcAudioEnabled(false); + } + return device->finished.load() && decoded > 0; + }); + const auto diagnostics = rtl::RtlCaptureBackendTestAccess::diagnostics(*source); + check(completed && error.isEmpty(), "paced CU8 capture completes without backend failure"); + check(decoded == 1, "only one exact packet emerges from the selected receiver"); + check(selectedFrames > 24000 && otherFrames > 24000 && convertedSamples > 12000 && !wrongInput, + "both real native48 slices run while only selected slice supplies continuous mono24"); + check(resets == nativeDiscontinuities && (changeMonitor || resets == 1), + "decoder resets exactly for native discontinuities, with one initial reset during steady monitoring-off RX"); + check(sourceIdentityChanges == 0, "monitor controls preserve the selected native source identity"); + check(diagnostics && diagnostics->droppedPackets == 0 && diagnostics->mixerRejectedBlocks == 0 + && diagnostics->mixerConfigurationFailures == 0, + "actual pipeline retains bounded packets and a valid mixer configuration"); + const int streamResets = resets; + source->disconnectRadio(); + check(!selectedSource.current(), "disconnect revokes the actual native source lease"); + if (!dumpRoot.isEmpty()) { + for (const auto& output : {std::pair{QStringLiteral("native48.f32"), nativePcm}, + std::pair{QStringLiteral("decoder24.f32"), decoderPcm}}) { + QFile file(dumpRoot + QLatin1Char('/') + + (changeMonitor ? QStringLiteral("controls-") : QStringLiteral("steady-")) + output.first); + check(file.open(QIODevice::WriteOnly) && file.write(output.second) == output.second.size(), + "requested diagnostic PCM is saved completely"); + } + } + const QJsonObject receipt{{"scenario", changeMonitor ? QStringLiteral("monitor-controls") : QStringLiteral("steady-monitor-off")}, + {"fixture_cu8_sha256", QString::fromLatin1(fixtureHash)}, + {"fixture_cu8_bytes", device->iq.size()}, {"capture_rate_hz", kSampleRate}, + {"capture_center_hz", qint64(captureCenterHz)}, + {"expected_fcs", QStringLiteral("1e9b")}, {"native_rate_hz", 48000}, + {"decoder_rate_hz", 24000}, {"selected_native_frames", qint64(selectedFrames)}, + {"other_native_frames", qint64(otherFrames)}, {"converted_samples", qint64(convertedSamples)}, + {"decoded_exact_packets", decoded}, {"stream_resets_before_disconnect", streamResets}, + {"native_discontinuities", nativeDiscontinuities}, + {"native_boundaries", nativeBoundaries}, {"source_identity_changes", sourceIdentityChanges}, + {"mixer_late_frames", diagnostics ? qint64(diagnostics->mixerLateFrames) : -1}, + {"dropped_packets", diagnostics ? qint64(diagnostics->droppedPackets) : -1}, + {"backend_error", error}, {"failures", failures - initialFailures}}; + std::puts(QJsonDocument(receipt).toJson().constData()); + return failures > initialFailures ? 1 : 0; +} + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("rtl-aprs-receive")); + if (!profile.isValid()) { return 1; } + QCoreApplication app(argc, argv); + runScenario(app, false); + runScenario(app, true); + return failures ? 1 : 0; +} diff --git a/tests/rtl_backend_test.cpp b/tests/rtl_backend_test.cpp index 907f85858f..83abaac5d0 100644 --- a/tests/rtl_backend_test.cpp +++ b/tests/rtl_backend_test.cpp @@ -38,12 +38,40 @@ int main(int argc, char** argv) check(!caps.canCreateSlices, "RTL-SDR retains its fixed single receiver in P01"); check(caps.family == "rtl", "capabilities.family is rtl"); check(!caps.canTransmit, "RTL-SDR cannot transmit"); + check(caps.receiveAudioExport && caps.receiveAudioExport->maximumReceivers == caps.maxSlices + && caps.receiveAudioExport->sampleRatesHz.contains(24000) + && caps.receiveAudioExport->sampleRatesHz.contains(48000), + "RTL declares its typed native receive audio export independently of TX/IQ"); + check(!caps.hasDaxStreams,"native receive export does not advertise Flex DAX/IQ streams"); + check(caps.txPowerMaxWatts == 0.0, "RTL-SDR max TX power is 0"); check(!caps.hostModulates, "RTL-SDR does not modulate on host"); check(caps.maxSlices == 1, "RTL-SDR maxSlices is 1"); check(caps.maxPanadapters == 1, "RTL-SDR maxPanadapters is 1"); check(!caps.persistsMemories, "RTL-SDR does not persist memories on device"); +#if (defined(Q_OS_LINUX) && defined(Q_PROCESSOR_X86_64)) \ + || (defined(Q_OS_MAC) && defined(Q_PROCESSOR_ARM_64)) \ + || (defined(Q_OS_WIN) && defined(Q_PROCESSOR_X86_64)) + qputenv("AETHER_RTL_EVALUATION_RECEIVERS", "8"); + qunsetenv("AETHER_AUTOMATION"); + { rtl::RtlSdrBackend normal; check(normal.capabilities().maxSlices == 1, "eight requires explicit evaluation environment"); } + qputenv("AETHER_AUTOMATION", "1"); + { + rtl::RtlSdrBackend eight; + const auto evaluationCaps = eight.capabilities(); + check(evaluationCaps.maxSlices == 8 && evaluationCaps.canCreateSlices, + "eight-receiver evaluation is advertised"); + check(evaluationCaps.receiveAudioExport && evaluationCaps.receiveAudioExport->maximumReceivers == 8, + "eight-receiver native DAX export is advertised"); + check(!evaluationCaps.canTransmit, "eight-receiver evaluation remains receive-only"); + } + qputenv("AETHER_RTL_EVALUATION_RECEIVERS", "9"); + { rtl::RtlSdrBackend invalid; check(invalid.capabilities().maxSlices == 1, "unsupported evaluation capacity is refused"); } + qunsetenv("AETHER_RTL_EVALUATION_RECEIVERS"); + qunsetenv("AETHER_AUTOMATION"); +#endif + // Check ClientSettingsDomains check(caps.clientSettingsDomains.testFlag(RadioCapabilities::ClientSettingsDomain::Tuning), "ClientSettingsDomain::Tuning declared"); diff --git a/tests/rtl_capture_worker_test.cpp b/tests/rtl_capture_worker_test.cpp index cc57e3d5df..93210616be 100644 --- a/tests/rtl_capture_worker_test.cpp +++ b/tests/rtl_capture_worker_test.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,7 @@ #include #include #include +#include using namespace AetherSDR; using T = rtl::RtlCaptureTransaction; @@ -40,6 +42,11 @@ struct RtlCaptureBackendTestAccess { static T::State state(const RtlSdrBackend& backend) { return *backend.m_capture.confirmed(); } static void spectrum(RtlSdrBackend& backend, const QByteArray& frame, T::Token token) { emit backend.m_worker->spectrumFrameReady(token.session, token.revision, 0, frame); } + static void service(RtlSdrBackend& backend) { backend.serviceCapture(); } + static bool stopWorker(RtlSdrBackend& backend) { return backend.m_worker->stopReading(); } + static RtlSdrWorker* worker(RtlSdrBackend& backend) { return backend.m_worker.get(); } + static RtlReceivePipeline::Diagnostics diagnostics(const RtlSdrBackend& backend) + { return backend.m_worker->diagnostics(); } }; } static int failures = 0; @@ -58,6 +65,61 @@ template bool waitFor(Predicate predicate) return predicate(); } +static void callbackMeasurement() +{ + auto device = std::make_shared(); + rtl::RtlSdrBackend backend; + rtl::RtlCaptureBackendTestAccess::start(backend, std::make_unique(device), 2); + device->releaseReadback(); + const bool connected = waitFor([&] { return backend.isConnected() && device->starts == 1; }); + check(connected, "callback measurement fixture establishes actual USB-worker acquisition"); + if (!connected) { backend.disconnectRadio(); return; } + // Deliver one block at a time through Device::readAsync, including malformed + // lengths that must be counted without touching their purported samples. + constexpr std::array bytes{16384, 4096, 2, 0, 1, 16385}; + for (const std::uint32_t length : bytes) { + const int expected = device->callbacks + 1; + device->callbackBytes = length; + device->block(); + check(waitFor([&] { return device->callbacks >= expected; }), + "measurement fixture completes each actual callback before changing its length"); + } + check(rtl::RtlCaptureBackendTestAccess::stopWorker(backend), + "callback counters are frozen only after acquisition joins"); + const auto measured = rtl::RtlCaptureBackendTestAccess::diagnostics(backend); + check(measured.callbackCount == 3 && measured.callbackIqSamples == 8192 + 2048 + 1 + && measured.malformedCallbacks == 3, + "valid callback sample totals exclude zero, odd and oversized blocks"); + check(measured.usbReadStarts == static_cast(device->starts.load()) + && measured.usbCancelRequests == static_cast(device->cancels.load()) + && measured.usbCancelRequests > 0, + "worker USB counters count actual read and cancellation API invocations"); + std::uint64_t histogramCount = 0; + std::uint64_t histogramLowerTotal = 0; + std::uint64_t histogramUpperTotal = 0; + for (std::size_t i = 0; i < measured.callbackDurationBuckets.size(); ++i) { + const std::uint64_t count = measured.callbackDurationBuckets[i]; + histogramCount += count; + if (count == 0) { continue; } + const std::uint64_t lower = i == 0 ? 0 : rtl::RtlSdrWorker::kCallbackDurationUpperBoundsNs[i - 1] + 1; + // Exact observed maximum safely bounds even the final overflow bucket. + const std::uint64_t upper = std::min(measured.callbackMaxNs, + rtl::RtlSdrWorker::kCallbackDurationUpperBoundsNs[i]); + check(measured.callbackMaxNs >= lower, + "occupied duration bucket agrees with the measured maximum"); + histogramLowerTotal += lower * count; + histogramUpperTotal += upper * count; + } + check(histogramCount == measured.callbackCount + && measured.callbackTotalNs > 0 && measured.callbackMaxNs > 0 + && measured.callbackTotalNs >= histogramLowerTotal + && measured.callbackTotalNs <= histogramUpperTotal + && measured.callbackTotalNs >= measured.callbackMaxNs + && measured.callbackDeadlineMisses <= measured.callbackCount, + "joined callback histogram, total, maximum and deadline accounting are consistent"); + backend.disconnectRadio(); +} + // A held pointer keeps issuing newer centers before each hardware readback. // Each completed capture must nevertheless publish a fresh, correctly framed // FFT before the next retune, without relaxing non-pan supersession rules. @@ -182,34 +244,93 @@ static void receiverTuneKeepsDisplayAverage() rtl::RtlSdrBackend receiver; rtl::RtlCaptureBackendTestAccess::start(receiver, std::make_unique(device)); device->releaseReadback(); - check(waitFor([&] { return receiver.isConnected(); }), "averaging worker connects"); + const bool connected = waitFor([&] { return receiver.isConnected(); }); + check(connected, "averaging worker connects"); + if (!connected) { receiver.disconnectRadio(); return; } receiver.setPanAverage({}, 100); int frames = 0; float lastDc = -999; + bool captureCorrected = false; + int correctedFrames = 0; + std::optional firstCorrectedDc; + T::Token firstCorrectedToken; QObject::connect(&receiver, &IRadioBackend::spectrumFrameReady, [&](int, const QByteArray& frame, const SpectrumCoverage&) { ++frames; std::memcpy(&lastDc, frame.constData() + frame.size() / 2, sizeof(lastDc)); + const T::State accepted = rtl::RtlCaptureBackendTestAccess::state(receiver); + if (captureCorrected && accepted.dcSuppression) { + ++correctedFrames; + if (!firstCorrectedDc) { + firstCorrectedDc = lastDc; + firstCorrectedToken = accepted.token; + } + } }); - for (int i = 0; i < 8; ++i) { device->block(); } + + // Grant only one callback and wait for its completion before granting the + // next. Polling predicates must not enqueue work: waitFor evaluates its + // predicate again on return, and DeviceState retains permits across USB + // restarts. Bursts also outrun native WFM's nonblocking WDSP output ring. + const unsigned long callbackMicroseconds = static_cast(std::ceil( + 1.0e6 * (device->callbackBytes.load() / 2.0) / 2'400'000)); + const auto clockBlock = [&] { + { + std::lock_guard lock(device->mutex); + check(device->blocks == 0, "averaging callback clock has no queued permits"); + if (device->blocks != 0) { return false; } + } + const int completed = device->callbacks + 1; + device->block(); + const bool finished = waitFor([&] { return device->callbacks >= completed; }); + check(finished, "averaging callback clock waits for actual completion"); + if (!finished) { return false; } + QThread::usleep(callbackMicroseconds); + // Publish an acknowledged adoption on its owner thread before another + // callback can complete its first FFT under an unpublished revision. + rtl::RtlCaptureBackendTestAccess::service(receiver); + QCoreApplication::processEvents(); + const bool exact = device->callbacks == completed; + check(exact, "averaging callback clock delivers exactly one block"); + return exact; + }; + const auto clockBlocks = [&](int count) { + for (int i = 0; i < count; ++i) { + if (!clockBlock()) { return false; } + } + return true; + }; + const auto adoptWithClock = [&] { + QElapsedTimer timer; + timer.start(); + while (rtl::RtlCaptureBackendTestAccess::busy(receiver) && timer.elapsed() < 3000) { + if (!clockBlock()) { return false; } + } + return timer.elapsed() < 3000 && !rtl::RtlCaptureBackendTestAccess::busy(receiver); + }; + + if (!clockBlocks(8)) { receiver.disconnectRadio(); return; } check(waitFor([&] { return frames > 0; }), "averaging worker seeds a complete observation"); const int writes = device->writes; - device->iqLevel = 190; receiver.setSliceFrequency(0, 100'100'000); - check(waitFor([&] { device->block(); return !rtl::RtlCaptureBackendTestAccess::busy(receiver); }), - "receiver-only tune adopts through the actual USB callback"); + const bool tuned = adoptWithClock(); + check(tuned, "receiver-only tune adopts through the actual USB callback"); + if (!tuned) { receiver.disconnectRadio(); return; } + // Keep the weak seed unchanged while asynchronous preparation runs. Its + // duration must not age the old average with the stronger test input. + device->iqLevel = 190; const int beforeFrames = frames; - for (int i = 0; i < 8; ++i) { device->block(); } + if (!clockBlocks(8)) { receiver.disconnectRadio(); return; } check(waitFor([&] { return frames > beforeFrames; }), "tuned receiver emits new accepted FFT"); const double raw = 20 * std::log10(std::sqrt(2.) * (190 - 127.5) / 127.5 * .35875 * (65535. / 65536)); check(device->writes == writes && lastDc < raw - 8, "receiver-only adoption preserves display smoothing with unchanged hardware"); receiver.setPanRfGain({}, 25); - check(waitFor([&] { device->block(); return !rtl::RtlCaptureBackendTestAccess::busy(receiver); }), + check(waitFor([&] { return !rtl::RtlCaptureBackendTestAccess::busy(receiver); }), "gain change adopts through verified hardware path"); const int beforeGainFrames = frames; - for (int i = 0; i < 8; ++i) { device->block(); } + if (!clockBlocks(8)) { receiver.disconnectRadio(); return; } check(waitFor([&] { return frames > beforeGainFrames; }), "new gain emits complete frame"); check(std::abs(lastDc - raw) < .02, "gain transition discards incompatible amplitude history"); @@ -234,7 +355,7 @@ static void receiverTuneKeepsDisplayAverage() rtl::RtlCaptureBackendTestAccess::spectrum(receiver, obsolete, beforePpm.token); check(frames == beforePpmFrames, "PPM adoption rejects queued spectrum from the old capture"); - for (int i = 0; i < 8; ++i) { device->block(); } + if (!clockBlocks(8)) { receiver.disconnectRadio(); return; } check(waitFor([&] { return frames > beforePpmFrames; }), "PPM change emits complete observation"); const double weak = 20 * std::log10(std::sqrt(2.) * (130 - 127.5) / 127.5 * .35875 * (65535. / 65536)); @@ -242,14 +363,26 @@ static void receiverTuneKeepsDisplayAverage() && std::abs(lastDc - weak) < .02, "PPM change cannot reinterpret the previous RF history"); device->iqLevel = 190; + captureCorrected = true; // Arm before intent; never substitute a later FFT. receiver.invokeExtension("rtl", "dc_suppression.set", 2, true); - check(waitFor([&] { - device->block(); QThread::msleep(20); - return !rtl::RtlCaptureBackendTestAccess::busy(receiver); - }), "DC correction adopts on a real callback boundary"); - const int beforeDcFrames = frames; - for (int i = 0; i < 8; ++i) { device->block(); } - check(waitFor([&] { return frames > beforeDcFrames; }), "DC correction emits complete observation"); + const bool correctedAdopted = adoptWithClock(); + check(correctedAdopted, "DC correction adopts on a real callback boundary"); + if (!correctedAdopted) { receiver.disconnectRadio(); return; } + const T::State afterDc = rtl::RtlCaptureBackendTestAccess::state(receiver); + const int afterDcCallbacks = device->callbacks; + check(afterDc.dcSuppression && afterDc.token.session == afterPpm.token.session + && afterDc.token.revision > afterPpm.token.revision && correctedFrames == 0, + "DC adoption publishes before its first complete corrected window"); + // Adoption processes one 8192-sample block. Six more still cannot fill + // the 65536-sample FFT; precisely the seventh completes its first window. + if (!clockBlocks(6)) { receiver.disconnectRadio(); return; } + check(correctedFrames == 0, "DC correction cannot publish a partial first window"); + if (!clockBlock()) { receiver.disconnectRadio(); return; } + check(waitFor([&] { return firstCorrectedDc.has_value(); }), "DC correction emits complete observation"); + check(correctedFrames == 1 && firstCorrectedToken == afterDc.token + && rtl::RtlCaptureBackendTestAccess::state(receiver).token == afterDc.token + && device->callbacks == afterDcCallbacks + 7, + "DC observation is exactly the first complete window of the accepted revision"); const double pole = std::exp(-2 * std::numbers::pi * rtl::RtlDcBlocker::kCornerHz / 2'400'000); double correctedWindowGain = 0; for (int i = 0; i < 65536; ++i) { @@ -260,17 +393,107 @@ static void receiverTuneKeepsDisplayAverage() const double corrected = 20 * std::log10(std::sqrt(2.) * (190 - 127.5) / 127.5 * (1 + pole) / 2 * correctedWindowGain); check(rtl::RtlCaptureBackendTestAccess::state(receiver).dcSuppression - && std::abs(lastDc - corrected) < .03, + && firstCorrectedDc && std::abs(*firstCorrectedDc - corrected) < .03, "DC mode change discards old estimates and shows the actual first corrected window"); + const auto measured = rtl::RtlCaptureBackendTestAccess::diagnostics(receiver); + check(std::ranges::all_of(measured.receiverWithdrawals, [](std::uint64_t count) { return count == 0; }), + "paced averaging fixture never withdraws a native receiver for repair"); + { + std::lock_guard lock(device->mutex); + check(device->blocks == 0, "averaging fixture finishes without queued callback permits"); + } receiver.disconnectRadio(); } +static void rapidMonitorEditsKeepSpectrum(bool metersEnabled) +{ + auto device = std::make_shared(); + rtl::RtlSdrBackend backend; + rtl::RtlCaptureBackendTestAccess::start(backend, std::make_unique(device), 2); + device->releaseReadback(); + if (!waitFor([&] { return backend.isConnected(); })) { + check(false, "monitor averaging fixture connects"); + backend.disconnectRadio(); return; + } + backend.setPanAverage({}, 100); + backend.invokeExtension("rtl", "receive_meters.set", 1, metersEnabled); + const auto clockBlock = [&] { + const int completed = device->callbacks + 1; + device->block(); + check(waitFor([&] { return device->callbacks == completed; }), "monitor callback completes"); + QThread::usleep(3500); + rtl::RtlCaptureBackendTestAccess::service(backend); + QCoreApplication::processEvents(); + }; + check(backend.createSlice({}, 100'200'000), "monitor fixture admits independent second slice"); + for (int i = 0; i < 200 && rtl::RtlCaptureBackendTestAccess::busy(backend); ++i) { clockBlock(); } + const T::State before = rtl::RtlCaptureBackendTestAccess::state(backend); + check(before.receivers.size() == 2, "monitor fixture publishes both receivers"); + int frames = 0; + float lastDc = -999; + // Observe production worker frames before publication fencing: superseded + // tokens must still be rejected by the backend, but RF cadence must survive. + QObject::connect(rtl::RtlCaptureBackendTestAccess::worker(backend), + &rtl::RtlSdrWorker::spectrumFrameReady, &backend, + [&](quint64, quint64, int, const QByteArray& frame) { + ++frames; + std::memcpy(&lastDc, frame.constData() + frame.size() / 2, sizeof(lastDc)); + }); + for (int i = 0; i < 24; ++i) { clockBlock(); } + const int seededFrames = frames; + const int writes = device->writes; + const int starts = device->starts; + device->iqLevel = 190; + for (int i = 0; i < 96; ++i) { + if (i % 4 == 0) { + const int id = (i / 4) % 2; + // The second request supersedes the first before its callback; + // this exercises real compensation, not a sequential setter test. + backend.setSliceAudioGain(id, 20); + backend.setSliceAudioGain(id, 60 + id); + backend.setSliceAudioPan(id, 30 + id); + backend.setSliceAudioMute(id, true); + } + clockBlock(); + } + check(frames - seededFrames >= 8, + "rapid independent monitor edits preserve continuous FFT cadence through compensation"); + const double raw = 20 * std::log10(std::sqrt(2.) * (190 - 127.5) / 127.5 + * .35875 * (65535. / 65536)); + for (int i = 0; i < 200 && rtl::RtlCaptureBackendTestAccess::busy(backend); ++i) { clockBlock(); } + const int framesBeforeFinal = frames; + for (int i = 0; i < 12; ++i) { clockBlock(); } + check(frames > framesBeforeFinal && lastDc < raw - 3, + "rapid monitor edits retain accumulated averaging instead of reseeding raw power"); + const T::State after = rtl::RtlCaptureBackendTestAccess::state(backend); + check(!rtl::RtlCaptureBackendTestAccess::busy(backend) + && after.receivers.size() == 2 && after.receivers[0].audioGain == 60 + && after.receivers[1].audioGain == 61 && after.receivers[0].audioPan == 30 + && after.receivers[1].audioPan == 31 && after.receivers[0].audioMute + && after.receivers[1].audioMute && after.capture == before.capture + && after.hardware == before.hardware && device->writes == writes && device->starts == starts, + "latest per-slice monitor intent is confirmed without hardware writes or USB restart"); + const double retainedDb = raw - lastDc; + int staleFrames = 0; + QObject::connect(&backend, &IRadioBackend::spectrumFrameReady, + [&](int, const QByteArray&) { ++staleFrames; }); + rtl::RtlCaptureBackendTestAccess::spectrum(backend, + QByteArray(65536 * int(sizeof(float)), '\0'), before.token); + check(staleFrames == 0, "retaining RF history never admits obsolete revision output"); + std::printf("monitor_average meters=%d frames=%d retained_db=%.3f\n", + metersEnabled, frames - seededFrames, retainedDb); + backend.disconnectRadio(); +} + int main(int argc, char** argv) { QCoreApplication app(argc, argv); + callbackMeasurement(); sustainedPanProgress(false); sustainedPanProgress(true); receiverTuneKeepsDisplayAverage(); + rapidMonitorEditsKeepSpectrum(true); + rapidMonitorEditsKeepSpectrum(false); deferredPanCannotOutliveNewerIntent(); auto state = std::make_shared(); rtl::RtlSdrBackend backend; diff --git a/tests/rtl_device_settings_test.cpp b/tests/rtl_device_settings_test.cpp index 41d6c1fe3f..bc9b7aa1a8 100644 --- a/tests/rtl_device_settings_test.cpp +++ b/tests/rtl_device_settings_test.cpp @@ -40,6 +40,13 @@ int main(int argc, char** argv) check(Settings(first).saveAccepted({-12, true}, reason), "update accepted controls"); check(first.featureExact(Settings::featureName()).value("futureField").toObject().value("keep").toInt() == 123, "unknown members survive read-modify-write"); check(Settings(first).load().values == Settings::Values{-12, true}, "DC and PPM roundtrip independently"); + check(Settings(first).load().values.receiveMetersEnabled, "legacy settings default meters to enabled"); + check(Settings(first).saveAccepted({-12, true, false}, reason), "meter disable saves through device settings owner"); + check(!Settings(first).load().values.receiveMetersEnabled && Settings(second).load().values.receiveMetersEnabled, + "meter setting roundtrips per serial without leaking into another radio"); + Settings::Values valuesForInvalidMeter; + check(!Settings::decode({{"ppm", 0}, {"dcSuppression", false}, {"receiveMetersEnabled", "false"}}, valuesForInvalidMeter), + "meter setting refuses non-boolean values"); const QJsonObject future{{"privateFutureState", 42}}; check(first.setFeature(Settings::featureName(), 99, future), "seed future schema"); check(Settings(first).load().status == Settings::ReadStatus::Refused, "future document is not reinterpreted as current controls"); diff --git a/tests/rtl_model_acceptance_test.cpp b/tests/rtl_model_acceptance_test.cpp index 49e66f8b11..43324c1025 100644 --- a/tests/rtl_model_acceptance_test.cpp +++ b/tests/rtl_model_acceptance_test.cpp @@ -121,11 +121,236 @@ class CaptureClock { qint64 m_nextBlockMs = 0; }; +static std::uint64_t monotonicMs() +{ + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +static void analogBankRestoreAndSharedCapture() +{ + using Access = rtl::RtlCaptureBackendTestAccess; + const RadioSettingsScope scope("rtl", "analog-bank-restore"); + QVector saved; + for (int id = 0; id < 8; ++id) { + RtlSliceSettings::Slice receiver; + receiver.id = id; + receiver.frequencyHz = 99'400'000 + id * 200'000; + receiver.mode = id < 2 ? QStringLiteral("WFM") : QStringLiteral("FMN"); + receiver.filterLowHz = id < 2 ? -90000 : -8000; + receiver.filterHighHz = -receiver.filterLowHz; + receiver.wfmDeemphasisUs = id == 0 ? 50 : 75; + receiver.wfmForceMono = id == 0; + receiver.audioGain = 40 + id * 5; + receiver.audioPan = id * 12; + receiver.audioMute = id == 3; + receiver.squelchEnabled = id >= 2; + receiver.automaticSquelch = id >= 2; + receiver.automaticSquelchMarginDb = 5 + id; + saved.append(receiver); + } + check(RtlSliceSettings(scope).patch(100'000'000, 2'400'000, saved), "eight analog saved recipes seeded"); + RadioModel model; + check(model.rebuildBackendForTest("rtl"), "restore bank model initialized"); + RadioModelSliceLifecycleTestAccess::restore(model, "analog-bank-restore"); + auto& backend = *static_cast(model.backend()); + auto device = std::make_shared(); + CaptureClock clock(device); + Access::start(backend, std::make_unique(device), 8); + device->releaseReadback(); + const auto settled = [&] { clock.advance(); return Access::idle(backend); }; + check(waitFor([&] { return settled() && model.slice(7); }), "eight analog saved receivers adopted"); + if (!model.slice(7)) { backend.disconnectRadio(); return; } + for (int id = 0; id < 8; ++id) { + const auto* slice = model.slice(id); + check(slice && slice->mode() == saved[id].mode && slice->frequency() * 1e6 == saved[id].frequencyHz + && slice->audioGain() == saved[id].audioGain && slice->audioPan() == saved[id].audioPan + && slice->audioMute() == saved[id].audioMute && slice->filterLow() == saved[id].filterLowHz + && slice->automaticSquelch() == saved[id].automaticSquelch + && slice->automaticSquelchMarginDb() == saved[id].automaticSquelchMarginDb, + "restore retains each stable receiver recipe and monitor settings"); + } + check(model.slice(0)->wfmForceMono() && model.slice(0)->wfmDeemphasisUs() == 50 + && !model.slice(1)->wfmForceMono() && model.slice(1)->wfmDeemphasisUs() == 75, + "restore retains distinct analog stereo and deemphasis policies"); + model.slice(2)->setActive(true); + const auto before = Access::state(backend); + backend.invokeExtension("rtl", "ppm.set", 800, -16); + backend.invokeExtension("rtl", "ppm.set", 801, -17); + check(waitFor([&] { return settled() && Access::state(backend).hardware.ppm == -17; }), + "coalesced PPM edit reaches the entire eight-receiver capture"); + check(Access::state(backend).receivers == before.receivers && model.slice(2)->isActive(), + "shared PPM changes preserve every recipe and selection"); + const auto valid = Access::state(backend); + backend.invokeExtension("rtl", "sample_rate.set", 802, 0); + check(Access::idle(backend) && Access::state(backend).token == valid.token, + "invalid capture rate refuses without altering eight receivers"); + device->failWriteAt = device->writes + 1; + backend.invokeExtension("rtl", "ppm.set", 803, -18); + check(waitFor([&] { return settled(); }) && backend.isConnected() + && Access::state(backend).hardware.ppm == -17 + && Access::state(backend).receivers == before.receivers && model.slice(2)->isActive(), + "failed PPM application compensates all eight recipes without moving focus"); + backend.disconnectRadio(); +} + +static void analogBankSelection(int capacity = 4) +{ + using Access = rtl::RtlCaptureBackendTestAccess; + RadioModel model; + check(model.rebuildBackendForTest("rtl"), "analog bank model initialized"); + auto& backend = *static_cast(model.backend()); + auto device = std::make_shared(); + CaptureClock clock(device); + Access::start(backend, std::make_unique(device), capacity); + device->releaseReadback(); + const auto settled = [&] { clock.advance(); return Access::idle(backend); }; + check(waitFor([&] { return settled() && model.slice(0); }), "analog bank initial receiver adopted"); + if (!model.slice(0)) { backend.disconnectRadio(); return; } + const auto selected = [&](int id) { + int count = 0; + for (int slot = 0; slot < 8; ++slot) { + if (const auto* slice = model.slice(slot); slice && slice->isActive()) { + ++count; + if (slot != id) { return false; } + } + } + return count == 1; + }; + check(selected(0), "bootstrap selects exactly one stable receiver"); + for (int id = 1; id < capacity; ++id) { + check(backend.createSlice({}, 99'500'000 + id * 200'000), "analog sibling creation admitted"); + check(waitFor([&] { return settled() && model.slice(id); }), "analog sibling creation adopted"); + if (!model.slice(id)) { backend.disconnectRadio(); return; } + model.slice(id)->setActive(true); + check(selected(id), "selection clears every previous active model"); + } + check(!backend.createSlice({}, 100'650'000), "receiver above configured capacity refused at evaluation capacity"); + check(waitFor([&] { + clock.advance(); + for (int id = 0; id < capacity; ++id) { + if (!model.meterModel().relativeLevelForSlice(id)) { return false; } + } + return true; + }), "real injected IQ reaches relative RF meters for every stable receiver ID"); + for (int id = 0; id < capacity; ++id) { + check(!model.meterModel().sLevelForSlice(id), "RTL never claims a calibrated S reading"); + } + + for (int id = 0; id < capacity; ++id) { + model.slice(id)->setAutomaticSquelch(true, 5 + id); + model.slice(id)->setAudioMute(id % 2); + } + check(waitFor([&] { return settled() && model.slice(capacity - 1)->automaticSquelch(); }), + "all receiver Auto requests adopt"); + for (int id = 0; id < capacity; ++id) { + check(model.slice(id)->automaticSquelchMarginDb() == 5 + id + && model.slice(id)->audioMute() == bool(id % 2), "SQL and mute stay per receiver"); + } + const auto beforeFocus = Access::state(backend); + const int beforeFocusWrites = device->writes; + for (int iteration = 0; iteration < 32; ++iteration) { + const int id = iteration % capacity; + model.slice(id)->setActive(true); + check(selected(id), "rapid focus retains one exact model identity"); + } + check(Access::state(backend).token == beforeFocus.token && device->writes == beforeFocusWrites + && Access::state(backend).receivers == beforeFocus.receivers, + "focus changes no capture revision, hardware, monitor or receiver recipe"); + model.slice(3)->setActive(true); // start the reentrant scenario on its observed source + bool redirected = false; + const auto redirect = QObject::connect(model.slice(3), &SliceModel::activeChanged, &model, + [&](bool active) { + if (!active && !redirected) { redirected = true; model.slice(2)->setActive(true); } + }); + model.slice(1)->setActive(true); + check(redirected && selected(2), "reentrant newer selection supersedes the old publication tail"); + QObject::disconnect(redirect); + model.slice(3)->setActive(true); + model.slice(1)->setMode("WFM"); + check(waitFor([&] { return settled() && model.slice(1)->mode() == "WFM"; }), + "WFM admitted beside three native analog receivers"); + model.slice(2)->setMode("WFM"); + check(waitFor([&] { return settled() && model.slice(2)->mode() == "WFM"; }), + "second WFM receiver independently adopted"); + model.slice(1)->setWfmForceMono(true); + model.slice(1)->setWfmDeemphasis(50); + model.slice(1)->setFilterWidth(-90000, 90000); + model.slice(2)->setWfmForceMono(false); + model.slice(2)->setWfmDeemphasis(75); + model.slice(2)->setFilterWidth(-80000, 80000); + check(waitFor([&] { return settled() && model.slice(1)->filterLow() == -90000 + && model.slice(2)->filterLow() == -80000; }), "two independent WFM recipes adopt"); + check(model.slice(1)->wfmForceMono() + && model.slice(1)->wfmDeemphasisUs() == 50 + && !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"); + model.slice(2)->setActive(true); + auto* pan = model.panadapter(model.slice(2)->panId()); + check(pan != nullptr, "multi receiver pan materialized"); + if (pan) { + model.requestPanCenter(pan->panId(), 105.0, -1.0, IRadioBackend::PanCenterIntent::Drag); + check(waitFor([&] { return settled() && Access::state(backend).receivingIds.empty(); }), + "all configured analog receivers can park together"); + for (int id = 0; id < capacity; ++id) { + check(!model.meterModel().relativeLevelForSlice(id), "parking withdraws live RF meter samples"); + } + + check(selected(2) && model.slice(1)->wfmDeemphasisUs() == 50 + && !model.slice(1)->wfmReceptionDiagnostics().valid, + "parked selection and recipe survive while reception clears"); + model.requestPanCenter(pan->panId(), 100.0, -1.0, IRadioBackend::PanCenterIntent::Drag); + check(waitFor([&] { return settled() && Access::state(backend).receivingIds.size() == static_cast(capacity); }), + "all configured analog receivers resume at their configured RF"); + } + QPointer retired = model.slice(2); + QObject::connect(&backend, &IRadioBackend::sliceRemoved, &model, [&](int id) { + if (id == 2 && retired) { QCoreApplication::removePostedEvents(retired, QEvent::DeferredDelete); } + }); + check(backend.removeSlice(2), "selected middle slot removal admitted"); + check(waitFor([&] { return settled() && !model.slice(2); }), "selected middle slot removal adopted"); + check(selected(0), "removed selection resolves deterministically to a surviving identity"); + check(model.meterModel().findMeter("SLC", "LEVEL", 2) == -1, + "removed receiver withdraws its meter definition"); + + check(backend.createSlice({}, 100'200'000), "middle stable slot reuse admitted"); + check(waitFor([&] { return settled() && model.slice(2); }), "middle stable slot replacement adopted"); + check(retired && model.slice(2) != retired && selected(0), "replacement never inherits retired selection"); + if (retired) { + int staleFocusEdges = 0; + QObject::connect(retired, &SliceModel::activeChanged, &model, + [&](bool) { ++staleFocusEdges; }); + retired->setActive(true); + check(selected(0) && staleFocusEdges == 0, + "retired selection cannot emit a UI focus edge or target a reused numeric slot"); + retired->deleteLater(); + } + const auto disconnectDuringSelect = QObject::connect(model.slice(0), &SliceModel::activeChanged, + &model, [&](bool active) { if (!active) { backend.disconnectRadio(); } }); + model.slice(1)->setActive(true); + check(!backend.isConnected() && !model.slice(1)->isActive(), + "reentrant disconnect cancels the unaccepted selection tail"); + check(!model.meterModel().relativeLevelForSlice(0), "disconnect clears relative meter samples"); + QObject::disconnect(disconnectDuringSelect); + backend.disconnectRadio(); +} + int main(int argc, char** argv) { TestSettingsProfile profile(QStringLiteral("rtl-model-acceptance")); if (!profile.isValid()) { return 1; } QCoreApplication app(argc, argv); AppSettings::instance().load(); + analogBankSelection(); + analogBankSelection(8); + analogBankRestoreAndSharedCapture(); const RadioSettingsScope scope("rtl", "model-accepted"); RtlSliceSettings::Slice saved; saved.id = 3; saved.frequencyHz = 99'900'000; saved.mode = "FM"; @@ -200,6 +425,23 @@ int main(int argc, char** argv) check(RtlSliceSettings(scope).load().document.slices[3].squelchEnabled && RtlSliceSettings(scope).load().document.slices[3].squelchLevel == 61, "only accepted squelch threshold reaches persistence"); + slice->setAutomaticSquelch(true, 12); + check(!slice->automaticSquelch(), "pending Auto intent is not published optimistically"); + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend) + && slice->automaticSquelch(); }), "Auto command adopts on sparse stable receiver"); + check(slice->automaticSquelchMarginDb() == 12 && slice->squelchLevel() == 61, + "Auto margin is distinct from preserved manual threshold"); + RadioModelSliceLifecycleTestAccess::flush(model); + const auto sqlSaved = RtlSliceSettings(scope).load().document.slices[3]; + check(sqlSaved.automaticSquelch && sqlSaved.automaticSquelchMarginDb == 12, + "accepted per-receiver Auto persists"); + slice->setAutomaticSquelch(true, 100); + check(rtl::RtlCaptureBackendTestAccess::idle(backend) && slice->automaticSquelchMarginDb() == 12, + "invalid Auto margin is refused"); + slice->setSquelch(true, 61); + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend) + && !slice->automaticSquelch(); }), "same manual threshold still exits Auto"); + RadioModelSliceLifecycleTestAccess::flush(model); const auto accepted = scope.featureExact("RtlSlices"); const int refusedBefore = observed; slice->setFilterWidth(9000, -9000); slice->setMode("not-a-mode"); diff --git a/tests/rtl_receive_pipeline_test.cpp b/tests/rtl_receive_pipeline_test.cpp index 36ae3bca6c..fab55ca4b2 100644 --- a/tests/rtl_receive_pipeline_test.cpp +++ b/tests/rtl_receive_pipeline_test.cpp @@ -24,6 +24,54 @@ namespace AetherSDR::rtl { struct RtlReceivePipelineTestAccess { // Observation seam only: the receiver supplies a fixed failure; this does // not claim to reproduce the underlying WDSP underrun. + static bool rfObservation(RtlReceivePipeline& pipeline) + { + pipeline.m_token = {19, 23}; + pipeline.m_capture.achievedSampleRateHz = 2048000; + pipeline.m_capture.centerHz = 100000000; + pipeline.m_receivingMask = 0xff; + std::array bins; bins.fill(-120); + for (int id = 0; id < 8; ++id) { + auto& band = pipeline.m_meterPassbands[id]; + band.stableId = id; band.carrierHz = 100000000 + (id - 4) * 100000; + band.filterLowHz = -1000; band.filterHighHz = 1000; + bins[1024 + (id - 4) * 100] = -20 - 7 * id; + } + pipeline.observeSpectrum(bins, 0); + RtlReceivePipeline::RfObservation observation; + if (!pipeline.takeRfObservation(observation) || observation.mask != 0xff + || observation.token != pipeline.m_token || !observation.producedMs) { return false; } + for (int id = 0; id < 8; ++id) { + if (observation.dbfs[id] != -20 - 7 * id) { return false; } + } + // Sparse membership never renumbers receivers. Removed/parked IDs have + // no observation; invalid input invalidates only its own passband. + pipeline.m_receivingMask = 0x89; + bins[1024 + 300] = std::numeric_limits::quiet_NaN(); + pipeline.m_token.revision++; + pipeline.observeSpectrum(bins, 4096); + if (!pipeline.takeRfObservation(observation) || observation.mask != 0x09 + || observation.token != pipeline.m_token || observation.dbfs[3] != -41) { return false; } + pipeline.m_receivingMask = 0xff; + bins[1324] = -33; + pipeline.observeSpectrum(bins, 8192); + if (!pipeline.takeRfObservation(observation) || observation.mask != 0xff + || observation.dbfs[7] != -33 || pipeline.takeRfObservation(observation)) { return false; } + pipeline.observeSpectrum(bins, 9000); // leave an old sample queued + pipeline.setReceiveMetersEnabled(false); + const auto write = pipeline.m_rfWrite.load(); + pipeline.observeSpectrum(bins, 10000); + if (pipeline.m_rfWrite.load() != write || pipeline.takeRfObservation(observation) + || !pipeline.m_spectrumFresh || pipeline.m_spectrumFirstSample != 10000) { return false; } + pipeline.setReceiveMetersEnabled(true); + if (pipeline.takeRfObservation(observation)) { return false; } + pipeline.observeSpectrum(bins, 11000); + pipeline.setReceiveMetersEnabled(false); + pipeline.setReceiveMetersEnabled(true); + if (pipeline.takeRfObservation(observation)) { return false; } // rapid off/on rejects queued sample + pipeline.observeSpectrum(bins, 12000); + return pipeline.takeRfObservation(observation) && observation.mask == 0xff; + } static bool traceObservation(RtlReceivePipeline& pipeline) { struct FailingReceiver final : RtlReceiverRegistry::Receiver { @@ -83,7 +131,8 @@ struct RtlReceivePipelineTestAccess { RtlReceivePipeline::Packet packet; if (!pipeline.takePacket(packet) || packet.slot != -1 || packet.firstSample != 100 || packet.frames != 128 || packet.samples[0] != 0 || packet.samples[2] != 0.25f || packet.samples[254] != 0 - || pipeline.takePacket(packet) || pipeline.diagnostics().mixerLateFrames != 2) { return false; } + || pipeline.takePacket(packet) || pipeline.diagnostics().mixerLateFrames != 2 + || pipeline.diagnostics().receiverWithdrawals[3] != 1) { return false; } // Fill the independent trace ring, then partially drain and wrap it. // Every accepted event must survive in FIFO order; only new ones drop. @@ -210,12 +259,177 @@ struct RtlReceivePipelineTestAccess { && diagnostic.mixerRejectedBlocks == 0 && diagnostic.droppedPackets == 0 && diagnostic.mixerConfigurationFailures == 0 && !pipeline.needsRepair(); } + struct MixerEpochPrefixResult { + bool configured = false; + bool retainedTap = false; + bool retiredPrefix = false; + bool crossingTap = false; + bool crossingSpeaker = false; + bool currentTap = false; + bool currentSpeaker = false; + bool lateRejected = false; + bool emptyRejected = false; + bool nonFiniteRejected = false; + bool bounded = false; + }; + // Reproduce the retained WFM block [383745, 384001) at the speaker + // epoch boundary observed when adding two narrow receivers. Inject only + // decoded PCM; publication, mixer validation and both queues remain real. + // The generated-RF integration separately exercises actual bank adoption. + static MixerEpochPrefixResult mixerEpochPrefix(RtlReceivePipeline& pipeline) + { + MixerEpochPrefixResult result; + pipeline.m_legacy = false; pipeline.m_token = {47, 4}; pipeline.m_captureEpoch = 18; + pipeline.m_capture = {47, 1, 100000000, 2400000, 1080000, 1080000}; + pipeline.m_mixerOrigin = 384001; + RtlReceiverRegistry::ReceiverSpec spec; + spec.handle = {47, 93, 3}; spec.epoch = 11; spec.passband.stableId = 3; + spec.dsp.wbfmReceive.emplace(); // already-decoded stereo bypasses narrow-FM squelch + const std::array inputs{{{3, 93, 11}}}; + result.configured = pipeline.m_mixer.configure(47, 18, inputs, 384001); + if (!result.configured) { return result; } + const auto takeAudio = [&](int slot, std::uint64_t first, + std::span left, std::span right, + bool discontinuity) { + RtlReceivePipeline::Packet packet; + if (!pipeline.takePacket(packet) || packet.slot != slot || packet.firstSample != first + || packet.frames != left.size() || packet.token != RtlCaptureTransaction::Token{47, 4} + || packet.captureEpoch != 18 || packet.discontinuity != discontinuity + || (slot >= 0 && (packet.instance != 93 || packet.receiverEpoch != 11))) { return false; } + for (std::size_t i = 0; i < left.size(); ++i) { + if (packet.samples[2 * i] != left[i] || packet.samples[2 * i + 1] != right[i]) { return false; } + } + return true; + }; + RtlReceivePipeline::Packet extra; + std::array left{}, right{}; + for (std::size_t i = 0; i < left.size(); ++i) { + left[i] = 0.125f + static_cast(i) / 2048; + right[i] = -0.375f - static_cast(i) / 2048; + } + pipeline.audioBlock(spec, 383745, left, right, false); + pipeline.m_mixer.drain(384129, pipeline); + result.retainedTap = takeAudio(3, 383745, left, right, false); + result.retiredPrefix = pipeline.m_mixer.rejectedBlocks() == 0 + && pipeline.m_mixer.nextSample() == 384001 && !pipeline.takePacket(extra); + + // The first half predates the new epoch; only the second half belongs + // in its first speaker quantum. The independent tap keeps both halves. + pipeline.audioBlock(spec, 383873, left, right, false); + pipeline.m_mixer.drain(384129, pipeline); + result.crossingTap = takeAudio(3, 383873, left, right, false); + result.crossingSpeaker = takeAudio(-1, 384001, std::span(left).subspan(128), + std::span(right).subspan(128), true) && !pipeline.takePacket(extra); + + std::array currentLeft{}, currentRight{}; + currentLeft.fill(0.5f); currentRight.fill(-0.25f); + pipeline.audioBlock(spec, 384129, currentLeft, currentRight, false); + pipeline.m_mixer.drain(384257, pipeline); + result.currentTap = takeAudio(3, 384129, currentLeft, currentRight, false); + result.currentSpeaker = takeAudio(-1, 384129, currentLeft, currentRight, false) + && pipeline.m_mixer.nextSample() == 384257 && !pipeline.takePacket(extra); + + // This block belongs to the current epoch but its quantum has already + // played. Comparing against the moving mixer cursor would conceal it. + const std::uint64_t beforeLate = pipeline.m_mixer.rejectedBlocks(); + pipeline.audioBlock(spec, 384129, currentLeft, currentRight, false); + pipeline.m_mixer.drain(384257, pipeline); + result.lateRejected = pipeline.m_mixer.rejectedBlocks() == beforeLate + 1 + && takeAudio(3, 384129, currentLeft, currentRight, false) && !pipeline.takePacket(extra); + + const std::uint64_t beforeEmpty = pipeline.m_mixer.rejectedBlocks(); + pipeline.audioBlock(spec, 383745, {}, {}, false); + result.emptyRejected = pipeline.m_mixer.rejectedBlocks() == beforeEmpty + 1; + while (pipeline.takePacket(extra)) {} // malformed taps are outside this mixer regression + + // Invalid samples must still fail validation even when all their + // coordinates predate the epoch. Check each stereo side separately. + result.nonFiniteRejected = true; + for (int side = 0; side < 2; ++side) { + const std::uint64_t beforeInvalid = pipeline.m_mixer.rejectedBlocks(); + if (side == 0) { left[17] = std::numeric_limits::quiet_NaN(); } + else { right[29] = std::numeric_limits::infinity(); } + pipeline.audioBlock(spec, 383745, left, right, false); + result.nonFiniteRejected &= pipeline.m_mixer.rejectedBlocks() == beforeInvalid + 1; + left[17] = 0.125f + 17.0f / 2048; + while (pipeline.takePacket(extra)) {} + } + pipeline.m_mixer.drain(384257, pipeline); + result.bounded = pipeline.m_mixer.lateFrames() == 0 && pipeline.droppedPackets() == 0 + && !pipeline.takePacket(extra) && !pipeline.needsRepair(); + return result; + } static void exhaustEpoch(RtlReceivePipeline& pipeline) { pipeline.m_nextEpoch = std::numeric_limits::max(); } static std::uint64_t requested(RtlReceivePipeline& pipeline) { return pipeline.m_registry.service().requested; } static std::uint64_t receiverEpoch(RtlReceivePipeline& pipeline, int slot) { return pipeline.m_specs[slot].epoch; } + static bool packetQueueAccounting(RtlReceivePipeline& pipeline, unsigned expectedCapacity) + { + RtlReceivePipeline::Packet packet; + packet.token = {7, 11}; packet.slot = 3; packet.captureEpoch = 19; + packet.receiverEpoch = 23; packet.frames = 256; packet.samples[0] = .375f; + if (pipeline.diagnostics().packetQueueCapacity != expectedCapacity) { return false; } + // Repeated fills cross the physical wrap. Drop-new must retain every + // accepted packet and preserve a genuine position gap on resumption. + for (unsigned cycle = 0; cycle < 3; ++cycle) { + const std::uint64_t first = cycle * (expectedCapacity + 1); + for (unsigned i = 0; i < expectedCapacity; ++i) { + packet.firstSample = first + i; + if (!pipeline.enqueue(packet)) { return false; } + } + const auto full = pipeline.diagnostics(); + packet.firstSample = first + expectedCapacity; + if (full.queuedPackets != expectedCapacity || full.packetQueueHighWater != expectedCapacity + || full.droppedPackets != cycle || pipeline.enqueue(packet)) { return false; } + for (unsigned i = 0; i < expectedCapacity; ++i) { + if (!pipeline.takePacket(packet) || packet.firstSample != first + i + || packet.token != RtlReceivePipeline::Transaction::Token{7, 11} + || packet.slot != 3 || packet.captureEpoch != 19 || packet.receiverEpoch != 23 + || packet.frames != 256 || packet.samples[0] != .375f || packet.discontinuity + || packet.enqueuedMonotonicNs == 0) { return false; } + } + if (pipeline.takePacket(packet)) { return false; } + } + const auto empty = pipeline.diagnostics(); + return empty.queuedPackets == 0 && empty.packetQueueHighWater == expectedCapacity + && empty.droppedPackets == 3; + } + static bool publicationHeadroom(RtlReceivePipeline& pipeline, unsigned receivers) + { + // 192 ms without an owner drain, below the original singleton's + // nominal 225 ms headroom. Four UI publications measured 151 ms. + // Replay the real analog packet cadence: each receiver's 256-frame + // tap plus the shared speaker's two 128-frame quanta, at 48 kHz. + RtlReceivePipeline::Packet packet; + packet.token = {1, 3}; + bool accepted = true; + for (std::uint64_t first = 0; first < 9216; first += 256) { + for (unsigned slot = 0; slot < receivers; ++slot) { + packet.slot = static_cast(slot); packet.firstSample = first; packet.frames = 256; + accepted = pipeline.enqueue(packet) && accepted; + } + for (unsigned half = 0; half < 2; ++half) { + packet.slot = -1; packet.firstSample = first + half * 128; packet.frames = 128; + accepted = pipeline.enqueue(packet) && accepted; + } + } + if (!accepted || pipeline.droppedPackets() != 0) { return false; } + for (std::uint64_t first = 0; first < 9216; first += 256) { + for (unsigned slot = 0; slot < receivers; ++slot) { + if (!pipeline.takePacket(packet) || packet.token != RtlReceivePipeline::Transaction::Token{1, 3} + || packet.slot != static_cast(slot) || packet.firstSample != first + || packet.frames != 256) { return false; } + } + for (unsigned half = 0; half < 2; ++half) { + if (!pipeline.takePacket(packet) || packet.token != RtlReceivePipeline::Transaction::Token{1, 3} + || packet.slot != -1 || packet.firstSample != first + half * 128 + || packet.frames != 128) { return false; } + } + } + return !pipeline.takePacket(packet); + } static bool rejectMalformedMixer(RtlReceivePipeline& pipeline) { pipeline.m_legacy = false; pipeline.m_token = {1, 1}; pipeline.m_captureEpoch = 1; @@ -332,17 +546,18 @@ static void measureSquelchPipeline() ddc.setSpectrumRateFps(1); // display throttling must not chatter the gate std::uint64_t first = 0; QVector> iq(8192); - std::array tapPeak{}, speakerPeak{}; - std::array counts{}; + std::array tapPeak{}, speakerPeak{}; + std::array counts{}; const auto start = std::chrono::steady_clock::now(); int phase = 0; constexpr std::uint64_t kPhaseSamples = 1920000; // 0.8 seconds per state - while (first < 4 * kPhaseSamples) { + while (first < 6 * kPhaseSamples) { const int wanted = first / kPhaseSamples; if (wanted != phase) { phase = wanted; state.token.revision++; state.receivers[0].squelchEnabled = phase != 3; + state.receivers[0].automaticSquelch = phase >= 4; state.receivers[0].squelchLevel = phase == 1 ? 25 : 100; check(pipeline->prepare(state) && ready(*pipeline) && pipeline->adopt(), "squelch edit adopts without rebuilding the demodulator"); @@ -351,7 +566,8 @@ static void measureSquelchPipeline() } for (int i = 0; i < iq.size(); ++i) { const double angle = 2.5 * std::sin(2 * std::numbers::pi * 1000 * (first + i) / 2400000.0); - iq[i] = {float(0.3 * std::cos(angle)), float(0.3 * std::sin(angle))}; + const double amplitude = phase == 4 ? 0.0 : 0.3; + iq[i] = {float(amplitude * std::cos(angle)), float(amplitude * std::sin(angle))}; } ddc.processIqData(iq, false); // real FFT, no legacy audio or USB const auto spectrum = ddc.takeSquelchSpectrum(); @@ -366,7 +582,7 @@ static void measureSquelchPipeline() for (std::size_t i = 0; i < packet.frames; ++i) { const std::uint64_t frame = packet.firstSample + i; const int window = frame / 38400; - if (window >= 4 || frame % 38400 < 24000 || frame % 38400 > 33600) { continue; } + if (window >= 6 || frame % 38400 < 24000 || frame % 38400 > 33600) { continue; } const double value = std::abs(packet.samples[2 * i]); if (packet.slot == 3) { tapPeak[window] = std::max(tapPeak[window], value); ++counts[window]; } else if (packet.slot == -1) { speakerPeak[window] = std::max(speakerPeak[window], value); } @@ -374,13 +590,13 @@ static void measureSquelchPipeline() } std::this_thread::sleep_until(start + std::chrono::microseconds(first * 1000000 / 2400000)); } - for (int window = 0; window < 4; ++window) { + for (int window = 0; window < 6; ++window) { check(counts[window] > 8000, "squelch phase contains settled tap samples"); - const bool open = window == 1 || window == 3; + const bool open = window == 1 || window == 3 || window == 5; std::printf("SQL window=%d tap_peak=%.6f speaker_peak=%.6f\n", window, tapPeak[window], speakerPeak[window]); check(open ? tapPeak[window] > 0.4 && speakerPeak[window] > 0.4 : tapPeak[window] == 0 && speakerPeak[window] == 0, - "real FFT threshold gates both sparse receiver tap and speaker; Off passes audio"); + "real FFT manual/Auto gates sparse receiver tap and speaker; Off passes audio"); } pipeline->stop(); } @@ -395,7 +611,28 @@ static void measureParkAndResume() state.capture = {111, 1, 100'000'000, 2'400'000, 1'080'000, 1'080'000}; state.receivers = {{{0, 100'000'000, -8000, 8000, 0, 3000, 3000}, T::Mode::Fm}}; state.receivingIds = {0}; - check(pipeline->prepare(state, true) && ready(*pipeline) && pipeline->adopt(), + const auto prepareAndAdopt = [&] { + const auto end = std::chrono::steady_clock::now() + 15s; + bool submitted = false; + while (std::chrono::steady_clock::now() < end) { + // Match the owner's control-side service: adoption only marks the + // old bank retired; slot reuse waits for off-thread destruction. + (void)pipeline->service(); + if (!submitted) { + const auto result = pipeline->prepareDetailed(state, true); + if (result == Pipeline::Submission::Failed) { return false; } + submitted = result == Pipeline::Submission::Accepted; + } + if (submitted) { + const auto status = pipeline->service(); + if (status == Pipeline::Preparation::Ready) { return pipeline->adopt(); } + if (status == Pipeline::Preparation::Failed) { return false; } + } + std::this_thread::sleep_for(1ms); + } + return false; + }; + check(prepareAndAdopt(), "captured receiver prepares before parking"); std::array, 8192> iq; iq.fill({0.25f, 0.0f}); @@ -409,6 +646,7 @@ static void measureParkAndResume() "audio keeps the adopted capture revision"); observed = true; } + (void)pipeline->service(); // owner timer reaps banks outside the sample callback std::this_thread::sleep_for(3ms); } check(observed == expectAudio, @@ -421,7 +659,7 @@ static void measureParkAndResume() state.capture.centerHz = 103'000'000; state.hardware.centerHz = 103'000'000; state.receivingIds.clear(); - check(pipeline->prepare(state, true) && ready(*pipeline) && pipeline->adopt(), + check(prepareAndAdopt(), "all-parked capture retains a valid empty receiver bank"); consume(2, false); @@ -430,7 +668,7 @@ static void measureParkAndResume() state.capture.centerHz = 100'000'000; state.hardware.centerHz = 100'000'000; state.receivingIds = {0}; - check(pipeline->prepare(state, true) && ready(*pipeline) && pipeline->adopt(), + check(prepareAndAdopt(), "returning capture rebuilds the preserved receiver"); consume(3, true); pipeline->stop(); @@ -439,6 +677,59 @@ static void measureParkAndResume() int main(int argc, char** argv) { QCoreApplication app(argc, argv); + { + auto rf = std::make_unique(); + check(AetherSDR::rtl::RtlReceivePipelineTestAccess::rfObservation(*rf), + "relative RF measurements preserve all eight IDs, sparse membership and revisions"); + } + { + auto epoch = std::make_unique(); + const std::size_t allocationsBefore = callbackAllocations; + inCallback = true; + const auto result = AetherSDR::rtl::RtlReceivePipelineTestAccess::mixerEpochPrefix(*epoch); + inCallback = false; + check(result.configured, "retained-receiver fixture configures a new speaker epoch"); + check(result.retainedTap, "retained pre-epoch PCM keeps its exact independent stereo tap and receiver identity"); + check(result.retiredPrefix, "retained PCM ending at the speaker epoch origin causes no rejection or speaker output"); + check(result.crossingTap, "epoch-crossing PCM keeps the complete independent stereo tap"); + check(result.crossingSpeaker, "epoch-crossing PCM contributes only its exact stereo suffix to the discontinuous speaker quantum"); + check(result.currentTap && result.currentSpeaker, "current-epoch stereo PCM continues both tap and speaker without an extra discontinuity"); + check(result.lateRejected, "genuinely late current-epoch PCM remains a counted mixer rejection"); + check(result.emptyRejected, "empty pre-epoch PCM remains a counted mixer rejection"); + check(result.nonFiniteRejected, "nonfinite pre-epoch PCM on either stereo side remains a counted mixer rejection"); + check(result.bounded, "epoch-prefix handling creates no missing frames, queue loss or receiver repair"); + check(callbackAllocations == allocationsBefore, "epoch-prefix publication and mixer validation allocate no callback memory"); + } + for (const auto [receivers, usable] : std::array, 4>{{ + {1, 127}, {2, 170}, {4, 254}, {8, 424}}}) { + auto queued = std::make_unique(receivers); + const auto allocationsBefore = callbackAllocations; + inCallback = true; + const bool observed = AetherSDR::rtl::RtlReceivePipelineTestAccess::packetQueueAccounting(*queued, usable); + inCallback = false; + check(observed, "bounded packet queue retains exact capacity, FIFO identity and drop-new gaps across wraps"); + check(callbackAllocations == allocationsBefore, "packet queue observations allocate no callback memory"); + } + for (std::size_t invalidCapacity : {std::size_t{0}, std::size_t{9}, + std::numeric_limits::max()}) { + auto invalid = std::make_unique(invalidCapacity); + T::State requested; + requested.token = {42, 1}; requested.hardware.centerHz = 100000000; + requested.capture = {42, 1, 100000000, 2400000, 1080000, 1080000}; + requested.receivers.push_back({{0, 100400000, -15000, 15000, 0, 3000, 3000}, T::Mode::Fm}); + requested.receivingIds.push_back(0); + check(invalid->diagnostics().packetQueueCapacity <= 424 && !invalid->prepare(requested, true), + "invalid receiver capacity remains refused and cannot allocate an unbounded packet ring"); + } + for (unsigned receivers : {1U, 2U, 4U, 8U}) { + auto queued = std::make_unique(receivers); + const auto allocationsBefore = callbackAllocations; + inCallback = true; + const bool retained = AetherSDR::rtl::RtlReceivePipelineTestAccess::publicationHeadroom(*queued, receivers); + inCallback = false; + check(retained, "each admitted analog bank retains the singleton publication headroom with exact FIFO tap/speaker data"); + check(callbackAllocations == allocationsBefore, "publication headroom allocates no acquisition or delivery memory"); + } { auto traced = std::make_unique(); inCallback = true; diff --git a/tests/rtl_receiver_settings_widget_test.cpp b/tests/rtl_receiver_settings_widget_test.cpp index 8fa77f703f..44ba66476b 100644 --- a/tests/rtl_receiver_settings_widget_test.cpp +++ b/tests/rtl_receiver_settings_widget_test.cpp @@ -59,11 +59,11 @@ class ExtensionBackend final : public IRadioBackend { QVariant requested; QString verb; QVariantMap accepted{{"serial", "widget-device"}, {"applied", true}, - {"ppm", 7}, {"requestedPpm", 7}, {"dcSuppression", false}, {"pending", false}, {"saved", true}}; + {"ppm", 7}, {"requestedPpm", 7}, {"dcSuppression", false}, {"receiveMetersEnabled", true}, {"pending", false}, {"saved", true}}; ExtensionBackend() { caps.extensionNamespaces = {QStringLiteral("rtl")}; - caps.extensions["rtl"] = QVariantMap{{"settingsVersion", 1}}; + caps.extensions["rtl"] = QVariantMap{{"settingsVersion", 1}, {"receiveMetersControl", true}}; } RadioCapabilities capabilities() const override { return caps; } bool isConnected() const override { return connectedState; } @@ -91,7 +91,8 @@ class ExtensionBackend final : public IRadioBackend { // Copy before emitting: a result may synchronously reenter settings.get. const quint64 id = pendingId; const QVariant value = requested; - accepted[verb == QLatin1String("ppm.set") ? "ppm" : "dcSuppression"] = value; + accepted[verb == QLatin1String("ppm.set") ? "ppm" + : verb == QLatin1String("receive_meters.set") ? "receiveMetersEnabled" : "dcSuppression"] = value; accepted["pending"] = false; emit extensionStatus("rtl", "settings", accepted); emit extensionResult(id, value); @@ -124,6 +125,9 @@ int main(int argc, char** argv) auto* ppm = widget.findChild(QStringLiteral("rtlPpmRequest")); auto* apply = widget.findChild(QStringLiteral("rtlPpmApply")); auto* dc = widget.findChild(QStringLiteral("rtlDcSuppression")); + auto* meters = widget.findChild(QStringLiteral("rtlReceiveMeters")); + check(meters != nullptr, "RTL receiver settings expose the receive-meter switch"); + if (!meters) { return 1; } auto* applied = widget.findChild(QStringLiteral("rtlCorrectionsApplied")); auto* status = widget.findChild(QStringLiteral("rtlCorrectionsStatus")); if (!ppm || !apply || !dc || !applied || !status) { return 1; } @@ -311,6 +315,16 @@ int main(int argc, char** argv) ppm->setValue(10); apply->click(); pumpFor(260); check(source->controlRequests == 2 && applied->text().contains("10 ppm") && !status->text().contains("Applying"), "synchronous immediate Apply cancels debounce before invoking the extension"); + check(meters->isChecked() && meters->isEnabled(), "receive meters default to confirmed enabled state"); + meters->click(); + check(source->verb == QLatin1String("receive_meters.set") && source->requested.metaType().id() == QMetaType::Bool + && !source->requested.toBool() && meters->isChecked(), "meter toggle waits for accepted readback"); + source->confirm(); + check(!meters->isChecked() && !status->text().contains("Applying"), "confirmed meter disable clears pending state"); + meters->click(); source->refuse("test refusal"); + check(!meters->isChecked(), "refused enable cannot masquerade as enabled"); + meters->click(); source->confirm(); + check(meters->isChecked(), "confirmed re-enable restores the switch"); const int beforeDestroy = source->controlRequests; { auto closing = std::make_unique(model); diff --git a/tests/rtl_runtime_settings_test.cpp b/tests/rtl_runtime_settings_test.cpp index e9aea2d84a..353248474c 100644 --- a/tests/rtl_runtime_settings_test.cpp +++ b/tests/rtl_runtime_settings_test.cpp @@ -454,6 +454,38 @@ int main(int argc, char** argv) && movedDocument.slices[5].audioGain == outside.audioGain && movedDocument.slices[5].audioPan == outside.audioPan, "drag persists the new capture center without changing saved slice RF or monitor settings"); + const auto beforeMeters = rtl::RtlCaptureBackendTestAccess::state(live); + int meterResults = 0; + int meterErrors = 0; + QHash reasons; + QObject::connect(&live, &IRadioBackend::extensionResult, [&](quint64 id, const QVariant&) { + if (id >= 9100 && id <= 9102) { ++meterResults; } + }); + QObject::connect(&live, &IRadioBackend::extensionError, [&](quint64 id, const QString&) { + if (id >= 9100 && id <= 9102) { ++meterErrors; } + }); + QObject::connect(&live, &IRadioBackend::meterDefined, [&](const MeterDef& def) { + reasons[def.sourceIndex] = def.unavailableReason; + }); + live.invokeExtension("rtl", "receive_meters.set", 9100, false); + check(meterResults == 1 && meterErrors == 0 && reasons.size() == 3 + && !reasons[1].isEmpty() && !reasons[3].isEmpty() && !reasons[5].isEmpty() + && !RtlDeviceSettings(liveScope).load().values.receiveMetersEnabled, + "disabled meters publish reasons for sparse IDs and persist through the sole device owner"); + const auto afterMeters = rtl::RtlCaptureBackendTestAccess::state(live); + check(beforeMeters && afterMeters && beforeMeters->token == afterMeters->token + && beforeMeters->hardware == afterMeters->hardware + && beforeMeters->receivers == afterMeters->receivers + && beforeMeters->receivingIds == afterMeters->receivingIds + && beforeMeters->dcSuppression == afterMeters->dcSuppression + && !rtl::RtlCaptureBackendTestAccess::busy(live), + "meter toggle does not restart capture or change receivers, squelch or audio settings"); + live.invokeExtension("rtl", "receive_meters.set", 9101, QStringLiteral("true")); + check(meterErrors == 1 && meterResults == 1, "meter control rejects a string masquerading as boolean"); + live.invokeExtension("rtl", "receive_meters.set", 9102, true); + check(meterResults == 2 && reasons[1].isEmpty() && reasons[3].isEmpty() && reasons[5].isEmpty() + && RtlDeviceSettings(liveScope).load().values.receiveMetersEnabled, + "re-enabled sparse meters clear unavailable reasons and save accepted state"); live.disconnectRadio(); } reconnectAtCaptureLimits(); diff --git a/tests/rtl_spectrum_averaging_test.cpp b/tests/rtl_spectrum_averaging_test.cpp index ecd4a52c21..3cef59198c 100644 --- a/tests/rtl_spectrum_averaging_test.cpp +++ b/tests/rtl_spectrum_averaging_test.cpp @@ -2,6 +2,7 @@ #include "core/backends/rtl/RtlSdrBackend.h" #include #include +#include #include #include #include @@ -310,6 +311,41 @@ void fixedRfGridDoesNotDiffuse() "invalidated converter evidence seeds measured RF rather than erasing current bins"); } } + +void alignedGridValidityAndReuse() +{ + for (bool logarithmic : {false, true}) { + SpectrumTemporalAverage average(6); + // With a 500 ms time constant, this interval retains exactly half. + const double halfLife = .5 * std::log(2.); + const auto matches = [logarithmic](const std::array& actual, + const std::array& expectedDomain) { + for (std::size_t i = 0; i < actual.size(); ++i) { + const double expected = logarithmic ? expectedDomain[i] + : 10 * std::log10(expectedDomain[i]); + if (!(std::abs(actual[i] - expected) < .00001)) { return false; } + } + return true; + }; + std::array values{2, 4, 6, 8, 10, 12}; + check(average.processFrame(values, 50, logarithmic, halfLife, 1000, 1, 1, 5) == 0 + && matches(values, {2, 4, 6, 8, 10, 12}), + "aligned first frame displays actual observations with no reused history"); + + average.invalidate(1002, 1002); + values = {20, 40, 60, 80, 100, 120}; + check(average.processFrame(values, 50, logarithmic, halfLife, 1000, 1, 2, 4) == 1, + "aligned reuse counts only old-valid bins inside the current usable interval"); + check(matches(values, {20, 22, 60, 44, 55, 120}), + "narrowing usable RF retains old-valid output for this frame while invalidated bins seed current observations"); + + values = {200, 400, 600, 800, 1000, 1200}; + check(average.processFrame(values, 50, logarithmic, halfLife, 1000, 1, 1, 5) == 2, + "aligned widening reuses only the two bins retained inside the previous usable interval"); + check(matches(values, {200, 400, 330, 422, 1000, 1200}), + "newly exposed aligned bins seed observations instead of resurrecting excluded history"); + } +} } int main(int argc, char** argv) { @@ -353,5 +389,6 @@ int main(int argc, char** argv) amountChangeContinuity(); captureTransitionContinuity(); fixedRfGridDoesNotDiffuse(); + alignedGridValidityAndReuse(); return failures ? 1 : 0; } diff --git a/tests/rtl_squelch_test.cpp b/tests/rtl_squelch_test.cpp index b24ef3dc6f..7122067e98 100644 --- a/tests/rtl_squelch_test.cpp +++ b/tests/rtl_squelch_test.cpp @@ -3,6 +3,7 @@ #include #include #include +#include using Gate = AetherSDR::rtl::RtlSquelchGate; int main() @@ -75,5 +76,47 @@ int main() bins.fill(std::numeric_limits::quiet_NaN()); check(!AetherSDR::SpectrumSquelchLogic::suggest(bins, floor, -120, 1.2, 10), "invalid spectra produce no Auto command"); + // Auto belongs to each acquisition gate and consumes its detector bins, + // independent of display FFT size, smoothing, visibility or selected slice. + std::array receivers; + for (auto& receiver : receivers) { receiver.configure(true, 20, true, true, 10); } + bins.fill(-60); + for (int n = 0; n < 100; ++n) { + const auto frame = static_cast(n * 1600); + for (auto& receiver : receivers) { + receiver.observeSpectrum(bins, 1000, 1012, frame); + for (int i = 0; i < 1600; ++i) { receiver.gain(frame + i); } + check(receiver.gain(frame + 1599) == 0, "Auto closes detector noise in all eight slots"); + } + } + bins[1006] = -40; + receivers[7].observeSpectrum(bins, 1000, 1012, 160000); + for (int i = 0; i < 300; ++i) { receivers[7].gain(160000 + i); } + check(receivers[7].gain(160300) == 1, "Auto opens signal above local floor plus margin"); + check(receivers[0].gain(160300) == 0, "one receiver signal does not open another"); + bins.fill(-40); // sustained rise must be learned from the same detector + for (int n = 101; n < 201; ++n) { + receivers[7].observeSpectrum(bins, 1000, 1012, n * 1600); + for (int i = 0; i < 1600; ++i) { receivers[7].gain(n * 1600 + i); } + } + check(receivers[7].gain(321599) == 0, "Auto adapts to a rising noise floor"); + receivers[7].configure(true, 80, false, false, 10); + receivers[7].observeSpectrum(bins, 1000, 1012, 322000); + for (int i = 0; i < 300; ++i) { receivers[7].gain(322000 + i); } + check(receivers[7].gain(322300) == 0, "manual threshold replaces Auto without stale automatic state"); + Gate noisy; + noisy.configure(true, 20, true, true, 10); + std::mt19937 random(5468); + std::exponential_distribution noisePower(1.0); + int quietFrames = 0; + for (int n = 0; n < 300; ++n) { + // Complex Gaussian receiver noise has exponential FFT-bin power. + // A trimmed log mean underestimates that floor and holds noise open. + for (auto& bin : bins) { bin = -60 + 10 * std::log10(noisePower(random)); } + noisy.observeSpectrum(bins, 1000, 1012, n * 1600); + for (int i = 0; i < 1600; ++i) { noisy.gain(n * 1600 + i); } + quietFrames += noisy.gain(n * 1600 + 1599) == 0; + } + check(quietFrames >= 285, "default Auto margin rejects at least 95 percent of noise-only frames"); return failures ? 1 : 0; } diff --git a/tests/rtl_wfm_pipeline_test.cpp b/tests/rtl_wfm_pipeline_test.cpp index 4dcc7b2bac..c71aa788cd 100644 --- a/tests/rtl_wfm_pipeline_test.cpp +++ b/tests/rtl_wfm_pipeline_test.cpp @@ -105,6 +105,7 @@ struct Audio { std::vector left; std::vector right; std::uint64_t next = 0; + std::uint64_t first = 0; std::uint64_t instance = 0; std::uint64_t receiverEpoch = 0; std::uint64_t captureEpoch = 0; @@ -114,14 +115,15 @@ struct Audio { bool discontinuity = false; double peak = 0; std::size_t clipped = 0; - void accept(const Pipeline::Packet& packet) + void accept(const Pipeline::Packet& packet, std::size_t sliceFrames = 256) { check(packet.sampleRateHz == 48000 && packet.channelCount == 2, "native audio declares 48 kHz paired stereo"); - check(packet.frames == (packet.slot < 0 ? 128u : 256u), + check(packet.frames == (packet.slot < 0 ? 128u : sliceFrames), "speaker and slice packet frame counts describe their actual output blocks"); if (seen) { check(packet.firstSample == next, "native output positions are contiguous at 48 kHz"); } else { + first = packet.firstSample; instance = packet.instance; receiverEpoch = packet.receiverEpoch; captureEpoch = packet.captureEpoch; @@ -155,8 +157,52 @@ std::complex referenceIq(std::uint64_t position, double rate) - 0.045 * (cosineIntegral(36000) - cosineIntegral(40000)) + 0.1 * sineIntegral(19000)); return {float(0.3 * std::cos(phase)), float(0.3 * std::sin(phase))}; } +void printRunFailure(Pipeline& pipeline, const Transaction::State& current, + const char* phase, std::uint64_t origin, + std::uint64_t position, std::uint64_t end) +{ + // The fault is latched until adoption. Report only after this phase ends; + // keep diagnostic work out of the generated-IQ feed and its pacing. + const auto diagnostics = pipeline.diagnostics(); + std::fprintf(stderr, "WFM_RUN_REPAIR phase=%s session=%llu revision=%llu origin=%llu position=%llu end=%llu mixer_configuration_failures=%llu trace_drops=%llu\n", + phase, static_cast(current.token.session), + static_cast(current.token.revision), static_cast(origin), + static_cast(position), static_cast(end), + static_cast(diagnostics.mixerConfigurationFailures), + static_cast(diagnostics.droppedTraceEvents)); + for (std::size_t slot = 0; slot < diagnostics.receiverWithdrawals.size(); ++slot) { + if (diagnostics.receiverWithdrawals[slot] == 0) { continue; } + std::fprintf(stderr, "WFM_RUN_WITHDRAWALS phase=%s slot=%zu count=%llu\n", phase, slot, + static_cast(diagnostics.receiverWithdrawals[slot])); + } + Pipeline::TraceEvent event; + unsigned traceCount = 0; + for (; traceCount < Pipeline::kTraceEvents && pipeline.takeTraceEvent(event); ++traceCount) { + std::fprintf(stderr, "WFM_RUN_TRACE phase=%s kind=%d session=%llu revision=%llu generation=%llu capture_epoch=%llu slot=%d stable_id=%d instance=%llu receiver_epoch=%llu capture_first=%llu capture_frames=%llu capture_clock=%llu quantum_first=%llu\n", + phase, static_cast(event.kind), static_cast(event.token.session), + static_cast(event.token.revision), static_cast(event.hardwareGeneration), + static_cast(event.captureEpoch), event.slot, event.stableId, + static_cast(event.instance), static_cast(event.receiverEpoch), + static_cast(event.captureFirst), static_cast(event.captureFrames), + static_cast(event.captureClock), static_cast(event.quantumFirst)); + if (event.failure) { + const auto& failure = *event.failure; + std::fprintf(stderr, "WFM_RUN_FAILURE phase=%s processing_reason=%d wdsp_result=%d has_expected=%d expected_capture_first=%llu capture_first=%llu capture_frames=%llu has_iq_first=%d iq_first=%llu iq_frames=%llu extraction_reason=%d converted_i=%d converted_q=%d\n", + phase, static_cast(failure.reason), static_cast(failure.processResult), + failure.hasExpectedCaptureFirst, static_cast(failure.expectedCaptureFirst), + static_cast(failure.captureFirst), static_cast(failure.captureFrames), + failure.hasIqFirst, static_cast(failure.iqFirst), + static_cast(failure.iqFrames), + failure.extraction ? static_cast(failure.extraction->reason) : -1, + failure.extraction ? failure.extraction->convertedI : 0, + failure.extraction ? failure.extraction->convertedQ : 0); + } + } + // An empty or dropped trace is missing evidence, not an inferred DSP cause. + std::fprintf(stderr, "WFM_RUN_TRACE_END phase=%s events=%u\n", phase, traceCount); +} std::array run(Pipeline& pipeline, const Transaction::State& current, - std::uint64_t& position, double seconds) + std::uint64_t& position, double seconds, const char* phase) { std::array result; for (Audio& audio : result) { @@ -198,7 +244,9 @@ std::array run(Pipeline& pipeline, const Transaction::State& current, std::this_thread::sleep_until(started + std::chrono::microseconds( std::uint64_t((position - origin) * 1'000'000.0 / rate))); } - check(!pipeline.needsRepair(), "real WDSP processing does not withdraw the receiver"); + const bool repair = pipeline.needsRepair(); + if (repair) { printRunFailure(pipeline, current, phase, origin, position, end); } + check(!repair, "real WDSP processing does not withdraw the receiver"); return result; } double amplitude(const std::vector& samples, double hz) @@ -221,7 +269,7 @@ void nativeRouting() "qualified WFM prepares and adopts the production native graph"); if (pipeline->legacy()) { pipeline->stop(); return; } std::uint64_t position = 25; - const auto output = run(*pipeline, current, position, 4.0); + const auto output = run(*pipeline, current, position, 4.0, "initial-stereo"); for (const Audio& audio : output) { const double left = amplitude(audio.left, 1000); const double leftLeak = amplitude(audio.left, 2000); @@ -242,7 +290,7 @@ void nativeRouting() current.token.revision++; current.receivers[0].audioMute = true; check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "monitor mute adopts without replacing the receiver"); - const auto muted = run(*pipeline, current, position, 0.5); + const auto muted = run(*pipeline, current, position, 0.5, "monitor-muted"); check(muted[0].peak > 0.001 && muted[1].peak == 0, "monitor mute silences speakers while the independent tap remains live"); check(muted[0].receiverEpoch == output[0].receiverEpoch, "monitor edits retain the decoder epoch"); @@ -250,7 +298,7 @@ void nativeRouting() current.receivers[0].audioMute = false; current.receivers[0].wfmDeemphasisUs = 50; check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "50 us deemphasis prepares a fresh immutable decoder recipe"); - const auto changed = run(*pipeline, current, position, 1.0); + const auto changed = run(*pipeline, current, position, 1.0, "deemphasis-50us"); check(changed[0].seen && changed[1].seen && changed[0].discontinuity && changed[1].discontinuity, "deemphasis changes mark both slice and speaker discontinuities"); check(changed[0].receiverEpoch != output[0].receiverEpoch && changed[1].captureEpoch != output[1].captureEpoch, @@ -259,7 +307,7 @@ void nativeRouting() current.token.revision++; current.receivers[0].wfmForceMono = true; check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "Force Mono prepares an accepted decoder recipe"); - const auto mono = run(*pipeline, current, position, 4.0); + const auto mono = run(*pipeline, current, position, 4.0, "force-mono"); for (const Audio& audio : mono) { check(audio.seen && audio.peak > 0.001 && audio.left == audio.right, "Force Mono is identical nonzero L/R at independent tap and speaker"); @@ -271,14 +319,14 @@ void nativeRouting() current.token.revision++; current.receivers[0].wfmForceMono = false; check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "Auto Stereo readopts"); - const auto automatic = run(*pipeline, current, position, 4.0); + const auto automatic = run(*pipeline, current, position, 4.0, "auto-stereo"); check(amplitude(automatic[0].left, 1000) > 100 * amplitude(automatic[0].right, 1000) && amplitude(automatic[1].right, 2000) > 100 * amplitude(automatic[1].left, 2000), "Auto Stereo restores separated native tap and speaker audio"); current.token.revision++; current.receivingIds.clear(); check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "parking admits an empty active bank"); - const auto parked = run(*pipeline, current, position, 0.05); + const auto parked = run(*pipeline, current, position, 0.05, "parked"); check(!parked[0].seen && !parked[1].seen, "parked WFM emits neither stale audio nor pilot observations"); current.token.revision++; current.receivingIds = {3}; @@ -295,7 +343,7 @@ void nativeRouting() } check(resume == Pipeline::Submission::Accepted && ready(*pipeline) && pipeline->adopt(), "resuming waits for safe retirement before constructing a fresh WFM receiver"); - const auto resumed = run(*pipeline, current, position, 1.5); + const auto resumed = run(*pipeline, current, position, 1.5, "resumed"); check(resumed[0].seen && resumed[0].instance != changed[0].instance && resumed[0].discontinuity && resumed[1].discontinuity, "resumed WFM cannot inherit the retired receiver's PCM or pilot lifetime"); @@ -314,7 +362,7 @@ void nativeRouting() current.token.revision++; check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "an unchanged WFM recipe can repair its withdrawn decoder"); - const auto repaired = run(*pipeline, current, position, 1.0); + const auto repaired = run(*pipeline, current, position, 1.0, "capture-gap-repaired"); check(repaired[0].seen && repaired[1].seen && repaired[0].receiverEpoch != resumed[0].receiverEpoch && repaired[1].captureEpoch != resumed[1].captureEpoch @@ -429,7 +477,7 @@ void workerUnderrunTrace() const bool repaired = pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(); check(repaired, "unchanged WFM recipe repairs after the real worker is released"); if (repaired) { - const auto audio = run(*pipeline, current, position, 2.0); + const auto audio = run(*pipeline, current, position, 2.0, "held-worker-repaired"); check(audio[0].seen && audio[1].seen && audio[0].receiverEpoch != firstSlice.receiverEpoch && audio[1].captureEpoch != firstSlice.captureEpoch && audio[0].discontinuity && audio[1].discontinuity, @@ -601,6 +649,276 @@ void boundedBenchmark() check(output[0].stereo, "benchmark preserves actual WFM pilot acquisition under the faster RF clock"); pipeline->stop(); } +struct AnalogCarrier { + int id; + double frequencyHz; + double leftHz; + double rightHz; // zero denotes narrow FM, otherwise a stereo multiplex +}; +constexpr std::array kAnalogCarriers{{ + {0, 99'100'000, 500, 0}, {2, 99'500'000, 1000, 2000}, + {5, 100'150'000, 3000, 4000}, {7, 100'800'000, 700, 0}}}; +std::complex carrierIq(const AnalogCarrier& carrier, std::uint64_t position, + const Transaction::State& current) +{ + const double time = position / current.capture.achievedSampleRateHz; + double phase = kTau * (carrier.frequencyHz - current.capture.centerHz) * time; + if (carrier.rightHz == 0) { + phase += 2000 / carrier.leftHz * std::sin(kTau * carrier.leftHz * time); + } else { + const auto sineIntegral = [time](double hz) { return -std::cos(kTau * hz * time) / hz; }; + const auto cosineIntegral = [time](double hz) { return std::sin(kTau * hz * time) / hz; }; + phase += 75000 * (0.1575 * sineIntegral(carrier.leftHz) + 0.09 * sineIntegral(carrier.rightHz) + + 0.07875 * (cosineIntegral(38000 - carrier.leftHz) - cosineIntegral(38000 + carrier.leftHz)) + - 0.045 * (cosineIntegral(38000 - carrier.rightHz) - cosineIntegral(38000 + carrier.rightHz)) + + 0.1 * sineIntegral(19000)); + } + return {float(0.15 * std::cos(phase)), float(0.15 * std::sin(phase))}; +} +using MultiAudio = std::array; // addressable identities, followed by speaker mix +MultiAudio runAnalogBank(Pipeline& pipeline, const Transaction::State& current, + std::uint64_t& position, double seconds) +{ + MultiAudio result; + for (Audio& audio : result) { + audio.left.reserve(std::size_t(seconds * 48000) + 2048); + audio.right.reserve(std::size_t(seconds * 48000) + 2048); + } + // All carriers and modulation tones are multiples of 100 Hz. Build one + // analytic period outside acquisition, then feed the same complete RF scene + // to every receiver. Parking never removes a carrier from this RF input. + std::vector> period(std::size_t(current.capture.achievedSampleRateHz / 100)); + for (std::size_t i = 0; i < period.size(); ++i) { + for (const AnalogCarrier& carrier : kAnalogCarriers) { period[i] += carrierIq(carrier, i, current); } + } + const std::uint64_t origin = position; + const std::uint64_t end = position + std::uint64_t(seconds * current.capture.achievedSampleRateHz); + const auto started = std::chrono::steady_clock::now(); + std::array, 8192> iq; + while (position < end) { + const std::size_t count = std::min(iq.size(), end - position); + for (std::size_t i = 0; i < count; ++i) { iq[i] = period[(position + i) % period.size()]; } + check(pipeline.process(position, std::span(iq).first(count)), + "analog bank accepts the shared continuous RF scene"); + position += count; + Pipeline::Packet packet; + while (pipeline.takePacket(packet)) { + check(packet.token == current.token, "each analog packet belongs to the adopted whole-bank revision"); + if (packet.slot == -1) { + check(!packet.wfmReception && !packet.wfmStereoDetected, + "mixed speaker output does not borrow a receiver's WFM observation"); + result.back().accept(packet); + continue; + } + const auto receiver = std::ranges::find_if(current.receivers, [&packet](const auto& value) { + return value.passband.stableId == packet.slot; + }); + const bool receiving = std::ranges::find(current.receivingIds, packet.slot) != current.receivingIds.end(); + check(receiver != current.receivers.end() && receiving && packet.slot >= 0 && packet.slot < 8, + "only an accepted receiving identity can publish independent audio"); + if (receiver == current.receivers.end() || packet.slot < 0 || packet.slot >= 8) { continue; } + const bool wide = receiver->mode == Transaction::Mode::Wfm; + check(wide || (!packet.wfmReception && !packet.wfmStereoDetected), + "narrow FM never inherits a sibling's WFM status"); + if (packet.wfmReception) { + check(wide && packet.wfmReception->valid && packet.wfmStereoDetected + == (packet.wfmReception->pilotLocked && !receiver->wfmForceMono), + "every WFM observation honors its own immutable stereo choice"); + } + result[packet.slot].accept(packet, wide ? 256 : 1024); + } + if (pipeline.needsRepair()) { check(false, "analog bank never withdraws a healthy receiver"); break; } + std::this_thread::sleep_until(started + std::chrono::duration( + double(position - origin) / current.capture.achievedSampleRateHz)); + } + const Pipeline::Diagnostics diagnostic = pipeline.diagnostics(); + std::printf("ANALOG_BANK revision=%llu receivers=%zu position=%llu drops=%llu late=%llu rejected=%llu repair=%d\n", + static_cast(current.token.revision), current.receivingIds.size(), + static_cast(position), static_cast(diagnostic.droppedPackets), + static_cast(diagnostic.mixerLateFrames), + static_cast(diagnostic.mixerRejectedBlocks), pipeline.needsRepair()); + check(diagnostic.droppedPackets == 0 && diagnostic.mixerLateFrames == 0 + && diagnostic.mixerRejectedBlocks == 0 && diagnostic.mixerConfigurationFailures == 0, + "analog mixtures keep the existing bounded mixer deadline without missing or rejected contributions"); + return result; +} +bool installBank(Pipeline& pipeline, const Transaction::State& current, bool reset = false) +{ + const auto deadline = std::chrono::steady_clock::now() + 20s; + Pipeline::Submission result; + do { + result = pipeline.prepareDetailed(current, reset); + if (result != Pipeline::Submission::RetryRetiringSlot && result != Pipeline::Submission::RetryPlannerBusy) { break; } + pipeline.service(); + std::this_thread::sleep_for(1ms); + } while (std::chrono::steady_clock::now() < deadline); + return result == Pipeline::Submission::Accepted && ready(pipeline) && pipeline.adopt(); +} +Transaction::Receiver analogReceiver(const AnalogCarrier& carrier) +{ + const bool wide = carrier.rightHz != 0; + Transaction::Receiver receiver{{carrier.id, carrier.frequencyHz, + wide ? -100'000.0 : -8000.0, wide ? 100'000.0 : 8000.0, 0, 3000, 3000}, + wide ? Transaction::Mode::Wfm : carrier.id == 0 ? Transaction::Mode::Fm : Transaction::Mode::Fmn}; + if (carrier.id == 5) { + receiver.wfmDeemphasisUs = 50; + receiver.passband.filterLowHz = -110'000; + receiver.passband.filterHighHz = 110'000; + } + return receiver; +} +void checkAnalogSignals(const MultiAudio& output, const Transaction::State& current) +{ + for (const Transaction::Receiver& receiver : current.receivers) { + const int id = receiver.passband.stableId; + if (std::ranges::find(current.receivingIds, id) == current.receivingIds.end()) { + check(!output[id].seen, "a parked receiver contributes no stale PCM or diagnostics"); + continue; + } + const auto carrier = std::ranges::find_if(kAnalogCarriers, [id](const auto& value) { return value.id == id; }); + const Audio& audio = output[id]; + const double left = amplitude(audio.left, carrier->leftHz); + const double right = amplitude(audio.right, carrier->rightHz == 0 ? carrier->leftHz : carrier->rightHz); + std::printf("ANALOG_SIGNAL id=%d frames=%zu left=%.6f right=%.6f peak=%.6f stereo=%d pilot=%d\n", + id, audio.left.size(), left, right, audio.peak, audio.stereo, audio.pilot); + check(audio.left.size() > 48000 && left > 0.001 && right > 0.001, + "every admitted analog receiver independently demodulates its actual nonzero carrier"); + check(audio.peak < 1 && audio.clipped == 0, "independent analog taps retain normalized PCM headroom"); + if (carrier->rightHz != 0) { + check(audio.pilot, "each WFM receiver acquires its own real pilot"); + if (receiver.wfmForceMono) { + check(!audio.stereo && audio.left == audio.right, "forced mono applies only to the requested WFM receiver"); + } else { + check(audio.stereo && left > 30 * amplitude(audio.right, carrier->leftHz) + && right > 30 * amplitude(audio.left, carrier->rightHz), + "each automatic WFM receiver preserves its own separated stereo program"); + } + } + for (const AnalogCarrier& sibling : kAnalogCarriers) { + if (sibling.id == id) { continue; } + check(left > 30 * amplitude(audio.left, sibling.leftHz), + "each analog demodulator rejects the other captured stations"); + } + } + check(output.back().seen && output.back().peak < 1 && output.back().clipped == 0, + "simultaneous analog reception reaches one unclipped speaker stream"); +} +void analogMultiReceiver() +{ + auto pipeline = std::make_unique(4, true); // fixture capacity; production remains one + Transaction::State current = state(); + current.receivers = {analogReceiver(kAnalogCarriers[0]), analogReceiver(kAnalogCarriers[3])}; + current.receivingIds = {0, 7}; + check(installBank(*pipeline, current, true), "two distinct FM and FM-N paths adopt together"); + std::uint64_t position = 0; + const MultiAudio narrow = runAnalogBank(*pipeline, current, position, 2.0); + checkAnalogSignals(narrow, current); + pipeline->stop(); + pipeline.reset(); + check(QThreadPool::globalInstance()->waitForDone(15000), "narrow pair drains before the independent WFM workload"); + + pipeline = std::make_unique(4, true); + current = state(); + current.receivers = {analogReceiver(kAnalogCarriers[1]), analogReceiver(kAnalogCarriers[2])}; + current.receivers[1].wfmForceMono = true; + current.receivingIds = {2, 5}; + check(installBank(*pipeline, current, true), "two analog WFM recipes adopt with distinct filters, deemphasis and stereo choices"); + position = 25; + const MultiAudio initial = runAnalogBank(*pipeline, current, position, 3.0); + checkAnalogSignals(initial, current); + current.token.revision++; + current.receivers[1].wfmDeemphasisUs = 75; + check(installBank(*pipeline, current), "one WFM receiver changes deemphasis independently"); + const MultiAudio deemphasis = runAnalogBank(*pipeline, current, position, 2.0); + checkAnalogSignals(deemphasis, current); + const double measuredResponse = amplitude(deemphasis[5].left, 4000) / amplitude(initial[5].left, 4000); + const double expectedResponse = std::sqrt((1 + std::pow(kTau * 4000 * 50e-6, 2)) + / (1 + std::pow(kTau * 4000 * 75e-6, 2))); + check(std::abs(measuredResponse - expectedResponse) < 0.04, + "changed receiver's actual 4 kHz audio follows its own 50-to-75 us deemphasis response"); + check(std::abs(amplitude(deemphasis[2].left, 1000) / amplitude(initial[2].left, 1000) - 1) < 0.01, + "sibling deemphasis and actual audio response remain unchanged"); + check(initial[2].receiverEpoch == deemphasis[2].receiverEpoch && !deemphasis[2].discontinuity + && initial[2].next == deemphasis[2].first, + "independent deemphasis replacement leaves the sibling decoder continuous"); + current.token.revision++; + current.receivers[1].wfmForceMono = false; + current.receivers[1].passband.filterLowHz = -100'000; + current.receivers[1].passband.filterHighHz = 100'000; + check(installBank(*pipeline, current), "one WFM recipe changes without reconstructing its sibling"); + const MultiAudio edited = runAnalogBank(*pipeline, current, position, 3.0); + checkAnalogSignals(edited, current); + check(initial[2].instance == edited[2].instance && initial[2].receiverEpoch == edited[2].receiverEpoch + && !edited[2].discontinuity && deemphasis[2].next == edited[2].first, + "editing one WFM decoder preserves the sibling's exact continuous receiver lifetime"); + check(initial[5].receiverEpoch != edited[5].receiverEpoch && edited[5].discontinuity, + "changed WFM controls retire only their originating decoder recipe"); + + current.token.revision++; + current.receivers.insert(current.receivers.begin(), analogReceiver(kAnalogCarriers[0])); + current.receivers.push_back(analogReceiver(kAnalogCarriers[3])); + current.receivingIds = {0, 2, 5, 7}; + check(installBank(*pipeline, current), "four real FM, FM-N and stereo WFM receivers adopt one shared RF capture"); + const MultiAudio four = runAnalogBank(*pipeline, current, position, 3.0); + checkAnalogSignals(four, current); + for (const int id : {2, 5}) { + check(four[id].instance == edited[id].instance && four[id].receiverEpoch == edited[id].receiverEpoch + && !four[id].discontinuity && four[id].first == edited[id].next, + "adding analog siblings retains the existing WFM decoder histories"); + } + current.token.revision++; + current.receivers[1].audioGain = 23; + current.receivers[1].audioMute = true; + current.receivers[2].audioGain = 40; + current.receivers[2].audioPan = 100; + check(installBank(*pipeline, current), "independent gain, mute and pan adopt without RF or decoder reconfiguration"); + const MultiAudio monitored = runAnalogBank(*pipeline, current, position, 1.5); + checkAnalogSignals(monitored, current); + for (const int id : {0, 2, 5, 7}) { + check(monitored[id].instance == four[id].instance && monitored[id].receiverEpoch == four[id].receiverEpoch + && monitored[id].captureEpoch == four[id].captureEpoch && !monitored[id].discontinuity, + "monitor controls never replace any receiver or speaker epoch"); + } + check(amplitude(monitored.back().left, 1000) < 0.001 && amplitude(monitored.back().right, 2000) < 0.001 + && amplitude(monitored[2].left, 1000) > 0.001, + "one WFM monitor mute leaves its independent recording tap nonzero"); + check(amplitude(monitored.back().left, 3000) < 0.001 + && amplitude(monitored.back().right, 4000) > 0.001, + "one WFM monitor pan affects only its own speaker contribution"); + + current.token.revision++; + ++current.capture.generation; + current.capture.centerHz = current.hardware.centerHz = 100'600'000; + current.receivingIds = {5, 7}; // the complete -500 kHz WFM passband no longer fits + const auto recipes = current.receivers; + check(installBank(*pipeline, current, true), "capture retune parks complete out-of-capture passbands"); + position = 0; + const MultiAudio partial = runAnalogBank(*pipeline, current, position, 2.0); + checkAnalogSignals(partial, current); + current.token.revision++; + ++current.capture.generation; + current.capture.centerHz = current.hardware.centerHz = 103'000'000; + current.receivingIds.clear(); + check(installBank(*pipeline, current, true), "all four configured receivers admit a capture with an empty receiving bank"); + position = 0; + const MultiAudio parked = runAnalogBank(*pipeline, current, position, 0.05); + check(std::ranges::none_of(parked, [](const Audio& audio) { return audio.seen; }), + "an empty analog bank emits no stale receiver or speaker packets"); + current.token.revision++; + ++current.capture.generation; + current.capture.centerHz = current.hardware.centerHz = 100'000'000; + current.receivingIds = {0, 2, 5, 7}; + check(current.receivers == recipes, "parking retains every exact configured RF and audio recipe"); + check(installBank(*pipeline, current, true), "returning capture readopts all four preserved analog recipes"); + position = 0; + const MultiAudio resumed = runAnalogBank(*pipeline, current, position, 3.0); + checkAnalogSignals(resumed, current); + for (const int id : {0, 2, 5, 7}) { + check(resumed[id].instance != monitored[id].instance && resumed[id].discontinuity, + "resumed identities cannot reuse retired PCM or decoder observations"); + } + pipeline->stop(); +} void admission() { auto legacy = std::make_unique(); @@ -622,6 +940,20 @@ void admission() invalid.receivingIds.push_back(4); check(!native->prepare(invalid), "WFM qualification does not increase production receiver 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}) { + 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"); + std::reverse(invalid.receivers.begin(), invalid.receivers.end()); + check(!multiple->prepare(invalid), + "configured legacy singleton refusal is independent of receiver order"); + } + multiple->stop(); } } int main(int argc, char** argv) @@ -630,6 +962,7 @@ int main(int argc, char** argv) outputClockAlignment(); admission(); nativeRouting(); + analogMultiReceiver(); workerUnderrunTrace(); if (qEnvironmentVariableIntValue("AETHER_WFM_BENCHMARK") == 1) { boundedBenchmark(); } QThreadPool::globalInstance()->waitForDone(); diff --git a/tests/rx_applet_squelch_reconciliation_test.cpp b/tests/rx_applet_squelch_reconciliation_test.cpp index 61ffc92df3..57ffa54e22 100644 --- a/tests/rx_applet_squelch_reconciliation_test.cpp +++ b/tests/rx_applet_squelch_reconciliation_test.cpp @@ -15,6 +15,11 @@ using namespace AetherSDR; namespace AetherSDR { +struct RxAppletSquelchTestAccess { + static void engineSql(RxApplet& applet) { + applet.m_receiveSquelchModel = ReceiveSquelchModel{{"FM", "FMN"}, -120, 1.2, "dBFS/2048-bin", true}; + } +}; struct RadioModelWakeTestAccess { static void identity(RadioModel& radio, const QString& family, const QString& id) { @@ -56,6 +61,72 @@ class RxAppletSquelchReconciliationTest : public QObject Q_OBJECT private slots: + void engineAutoRestoreKeepsManualThresholdOnAttachedSlice() + { + SliceModel first(0); + RxApplet rx; + RxAppletSquelchTestAccess::engineSql(rx); + rx.setSlice(&first); + QSignalSpy commands(&first, &SliceModel::commandReady); + SliceDelta restored; + restored.mode = QStringLiteral("FMN"); + restored.squelchOn = true; + restored.squelchLevel = 54; + restored.automaticSquelch = true; + restored.automaticSquelchMarginDb = 13; + first.applyChanges(restored); + QCOMPARE(rx.sqlMode(), RxApplet::SqlMode::Auto); + QCOMPARE(rx.autoSqlMarginDb(), 13); + QCOMPARE(first.manualSquelchLevel(), 54); + QCOMPARE(rx.sqlManualLevel(), 54); + QVERIFY(commands.isEmpty()); + rx.cycleSqlModeExternal(); // Auto -> Off + rx.cycleSqlModeExternal(); // Off -> Manual restores the saved threshold + QCOMPARE(rx.sqlManualLevel(), 54); + QCOMPARE(first.squelchLevel(), 54); + } + + void engineAutoFollowsSelectedSliceWithoutCrossWrites() + { + SliceModel first(0), eighth(7); + status(first, true, 54, "FMN"); + status(eighth, true, 61, "FM"); + SliceDelta automatic; + automatic.automaticSquelch = true; + automatic.automaticSquelchMarginDb = 13; + eighth.applyChanges(automatic); + RxApplet rx; + RxAppletSquelchTestAccess::engineSql(rx); + QSignalSpy firstAuto(&first, &SliceModel::automaticSquelchRequested); + QSignalSpy eighthAuto(&eighth, &SliceModel::automaticSquelchRequested); + rx.setSlice(&eighth); + QCOMPARE(rx.sqlMode(), RxApplet::SqlMode::Auto); + QCOMPARE(rx.autoSqlMarginDb(), 13); + rx.setSlice(&first); + QCOMPARE(rx.sqlMode(), RxApplet::SqlMode::Manual); + QCOMPARE(rx.sqlManualLevel(), 54); + QVERIFY(firstAuto.isEmpty() && eighthAuto.isEmpty()); + rx.cycleSqlModeExternal(); + QCOMPARE(firstAuto.count(), 1); + QCOMPARE(firstAuto.at(0).at(1).toInt(), 10); + QVERIFY(eighthAuto.isEmpty()); + rx.setSlice(&eighth); + rx.setSqlSliderValueExternal(17); + QCOMPARE(eighthAuto.count(), 1); + QCOMPARE(eighthAuto.at(0).at(1).toInt(), 17); + QCOMPARE(firstAuto.count(), 1); + QCOMPARE(first.manualSquelchLevel(), 54); + QCOMPARE(eighth.manualSquelchLevel(), 61); + SliceDelta manual; + manual.automaticSquelch = false; + manual.squelchOn = true; + manual.squelchLevel = 72; + eighth.applyChanges(manual); + QCOMPARE(rx.sqlMode(), RxApplet::SqlMode::Manual); + QCOMPARE(rx.sqlManualLevel(), 72); + QCOMPARE(first.manualSquelchLevel(), 54); + } + void deferredWritesCoalesceAndFlush() { int writes = 0; diff --git a/tests/s_meter_geometry_test.cpp b/tests/s_meter_geometry_test.cpp index 99339b81ce..e663979a49 100644 --- a/tests/s_meter_geometry_test.cpp +++ b/tests/s_meter_geometry_test.cpp @@ -1188,6 +1188,31 @@ int main(int argc, char** argv) expect(changedPixelCount(minimumRender) > 250, "minimum-size face renders"); expect(changedPixelCount(enlargedRender) > 1000, "enlarged face renders"); + meter.setTransmitting(false); + meter.setRelativeLevel(-40.0f, 7); + expect(std::isnan(meter.levelDbm()), "relative RF has no calibrated widget accessor value"); + expect(meter.accessibleDescription().contains("dBFS") + && meter.accessibleDescription().contains("uncalibrated"), "relative RF description names its actual reference"); + expect(meter.accessibleValueText().contains("dBFS") + && !meter.accessibleValueText().contains("dBm") + && !meter.sUnitsText().contains("S9"), "relative RF is never labeled dBm or S-units"); + const QImage relativeFace = render(meter, QSize(280, 140)); + meter.setRelativeLevel(std::nullopt, 2); + expect(meter.accessibleValueText().contains("unavailable"), "parked selection withdraws its old reading"); + expect(imageDigest(relativeFace) != imageDigest(render(meter, QSize(280, 140))), + "relative no-data face differs from a live RF reading"); + meter.setRelativeLevel(std::nullopt, 2, QStringLiteral("Receive meters disabled")); + expect(meter.accessibleValueText() == QStringLiteral("Receive meters disabled") + && meter.accessibleDescription() == QStringLiteral("Receive meters disabled"), + "disabled RF meter exposes its actual reason to assistive technology"); + const QImage offFace = render(meter, QSize(280, 140)); + meter.setRelativeLevel(-40.0f, 2); + expect(meter.accessibleValueText().contains("dBFS") + && imageDigest(offFace) != imageDigest(render(meter, QSize(280, 140))), + "re-enabled RF meter recovers the selected live face"); + meter.setLevel(-73); + expect(meter.accessibleValueText().contains("dBm"), "calibrated input restores the calibrated meter"); + if (g_failures == 0) { // SAY WHICH HALF RAN. Without this the binary exits 0 whether or not the // announcement section executed, and ctest reports a bare "Passed" for diff --git a/tests/spectrum_confirmed_geometry_test.cpp b/tests/spectrum_confirmed_geometry_test.cpp index 14434f29a3..24a7666fcd 100644 --- a/tests/spectrum_confirmed_geometry_test.cpp +++ b/tests/spectrum_confirmed_geometry_test.cpp @@ -1,6 +1,7 @@ // Opt-in real-widget test: no sockets, radio, USB, or synthetic firmware peer. #include "TestSettingsProfile.h" #include "gui/SpectrumWidget.h" +#include "gui/VfoWidget.h" #include "gui/SpectrumOverlayMenu.h" #include "gui/MainWindowHelpers.h" #include "RtlInjectedDevice.h" diff --git a/tests/tci_rx_audio_test.cpp b/tests/tci_rx_audio_test.cpp index 0743b85b32..c8c695905d 100644 --- a/tests/tci_rx_audio_test.cpp +++ b/tests/tci_rx_audio_test.cpp @@ -6,8 +6,13 @@ #include "core/TciRxConverter.h" #include "core/backends/IRadioBackend.h" #include "models/RadioModel.h" +#include "models/DaxReceiveModel.h" #include "models/SliceModel.h" #include +#include +#include +#include +#include #include #include "core/TciClient.h" #include @@ -76,7 +81,12 @@ double energy(const QVector& samples, int channel) } class InjectedBackend final : public IRadioBackend { public: - RadioCapabilities capabilities() const override { return {}; } + RadioCapabilities capabilities() const override { + RadioCapabilities caps; + if (exportAvailable) { caps.receiveAudioExport = ReceiveAudioExport{{24000,48000},8}; } + return caps; + } + bool exportAvailable = true; bool ownsRxAudio() const override { return true; } void connectRadio(const RadioConnectRequest&) override {} // no transport void disconnectRadio() override { retirePcmStreams(); emit disconnected(); } @@ -795,8 +805,246 @@ class TciRxAudioTest { check(true, "copied network ingress remains inert after concurrent controller destruction"); } + static void eightNativeDaxConsumers() + { + const std::array ids{3,17,29,41,57,63,80,99}; + const std::array rates{8000,12000,24000,48000,8000,12000,24000,48000}; + for (int sourceRate : {24000,48000}) { + Fixture f; + DaxReceiveModel dax(f.model, f.server); + std::array received; + std::array resets{}; + std::array producers; + std::array clients{}; + QObject::connect(&dax, &DaxReceiveModel::audioReady, &dax, + [&](int channel, const QByteArray& pcm) { + check(channel >= 1 && channel <= 8 && !pcm.isEmpty() + && pcm.size() <= 1024*2*int(sizeof(float)) + && pcm.size() % (2*sizeof(float)) == 0, + "DAX emits bounded float stereo24 blocks on channels one through eight"); + if (channel >= 1 && channel <= 8) { received[channel-1].append(pcm); } + }); + QObject::connect(&dax, &DaxReceiveModel::channelReset, &dax, + [&](int channel) { ++resets[channel-1]; }); + for (int i = 0; i < 8; ++i) { + f.backend->add(ids[i]); + check(producers[i].start(PcmPurpose::Slice,ids[i],{sourceRate,PcmLayout::Stereo}), + "start eight distinct native receiver producers"); + clients[i] = f.client(24000,i); + SliceDelta mute; mute.audioMute = true; mute.audioGain = 0; + emit f.backend->sliceChanged(ids[i],mute); + } + dax.setEnabled(true); + std::array frames; + for (int i = 0; i < 8; ++i) { + frames[i] = frame(producers[i],sourceRate,float(i+1)/16.0f,-float(i+1)/32.0f); + f.feed(ids[i],frames[i]); + } + for (int i = 0; i < 8; ++i) { + headers(f.packets[clients[i]],24000,i,2,3); + check(std::abs(floats(f.packets[clients[i]]).size()/2-24000) <= 24000*256/sourceRate+1, + "eight independent TCI consumers retain negotiated rate and receiver identity"); + QByteArray comparison; + for (const QByteArray& packet : f.packets[clients[i]]) { comparison.append(packet.mid(64)); } + check(!received[i].isEmpty() && received[i] == comparison, + "each DAX channel is exactly the same independently converted PCM as its live TCI24 route"); + const int count = received[i].size()/int(2*sizeof(float)); + check(std::abs(count-24000) <= 256*24000/sourceRate+1, + "DAX duration follows the producer descriptor rather than relabeling its sample rate"); + float pair[2]{}; + if (received[i].size() >= int(sizeof(pair))) { + std::memcpy(pair,received[i].constData()+received[i].size()-sizeof(pair),sizeof(pair)); + } + check(std::abs(pair[0]-float(i+1)/16.0f)<0.001f + && std::abs(pair[1]+float(i+1)/32.0f)<0.001f, + "eight muted receivers preserve their distinct stereo channel signatures in DAX"); + const qsizetype size = received[i].size(); + f.feed(ids[i],frames[i]); + check(received[i].size()==size,"DAX replay guard rejects a repeated frame independently of TCI"); + } + f.packets.clear(); + for (int i = 0; i < 8; ++i) { + f.command(clients[i],QStringLiteral("audio_samplerate:%1;").arg(rates[i])); + f.feed(ids[i],frame(producers[i],sourceRate,float(i+1)/16.0f,-float(i+1)/32.0f)); + headers(f.packets[clients[i]],rates[i],i,2,3); + check(std::abs(floats(f.packets[clients[i]]).size()/2-rates[i]) <= rates[i]*256/sourceRate+1, + "eight live clients independently renegotiate supported TCI rates while DAX continues"); + } + const qsizetype beforeStop=received[7].size(); + dax.setEnabled(false); + f.feed(ids[7],frame(producers[7],512)); + check(received[7].size()==beforeStop,"disabled DAX does not feed while TCI remains live"); + dax.setEnabled(true); + f.feed(ids[7],frame(producers[7],4096)); + check(received[7].size()>beforeStop,"DAX resumes a live producer after its own stop without restarting TCI"); + const PcmFrame stale=frame(producers[0],4096); + const qsizetype beforeRemoval=received[0].size(); + f.backend->remove(ids[0]); + f.feed(ids[0],stale); + f.backend->add(ids[0]); + f.feed(ids[0],stale); + check(received[0].size()==beforeRemoval,"removal and same-id recreation cannot admit the old live producer"); + producers[0].invalidate(); + producers[0].start(PcmPurpose::Slice,ids[0],{sourceRate,PcmLayout::Stereo}); + f.feed(ids[0],frame(producers[0],4096)); + check(received[0].size()>beforeRemoval,"new receiver epoch resumes its original DAX channel"); + const qsizetype survivor=received[6].size(); + f.feed(ids[6],frame(producers[6],4096)); + check(received[6].size()>survivor,"recreating an earlier slice never renumbers a surviving DAX receiver"); + f.backend->remove(ids[0]); + QEventLoop settle; + QTimer::singleShot(550,&settle,&QEventLoop::quit); + settle.exec(); + f.backend->add(123); + PcmProducer replacement; + replacement.start(PcmPurpose::Slice,123,{sourceRate,PcmLayout::Stereo}); + const qsizetype beforeReuse=received[0].size(); + f.feed(123,frame(replacement,4096)); + check(received[0].size()>beforeReuse, + "a genuinely closed receiver's settled TCI index is reused by the same DAX input"); + const qsizetype survivorAfterReuse=received[7].size(); + f.feed(ids[7],frame(producers[7],4096)); + check(received[7].size()>survivorAfterReuse, + "reusing a sparse hole does not move the eighth surviving receiver"); + const qsizetype beforeListenerStop=received[7].size(); + f.server.stop(); + f.feed(ids[7],frame(producers[7],4096)); + check(!f.server.isRunning() && received[7].size()>beforeListenerStop, + "DAX continues independently when the TCI listener and clients are stopped"); + for (int reset : resets) { check(reset>0,"all eight DAX channels establish a reset boundary"); } + } + } + static void nativeDaxEpochBoundaries() + { + Fixture f; f.backend->add(17); + DaxReceiveModel dax(f.model,f.server); + QByteArray audio; + int resets=0; + QObject::connect(&dax,&DaxReceiveModel::audioReady,&dax, + [&](int,const QByteArray& pcm) { audio.append(pcm); }); + QObject::connect(&dax,&DaxReceiveModel::channelReset,&dax,[&](int) { ++resets; }); + dax.setEnabled(true); + PcmProducer producer,competing; + producer.start(PcmPurpose::Slice,17,{48000,PcmLayout::Stereo}); + f.feed(17,frame(producer,128,0.8f,0.8f)); + check(audio.isEmpty(),"DAX conversion retains a bounded partial source block"); + const PcmFrame stale=frame(producer,128,0.8f,0.8f); + producer.setFormat({24000,PcmLayout::Stereo}); + f.feed(17,stale); + check(audio.isEmpty(),"revoked queued epoch cannot complete old DAX converter staging"); + f.feed(17,frame(producer,4096,0,0)); + check(!audio.isEmpty() && std::all_of(audio.cbegin(),audio.cend(),[](char byte) {return byte==0;}), + "24k epoch starts silent with no retained 48k signal history"); + const qsizetype beforeCompeting=audio.size(); + competing.start(PcmPurpose::Slice,17,{24000,PcmLayout::Stereo}); + f.feed(17,frame(competing,4096)); + check(audio.size()==beforeCompeting,"a competing live producer cannot replace a pinned native DAX source"); + audio.clear(); + producer.setFormat({24000,PcmLayout::Mono}); + const auto mono=producer.produce({0.125f,-0.25f}); + f.feed(17,*mono); + float samples[4]{}; + if(audio.size()==int(sizeof(samples))) {std::memcpy(samples,audio.constData(),sizeof(samples));} + check(audio.size()==int(sizeof(samples)) && samples[0]==0.125f && samples[1]==0.125f + && samples[2]==-0.25f && samples[3]==-0.25f, + "native mono is deliberately duplicated into the bridge stereo ABI"); + audio.clear(); + const auto stillLive=producer.produce({0.5f,0.5f}); + f.replaceBackend(); f.backend->add(17); + f.feed(17,*stillLive); + check(audio.isEmpty(),"backend replacement retires old native DAX route even with a still-live producer"); + producer.invalidate(); producer.start(PcmPurpose::Slice,17); + f.feed(17,frame(producer,16)); + check(!audio.isEmpty() && resets>=4,"new backend route resumes after independent lifecycle resets"); + audio.clear(); + f.backend->exportAvailable=false; + emit f.model.capabilitiesChanged(f.model.isConnected(),f.backend->capabilities()); + f.feed(17,frame(producer,16)); + check(audio.isEmpty(),"withdrawing native export capability immediately refuses further DAX audio"); + f.backend->exportAvailable=true; + emit f.model.capabilitiesChanged(f.model.isConnected(),f.backend->capabilities()); + f.feed(17,frame(producer,16)); + check(!audio.isEmpty(),"redeclared native export resumes with a reset converter"); + audio.clear(); + bool revoke=true; + QObject::connect(&dax,&DaxReceiveModel::audioReady,&dax,[&](int,const QByteArray&) { + if(revoke) {revoke=false; producer.invalidate();} + }); + f.feed(17,frame(producer,4096)); + check(audio.size()==int(1024*2*sizeof(float)), + "revocation from a consumer callback prevents every remaining block of the same DAX input"); + } + + static void serverConstructedAfterReceiverRestore() + { + RadioModel model; + auto backend = std::make_unique(); + InjectedBackend* source = backend.get(); + model.setBackendForTest(std::move(backend), QStringLiteral("rtl")); + for (int id = 0; id < 8; ++id) { source->add(id); } + // MainWindow can construct the session's TCI server after the radio + // restored its slices. Their sliceAdded signals have already fired. + TciServer server(&model); + source->remove(3); + QEventLoop settle; + QTimer::singleShot(550, &settle, &QEventLoop::quit); + settle.exec(); + for (int id : {0, 1, 2, 4, 5, 6, 7}) { + const auto binding = server.sliceRxBinding(id); + check(binding.current() && binding.trx == id, + "late-constructed TCI server preserves surviving receiver assignments"); + } + for (const QJsonValue& value : server.routingSnapshot().value(QStringLiteral("endpoints")).toArray()) { + const QJsonObject endpoint = value.toObject(); + const int id = endpoint.value(QStringLiteral("sliceId")).toInt(); + check(endpoint.value(QStringLiteral("trx")).toInt() == server.sliceRxBinding(id).trx, + "routing diagnostics report the same stable receiver assignment as audio"); + } + source->add(99); + const auto replacement = server.sliceRxBinding(99); + check(replacement.current() && replacement.trx == 3, + "late-constructed TCI server reuses the actual removed receiver slot"); + } + + static void simultaneousEightReceiverHandshakes() + { + Fixture f; + for (int id = 0; id < 8; ++id) { f.backend->add(id); } + // Exercise the production mailbox with its existing worker paused. + // No server/socket is opened and no firmware peer is simulated. + QSemaphore paused; + QSemaphore resume; + f.server.m_io->m_accepting = true; + f.server.m_ioThread = std::make_unique(); + f.server.m_io->moveToThread(f.server.m_ioThread.get()); + f.server.m_ioThread->start(); + QMetaObject::invokeMethod(f.server.m_io.get(), [&] { + paused.release(); + resume.acquire(); + }, Qt::QueuedConnection); + paused.acquire(); + f.server.m_running.store(true); + for (int id = 0; id < 8; ++id) { + f.server.onClientOpened(std::make_shared(), + QHostAddress(QStringLiteral("192.0.2.1")), 50000 + id); + } + { + QMutexLocker lock(&f.server.m_io->m_mailboxMutex); + check(!f.server.m_io->m_overloaded, + "eight full receiver handshakes fit the unchanged bounded worker mailbox"); + check(f.server.m_io->m_mailboxBytes > 0, + "handshake payload bytes remain charged to the mailbox bound"); + } + check(f.server.m_clients.size() == 8, "eight simultaneous clients remain registered"); + resume.release(); + f.server.stop(); + } + static int run() { + serverConstructedAfterReceiverRestore(); + simultaneousEightReceiverHandshakes(); + eightNativeDaxConsumers(); nativeDaxEpochBoundaries(); ingressOutlivesController(); rateMatrixAndStereo(); unsupportedRatePreservesStream(); sparseRoutingAndSingleFeed(); formatEncoding(); replayAndEpochs(); resetIsolation(); subscriptionAndForwardGapStaging(); retiredRouteAndCapacity(); daxLifecycle(); daxOwnerTransition(); staleFinalCheckAndChurn(); levelCallbackRetirement(); negotiationClientChurn(); lifecycleChurnAndConcurrentRevocation(); failedSendIsolation(); failedSendSocketDeletion(); backlogDiagnostics(); refusalCloseDistinction(); daxRouteSurvivesSliceRecreate(); pressureAndReplacement(); diff --git a/tests/tests.cmake b/tests/tests.cmake index c40f07c210..4d019df75b 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -84,6 +84,12 @@ target_link_libraries(decoder_audio_routing_test PRIVATE aethercore Qt6::Core) add_test(NAME decoder_audio_routing_test COMMAND decoder_audio_routing_test) set_tests_properties(decoder_audio_routing_test PROPERTIES TIMEOUT 30) +# Injected typed PCM and the real modem worker; no sockets, sound device or RF. +add_executable(ax25_receive_model_test tests/ax25_receive_model_test.cpp) +target_link_libraries(ax25_receive_model_test PRIVATE aethercore Qt6::Core) +add_test(NAME ax25_receive_model_test COMMAND ax25_receive_model_test) +set_tests_properties(ax25_receive_model_test PROPERTIES TIMEOUT 60) + # CW waveform at the selected pre-monitor boundary; no decoder, socket or TX. add_executable(cw_pcm_consumer_test tests/cw_pcm_consumer_test.cpp) target_link_libraries(cw_pcm_consumer_test PRIVATE aethercore Qt6::Core) @@ -1435,6 +1441,14 @@ if(AETHER_BACKEND_RTL) add_test(NAME rtl_capture_worker_test COMMAND rtl_capture_worker_test) set_tests_properties(rtl_capture_worker_test PROPERTIES TIMEOUT 20) + # Independent AX25/Bell202 CU8 fixture through actual RTL worker and model. + # USB device operations are injected; no sockets, USB enumeration or RF. + add_executable(rtl_aprs_receive_test tests/rtl_aprs_receive_test.cpp) + target_include_directories(rtl_aprs_receive_test PRIVATE src tests) + target_link_libraries(rtl_aprs_receive_test PRIVATE aethercore Qt6::Core) + add_test(NAME rtl_aprs_receive_test COMMAND rtl_aprs_receive_test) + set_tests_properties(rtl_aprs_receive_test PROPERTIES TIMEOUT 60) + # Generated IQ only: genuine resolution, continuity, gain and detector independence. add_executable(rtl_spectrum_resolution_test tests/rtl_spectrum_resolution_test.cpp) target_include_directories(rtl_spectrum_resolution_test PRIVATE src) @@ -6668,6 +6682,35 @@ add_test(NAME rx_applet_squelch_reconciliation_test set_tests_properties(rx_applet_squelch_reconciliation_test PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +# Socket-free real VFO/SmartMTR paint and label invalidation regression. +add_executable(vfo_meter_overlay_invalidation_test + tests/vfo_meter_overlay_invalidation_test.cpp + src/gui/RxApplet.cpp + src/gui/ControlAvailabilityRegistry.cpp + src/gui/VfoWidget.cpp + src/gui/ModeFilterPresets.cpp + src/gui/VfoDisplayDefaults.cpp + src/gui/FrequencyEntryParser.cpp + src/gui/DragValuePopup.cpp + src/gui/FilterPassbandWidget.cpp + src/gui/SliceColorManager.cpp + src/gui/SliceLabel.cpp + src/gui/PhaseKnob.cpp + src/gui/SmartMtrWidget.cpp + src/gui/SmartMtrConfig.cpp + src/gui/MeterViewController.cpp + src/gui/AdaptiveFilterControls.cpp + src/gui/GuardedSlider.h +) +target_include_directories(vfo_meter_overlay_invalidation_test PRIVATE src) +target_link_libraries(vfo_meter_overlay_invalidation_test PRIVATE + aethercore Qt6::Widgets Qt6::Test +) +add_test(NAME vfo_meter_overlay_invalidation_test + COMMAND vfo_meter_overlay_invalidation_test) +set_tests_properties(vfo_meter_overlay_invalidation_test PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + # FM filter capability and edge routing through the real RX and VFO widgets. # Injects capability data; no socket, USB access or synthetic firmware peer. add_executable(fm_filter_controls_test @@ -6959,12 +7002,27 @@ target_link_libraries(CAT_Flex_test PRIVATE Qt6::Core Qt6::Network) # Every standalone test/tool target that compiles ${AETHER_SETTINGS_SOURCES} # directly (rather than linking aethercore) needs the vendored SQLite engine. # Conditional targets are guarded with if(TARGET ...). +# Real applet with injected receive capabilities; no sockets/audio devices. +if(NOT WIN32) + add_executable(dax_receive_applet_test tests/dax_receive_applet_test.cpp + src/gui/DaxApplet.cpp src/gui/MeterSlider.h src/gui/DragValuePopup.cpp + src/gui/SliceLabel.cpp src/gui/SliceColorManager.cpp + src/gui/ControlAvailabilityRegistry.cpp) + target_include_directories(dax_receive_applet_test PRIVATE src tests) + target_link_libraries(dax_receive_applet_test PRIVATE aetherdesktop_support Qt6::Widgets) + add_test(NAME dax_receive_applet_test COMMAND dax_receive_applet_test) + set_tests_properties(dax_receive_applet_test PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +endif() + set(AETHER_SETTINGS_CONSUMERS + dax_receive_applet_test anan_backend_test anan_noise_blanker_readback_test tci_rx_audio_test bandscope_trace_render_test decoder_audio_routing_test + ax25_receive_model_test + rtl_aprs_receive_test cw_pcm_consumer_test noise_floor_auto_adjust_gate_test qso_recorder_rates_test @@ -6996,6 +7054,7 @@ set(AETHER_SETTINGS_CONSUMERS client_display_settings_test gui_nested_lifetime_test rx_applet_squelch_reconciliation_test + vfo_meter_overlay_invalidation_test fm_filter_controls_test wfm_controls_test spectrum_confirmed_geometry_test @@ -7439,3 +7498,11 @@ target_include_directories(cw_rx_model_test PRIVATE src) target_link_libraries(cw_rx_model_test PRIVATE aethercore Qt6::Core) add_test(NAME cw_rx_model_test COMMAND cw_rx_model_test) set_tests_properties(cw_rx_model_test PROPERTIES TIMEOUT 15) + +# Anonymous-pipe output injection: no sockets, audio devices or helper processes. +if(HAVE_PIPEWIRE) + add_executable(pipewire_rx_bridge_test tests/pipewire_rx_bridge_test.cpp) + target_include_directories(pipewire_rx_bridge_test PRIVATE src) + target_link_libraries(pipewire_rx_bridge_test PRIVATE aethercore Qt6::Core) + add_test(NAME pipewire_rx_bridge_test COMMAND pipewire_rx_bridge_test) +endif() diff --git a/tests/vfo_meter_overlay_invalidation_test.cpp b/tests/vfo_meter_overlay_invalidation_test.cpp new file mode 100644 index 0000000000..b92c3d9838 --- /dev/null +++ b/tests/vfo_meter_overlay_invalidation_test.cpp @@ -0,0 +1,189 @@ +// A moving in-flag bar must not invalidate the spectrum's separate value-label +// overlay when there are no labels to draw. Clearing the last label must still +// invalidate immediately, even inside the animation notification throttle. +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" +#include "gui/MeterViewController.h" +#include "gui/SmartMtrWidget.h" +#include "gui/VfoWidget.h" + +#include +#include +#include + +using namespace AetherSDR; + +class PaintCountingVfo : public VfoWidget +{ +public: + int paints = 0; +protected: + void paintEvent(QPaintEvent* event) override + { + ++paints; + VfoWidget::paintEvent(event); + } +}; + +class VfoMeterOverlayInvalidationTest : public QObject +{ + Q_OBJECT +private slots: + void init() + { + MeterViewController::instance().setSmartMtr(true); + MeterViewController::instance().setShowExtremes(false); + MeterViewController::instance().setShowValues(DisplaySettings::MeterValues::None); + } + + void barWithoutValueLabelsDoesNotInvalidateOverlay_data() + { + QTest::addColumn("relative"); + QTest::newRow("calibrated") << false; + QTest::newRow("relative-dBFS") << true; + } + + void barWithoutValueLabelsDoesNotInvalidateOverlay() + { + QFETCH(bool, relative); + VfoWidget vfo; + vfo.show(); + SmartMtrWidget* meter = vfo.findChild(); + QVERIFY(meter); + QTest::qWait(60); + QSignalSpy invalidations(&vfo, &VfoWidget::smartMtrLabelsChanged); + const auto setLevel = [&vfo, relative](float level) { + if (relative) { + vfo.setRelativeSignalLevel(level); + } else { + vfo.setSignalLevel(level); + } + }; + setLevel(-100); + QTest::qWait(200); + const QImage low = meter->grab().toImage(); + setLevel(-20); + QTest::qWait(200); + const QImage high = meter->grab().toImage(); + QVERIFY(!low.isNull()); + QVERIFY(low != high); // the real bar still animates/renders + QVERIFY(meter->extremeLabels().isEmpty()); + QVERIFY2(invalidations.isEmpty(), "bar-only paints must not rebuild the spectrum label overlay"); + } + + void valueLabelsStillUpdateAndClearWithoutWaitingForThrottle() + { + MeterViewController::instance().setShowValues(DisplaySettings::MeterValues::Signal); + VfoWidget vfo; + vfo.show(); + SmartMtrWidget* meter = vfo.findChild(); + QVERIFY(meter); + QSignalSpy invalidations(&vfo, &VfoWidget::smartMtrLabelsChanged); + vfo.setRelativeSignalLevel(-60); + QTest::qWait(60); + meter->grab(); + QVERIFY(!invalidations.isEmpty()); + QCOMPARE(meter->extremeLabels().size(), 1); + QVERIFY(meter->extremeLabels().front().primary.contains(QStringLiteral("dBFS"))); + + // The immediately following disappearance must erase the overlay even + // when the preceding paint has just consumed the normal throttle slot. + invalidations.clear(); + vfo.setRelativeSignalLevel(std::nullopt); + meter->grab(); + QCOMPARE(invalidations.size(), 1); + QVERIFY(meter->extremeLabels().isEmpty()); + invalidations.clear(); + QTest::qWait(60); + meter->grab(); + QVERIFY(invalidations.isEmpty()); // no repeated clear redraws + + vfo.setRelativeSignalLevel(-40); + QTest::qWait(60); + meter->grab(); + QVERIFY(!invalidations.isEmpty()); + QCOMPARE(meter->extremeLabels().size(), 1); + invalidations.clear(); + vfo.setSignalLevel(-73); + QTest::qWait(60); + meter->grab(); + QVERIFY(!invalidations.isEmpty()); + QVERIFY(meter->extremeLabels().front().secondary.contains(QStringLiteral("dBm"))); + } + + void unavailableRelativeMetersStayQuietAndRecover() + { + PaintCountingVfo vfo; + vfo.show(); + SmartMtrWidget* meter = vfo.findChild(); + QVERIFY(meter); + vfo.setRelativeSignalLevel(-20); + QTest::qWait(100); + const QImage live = meter->grab().toImage(); + vfo.setRelativeSignalLevel(std::nullopt, QStringLiteral("Receive meters disabled")); + QTest::qWait(60); + const QImage off = meter->grab().toImage(); + QVERIFY(live != off); + QCOMPARE(meter->accessibleDescription(), QStringLiteral("Receive meters disabled")); + QVERIFY(meter->extremeLabels().isEmpty()); + QTest::qWait(60); + vfo.paints = 0; + for (int i = 0; i < 4; ++i) { + vfo.setRelativeSignalLevel(std::nullopt, QStringLiteral("Receive meters disabled")); + QTest::qWait(50); + } + QCOMPARE(vfo.paints, 0); + vfo.setRelativeSignalLevel(-40); + QTest::qWait(100); + QVERIFY(meter->grab().toImage() != off); + QVERIFY(meter->accessibleDescription().isEmpty()); + } + + void inactiveStandardMeterDoesNotAnimateParent() + { + PaintCountingVfo vfo; + vfo.show(); + SmartMtrWidget* meter = vfo.findChild(); + QVERIFY(meter); + // Isolate parent repaints from the visible child's legitimate animation. + meter->hide(); + vfo.setRelativeSignalLevel(-10); + QTest::qWait(300); + vfo.setRelativeSignalLevel(-110); + QTest::qWait(60); + vfo.paints = 0; + QTest::qWait(200); + QCOMPARE(vfo.paints, 0); + + MeterViewController::instance().setSmartMtr(false); + QTest::qWait(60); + vfo.setRelativeSignalLevel(-10); + QTest::qWait(60); + vfo.paints = 0; + vfo.setRelativeSignalLevel(-110); + QTest::qWait(200); + QVERIFY(vfo.paints > 1); // the selected standard meter still animates + + // Switching away must stop an already-running standard animation too. + MeterViewController::instance().setSmartMtr(true); + meter->hide(); + QTest::qWait(60); + vfo.paints = 0; + QTest::qWait(200); + QCOMPARE(vfo.paints, 0); + } +}; + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("vfo-meter-overlay-invalidation")); + if (!profile.isValid()) { + return 1; + } + QApplication app(argc, argv); + AppSettings::instance().load(); + VfoMeterOverlayInvalidationTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "vfo_meter_overlay_invalidation_test.moc" diff --git a/tests/wfm_controls_test.cpp b/tests/wfm_controls_test.cpp index b95f6d9070..bc1a56a1d6 100644 --- a/tests/wfm_controls_test.cpp +++ b/tests/wfm_controls_test.cpp @@ -14,6 +14,7 @@ #include "models/SliceModel.h" #include +#include #include #include #include @@ -444,6 +445,203 @@ private slots: QVERIFY(!rx.findChild(QStringLiteral("broadcastFmControls"))); } + void receiverIdentityAndRecipesFollowSelectionWithoutCommands() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + SliceDelta first = initialWfm(); + first.letter = QStringLiteral("A"); + first.wfmForceMono = true; + emit source->sliceChanged(3, first); + SliceDelta second = initialWfm(); + second.letter = QStringLiteral("B"); + second.frequency = 100.9; + second.wfmDeemphasisUs = 50; + second.filterLow = -70000; + second.filterHigh = 80000; + emit source->sliceChanged(4, second); + WfmApplet applet; + applet.setRadioModel(&model); + applet.setSlice(model.slice(3)); + auto* identity = applet.findChild(QStringLiteral("wfmReceiverIdentity")); + auto* deemphasis = applet.findChild(QStringLiteral("wfmDeemphasis")); + auto* bandwidth = applet.findChild(QStringLiteral("wfmBandwidth")); + auto* cycle = applet.findChild(QStringLiteral("wfmAudioMode")); + auto* status = applet.findChild(QStringLiteral("wfmStereoStatus")); + QVERIFY(identity && deemphasis && bandwidth && cycle && status); + QCOMPARE(identity->text(), QStringLiteral("Slice A · 100.300000 MHz")); + QCOMPARE(identity->textFormat(), Qt::PlainText); + QVERIFY(identity->accessibleName().contains(identity->text())); + for (int pass = 0; pass < 20; ++pass) { + applet.setSlice(model.slice(4)); + QCOMPARE(identity->text(), QStringLiteral("Slice B · 100.900000 MHz")); + QCOMPARE(deemphasis->currentData().toInt(), 50); + QCOMPARE(bandwidth->currentText(), QStringLiteral("150 kHz (custom)")); + QCOMPARE(cycle->text(), QStringLiteral("Auto Stereo")); + applet.setSlice(model.slice(3)); + QCOMPARE(identity->text(), QStringLiteral("Slice A · 100.300000 MHz")); + QCOMPARE(deemphasis->currentData().toInt(), 75); + QCOMPARE(bandwidth->currentData().toInt(), 180000); + QCOMPARE(cycle->text(), QStringLiteral("Mono")); + } + QVERIFY(source->requests.isEmpty()); + QVERIFY(source->monoRequests.isEmpty()); + QVERIFY(source->monoRequests.isEmpty()); + QTest::keyClick(deemphasis, Qt::Key_Up); + QCOMPARE(source->requests.size(), 1); + QCOMPARE(source->requests.last(), qMakePair(3, 50)); + QCOMPARE(model.slice(3)->wfmDeemphasisUs(), 75); + QCOMPARE(model.slice(4)->wfmDeemphasisUs(), 50); + SliceDelta parked; + parked.inCapture = false; + emit source->sliceChanged(3, parked); + QCOMPARE(identity->text(), QStringLiteral("Slice A · 100.300000 MHz")); + QCOMPARE(cycle->text(), QStringLiteral("Mono")); + QCOMPARE(deemphasis->currentData().toInt(), 75); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + source->connected = false; + emit model.connectionStateChanged(false); + QCOMPARE(identity->text(), QStringLiteral("No selected WFM receiver")); + source->connected = true; + emit model.connectionStateChanged(true); + QCOMPARE(identity->text(), QStringLiteral("Slice A · 100.300000 MHz")); + model.slice(3)->invalidateFrequencyObservation(); + QCOMPARE(identity->text(), QStringLiteral("Slice A · Frequency unavailable")); + SliceDelta restored; + restored.frequency = 100.3; // same-value report must refresh identity too + restored.letter = QStringLiteral("Local receiver"); + emit source->sliceChanged(3, restored); + QCOMPARE(identity->text(), QStringLiteral("Slice Local receiver · 100.300000 MHz")); + emit source->sliceRemoved(3); + QCOMPARE(identity->text(), QStringLiteral("No selected WFM receiver")); + emit source->sliceChanged(3, first); + QCOMPARE(identity->text(), QStringLiteral("No selected WFM receiver")); + } + + void popupAndHeldButtonCannotTargetANewSelectionOrReusedId() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + SliceDelta analog = initialWfm(); + emit source->sliceChanged(3, analog); + emit source->sliceChanged(4, analog); + WfmApplet applet; + applet.setRadioModel(&model); + applet.setSlice(model.slice(3)); + applet.show(); + QSignalSpy firstFilter(model.slice(3), &SliceModel::filterCommandIssued); + QSignalSpy secondFilter(model.slice(4), &SliceModel::filterCommandIssued); + for (const QString& name : {QStringLiteral("wfmDeemphasis"), QStringLiteral("wfmBandwidth")}) { + auto* combo = applet.findChild(name); + QVERIFY(combo && combo->isEnabled()); + applet.setSlice(model.slice(3)); + combo->showPopup(); + QVERIFY(combo->view()->isVisible()); + const int staleIndex = combo->currentIndex() == 0 ? 1 : 0; + applet.setSlice(model.slice(4)); + QVERIFY(!combo->view()->isVisible()); + // Qt's activation can follow popup closure; carry the opening + // identity through that interval, including identical menu rows. + const int acceptedIndex = combo->currentIndex(); + combo->setCurrentIndex(staleIndex); + QVERIFY(QMetaObject::invokeMethod(combo, "activated", Q_ARG(int, staleIndex))); + QCOMPARE(combo->currentIndex(), acceptedIndex); + } + QVERIFY(source->requests.isEmpty()); + QVERIFY(firstFilter.isEmpty()); + QVERIFY(secondFilter.isEmpty()); + auto* cycle = applet.findChild(QStringLiteral("wfmAudioMode")); + QVERIFY(cycle); + applet.setSlice(model.slice(3)); + QTest::mousePress(cycle, Qt::LeftButton); + applet.setSlice(model.slice(4)); + QTest::mouseRelease(cycle, Qt::LeftButton); + QVERIFY(source->monoRequests.isEmpty()); + cycle->click(); + QCOMPARE(source->monoRequests.size(), 1); + QCOMPARE(source->monoRequests.last().first, 4); + auto* deemphasis = applet.findChild(QStringLiteral("wfmDeemphasis")); + applet.setSlice(model.slice(3)); + deemphasis->showPopup(); + std::thread removalNotice([&model] { emit model.sliceRemoved(3); }); + removalNotice.join(); + emit source->sliceRemoved(3); + emit source->sliceChanged(3, analog); + applet.setSlice(model.slice(3)); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + QVERIFY(applet.isAvailable()); + QVERIFY(QMetaObject::invokeMethod(deemphasis, "activated", Q_ARG(int, 0))); + QVERIFY(source->requests.isEmpty()); + QTest::keyClick(deemphasis, Qt::Key_Up); + QCOMPARE(source->requests.size(), 1); + QCOMPARE(source->requests.last(), qMakePair(3, 50)); + } + + void queuedOldSliceCallbacksCannotClearOrAppendNewScopeHistory() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + emit source->sliceChanged(4, initialWfm()); + SliceModel* oldSlice = model.slice(3); + WfmApplet applet; + applet.setRadioModel(&model); + applet.setSlice(oldSlice); + applet.findChild(QStringLiteral("wfmShowLockScope"))->setChecked(true); + applet.show(); + auto* scope = applet.findChild(); + QVERIFY(scope && scope->isVisible()); + std::thread queued([oldSlice] { + emit oldSlice->frequencyChanged(100.7); + emit oldSlice->wfmForceMonoChanged(true); + emit oldSlice->wfmReceptionDiagnosticsChanged(measuredPilot()); + }); + queued.join(); + applet.setSlice(model.slice(4)); + SliceDelta measured; + measured.wfmStereoStatus = WfmStereoStatus::Stereo; + measured.wfmReceptionDiagnostics = measuredPilot(true); + emit source->sliceChanged(4, measured); + QCOMPARE(scope->sampleCount(), 1); + ++measured.wfmReceptionDiagnostics->observationSequence; + emit source->sliceChanged(4, measured); + QCOMPARE(scope->sampleCount(), 2); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + QCOMPARE(scope->sampleCount(), 2); + auto* status = applet.findChild(QStringLiteral("wfmStereoStatus")); + QCOMPARE(status->text(), QStringLiteral("Stereo")); + + // Returning to the same pointer must not revive the previous binding. + applet.setSlice(oldSlice); + std::thread returning([oldSlice] { emit oldSlice->frequencyChanged(100.9); }); + returning.join(); + applet.setSlice(model.slice(4)); + applet.setSlice(oldSlice); + emit source->sliceChanged(3, measured); + QCOMPARE(scope->sampleCount(), 1); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + QCOMPARE(scope->sampleCount(), 1); + } + + void queuedPreviousModelStatusCannotRepaintCurrentBinding() + { + RadioModel first; + attachBackend(first); + RadioModel second; + attachBackend(second); + WfmApplet applet; + applet.setRadioModel(&first); + applet.setSlice(first.slice(3)); + std::thread queued([&first] { emit first.capabilitiesChanged(false, RadioCapabilities{}); }); + queued.join(); + applet.setRadioModel(&second); + applet.setSlice(second.slice(3)); + QVERIFY(applet.isAvailable()); + QCoreApplication::sendPostedEvents(nullptr, QEvent::MetaCall); + QVERIFY(applet.isAvailable()); + auto* identity = applet.findChild(QStringLiteral("wfmReceiverIdentity")); + QCOMPARE(identity->text(), QStringLiteral("Slice D · 100.300000 MHz")); + } + void audioCycleWaitsForAcceptanceAndPreservesObservedPilot() { RadioModel model; diff --git a/tools/check_rtl_package.py b/tools/check_rtl_package.py new file mode 100644 index 0000000000..a91a22f2ef --- /dev/null +++ b/tools/check_rtl_package.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Assert that a staged RTL application actually links its shipped dependencies.""" +import argparse +from pathlib import Path +import re +import subprocess + +p = argparse.ArgumentParser(description=__doc__) +p.add_argument("root", type=Path) +p.add_argument("--platform", choices=("macos", "linux", "windows"), required=True) +a = p.parse_args() +root = a.root.resolve() +patterns = {"macos": ("librtlsdr*.dylib", "libusb-1.0*.dylib", "libfftw3f*.dylib"), + "linux": ("librtlsdr.so*", "libusb-1.0.so*", "libfftw3f.so*"), + "windows": ("rtlsdr.dll", "libusb-1.0.dll", "libfftw3f-3.dll")}[a.platform] +libs = [] +for pattern in patterns: + found = sorted(x for x in root.rglob(pattern) if x.is_file()) + if not found: + raise SystemExit(f"Missing RTL package dependency: {pattern}") + libs.append(found[0]) +app_name = "AetherSDR.exe" if a.platform == "windows" else "AetherSDR" +apps = [x for x in root.rglob("*") if x.is_file() and x.name in ({"AetherSDR", "aethersdr"} if a.platform == "linux" else {app_name})] +if len(apps) != 1: + raise SystemExit(f"Expected one {app_name}, got {apps}") +def imports(path): + command = {"macos": ["otool", "-L"], "linux": ["readelf", "-d"], + "windows": ["dumpbin", "/nologo", "/dependents"]}[a.platform] + return subprocess.check_output(command + [str(path)], text=True).lower() +app_imports = imports(apps[0]) +for token in ("rtlsdr", "fftw3f"): + if token not in app_imports: + raise SystemExit(f"Application does not link {token}") +if "libusb" not in imports(libs[0]): + raise SystemExit("RTL driver does not link libusb") +if a.platform == "macos": + executable_dir = apps[0].parent + def rpaths(path): + data = subprocess.check_output(["otool", "-l", str(path)], text=True) + return re.findall(r"cmd LC_RPATH\s+cmdsize \d+\s+path (.*?) \(offset", data) + def expand(value, loader): + return Path(value.replace("@loader_path", str(loader.parent)).replace("@executable_path", str(executable_dir))) + main_rpaths = [expand(value, apps[0]) for value in rpaths(apps[0])] + magic = (b"\xfe\xed\xfa\xce", b"\xce\xfa\xed\xfe", b"\xfe\xed\xfa\xcf", b"\xcf\xfa\xed\xfe", b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca") + for path in root.rglob("*"): + if not path.is_file() or path.is_symlink(): + continue + with path.open("rb") as handle: + if handle.read(4) not in magic: + continue + search = [expand(value, path) for value in rpaths(path)] + main_rpaths + ids = subprocess.check_output(["otool", "-D", str(path)], text=True).splitlines()[1:] + own_id = ids[0].strip() if ids else None + for line in subprocess.check_output(["otool", "-L", str(path)], text=True).splitlines()[1:]: + name = line.strip().split(" (", 1)[0] + if name == own_id or name.startswith(("/usr/lib/", "/System/Library/")): + continue + candidates = [base / name[7:] for base in search] if name.startswith("@rpath/") else [expand(name, path)] + if not any(x.exists() and x.resolve().is_relative_to(root) for x in candidates): + raise SystemExit(f"Unreachable runtime dependency: {path}: {name}") +if a.platform == "linux": + # AppRun supplies usr/lib. Resolve the actual application/driver imports + # through that directory; a copy hidden elsewhere is not sufficient. + libdir = root / "usr/lib" + for binary in (apps[0], libs[0]): + needed = re.findall(r"Shared library: \[(.*?)\]", subprocess.check_output(["readelf", "-d", str(binary)], text=True)) + for name in needed: + if any(token in name for token in ("rtlsdr", "fftw3f", "libusb")) and not (libdir / name).is_file(): + raise SystemExit(f"Unreachable RTL dependency: {binary}: {name}") +if a.platform == "windows": + for path in libs: + if path.parent != apps[0].parent: + raise SystemExit(f"RTL runtime is not beside the executable: {path}") +print("RTL-PACKAGE-OK:", root) diff --git a/tools/test_require_rtl.py b/tools/test_require_rtl.py new file mode 100644 index 0000000000..4337a92585 --- /dev/null +++ b/tools/test_require_rtl.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Exercise release fail-closed versus deliberate optional developer builds.""" +import itertools +from pathlib import Path +import subprocess +import tempfile +module = Path(__file__).resolve().parents[1] / "cmake/AetherRequireRtl.cmake" +with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "probe.cmake" + script.write_text(f'include("{module.as_posix()}")\naether_require_rtl()\n') + for required, enabled, rtl, fftw in itertools.product((False, True), repeat=4): + values = dict(REQUIRE_RTL=required, ENABLE_RTL=enabled, RTLSDR_FOUND=rtl, RTL_FFTW3F_FOUND=fftw) + args = ["cmake"] + [f"-D{k}={'ON' if v else 'OFF'}" for k, v in values.items()] + result = subprocess.run(args + ["-P", str(script)], capture_output=True, text=True) + expected = not required or (enabled and rtl and fftw) + assert (result.returncode == 0) == expected, (values, result.stdout, result.stderr) +print("16 RTL requirement combinations passed")