From 1398f3b9f2cdcc351924545554bb0b0f0fae25b7 Mon Sep 17 00:00:00 2001 From: Ozy311 Date: Mon, 5 Oct 2026 18:37:09 -0700 Subject: [PATCH 1/2] Replay WFM DSP on current main. Principle X. --- src/core/dsp/WdspChannel.cpp | 110 ++++++- src/core/dsp/WdspChannel.h | 21 ++ tests/tests.cmake | 5 + tests/wdsp_wbfm_test.cpp | 409 +++++++++++++++++++++++++ third_party/wdsp/AETHERSDR-PATCHES.md | 103 ++++++- third_party/wdsp/README.md | 12 +- third_party/wdsp/include/aether_wdsp.h | 22 ++ third_party/wdsp/upstream/wbfm.c | 105 ++++++- third_party/wdsp/upstream/wbfm.h | 8 + 9 files changed, 772 insertions(+), 23 deletions(-) create mode 100644 tests/wdsp_wbfm_test.cpp diff --git a/src/core/dsp/WdspChannel.cpp b/src/core/dsp/WdspChannel.cpp index 98a91bc34f..bc1bc94a97 100644 --- a/src/core/dsp/WdspChannel.cpp +++ b/src/core/dsp/WdspChannel.cpp @@ -348,6 +348,31 @@ void WdspChannel::Reservation::release() noexcept } } +struct WdspChannel::WbfmState +{ + explicit WbfmState(const Config& config) + : input(2 * config.inputBlockSize), output(4 * config.inputBlockSize), + i(config.inputBlockSize), q(config.inputBlockSize), gain(config.wbfmReceive->outputGain), + filter(create_bps(1, 0, static_cast(config.inputBlockSize), input.data(), + output.data(), -config.filterHighHz, -config.filterLowHz, + config.inputSampleRate, 0, 1.0), destroy_bps) + { + // WDSP fir_bandpass uses the negative complex-frequency convention. + // Negate/swap edges so this prefilter follows mathematical RTL IQ. + // 2048-frame blocks give 2049 BH4 taps and a 4096-point overlap-save + // FFT. Unlike fircore, xbps has no control-update mutex on this path. + // PATIENT planning can overwrite its scratch input; begin with a + // defined overlap history after the plans have been constructed. + flush_bps(filter.get()); + } + std::vector input; + std::vector output; + std::vector i; + std::vector q; + double gain; + std::unique_ptr<_bps, decltype(&destroy_bps)> filter; +}; + WdspChannel::WdspChannel(int channelId, const Config& config) noexcept : m_channelId(channelId) , m_config(config) @@ -437,6 +462,20 @@ WdspChannel::ProcessResult WdspChannel::processIq(std::span inputI, } } + if (m_wbfm) { + for (std::size_t sample = 0; sample < inputI.size(); ++sample) { + m_wbfm->input[2 * sample] = channelI[sample]; + m_wbfm->input[2 * sample + 1] = channelQ[sample]; + } + xbps(m_wbfm->filter.get(), 0); + for (std::size_t sample = 0; sample < inputI.size(); ++sample) { + m_wbfm->i[sample] = static_cast(m_wbfm->output[2 * sample]); + m_wbfm->q[sample] = static_cast(m_wbfm->output[2 * sample + 1]); + } + channelI = m_wbfm->i.data(); + channelQ = m_wbfm->q.data(); + } + if (!m_running.load(std::memory_order_relaxed)) { // Once the down-slew finishes, fexchange2 neither writes nor zeroes the // output, so making "stopped" mean silence is our job. (WDSP's downslew2 @@ -450,6 +489,17 @@ WdspChannel::ProcessResult WdspChannel::processIq(std::span inputI, const_cast(channelI), const_cast(channelQ), outputLeft.data(), outputRight.data(), &wdspError); + if (m_wbfm) { + if (wdspError == 0) { + for (std::size_t sample = 0; sample < outputLeft.size(); ++sample) { + outputLeft[sample] *= static_cast(m_wbfm->gain); + outputRight[sample] *= static_cast(m_wbfm->gain); + } + } + const bool stereo = wdspError == 0 && m_running.load(std::memory_order_relaxed) + && GetRXAWBFMStereoIndicator(m_channelId) != 0; + m_wbfmStereo.store(stereo ? 1 : 0, std::memory_order_relaxed); + } const uint64_t allocationsAfter = wdspPortThreadAllocationSequence(); m_callbacksInFlight.fetch_sub(1, std::memory_order_seq_cst); @@ -496,6 +546,10 @@ bool WdspChannel::setRunning(bool running) noexcept // build; patch 8's Sleep(1) wait can't fire there because pre_main_build // clears flushflag before OpenChannel starts the channel (channel.c), an // invariant of vendored code. + if (m_wbfm) { + if (running) { flush_bps(m_wbfm->filter.get()); } + m_wbfmStereo.store(0, std::memory_order_relaxed); + } SetChannelState(m_channelId, running ? 1 : 0, 0); m_running.store(running, std::memory_order_relaxed); endControlOperation(); @@ -532,7 +586,8 @@ bool WdspChannel::setMode(Mode mode) noexcept { // An opt-in FM channel changes family through a complete reconfiguration; // otherwise its panel normalization would leak into another demodulator. - if (m_config.fmReceive && mode != Mode::Fm) { return false; } + if ((m_config.fmReceive && mode != Mode::Fm) + || (m_config.wbfmReceive && mode != Mode::Wbfm)) { return false; } if (!beginControlOperation()) { return false; } @@ -556,12 +611,16 @@ bool WdspChannel::setMode(Mode mode) noexcept bool WdspChannel::setFilter(double lowHz, double highHz) noexcept { if (!std::isfinite(lowHz) || !std::isfinite(highHz) || lowHz >= highHz || - !beginControlOperation()) { + (m_config.wbfmReceive && (lowHz < -0.45 * m_config.inputSampleRate + || highHz > 0.45 * m_config.inputSampleRate)) || !beginControlOperation()) { return false; } { const std::scoped_lock setupLock(g_setupMutex); - if (m_config.direction == Direction::Receive) { + if (m_wbfm) { + setFreqs_bps(m_wbfm->filter.get(), -highHz, -lowHz); + flush_bps(m_wbfm->filter.get()); + } else if (m_config.direction == Direction::Receive) { // RXASetPassband, not SetRXABandpassFreqs: the latter sets only the // bandpass and leaves the NBP stage — the filter actually in // circuit — untouched, so nothing selects a sideband. Both @@ -578,6 +637,24 @@ bool WdspChannel::setFilter(double lowHz, double highHz) noexcept return true; } +bool WdspChannel::setWbfmDeemphasis(WbfmReceive::Deemphasis deemphasis) noexcept +{ + if (!m_config.wbfmReceive + || (deemphasis != WbfmReceive::Deemphasis::Us75 && deemphasis != WbfmReceive::Deemphasis::Us50) + || !beginControlOperation()) { return false; } + SetRXAWBFMdmph(m_channelId, 1, deemphasis == WbfmReceive::Deemphasis::Us50 ? 1 : 0); + m_config.wbfmReceive->deemphasis = deemphasis; + endControlOperation(); + return true; +} + +std::optional WdspChannel::wbfmStereoDetected() const noexcept +{ + const int observed = m_wbfmStereo.load(std::memory_order_relaxed); + if (observed < 0) { return std::nullopt; } + return observed != 0; +} + bool WdspChannel::setFmDeviation(double deviationHz) noexcept { // Out of range is refused rather than clamped, and RANGE is the word: @@ -1099,6 +1176,19 @@ bool WdspChannel::validateConfig(const Config& config, std::string* error) noexc setError(error, "WDSP receive FM deviation is invalid for this channel"); return false; } + if (config.wbfmReceive && (config.fmReceive || config.direction != Direction::Receive + || config.mode != Mode::Wbfm || config.inputSampleRate != 384000 + || config.dspSampleRate != 192000 || config.outputSampleRate != 48000 + || config.inputBlockSize != 2048 || config.dspBlockSize != 1024 + || config.filterLowHz < -0.45 * config.inputSampleRate + || config.filterHighHz > 0.45 * config.inputSampleRate + || !std::isfinite(config.wbfmReceive->outputGain) + || config.wbfmReceive->outputGain < 0.0 || config.wbfmReceive->outputGain > 1.0 + || (config.wbfmReceive->deemphasis != WbfmReceive::Deemphasis::Us75 + && config.wbfmReceive->deemphasis != WbfmReceive::Deemphasis::Us50))) { + setError(error, "Invalid WDSP broadcast receive profile"); + return false; + } if (config.direction == Direction::Transmit && config.mode == Mode::Wbfm) { setError(error, "WDSP TX does not define a WBFM mode"); return false; @@ -1267,8 +1357,16 @@ void WdspChannel::open() noexcept SetRXAFMLimGain(m_channelId, 0.0); SetRXAFMLimRun(m_channelId, 1); } - SetRXABandpassFreqs(m_channelId, m_config.filterLowHz, m_config.filterHighHz); - RXANBPSetFreqs(m_channelId, m_config.filterLowHz, m_config.filterHighHz); + if (m_config.wbfmReceive) { + m_wbfm = std::make_unique(m_config); + SetRXAWBFMDiscriminatorCompensation(m_channelId, 1); + SetRXAWBFMdmph(m_channelId, 1, + m_config.wbfmReceive->deemphasis == WbfmReceive::Deemphasis::Us50 ? 1 : 0); + m_wbfmStereo.store(0, std::memory_order_relaxed); + } else { + SetRXABandpassFreqs(m_channelId, m_config.filterLowHz, m_config.filterHighHz); + RXANBPSetFreqs(m_channelId, m_config.filterLowHz, m_config.filterHighHz); + } applyRxAgc(m_channelId, m_config.agcMode, m_config.maximumAgcGainDb, m_config.agcSlopeDb, m_config.agcFixedGainDb); // Filter length / phase mode. RXASetNC internally stops and restarts @@ -1338,6 +1436,8 @@ void WdspChannel::close() noexcept // a callback can be inside processIq(), and destroying the stage under // one frees the delay line out from under xanb(). closeNoiseBlanker(); + m_wbfm.reset(); + m_wbfmStereo.store(-1, std::memory_order_relaxed); } m_open = false; } diff --git a/src/core/dsp/WdspChannel.h b/src/core/dsp/WdspChannel.h index ef7e58f298..67ee24b360 100644 --- a/src/core/dsp/WdspChannel.h +++ b/src/core/dsp/WdspChannel.h @@ -50,6 +50,18 @@ class WdspChannel final bool operator==(const FmReceive&) const = default; }; + // Broadcast receive recipe from WDSP Guide section 5.3.10. This opt-in + // owns an RF FIR before WDSP's 384 -> 192 kHz conversion and normalizes + // paired audio before any receiver tap. Other channel owners are unchanged. + struct WbfmReceive + { + enum class Deemphasis { Us75, Us50 }; + Deemphasis deemphasis = Deemphasis::Us75; + double outputGain = 0.25; + static constexpr double kRfTransitionGuardHz = 3000.0; + bool operator==(const WbfmReceive&) const = default; + }; + struct Config { Direction direction = Direction::Receive; @@ -60,6 +72,7 @@ class WdspChannel final int outputSampleRate = 48000; Mode mode = Mode::Usb; std::optional fmReceive; + std::optional wbfmReceive; double filterLowHz = 150.0; double filterHighHz = 3000.0; int agcMode = 3; @@ -247,6 +260,11 @@ class WdspChannel final bool reconfigure(const Config& config, std::string* error = nullptr) noexcept; bool setMode(Mode mode) noexcept; bool setFilter(double lowHz, double highHz) noexcept; + bool setWbfmDeemphasis(WbfmReceive::Deemphasis deemphasis) noexcept; + // Lock-free latest completed decoder observation, sampled by processIq(). + // It is not an exact PCM-ring timestamp. False after stop/open; absent for + // channels that do not own the broadcast recipe. Caller owns the lifetime. + [[nodiscard]] std::optional wbfmStereoDetected() const noexcept; // Runtime RX AGC change. agcMode is the WDSP RXA AGC mode (0 off, 1 long, // 2 slow, 3 medium, 4 fast); maximumGainDb is the AGC "top", the ceiling on // how much gain the AGC may apply. Receive channels only — returns false on @@ -460,6 +478,9 @@ class WdspChannel final bool beginControlOperation() noexcept; void endControlOperation() noexcept; + struct WbfmState; + std::unique_ptr m_wbfm; + std::atomic m_wbfmStereo {-1}; int m_channelId = -1; Config m_config; // Fixed for a given Config; cached at open()/reconfigure() so the real-time diff --git a/tests/tests.cmake b/tests/tests.cmake index 9d85af5a78..424e13384f 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -960,6 +960,11 @@ endif() # Standalone DSP smoke tests. Built alongside the main target so they share # the same toolchain and warning flags. Run manually with ./build/. +add_executable(wdsp_wbfm_test tests/wdsp_wbfm_test.cpp) +target_link_libraries(wdsp_wbfm_test PRIVATE aethercore) +add_test(NAME wdsp_wbfm_test COMMAND wdsp_wbfm_test) +set_tests_properties(wdsp_wbfm_test PROPERTIES TIMEOUT 120) + add_executable(wdsp_channel_test tests/wdsp_channel_test.cpp) target_link_libraries(wdsp_channel_test PRIVATE aethercore) add_test(NAME wdsp_channel_test COMMAND wdsp_channel_test) diff --git a/tests/wdsp_wbfm_test.cpp b/tests/wdsp_wbfm_test.cpp new file mode 100644 index 0000000000..06d6557db2 --- /dev/null +++ b/tests/wdsp_wbfm_test.cpp @@ -0,0 +1,409 @@ +#include "core/dsp/WdspChannel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int kInputRate = 384000; +constexpr int kOutputRate = 48000; +constexpr std::size_t kInputBlock = 2048; +constexpr double kTwoPi = 2.0 * std::numbers::pi; + +bool require(bool condition, const char* message) +{ + if (!condition) { std::cerr << "FAIL: " << message << '\n'; } + return condition; +} + +struct Measurement { + std::array, 3> leftTones{}; + std::array, 3> rightTones{}; + std::array frequencies{1000.0, 2000.0, 15000.0}; + double peak = 0.0; + double differenceEnergy = 0.0; + std::size_t frames = 0; + std::size_t clipped = 0; + bool finite = true; + void add(float left, float right, double time) + { + finite = finite && std::isfinite(left) && std::isfinite(right); + peak = std::max({peak, std::abs(static_cast(left)), std::abs(static_cast(right))}); + clipped += std::abs(left) >= 1.0f || std::abs(right) >= 1.0f; + differenceEnergy += (left - right) * (left - right); + for (std::size_t tone = 0; tone < frequencies.size(); ++tone) { + const std::complex oscillator = std::polar(1.0, -kTwoPi * frequencies[tone] * time); + leftTones[tone] += static_cast(left) * oscillator; + rightTones[tone] += static_cast(right) * oscillator; + } + ++frames; + } + double left(std::size_t tone) const { return 2.0 * std::abs(leftTones[tone]) / frames; } + double right(std::size_t tone) const { return 2.0 * std::abs(rightTones[tone]) / frames; } +}; + +WdspChannel::Config config(double low = -100000.0, double high = 100000.0) +{ + WdspChannel::Config value; + value.inputSampleRate = kInputRate; + value.dspSampleRate = 192000; + value.outputSampleRate = kOutputRate; + value.inputBlockSize = kInputBlock; + value.dspBlockSize = 1024; + value.mode = WdspChannel::Mode::Wbfm; + value.wbfmReceive = WdspChannel::WbfmReceive{}; + value.filterLowHz = low; + value.filterHighHz = high; + value.agcMode = 0; + value.agcFixedGainDb = 0; + value.blockForOutput = true; + return value; +} + +enum class Vector { Mono, Adjacent, Stereo, StereoLowAudio, StereoHighAudio, StereoHighModulation, Bandwidth, Carrier, Noise }; + +Measurement run(WdspChannel& channel, Vector vector, double duration = 4.0, + double measurementStart = 2.0, double carrierHz = 0.0, double amplitude = 0.1, + double clockRatio = 1.0, double pilotHz = 19000.0, double noiseAmplitude = 0.0) +{ + std::vector inputI(kInputBlock), inputQ(kInputBlock); + std::vector left(channel.outputBlockSize()), right(channel.outputBlockSize()); + Measurement result; + if (vector == Vector::StereoLowAudio) { result.frequencies = {300.0, 500.0, 1000.0}; } + if (vector == Vector::StereoHighAudio) { result.frequencies = {14000.0, 15000.0, 1000.0}; } + for (double& frequency : result.frequencies) { frequency *= clockRatio; } + const double physicalPilotHz = pilotHz * clockRatio; + const double subcarrierHz = 2.0 * physicalPilotHz; + const std::uint64_t allocations = WdspChannel::allocationSequenceForTest(); + const std::size_t blocks = static_cast(duration * kInputRate / kInputBlock); + for (std::size_t block = 0; block < blocks; ++block) { + for (std::size_t index = 0; index < kInputBlock; ++index) { + const double time = static_cast(block * kInputBlock + index) / kInputRate; + // Integrate the continuous FM waveform analytically. Rectangular + // phase accumulation at the DSP rate cancels the discriminator's + // sinc response and can falsely claim better stereo separation. + const auto sineIntegral = [time](double hz) { + return -std::cos(kTwoPi * hz * time) / hz; + }; + const auto stereoIntegral = [time, subcarrierHz](double hz) { + return 0.5 * (std::sin(kTwoPi * (subcarrierHz - hz) * time) / (subcarrierHz - hz) + - std::sin(kTwoPi * (subcarrierHz + hz) * time) / (subcarrierHz + hz)); + }; + double integrated = vector == Vector::Carrier ? 0.0 : 0.9 * sineIntegral(1000.0 * clockRatio); + if (vector == Vector::Stereo || vector == Vector::StereoLowAudio || vector == Vector::StereoHighAudio + || vector == Vector::StereoHighModulation) { + // ITU-R BS.450-4 section 2.2.2.5: sin(theta) pilot and + // sin(2*theta) suppressed subcarrier, not cosine quadrature. + const double program = vector == Vector::StereoHighModulation ? 0.45 : 0.225; + integrated = program * (sineIntegral(result.frequencies[0]) + sineIntegral(result.frequencies[1]) + + stereoIntegral(result.frequencies[0]) - stereoIntegral(result.frequencies[1])) + + 0.1 * sineIntegral(physicalPilotHz); + } else if (vector == Vector::Bandwidth) { + integrated = 0.08 * (sineIntegral(1000.0 * clockRatio) + sineIntegral(15000.0 * clockRatio)); + } + const double phase = 75000.0 * clockRatio * integrated; + std::complex sample = amplitude * std::polar(1.0, phase); + if (vector == Vector::Adjacent) { + // Desired low-deviation FM at DC, stronger adjacent FM at + // +60 kHz. Narrowing the RF filter must select the desired + // 1 kHz audio despite FM's capture effect. + const double desiredPhase = 5.0 * std::sin(kTwoPi * 1000.0 * time); + const double adjacentPhase = kTwoPi * 60000.0 * time + + 2.5 * std::sin(kTwoPi * 2000.0 * time); + sample = 0.1 * std::polar(1.0, desiredPhase) + + 0.3 * std::polar(1.0, adjacentPhase); + } + if (vector == Vector::Noise || noiseAmplitude > 0.0) { + // Deterministic independent I/Q hash noise: no transport, seed + // state, or platform-dependent random distribution involved. + const auto noise = [](std::uint32_t word) { + word ^= word >> 16; word *= 0x7feb352dU; + word ^= word >> 15; word *= 0x846ca68bU; + word ^= word >> 16; + return static_cast(word) / 2147483648.0 - 1.0; + }; + const std::uint32_t sampleIndex = static_cast(block * kInputBlock + index); + const std::complex disturbance(noise(2 * sampleIndex), noise(2 * sampleIndex + 1)); + if (vector == Vector::Noise) { sample = amplitude * disturbance; } + else { sample += noiseAmplitude * disturbance; } + } + sample *= std::polar(1.0, kTwoPi * carrierHz * time); + inputI[index] = static_cast(sample.real()); + inputQ[index] = static_cast(sample.imag()); + } + if (channel.processIq(inputI, inputQ, left, right) != WdspChannel::ProcessResult::Ok) { + result.finite = false; + } + for (std::size_t index = 0; index < left.size(); ++index) { + const double time = static_cast(block * left.size() + index) / kOutputRate; + if (time >= measurementStart) { result.add(left[index], right[index], time); } + } + } + result.finite = result.finite && WdspChannel::allocationSequenceForTest() == allocations; + return result; +} + +bool filterAndHeadroom() +{ + std::string error; + std::unique_ptr channel = WdspChannel::create(config(), &error); + if (!require(channel != nullptr, error.c_str())) { return false; } + const Measurement wide = run(*channel, Vector::Adjacent); + if (!require(channel->setFilter(-30000.0, 30000.0), "narrow WFM RF filter accepted")) { return false; } + const Measurement narrow = run(*channel, Vector::Adjacent); + const double selectedDb = 20.0 * std::log10(std::max(narrow.left(0), 1.0e-15) + / std::max(narrow.left(1), 1.0e-15)); + std::cout << "RF wide: desired=" << wide.left(0) << " adjacent=" << wide.left(1) + << " narrow: desired=" << narrow.left(0) << " adjacent=" << narrow.left(1) + << " selection_db=" << selectedDb << '\n'; + bool ok = require(wide.finite && narrow.finite, "RF outputs finite and successful"); + ok = require(wide.left(1) > 5 * wide.left(0), "wide filter admits adjacent capture") && ok; + ok = require(selectedDb >= 40.0, "selected WFM RF filter rejects stronger adjacent channel >=40 dB") && ok; + channel.reset(); + channel = WdspChannel::create(config(), &error); + if (!require(channel != nullptr, error.c_str())) { return false; } + const Measurement level = run(*channel, Vector::Mono); + std::cout << "Mono normalized peak=" << level.peak << " clipped_frames=" << level.clipped << '\n'; + ok = require(level.finite && level.peak > 0.1 && level.peak < 0.95 && level.clipped == 0, + "90 percent WFM modulation retains headroom without clipping") && ok; + return ok; +} + +bool deemphasis() +{ + std::string error; + std::unique_ptr channel = WdspChannel::create(config(), &error); + if (!require(channel != nullptr, error.c_str())) { return false; } + const Measurement us75 = run(*channel, Vector::Bandwidth); + if (!require(channel->setWbfmDeemphasis(WdspChannel::WbfmReceive::Deemphasis::Us50), + "50 us control accepted")) { return false; } + const Measurement us50 = run(*channel, Vector::Bandwidth); + const double ratio = us50.left(2) / us75.left(2); + const double bandwidthRatio = us75.left(2) / us75.left(0); + std::cout << "15k amplitude: 75us=" << us75.left(2) << " 50us=" << us50.left(2) + << " ratio=" << ratio << " bandwidth_15k_to_1k=" << bandwidthRatio << '\n'; + // Independent analog response ratio at15 kHz: sqrt((1+(2*pi*f*75us)^2) + // /(1+(2*pi*f*50us)^2)) =1.482; includes tolerance for bilinear warping. + bool ok = require(us75.finite && us50.finite && ratio > 1.43 && ratio < 1.54, + "50us changes actual 15 kHz response from 75us"); + // Nominal 75 us analog deemphasis gives 0.155 at15k relative to1k. + // The broadcast audio FIR may add <=1 dB at15k; a24k producer cannot pass. + ok = require(bandwidthRatio > 0.138 && bandwidthRatio < 0.170, + "broadcast audio retains15k bandwidth with expected deemphasis") && ok; + // Changing one channel must not alter another owner's regional response. + std::unique_ptr other = WdspChannel::create(config(), &error); + if (!require(other != nullptr, error.c_str())) { return false; } + const Measurement isolated75 = run(*other, Vector::Bandwidth); + ok = require(std::abs(isolated75.left(2) / us75.left(2) - 1.0) < 0.01, + "deemphasis remains per-channel") && ok; + return ok; +} +// The ADC meter observes post-prefilter, post384->192k decimator IQ before +// FM removes amplitude. This measures RF insertion/rejection rather than +// mistaking the FM capture effect for a filter response measurement. +bool rfEnvelope() +{ + std::string error; + std::unique_ptr channel = WdspChannel::create(config(-30000, 30000), &error); + if (!require(channel != nullptr, error.c_str())) { return false; } + // The ADC meter averages power over100ms. Two seconds clears previous + // in-band energy below the65dB stopband threshold before readback. + const auto power = [&](double hz) { + const Measurement result = run(*channel, Vector::Carrier, 2.0, 1.8, hz); + if (!result.finite) { return std::numeric_limits::quiet_NaN(); } + return channel->meter(WdspChannel::Meter::AdcAverage); + }; + const double reference = power(0); + bool ok = true; + for (const double hz : {-33000.0, -27000.0, 27000.0, 33000.0}) { + const double relative = power(hz) - reference; + std::cout << "RF 60k filter tone=" << hz << " relative_db=" << relative << '\n'; + ok = require(std::isfinite(relative) && (std::abs(hz) < 30000 + ? std::abs(relative) < 0.25 : relative < -65.0), + "RF filter preserves passband and rejects stopband across3k guard") && ok; + } + if (!require(channel->setFilter(-20000, 40000), "asymmetric RF filter accepted")) { return false; } + const double positive = power(30000) - reference; + const double negative = power(-30000) - reference; + std::cout << "RF asymmetric: +30k=" << positive << " -30k=" << negative << '\n'; + ok = require(std::abs(positive) < 0.25 && negative < -65.0, + "asymmetric RF filter follows mathematical IQ frequency sign") && ok; + if (!require(channel->setFilter(-100000, 100000), "broadcast RF filter accepted")) { return false; } + for (const double hz : {75000.0, 85000.0, 90000.0, 93000.0, 96000.0, 100000.0, 103000.0}) { + const double relative = power(hz) - reference; + std::cout << "RF 200k requested tone=" << hz << " relative_db=" << relative << '\n'; + ok = require(std::isfinite(relative), "overall RF response remains finite") && ok; + if (hz <= 85000.0) { + ok = require(std::abs(relative) < 0.5, + "broadcast RF path retains wanted spectrum through85k") && ok; + } + } + return ok; +} + +bool stereoAndPilot() +{ + std::string error; + std::unique_ptr channel = WdspChannel::create(config(), &error); + if (!require(channel != nullptr, error.c_str())) { return false; } + bool ok = require(channel->outputBlockSize() == 256, + "2048 complex384k input frames produce256 stereo48k frames"); + ok = require(channel->wbfmStereoDetected() == false, "fresh channel has no stereo observation") && ok; + const Measurement stereo = run(*channel, Vector::Stereo, 6.0); + const double leftSeparation = 20.0 * std::log10(stereo.left(0) / stereo.right(0)); + const double rightSeparation = 20.0 * std::log10(stereo.right(1) / stereo.left(1)); + std::cout << "Stereo separation L=" << leftSeparation << " R=" << rightSeparation + << " peak=" << stereo.peak << '\n'; + ok = require(stereo.finite && leftSeparation >= 40 && rightSeparation >= 40, + "reference stereo separates correctly oriented L/R >=40dB") && ok; + ok = require(channel->wbfmStereoDetected() == true, "real pilot acquires stereo indication") && ok; + ok = require(stereo.peak < 0.95 && stereo.clipped == 0, + "stereo reference preserves unclipped headroom") && ok; + for (const Vector vector : {Vector::StereoLowAudio, Vector::StereoHighAudio, Vector::StereoHighModulation}) { + const Measurement stress = run(*channel, vector, 4.0); + const double lDb = 20.0 * std::log10(stress.left(0) / stress.right(0)); + const double rDb = 20.0 * std::log10(stress.right(1) / stress.left(1)); + std::cout << (vector == Vector::StereoLowAudio ? "300/500Hz stereo" + : vector == Vector::StereoHighAudio ? "14/15k stereo" : "97.5 percent stereo") + << " separation L=" << lDb << " R=" << rDb << " peak=" << stress.peak << '\n'; + // RFC reference qualification is >=40 dB. Near-full modulation is + // a headroom/stability stress, with its separation reported explicitly: + // 192 kHz pre-discriminator IQ conversion loses sidebands near96k. + if (vector != Vector::StereoHighModulation) { + ok = require(lDb >= 40 && rDb >= 40, + "low and wide-audio reference stereo separates >=40dB") && ok; + } + ok = require(stress.finite && stress.clipped == 0 && stress.peak < 0.95, + "low/wide-audio/high-modulation stereo remains finite and unclipped") && ok; + } + const Measurement lost = run(*channel, Vector::Mono, 3.0, 2.0); + ok = require(channel->wbfmStereoDetected() == false && lost.finite + && std::sqrt(lost.differenceEnergy / lost.frames) < 1.0e-6, + "pilot loss returns truthful mono with identical L/R") && ok; + // RF amplitude changes must not change valid FM audio gain or stereo + // orientation; these clean vectors are not a weak-noisy-station claim. + const Measurement weak = run(*channel, Vector::Stereo, 5.0, 2.0, 0.0, 0.001); + ok = require(weak.finite && channel->wbfmStereoDetected() == true + && std::abs(weak.left(0) / stereo.left(0) - 1.0) < 0.01 + && std::abs(weak.right(1) / stereo.right(1) - 1.0) < 0.01, + "clean40dB lower RF amplitude retains FM level and stereo") && ok; + const Measurement noise = run(*channel, Vector::Noise, 4.0, 2.0); + std::cout << "No-signal noise peak=" << noise.peak << " pilot=" + << channel->wbfmStereoDetected().value_or(true) << '\n'; + ok = require(noise.finite && channel->wbfmStereoDetected() == false + && noise.clipped == 0 && noise.peak < 0.15, + "internal automatic squelch suppresses no-signal noise without a false pilot") && ok; + ok = require(channel->setRunning(false) && channel->wbfmStereoDetected() == false, + "stop immediately invalidates previous stereo indication") && ok; + if (!require(channel->setRunning(true), "WFM restarts")) { return false; } + const Measurement resumed = run(*channel, Vector::Mono, 3.0, 2.0); + ok = require(resumed.finite && channel->wbfmStereoDetected() == false + && resumed.peak < 0.95 && resumed.clipped == 0, + "restart on mono never retains old station's stereo flag") && ok; + return ok; +} + +bool clockPilotAndNoise() +{ + std::string error; + std::unique_ptr channel = WdspChannel::create(config(), &error); + if (!require(channel != nullptr, error.c_str())) { return false; } + struct Condition { + const char* name; + Vector vector; + double clockRatio; + double pilotHz; + double amplitude; + double noiseAmplitude; + std::optional expectedPilot; + }; + const std::array conditions{{ + {"clock -100ppm", Vector::Stereo, 0.9999, 19000.0, 0.1, 0.0, true}, + {"clock +100ppm", Vector::Stereo, 1.0001, 19000.0, 0.1, 0.0, true}, + {"coherent pilot -2Hz", Vector::Stereo, 1.0, 18998.0, 0.1, 0.0, true}, + {"coherent pilot +2Hz", Vector::Stereo, 1.0, 19002.0, 0.1, 0.0, true}, + {"noisy weak FM", Vector::Stereo, 1.0, 19000.0, 0.001, 0.0001, true}, + {"marginal noisy FM", Vector::Stereo, 1.0, 19000.0, 0.001, 0.0005, std::nullopt}, + {"marginal pilot absent", Vector::Mono, 1.0, 19000.0, 0.001, 0.0005, false} + }}; + bool ok = true; + for (const Condition& condition : conditions) { + const Measurement measured = run(*channel, condition.vector, 5.0, 2.0, 0.0, + condition.amplitude, condition.clockRatio, condition.pilotHz, condition.noiseAmplitude); + const bool observed = channel->wbfmStereoDetected().value_or(false); + std::cout << condition.name << ": pilot=" << observed << " peak=" << measured.peak + << " separation L=" << 20.0 * std::log10(std::max(measured.left(0), 1.0e-15) + / std::max(measured.right(0), 1.0e-15)) + << " R=" << 20.0 * std::log10(std::max(measured.right(1), 1.0e-15) + / std::max(measured.left(1), 1.0e-15)) << '\n'; + ok = require(measured.finite && measured.clipped == 0 && measured.peak < 0.95, + "clock/pilot/marginal conditions remain finite and unclipped") && ok; + if (condition.expectedPilot) { + ok = require(observed == *condition.expectedPilot, + "bounded pilot offsets acquire and absent pilot is never reported stereo") && ok; + } + if (condition.expectedPilot == false) { + ok = require(std::sqrt(measured.differenceEnergy / measured.frames) < 1.0e-6, + "marginal pilot loss produces paired mono") && ok; + } + } + return ok; +} + +bool invalidConfiguration() +{ + using Deemphasis = WdspChannel::WbfmReceive::Deemphasis; + std::string error; + bool ok = true; + const auto rejected = [&](const WdspChannel::Config& candidate) { + return WdspChannel::create(candidate, &error) == nullptr; + }; + WdspChannel::Config candidate = config(); + candidate.outputSampleRate = 24000; + ok = require(rejected(candidate), "broadcast recipe refuses24k audio interim") && ok; + candidate = config(); candidate.dspSampleRate = 384000; + ok = require(rejected(candidate), "broadcast recipe refuses wrong DSP rate") && ok; + candidate = config(); candidate.inputBlockSize = 1024; + ok = require(rejected(candidate), "broadcast recipe refuses unqualified input geometry") && ok; + candidate = config(); candidate.fmReceive = WdspChannel::FmReceive{}; + ok = require(rejected(candidate), "narrow and broadcast recipes are mutually exclusive") && ok; + for (const double gain : {-1.0, 1.01, std::numeric_limits::quiet_NaN()}) { + candidate = config(); candidate.wbfmReceive->outputGain = gain; + ok = require(rejected(candidate), "broadcast output gain bounded and finite") && ok; + } + candidate = config(); candidate.wbfmReceive->deemphasis = static_cast(99); + ok = require(rejected(candidate), "unknown deemphasis refused") && ok; + candidate = config(); candidate.filterHighHz = 190000; + ok = require(rejected(candidate), "RF filter includes input transition allowance") && ok; + std::unique_ptr channel = WdspChannel::create(config(), &error); + if (!require(channel != nullptr, error.c_str())) { return false; } + ok = require(!channel->setMode(WdspChannel::Mode::Fm) + && !channel->setWbfmDeemphasis(static_cast(99)) + && !channel->setFilter(-190000, 100000), + "invalid runtime controls cannot mutate broadcast recipe") && ok; + return ok; +} +} // namespace + +int main() +{ + const std::uint64_t allocations = WdspChannel::outstandingAllocationsForTest(); + const bool filter = filterAndHeadroom(); + const bool response = deemphasis(); + const bool envelope = rfEnvelope(); + const bool stereo = stereoAndPilot(); + const bool conditions = clockPilotAndNoise(); + const bool validation = invalidConfiguration(); + const bool leaks = require(WdspChannel::outstandingAllocationsForTest() == allocations, + "WFM channel teardown frees tracked allocations"); + return filter && response && envelope && stereo && conditions && validation && leaks ? 0 : 1; +} diff --git a/third_party/wdsp/AETHERSDR-PATCHES.md b/third_party/wdsp/AETHERSDR-PATCHES.md index 8daa0682c7..ca67db487c 100644 --- a/third_party/wdsp/AETHERSDR-PATCHES.md +++ b/third_party/wdsp/AETHERSDR-PATCHES.md @@ -2,10 +2,8 @@ The source snapshot is pinned to TAPR/OpenHPSDR-wdsp commit `b02d5bac675dd2f33ec2bab2b339f79a597c47dd` (`Release Version 2.10`). -AetherSDR carries eighteen local changes in the otherwise exact `Source/*.[ch]` -snapshot. The first ten are listed below — four teardown corrections, two -null/lifetime fixes, one added accessor set, two channel-state fixes, and one -performance change — and patches 11 to 18 follow in their own sections: +AetherSDR carries the local changes documented below in the otherwise exact +`Source/*.[ch]` snapshot: 1. `upstream/nbp.c`: `destroy_notchdb()` now frees the `notchdb` object after its member allocations. @@ -1011,3 +1009,100 @@ leaking and avoids adding a second hazard on the timeout path. When updating WDSP, keep this unless upstream's `destroy_calcc()` closes the semaphores itself. + +## Patch 19 — opt-in WFM reception and live decoder controls (RFC #5468) + +`upstream/wbfm.c` and `upstream/wbfm.h`, against the pinned 2.10 revision above. + +`SetRXAWBFMdmph()` previously changed only the parent fields used during +construction. Existing `dmphL` and `dmphR` kept their original run flag, time +constant and coefficients. A generated 1kHz/15kHz vector measured identical +15kHz amplitude after requesting75us then50us (`0.0413053`, ratio1.0) on +Nobara before this patch. The expected analog ratio at15kHz is approximately +1.482. The setter now updates both active filters under the existing DSP +critical section. It resets their history when the setting changes; no +allocation, FIR planning or third-party dependency is introduced. + +The existing stereo-indicator getter acquired the DSP mutex. The RTL sample +path must observe decoder state without waiting on that worker. A separate +interlocked `stereoPublished` value is published after `xwbfm()` completes its +L/R output and read with the port's existing atomic-load idiom. This is a +latest completed decoder-block observation, not an exact timestamp for audio +already buffered in the output ring. The caller still owns channel lifetime. + +`flush_wbfm()` previously cleared the nested pilot detector but retained its +parent stereo/magnitude fields and internal squelch noise/gain histories. It +now resets those histories and publishes no stereo immediately, so a new +station cannot inherit the old station's pilot observation or squelch state. +The decoder's existing automatic-squelch thresholds and50ms gain slew are +unchanged; no new user threshold mapping is added. + +The optional `WdspChannel::WbfmReceive` recipe fixes its geometry at 384 kHz +input (2048 complex frames), 192 kHz DSP (1024 frames), and paired 48 kHz audio +(256 frames). It applies an existing 2049-tap Blackman-Harris overlap-save +`BPS` FIR before WDSP's input conversion. WBFM disables RXA's normal RF +bandpass, so configuring that dormant filter did not provide selectivity. +The facade only exposes the existing BPS functions; vendor filter code is +unchanged. The preallocated FFT output holds twice the input frame count. +Filter setup, changes, and destruction use the existing planner/control fence; +processing adds no acquisition allocation or mutex. Mathematical complex-IQ +edges are negated/swapped to match WDSP's filter convention. A 3 kHz transition +allowance is tested on both sides and on an asymmetric passband. + +`WbfmReceive::outputGain`, default 0.25, scales both output channels before +receiver taps. WBFM bypasses RXA's output panel, so the usual panel-gain API +could not supply this normalization. The pre-fix 90% mono fixture peaked at +3.25825 with 76000 clipped frames; with normalization it peaked at 0.814467 +and no frames reached unity. This is scalar normalization, not a hidden clipper. + +`SetRXAWBFMDiscriminatorCompensation()` defaults off and is called only by +this recipe. Adjacent phase differences average the continuous instantaneous +frequency over one sample, producing sinc(omega/2) droop. At 38 kHz in the +192 kHz decoder, that attenuated L-R enough to give 29.218/29.385 dB separation +on analytically integrated 1/2 kHz stereo. A rectangular phase-sum fixture +at the DSP rate accidentally cancels this error and must not be used as proof. + +The opt-in correction is a 13-tap linear-phase inverse-sinc approximation. +Its coefficients are the polynomial sum for k=0..6 of +`C(2k,k)/(4^k*(2k+1)) * ((2-z-z^-1)/4)^k`. It has unity DC gain, six samples +of common delay, and less than 0.0047 dB residual response error through the +53 kHz multiplex band. Its maximum gain is 1.356417, so it also raises +high-frequency noise by up to 2.65 dB; this is the bounded noise tradeoff, +not additional recovered information. The implementation uses only fixed +history and arithmetic. Before the subsequent DC correction, measured 1/2 kHz +separation increased to 42.23/47.95 dB and 14/15 kHz reached 51.16/47.87 dB. + +The same opt-in flag moves the existing DC blocker after the L/R matrix, +with independent identical histories and the original 0.9995 pole. Leaving it +before the split rotated low-frequency L+R while barely rotating L-R near +38 kHz. The generated 300/500 Hz vector measured 31.8839/36.3027 dB before +this change, matching the calculated phase error. Legacy owners retain the +original MPX placement. Both paired histories reset on calculation, flush, +and recipe selection. + +The 192 kHz pre-discriminator IQ conversion still limits the effective RF +passband. A requested +/-100 kHz FIR does not mean a flat 200 kHz signal path: +the generated carrier response was flat through +/-90 kHz, approximately +-0.195 dB at 93 kHz and -6.02 dB at 96 kHz. Near-full stereo modulation loses +some sidebands beyond that range. Its measured separation is reported by the +test as a limitation; the RFC's >=40 dB requirement remains pinned on the +explicit reference vectors. Moving discrimination before IQ conversion would +address a different, broader change and is not part of this patch. + +The socket-free `wdsp_wbfm_test` exercises RF selection and envelope, normalized +headroom, live regional response, reference separation at low/mid/high audio +frequencies, pilot acquisition/loss, mono fallback, restart, per-channel +isolation, and allocation-free processing. Additional diagnostic vectors cover +near-full modulation, +/-100 ppm signal clock, coherent pilot/subcarrier +offsets, seeded weak/marginal noise, and pilot-absent reception. They are +synthetic DSP evidence, not hardware, on-air, or universal reception claims. +The old WFM mode and other channel owners keep their existing configuration, +gain, and discriminator/DC behavior. + +On refresh, retain the active-filter update until upstream applies both live +filters, retain the complete flush, and retain atomic stereo publication unless +upstream supplies an equivalent nonblocking observation. Do not restore a DSP +mutex to the acquisition-side getter. Keep the discriminator/DC changes +explicitly opt-in unless a separately reviewed upstream equivalent preserves +both continuous-waveform response and low-frequency stereo phase. Regenerate +the FIR from its polynomial and rerun analytic fixtures if its geometry changes. diff --git a/third_party/wdsp/README.md b/third_party/wdsp/README.md index f2d73481a3..b7dea38e86 100644 --- a/third_party/wdsp/README.md +++ b/third_party/wdsp/README.md @@ -20,12 +20,12 @@ therefore retains its upstream fallback behavior. ## Local boundary -`upstream/` matches that source snapshot except for the ten changes recorded -in `AETHERSDR-PATCHES.md` — four teardown corrections, two null/lifetime -fixes, one additive accessor set that lets Neural Noise Reduction be -controlled outside an RXA channel, two channel-state fixes, and one -performance change that builds the minimum-phase workspace only when minimum -phase is on. All portability +`upstream/` matches that source snapshot except for the local changes +recorded in `AETHERSDR-PATCHES.md`. The WFM integration adds patch 14: live +paired deemphasis, lock-free stereo observation, complete decoder reset, and +opt-in phase-difference/DC corrections that preserve stereo separation. +Existing WDSP overlap-save RF filtering is exposed +through the narrow C facade without changing the vendor filter. All portability changes live outside it: - `port/` implements the narrow Windows compatibility surface WDSP uses on diff --git a/third_party/wdsp/include/aether_wdsp.h b/third_party/wdsp/include/aether_wdsp.h index 6d9fc3066a..5797c941cc 100644 --- a/third_party/wdsp/include/aether_wdsp.h +++ b/third_party/wdsp/include/aether_wdsp.h @@ -44,6 +44,28 @@ void fexchange2(int channel, float* inputI, float* inputQ, // must pace a burst of input blocks against the worker. int GetChannelOutputReady(int channel); void SetRXAMode(int channel, int mode); +// Broadcast decoder controls, WDSP Guide section 5.3.10. The local patch +// applies deemphasis to both live filters; indicator readback is an atomic +// latest-completed-block observation. The caller must own channel lifetime. +// Opt-in 192 kHz phase-difference response correction and paired post-matrix +// DC removal. Existing owners default off and keep legacy MPX DC placement. +void SetRXAWBFMDiscriminatorCompensation(int channel, int enabled); +void SetRXAWBFMdmph(int channel, int run, int continent); +int GetRXAWBFMStereoIndicator(int channel); + +// Host-owned WFM RF prefilter. WBFM intentionally disables RXA's internal +// RF filters, so this existing overlap-save stage runs at the input rate +// BEFORE the channel's decimator. Prepare/destroy/setFreqs only under the +// planner/control fence. xbps allocates and locks nothing. Input is `size` +// interleaved-double complex frames; output must hold 2*size complex frames, +// of which the first size are valid. The host keeps a fixed unity gain. +typedef struct _bps* AetherWdspBandpass; +AetherWdspBandpass create_bps(int run, int position, int size, double* in, + double* out, double lowHz, double highHz, int sampleRate, int window, double gain); +void destroy_bps(AetherWdspBandpass filter); +void flush_bps(AetherWdspBandpass filter); +void xbps(AetherWdspBandpass filter, int position); +void setFreqs_bps(AetherWdspBandpass filter, double lowHz, double highHz); // Receive FM construction/control only: these take WDSP's DSP lock and the // limiter gain setter rebuilds its state. Never call from acquisition/audio. void SetRXAFMDeviation(int channel, double deviationHz); diff --git a/third_party/wdsp/upstream/wbfm.c b/third_party/wdsp/upstream/wbfm.c index b768b3d3ae..3a8660edf6 100644 --- a/third_party/wdsp/upstream/wbfm.c +++ b/third_party/wdsp/upstream/wbfm.c @@ -494,6 +494,35 @@ static void flush_sdelay(SDELAY a) * * ********************************************************************************************************/ +// AetherSDR: adjacent phase differences average instantaneous frequency over +// one sample, giving sinc(omega/2) droop. At 192 kHz the 38 kHz stereo channel +// is attenuated enough to limit separation to about 30 dB. This linear-phase +// FIR approximates the reciprocal through 53 kHz with <0.005 dB residual error. +// Reproducible polynomial: sum(k=0..6) C(2k,k)/(4^k*(2k+1))*s^k, +// s=(2-z-z^-1)/4. Unity DC; six samples common delay; maximum gain <1.357. +// It is OFF for existing owners and enabled only by the opt-in RX recipe. +static double compensate_discriminator(WBFM a, double value) +{ + static const double coefficients[13] = { + 0.0000042365147517277646, -0.000072685988632949079, + 0.00061676756022230263, -0.0035621651746758562, + 0.016914605226325121, -0.085469246778679744, + 1.1431369772813784, + -0.085469246778679744, 0.016914605226325121, + -0.0035621651746758562, 0.00061676756022230263, + -0.000072685988632949079, 0.0000042365147517277646 + }; + a->disc_history[a->disc_history_index] = value; + double result = 0.0; + int index = a->disc_history_index; + for (int tap = 0; tap < 13; ++tap) { + result += coefficients[tap] * a->disc_history[index]; + if (--index < 0) index = 12; + } + if (++a->disc_history_index == 13) a->disc_history_index = 0; + return result; +} + static void discriminator (WBFM a) { double Inew = 0.0; @@ -510,6 +539,7 @@ static void discriminator (WBFM a) Qold = a->discSave_Q; phase_diff = atan2(Iold * Qnew - Inew * Qold, Inew * Iold + Qnew * Qold); out_val = a->disc_gain_comp * phase_diff; + if (a->disc_compensate) out_val = compensate_discriminator(a, out_val); a->discSave_I = Inew; a->discSave_Q = Qnew; a->disc_out[2 * i + 0] = 2.0 * out_val; @@ -568,6 +598,23 @@ static void mx(int size, int stereo, double* LpR_in, double* LmR_in, double* L, } } +// AetherSDR: the legacy MPX DC blocker rotates low-frequency L+R while +// barely rotating 38 kHz L-R. Identical filters after the matrix preserve +// stereo separation and the same 0.9995 pole for the opt-in receive recipe. +static void audio_dc_block(WBFM a) +{ + double* channels[2] = { a->L, a->R }; + for (int channel = 0; channel < 2; ++channel) { + for (int i = 0; i < a->size; ++i) { + double x = channels[channel][i]; + double y = x - a->audio_dcb_x[channel] + 0.9995 * a->audio_dcb_y[channel]; + a->audio_dcb_x[channel] = x; + a->audio_dcb_y[channel] = y; + channels[channel][i] = y; + } + } +} + static void combine (int size, double* L, double* R, double* out) { for (int i = 0; i < size; i++) @@ -593,6 +640,10 @@ static void calc_wbfm (WBFM a) // discriminator & dcb a->discSave_I = 0.0; a->discSave_Q = 0.0; + memset(a->disc_history, 0, sizeof(a->disc_history)); + a->disc_history_index = 0; + memset(a->audio_dcb_x, 0, sizeof(a->audio_dcb_x)); + memset(a->audio_dcb_y, 0, sizeof(a->audio_dcb_y)); a->dcbSave_x = 0.0; a->dcbSave_y = 0.0; a->disc_gain_comp = a->rate / (TWOPI * 75000.0); @@ -703,6 +754,7 @@ WBFM create_wbfm (int run, int size, double* in, double* out, double rate) a->out = out; a->rate = rate; a->stereo = 0; + InterlockedExchange(&a->stereoPublished, 0); a->sqgain = 0.0; a->dmph = 1; a->dmph_type = 0; @@ -731,6 +783,10 @@ void flush_wbfm(WBFM a) flush_sdelay(a->del53); a->discSave_I = 0.0; a->discSave_Q = 0.0; + memset(a->disc_history, 0, sizeof(a->disc_history)); + a->disc_history_index = 0; + memset(a->audio_dcb_x, 0, sizeof(a->audio_dcb_x)); + memset(a->audio_dcb_y, 0, sizeof(a->audio_dcb_y)); a->dcbSave_x = 0.0; a->dcbSave_y = 0.0; flush_fircore(a->pfil0_15); @@ -738,6 +794,20 @@ void flush_wbfm(WBFM a) flush_fircore(a->pfil23_53); flush_wcpagc(a->pAGC_Pilot); flush_indy(a->pIndy); + // AetherSDR: a completed flush must not report the previous station. + a->stereo = 0; + a->mag19 = 0.0; + a->sqgain = a->psql->gain_min; + a->psql->gain = a->psql->gain_min; + a->psql->sqstate = 0; + a->psql->npwr.pwr = 0.0; + for (int section = 0; section < 2; ++section) { + a->psql->npwr.bqsec[section].x_z1 = 0.0; + a->psql->npwr.bqsec[section].x_z2 = 0.0; + a->psql->npwr.bqsec[section].y_z1 = 0.0; + a->psql->npwr.bqsec[section].y_z2 = 0.0; + } + InterlockedExchange(&a->stereoPublished, 0); } void xwbfm(WBFM a) @@ -745,7 +815,7 @@ void xwbfm(WBFM a) if (a->run) { discriminator(a); - dc_block(a); + if (!a->disc_compensate) dc_block(a); xfircore(a->pfil0_15); xfircore(a->pfil19); xfircore(a->pfil23_53); @@ -756,9 +826,11 @@ void xwbfm(WBFM a) xsdelay(a->del53); dsb_demod(a->size, a->fil19_out, a->fil53_out, a->LmR); mx(a->size, a->stereo, a->LpR, a->LmR, a->L, a->R, a->sqgain); + if (a->disc_compensate) audio_dc_block(a); xdmph(a->dmphL); xdmph(a->dmphR); combine(a->size, a->L, a->R, a->out); + InterlockedExchange(&a->stereoPublished, a->stereo); } else if (a->in != a->out) memcpy(a->out, a->in, a->size * sizeof(complex)); @@ -792,26 +864,43 @@ void setSize_wbfm(WBFM a, int size) ********************************************************************************************************/ +PORT +void SetRXAWBFMDiscriminatorCompensation(int channel, int enabled) +{ + WBFM a = rxa[channel].wbfm.p; + EnterCriticalSection(&ch[channel].csDSP); + a->disc_compensate = enabled && a->rate == 192000.0; + memset(a->disc_history, 0, sizeof(a->disc_history)); + a->disc_history_index = 0; + memset(a->audio_dcb_x, 0, sizeof(a->audio_dcb_x)); + memset(a->audio_dcb_y, 0, sizeof(a->audio_dcb_y)); + LeaveCriticalSection(&ch[channel].csDSP); +} + PORT void SetRXAWBFMdmph(int channel, int dmph_run, int dmph_continent) { WBFM a = rxa[channel].wbfm.p; + EnterCriticalSection(&ch[channel].csDSP); if ((a->dmph != dmph_run) || (a->dmph_type != dmph_continent)) { - EnterCriticalSection(&ch[channel].csDSP); a->dmph = dmph_run; a->dmph_type = dmph_continent; - LeaveCriticalSection(&ch[channel].csDSP); + // AetherSDR: the parent fields are construction inputs. Update the + // active left/right filters too; otherwise this setter is a no-op. + a->dmphL->run = a->dmphR->run = dmph_run; + a->dmphL->tau = a->dmphR->tau = dmph_continent ? 50.0e-6 : 75.0e-6; + calc_dmph(a->dmphL); + calc_dmph(a->dmphR); } + LeaveCriticalSection(&ch[channel].csDSP); } PORT int GetRXAWBFMStereoIndicator(int channel) { WBFM a = rxa[channel].wbfm.p; - int stereo = 0; - EnterCriticalSection(&ch[channel].csDSP); - stereo = a->stereo; - LeaveCriticalSection(&ch[channel].csDSP); - return stereo; + // AetherSDR: readback is used on the acquisition path. InterlockedAnd + // with an all-ones mask is the existing portable atomic-load idiom. + return (int)InterlockedAnd(&a->stereoPublished, ~0L); } \ No newline at end of file diff --git a/third_party/wdsp/upstream/wbfm.h b/third_party/wdsp/upstream/wbfm.h index 760d227815..168935c272 100644 --- a/third_party/wdsp/upstream/wbfm.h +++ b/third_party/wdsp/upstream/wbfm.h @@ -62,6 +62,12 @@ typedef struct _wbfm double dcbSave_x; double dcbSave_y; double disc_gain_comp; + // AetherSDR: opt-in continuous-FM discriminator response correction. + int disc_compensate; + double disc_history[13]; + int disc_history_index; + double audio_dcb_x[2]; + double audio_dcb_y[2]; // filter: 0-15 kHz FIRCORE pfil0_15; @@ -102,6 +108,8 @@ typedef struct _wbfm // Miscellaneous int stereo; + // AetherSDR: lock-free publication of the last completed decoder block. + long stereoPublished; double sqgain; double mag19; int dmph; From d8c4440127bb7e49f374cc1f07162d926f676d3b Mon Sep 17 00:00:00 2001 From: Ozy Date: Wed, 7 Oct 2026 07:35:42 -0700 Subject: [PATCH 2/2] RTL WFM: native stereo, dedicated applet and bounded receive exchange (#5974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rebased on current main — 2026-10-06 Current head: `ebb35cfd20892431758fd8a165b7c66357b74090`. Base stack starts at main `5f09371e25ffc032666d36f0ea265dc506621066`; parent PR/branch is unchanged. This is a signed replay of the existing increment. It preserves main's unified window chrome and release debug-symbol archives, alongside the RTL dependency packaging and explicit Digital-OFF release flags. Generated touchpoint counts were regenerated from the combined sources. Fresh rebase validation on analog `ca3a36bd`: native Apple Silicon / Qt 6.12 / RTL required / Digital OFF application build succeeds. **51/52 selected CTests pass** in 226.55 seconds: all 47 RTL regressions and four additional UI checks pass. The new upstream `unified_title_bar_test` fails its Alt+F/menu-rounding assertions under Mac offscreen rendering; an independent rebuild and run on clean main `5f09371e` reproduces the identical two failures. This is an inherited test failure, not a green full-suite claim. Static engine/routing/capability, generated manifests, registration/frozen-gate, theme and build-options checks pass; package-helper tests pass 9/9 and required-RTL configuration checks pass 16 combinations. The Digital increment and vendor trees are unchanged by replay; decoder-ON tests and release packaging were not rerun for these heads. Hosted checks are being refreshed by the push. All validation recorded below predates this rebase and remains evidence for the specific revisions named there. No new live-RF, capacity, or release-package qualification is claimed. Readiness is unchanged; Digital #6002 remains draft. --- ## Previous checkpoint record — 2026-10-05 ## Scope Analog WFM backend and applet integration under RFC #5468, based on the nine-file DSP foundation #5973 and main `035a858356949a0c902e286ee5250c04f5ef86c0`. Head: `b8aba26e677f49f07d155685e5f7bef8d2bfe425`. Parent: #5973. Digital #6002 / RFC #6079 is not a dependency. The selected RTL WFM receiver exposes Mono/Auto stereo, 50/75 µs deemphasis, bandwidth and measured pilot/stereo diagnostics. Accepted backend state drives readback and per-device persistence. The `broadcastFmReceive` capability is documented for all six backend families; RTL advertises the implemented controls and other families do not. WFM requests use the existing typed receive dispatcher through `SliceWfmRequest` / `requestSliceWfm`, including thread, current receiver identity, ownership, mode, value and backend-support guards. Refusals use the shared reporting path. This carries the #5919 request-routing contract into WFM. Speaker renegotiation prefers 48 kHz only for an accepted 48 kHz producer. If opening the new sink fails, it makes one bounded attempt using the previous rate policy and reports failure if neither opens. Producer-domain buffers, DSP and rate state are preserved. This shared AudioEngine behavior affects every backend family. The associated recorder-rate test follows producer rate rather than inferring it from the speaker sink. The opt-in nonblocking WFM exchange uses eight slots: 42.667 ms total exchange buffering, 32 ms above the legacy depth. Existing blocking/legacy owners retain their prior depth. Vendored WDSP exchange changes and test exports are included. Generic ContainerWidget managed presentation is used by the WFM applet; the default WFM tile placement applies to RTL. ## Validation and remaining decisions Exact checkpoint, native Apple Silicon / Qt 6.12 / optional decoder OFF: 4/4 rebuilt tests pass in 17.10 seconds (`wfm_controls_test`, `audio_engine_rates_test`, `fm_filter_controls_test`, `rtl_model_acceptance_test`). Removing speaker recovery produces six failures in the rate test; restored code passes. The regression injects failed opens, checks bounded fallback and actual-rate stereo rendering, and preserves producer/DSP/lifecycle state. Static ratchets and generated manifests pass; independent source review found no remaining typed-WFM routing defect. Historical operator stability on `7601593f` is not a new-head soak. An earlier Mac filter/stereo transition intermittently withdrew a WFM receiver; passing diagnostic reruns did not establish a root cause, and this remains unresolved. Maintainer review still covers WFM applet/default presentation, the typed public seam and vendor exchange/test surface, and the buffering tradeoff. The approved RTL-only free-pan and confirmed-control seam from #5924 are not reopened. Normal admission remains one receiver; 2/4/8 evaluation and capacity qualification belong to the delivery increment. No Digital approval is requested. Principles II, V/XIV and XI: accepted state, scoped persistence and demonstrated behavior. --- 73, Ozy **K6OZY** · Codex --- CMakeLists.txt | 2 + docs/architecture/aetherd-touchpoints.md | 12 +- docs/architecture/radio-capabilities-map.md | 6 + docs/audio-engine-rate-domains.md | 19 +- docs/rtl-m1-runtime.md | 26 +- docs/rtl-wfm.md | 217 +++++ src/core/AudioEngine.cpp | 197 ++++- src/core/AudioEngine.h | 20 + src/core/AudioFormatNegotiator.cpp | 15 +- src/core/RtlSliceSettings.cpp | 12 + src/core/RtlSliceSettings.h | 3 + src/core/WfmReceptionDiagnostics.h | 35 + src/core/backends/IRadioBackend.h | 10 + src/core/backends/RadioCapabilities.h | 7 + src/core/backends/ReceiveCommand.h | 13 + src/core/backends/SliceDelta.h | 11 + src/core/backends/anan/AnanBackend.cpp | 1 + src/core/backends/flex/FlexBackend.cpp | 1 + src/core/backends/hl2/Hl2Backend.cpp | 1 + src/core/backends/icom/IcomCivBackend.cpp | 1 + src/core/backends/rtl/RtlAudioMixer.cpp | 9 +- src/core/backends/rtl/RtlAudioMixer.h | 7 + .../backends/rtl/RtlCaptureTransaction.cpp | 1 + src/core/backends/rtl/RtlCaptureTransaction.h | 2 + src/core/backends/rtl/RtlReceivePipeline.cpp | 156 +++- src/core/backends/rtl/RtlReceivePipeline.h | 54 +- src/core/backends/rtl/RtlReceiverRegistry.cpp | 99 ++- src/core/backends/rtl/RtlReceiverRegistry.h | 28 + src/core/backends/rtl/RtlRfExtractor.cpp | 106 ++- src/core/backends/rtl/RtlRfExtractor.h | 33 + src/core/backends/rtl/RtlSdrBackend.cpp | 299 ++++++- src/core/backends/rtl/RtlSdrBackend.h | 18 + src/core/backends/rtl/RtlSdrWorker.h | 1 + src/core/backends/sim/SimBackend.cpp | 1 + src/core/dsp/WdspChannel.cpp | 80 +- src/core/dsp/WdspChannel.h | 27 +- src/gui/AppletPanel.cpp | 47 +- src/gui/AppletPanel.h | 4 + src/gui/ControlAvailabilityRegistry.cpp | 13 +- src/gui/ControlAvailabilityRegistry.h | 6 +- src/gui/MainWindow_Wiring.cpp | 2 + src/gui/ModeFilterPresets.cpp | 21 +- src/gui/ModeFilterPresets.h | 3 +- src/gui/WfmApplet.cpp | 479 ++++++++++ src/gui/WfmApplet.h | 64 ++ src/gui/WfmLockScope.cpp | 224 +++++ src/gui/WfmLockScope.h | 47 + src/gui/containers/ContainerWidget.cpp | 21 + src/gui/containers/ContainerWidget.h | 15 + .../containers/FloatingContainerWindow.cpp | 39 +- src/gui/containers/FloatingContainerWindow.h | 9 + src/models/RadioModel.cpp | 36 + src/models/RadioModel.h | 3 + src/models/SliceModel.cpp | 57 ++ src/models/SliceModel.h | 18 + tests/audio_engine_pcm_lifetime_test.cpp | 8 +- tests/audio_engine_rates_test.cpp | 289 +++++- tests/audio_format_negotiation_test.cpp | 79 ++ tests/container_widget_test.cpp | 140 ++- tests/mode_filter_presets_test.cpp | 21 + tests/qso_recorder_rates_test.cpp | 57 ++ tests/rtl_audio_mixer_test.cpp | 33 +- tests/rtl_capture_worker_test.cpp | 22 +- tests/rtl_model_acceptance_test.cpp | 191 +++- tests/rtl_receive_pipeline_test.cpp | 203 +++++ tests/rtl_receiver_registry_test.cpp | 62 +- tests/rtl_rf_extractor_test.cpp | 162 ++++ tests/rtl_runtime_settings_test.cpp | 200 ++++- tests/rtl_slice_settings_test.cpp | 57 ++ tests/rtl_usb_controls_test.cpp | 3 +- tests/rtl_wfm_pipeline_test.cpp | 638 ++++++++++++++ tests/tests.cmake | 37 + tests/wdsp_wbfm_test.cpp | 302 ++++++- tests/wfm_controls_test.cpp | 832 ++++++++++++++++++ tests/workspace_controller_test.cpp | 48 + third_party/wdsp/AETHERSDR-PATCHES.md | 132 +++ third_party/wdsp/README.md | 3 + .../wdsp/include/aether_wbfm_observation.h | 17 + third_party/wdsp/include/aether_wdsp.h | 26 + third_party/wdsp/port/wdsp_port.c | 104 +++ third_party/wdsp/upstream/channel.c | 30 +- third_party/wdsp/upstream/channel.h | 3 + third_party/wdsp/upstream/iobuffs.c | 59 +- third_party/wdsp/upstream/wbfm.c | 112 ++- third_party/wdsp/upstream/wbfm.h | 8 + third_party/wdsp/upstream/wdsp.h | 3 + 86 files changed, 6277 insertions(+), 242 deletions(-) create mode 100644 docs/rtl-wfm.md create mode 100644 src/core/WfmReceptionDiagnostics.h create mode 100644 src/gui/WfmApplet.cpp create mode 100644 src/gui/WfmApplet.h create mode 100644 src/gui/WfmLockScope.cpp create mode 100644 src/gui/WfmLockScope.h create mode 100644 tests/rtl_wfm_pipeline_test.cpp create mode 100644 tests/wfm_controls_test.cpp create mode 100644 third_party/wdsp/include/aether_wbfm_observation.h diff --git a/CMakeLists.txt b/CMakeLists.txt index be9ad0419c..b727f6470f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1367,6 +1367,8 @@ set(GUI_SOURCES src/gui/WaveformScopeModel.cpp src/gui/WaveformWidget.cpp src/gui/AetherClockApplet.cpp + src/gui/WfmApplet.cpp + src/gui/WfmLockScope.cpp src/gui/ClockAlignmentWidget.cpp src/gui/ClientEqApplet.cpp src/gui/ClientEqCurveWidget.cpp diff --git a/docs/architecture/aetherd-touchpoints.md b/docs/architecture/aetherd-touchpoints.md index a3eeff44f6..4eb7b7d7af 100644 --- a/docs/architecture/aetherd-touchpoints.md +++ b/docs/architecture/aetherd-touchpoints.md @@ -17,7 +17,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `core/AetherRxProfiles.h` | 1 | universal — Named-profile save/recall/import-export for the receive DSP chain; operates on core-profile DSP state only, same precedent as core/ChannelStripPresets.h, whose RX capture/apply it shares. | unconverted | | `core/AetherTxProfiles.h` | 1 | universal — Named-profile save/recall/import-export for the transmit DSP chain; operates on core-profile DSP state only. Mirror of core/AetherRxProfiles.h, and shares ChannelStripPresets' TX capture/apply. | unconverted | | `core/AgcTCalibrator.h` | 1 | universal — Engine algo sweeping slice AGC threshold vs audio RMS/S-meter to recommend a value; only canonical state. | unconverted | -| `core/AppSettings.h` | 107 | ui-support — Client-side XML settings store (SSDR.settings-style key/value persistence); app plumbing, not radio state. | unconverted | +| `core/AppSettings.h` | 108 | ui-support — Client-side XML settings store (SSDR.settings-style key/value persistence); app plumbing, not radio state. | unconverted | | `core/AudioEngine.h` | 47 | mixed(flex) — Client audio I/O + full RX/TX DSP chain (universal) fused with Flex VITA-49/DAX/Opus TX and Kiwi buffering | unconverted | | `core/AudioOutputRouter.h` | 1 | ui-support — Registry fanning the user-selected QAudioDevice to local playback sinks; OS device plumbing, no radio state | unconverted | | `core/AutomationBridgeSettings.h` | 3 | ui-support — Owned configuration for the agent automation bridge (enabled / txAllowed / txAck / readOnly) under one nested key; the secret token lives in the OS keychain, not here. Dev and test tooling — same home decision as core/AutomationServer.h. NOTE for RFC §6: txAllowed() is read as a TX-guard input by BOTH AutomationServer and Hl2Backend, so this header is a step-4 consolidation target. | unconverted | @@ -149,7 +149,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `core/SystemInventory.h` | 1 | ui-support — Startup and support-bundle inventory of host CPU, SIMD and RAM capabilities. Process diagnostics, not radio state. | unconverted | | `core/TciServer.h` | 3 | mixed(flex) — TCI WebSocket server for WSJT-X et al: protocol surface is canonical radio state, but audio/IQ rides Flex DAX | unconverted | | `core/TgxlConnection.h` | 3 | peripheral(4o3a) — Direct TCP client for the 4O3A Tuner Genius XL (port 9010, relay/autotune), reverse-engineered from the 4O3A management app — a standalone accessory transport, not SmartSDR. Not radio-family wire; a peripheral accessory, NOT behind the IRadioBackend radio seam (reclassified from vendor(flex), #4087 follow-up). | unconverted | -| `core/ThemeManager.h` | 165 | ui-support — Qt token-based theming singleton (colors/fonts/QSS, theme files, editor hooks) — pure client GUI plumbing, no radio state. | unconverted | +| `core/ThemeManager.h` | 167 | ui-support — Qt token-based theming singleton (colors/fonts/QSS, theme files, editor hooks) — pure client GUI plumbing, no radio state. | unconverted | | `core/ThreadCpuRing.h` | 2 | ui-support — Short host-thread CPU history used by Runtime Monitor peak and sparkline presentation. Diagnostic UI support, not radio state. | unconverted | | `core/TimeFrameVoter.h` | 1 | universal — Shared AetherClock time-frame types plus confidence-weighted cross-frame bit voting over a sliding window. Map-agnostic pure DSP/logic — no Qt, no GUI, no vendor ties. | unconverted | | `core/TxKeyingMarker.h` | 14 | ui-support — QWidget property marker guarding TX-keying controls from the automation bridge; GUI-shell plumbing, no radio state. | unconverted | @@ -179,7 +179,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `core/backends/HealthSnapshotMerge.h` | 1 | universal — The snapshot merge rule, family-neutral by construction: a key the winner declares but omits from `values` means "not reported" and must not erase a base value. Moved out of backends/hl2/Hl2TelemetrySource.h (which keeps hl2MergeHealth as a forwarder) because AutomationServer::doHealth() and RadioHealthDialog both need it, and neither may include a family header to get it. Names no family. | unconverted | | `core/backends/IRadioBackend.h` | 2 | universal — THE radio seam (RFC §5.5) — the canonical intent verbs, typed deltas and normalized signals every family implements. Universal by definition: the UI reaching this header is the seam working as designed, not coupling. Everything below it in core/backends// is family-private. | unconverted | | `core/backends/NoiseFloorAutoAdjustGate.h` | 2 | universal — The ONE predicate deciding whether the noise-floor auto-adjust may move the display reference level, as an OR over two backend-neutral properties (a real dBm-range echo, or absolute spectrum bins). Deliberately free of Qt and of RadioCapabilities so the widget and its test read the same function rather than two copies; the UI reaching it is the seam working, not coupling. Family backends populate the capability it is called with. | unconverted | -| `core/backends/RadioCapabilities.h` | 10 | universal — Backend-neutral capability descriptor consumed above the radio seam. Universal by definition; family implementations populate it. | unconverted | +| `core/backends/RadioCapabilities.h` | 11 | universal — Backend-neutral capability descriptor consumed above the radio seam. Universal by definition; family implementations populate it. | unconverted | | `core/backends/SquelchLevelScale.h` | 2 | universal — Where a slice's squelch level sits on the panadapter's vertical axis, as a backend-published record (RadioCapabilities::squelchLevelScale) plus the mode filter and Auto-SQL predicate both the widget and MainWindow read, so the SQL line and Auto SQL share one gate. No family name or wire concept; each backend declares its own map or leaves it absent (#6092). | unconverted | | `core/backends/anan/AnanDiscovery.h` | 2 | vendor(anan) — openHPSDR Protocol 2 discovery and ANAN-G2 identity handling. Family-specific discovery belongs below the ANAN backend seam. | unconverted | | `core/backends/anan/AnanSettings.h` | 1 | ui-support — Client-side connection and ADC configuration for the ANAN backend. Persisted setup plumbing, not live radio state. | unconverted | @@ -232,15 +232,15 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine | `models/ModelCapabilities.h` | 1 | mixed(flex) — Per-model capability table mirroring FlexLib ModelInfo.cs (RadioPlatform, has2Meters/has4Meters, loop A/B). This is the universal question 'what can this radio do', answered exclusively from a vendor table — and its band-availability half is precisely the band truth RFC §4.1 assigns to the capability descriptor. Consumed above the seam by SpectrumOverlayMenu, XvtrPolicy, WaveformInstallGate and MainWindow_Wiring. | unconverted | | `models/NetEntry.h` | 2 | ui-support — Net scheduler entry: RRULE/timezone/reminder metadata, operator-scoped local JSON; radio state only via embedded MemoryEntry | unconverted | | `models/Nr2SettingsModel.h` | 5 | universal — Process-wide owner of client-side NR2 configuration as one versioned object (Principle V), so every editing surface shares state. Engine DSP configuration; radio-agnostic. | unconverted | -| `models/PanadapterModel.h` | 4 | mixed(flex) — Per-pan display state is core-profile; DAX IQ ch, client_handle, VITA stream IDs, SmartSDR kv parsing are flex. | unconverted | +| `models/PanadapterModel.h` | 5 | mixed(flex) — Per-pan display state is core-profile; DAX IQ ch, client_handle, VITA stream IDs, SmartSDR kv parsing are flex. | unconverted | | `models/ProfileLoadCommand.h` | 1 | vendor(flex) — Parses SmartSDR 'profile global/tx/mic load' wire command + recall-hold timing/suppression sentinels; Flex-only | unconverted | -| `models/RadioModel.h` | 51 | mixed(flex) — Central radio aggregate: core slice/pan/TX/meter/memory state fused with Flex protocol, DAX, SmartLink, Multi-Flex | unconverted | +| `models/RadioModel.h` | 52 | mixed(flex) — Central radio aggregate: core slice/pan/TX/meter/memory state fused with Flex protocol, DAX, SmartLink, Multi-Flex | unconverted | | `models/RadioSession.h` | 1 | universal — Per-radio session aggregate: owns RadioModel + id/label; session concept is core-profile, no vendor surface | unconverted | | `models/RadioStatusOwnership.h` | 1 | vendor(flex) — SmartSDR status parsing helpers: Flex hex handles, client_handle ownership, remote_audio_rx, interlock gate | unconverted | | `models/ReceiverSlotCount.h` | 1 | universal — How many receiver letters (A, B, C, ...) the UI offers — the RX applet's slice tabs and the CAT applet's VFO targets — tracked from the connection/capability/slice edges. Family-agnostic: the backend's declared ceiling, floored by the slots live receivers occupy (#5775, #5776). | unconverted | | `models/Rn2SettingsModel.h` | 1 | universal — Process-wide owner of client-side RN2 configuration as one versioned object (Principle V). Engine DSP configuration; radio-agnostic. | unconverted | | `models/SliceLinkPolicy.h` | 2 | universal — Pure decision logic for Slice Link (cross-panadapter VFO link) — echo classification on integer Hz, no QObject, no I/O, no model access. Canonical slice state only. | unconverted | -| `models/SliceModel.h` | 34 | mixed(flex) — Slice state (freq/mode/filter/DSP) is core-profile; DAX, index_letter, SmartSDR status KVs are flex ext | unconverted | +| `models/SliceModel.h` | 36 | mixed(flex) — Slice state (freq/mode/filter/DSP) is core-profile; DAX, index_letter, SmartSDR status KVs are flex ext | unconverted | | `models/SpotModel.h` | 1 | universal — Panadapter spot store (callsign/freq/mode/lifetime/priority) on canonical state; kv ingest is trivially generic | unconverted | | `models/TnfModel.h` | 1 | universal — Tracking notch filter state (freq/width/depth/permanent, global enable) — generic DSP notch surface; kv parse is transport detail | unconverted | | `models/TransmitModel.h` | 17 | mixed(flex) — TX state model: power/MOX/VOX/CW/filter are core-profile; ATU, DAX, APD, profiles, interlock are Flex. | unconverted | diff --git a/docs/architecture/radio-capabilities-map.md b/docs/architecture/radio-capabilities-map.md index a16206d740..4423d0c6cc 100644 --- a/docs/architecture/radio-capabilities-map.md +++ b/docs/architecture/radio-capabilities-map.md @@ -55,6 +55,12 @@ semantics see [local frequency control](../aetherd-local-slice-frequency-control ## Wired and consumed +### Broadcast FM receiver controls + +| Record | Flex | HL2 | Sim | Icom | ANAN | RTL-SDR | Consumers | +|---|---|---|---|---|---|---|---| +| `broadcastFmReceive` | absent | absent | absent | absent | absent | de-emphasis 50/75 µs, force Mono, measured reception diagnostics | `WfmApplet` availability; `RadioModel::dispatchSliceWfm` validates the capability and routes `requestSliceWfm`, with explicit refusal reporting | + ### Local receive-control records The optional `receiveModeControl`, `receiveFilterControl`, `receiveAudioControl`, diff --git a/docs/audio-engine-rate-domains.md b/docs/audio-engine-rate-domains.md index 1b4b54bf47..ce3f1b63e6 100644 --- a/docs/audio-engine-rate-domains.md +++ b/docs/audio-engine-rate-domains.md @@ -5,6 +5,17 @@ device format remains independent: `m_rxProducerRate` describes main input; `m_rxOutputRate` describes device output. `DEFAULT_SAMPLE_RATE` stays 24000. Legacy byte input and every Kiwi auxiliary route retain 24 kHz stereo. +An accepted 48 kHz main producer asks the running RX speaker to prefer 48 kHz, +then 44.1 kHz, then 24 kHz through the consolidated `PreservePan` ladder. The +actual device rate remains separately reported; a 24 kHz fallback cannot retain +15 kHz audio. Default 24 kHz producer policy and the input/sidetone ladders are +unchanged. Requests are coalesced on the existing owner thread and checked +against the current producer lease and sink generation before opening. A +stopped speaker stays stopped. A brief retirement gap or same-rate replacement +retains the sink, including an already-negotiated fallback; real replacement +24 kHz PCM restores the legacy policy. Only the RX speaker reopens, preserving +the dedicated CW/Quindar sinks and producer-domain queues and processing state. + ## Queues and processing Raw queues and whole NR2 packets count time in their producer domain. Each @@ -70,10 +81,10 @@ fixed-rate contracts. RADE output converts from 24 kHz to the device rate, independently of the main producer. TX admission, cancellation and deferred release continue through the inherited #5591 coordinator and wiring. -The new RTL 48 kHz producer and multi-receiver runtime remain disabled. -Recording/container changes (A3), TCI conversion (A4), and decoder/clock -conversion (A5) remain separate; accepting test-injected speaker48 PCM does -not qualify those consumers for a live 48 kHz producer. +The single-receiver RTL WFM candidate now produces native 48 kHz stereo; its +scope and qualification limits are documented in [rtl-wfm.md](rtl-wfm.md). +Multi-receiver admission remains disabled. Recording/container, TCI and +decoder/clock consumers keep their separately defined conversion contracts. ## Software evidence diff --git a/docs/rtl-m1-runtime.md b/docs/rtl-m1-runtime.md index be80d821f7..60a3ab6d81 100644 --- a/docs/rtl-m1-runtime.md +++ b/docs/rtl-m1-runtime.md @@ -90,17 +90,25 @@ blocks to the backend thread, which constructs revocable typed PCM. Queue gaps are marked discontinuous. `AudioEngine::DEFAULT_SAMPLE_RATE` remains 24000 and the audio device's output rate remains independently negotiated. -WFM and the other existing non-FM modes retain their exclusive legacy DDC and -24 kHz format. Their demodulation is unchanged; monitor mute continues to clock -an independent pre-monitor tap. They cannot join a multi-FM bank. Selecting FM -with an incompatible inherited wide/sideband filter chooses a 16 kHz passband; -ordinary filter requests and saved restores are never resized. This mode -transition was approved in [the #5924 maintainer ruling](https://github.com/aethersdr/AetherSDR/pull/5924#issuecomment-5787852269). WFM stereo and normalization qualification -remain S1/S2; no replacement WFM claim is made here. +WFM uses an explicitly qualified 384 kHz IQ / 192 kHz WDSP / 48 kHz paired +stereo recipe with independently prepared RF filtering, 50/75 µs de-emphasis +and completed-block stereo observation. Its RF and audio limits, generated +reference results and remaining validation boundaries are documented in +[`rtl-wfm.md`](rtl-wfm.md). Manual WFM squelch remains unavailable with a stated +reason; WDSP's internal automatic squelch remains in use. Saved sessions use a +muted legacy bootstrap before adopting the original receiver recipe and ID. + +The other non-FM modes retain their exclusive legacy DDC and 24 kHz format. +Monitor mute continues to clock an independent pre-monitor tap. Production +admission remains one receiver. Selecting FM with an incompatible inherited +wide/sideband filter chooses a 16 kHz passband; ordinary filter requests and +saved restores are never resized. This mode transition was approved in +[the #5924 maintainer ruling](https://github.com/aethersdr/AetherSDR/pull/5924#issuecomment-5787852269). The desktop FM/FM-N presets use the existing DFM width ladder intersected with the backend's declared filter range; FMN and NFM share symmetric edge rules. -Fixed radio filter lists retain precedence. WFM filter and squelch controls -are unavailable with accessible reasons, and the backend refuses those edits. +WFM presets use the declared broadcast RF range. Fixed radio filter lists retain +precedence. WFM control placement and recipe choices remain subject to +maintainer review. ## FM squelch diff --git a/docs/rtl-wfm.md b/docs/rtl-wfm.md new file mode 100644 index 0000000000..98cf5beb51 --- /dev/null +++ b/docs/rtl-wfm.md @@ -0,0 +1,217 @@ +# RTL broadcast FM at 48 kHz + +Implementation of RFC #5468 section 4, layered on the capture/parking work in +draft #5924. Production receiver admission remains one. This does not qualify +multiple receivers, transmit, other radio families, or new architecture limits. +The free-pan amendment and placement/styling of the dedicated WFM applet remain +subject to maintainer review. The new recipe is enabled in this candidate after +generated-signal qualification. The previous installed WFM build is retained for +live comparison and rollback; this draft does not claim release qualification. + +## Signal path and RF meaning + +An active WFM receiver extracts paired 384 kHz IQ from the shared capture into +2048-frame blocks. An independently prepared WDSP BPS filter applies the saved +RF edges before WDSP converts to its documented 192 kHz broadcast decoder; +1024 DSP frames produce 256 independent L/R frames at 48 kHz. The BPS filter +uses 2049 Blackman-Harris coefficients and a 4096-point overlap-save FFT. Its +complete requested interval includes 3 kHz transition guards. The capture +policy checks actual captured RF, including when low input rates need bounded +interpolation; interpolation never expands captured coverage. + +Filter width is an RF selection, not an audio low-pass. The preserved default +200 kHz selection is not a flat 200 kHz usable-bandwidth claim: the fixed +192 kHz decoder conversion measured flat response at the sampled positive +offsets through +90 kHz, about -0.195 dB at +93 kHz, and -6.02 dB at +96 kHz. +The +100 kHz edge is already strongly attenuated. The corresponding negative +response is inferred from the symmetric filter; this sweep did not measure it. +Narrower filters intentionally trade modulation sidebands for adjacent-channel +rejection. Saved edges are never silently resized on +restore; entering WFM from a narrow incompatible mode selects the existing +200 kHz broadcast preset. Nonempty saved sessions bootstrap with muted, narrow +legacy AM before adopting their original receiver ID and recipe. This keeps the +sole native reservation free for sparse IDs and avoids FM's automatic DC +placement moving capture. +Old WFM could store narrow filters it did not apply. A saved WFM interval +outside the new declared range is preserved and refused with a configuration +warning, leaving the bootstrap muted. The operator must explicitly choose a +supported WFM filter and unmute; reconnect never silently widens the saved RF +selection. Validation may use an explicitly authorized 200 kHz selection. + +`WdspChannel::WbfmReceive` explicitly opts into this recipe. Existing WDSP +owners keep their configuration. The wrapper applies paired gain 0.25 before +the independent receiver tap and speaker mixer. WDSP's ordinary RX panel and +notch bandpass do not control WBFM, so this gain and RF filter have explicit +owners. There is no narrow-FM FFT squelch layered over broadcast FM: WDSP's +internal automatic squelch remains in use; the manual SQL control is unavailable. + +Two opted-in decoder corrections preserve stereo. Adjacent IQ phase differences +average instantaneous frequency over one sample, weakening the 38 kHz difference +channel. A bounded, preallocated 13-tap linear-phase FIR corrects that response +through 53 kHz. The existing DC blocker moves after the L/R matrix for this +recipe, with identical independent histories, so it cannot phase-shift the sum +channel relative to the difference channel. Legacy WBFM retains its previous +placement. See `third_party/wdsp/AETHERSDR-PATCHES.md` for source provenance, +coefficients, noise gain and refresh instructions. + +## Confirmed controls and observations + +The optional `broadcastFmReceive` capability declares supported de-emphasis +values and typed control/diagnostic support backed by backend verbs. The dedicated +WFM applet exposes 50 and 75 µs; +old schema-one settings without this optional per-slice field retain the previous +75 µs response. Changes become observed and persistent only after DSP adoption. +Unsupported values, stale model owners and foreign-thread requests do not dispatch. +The existing RtlSlices owner retains the setting across other modes and restart. +Pilot status is never persisted. + +The WFM applet follows the selected owned slice and is presented only while a +connected backend declares broadcast FM reception and that slice's accepted mode +is WFM. The standard applet container owns its dock/float placement, button and +open/closed choice. Temporary unavailability suppresses presentation without +closing the saved workspace item. Generic RX controls retain their behavior; +the broadcast-specific row is removed from RX. + +The single Mono/Stereo button requests a prepared receiver recipe. Mono selects +real L+R mono output; Stereo selects automatic pilot-based stereo with ordinary +mono fallback. The accepted per-slice choice persists in RtlSlices and defaults +to the previous automatic behavior for older settings. The button cannot claim +accepted state before DSP adoption. Decoder observations remain separate from +that selection, with current stereo shown through a success theme token and +plain accessible text. A missing pilot alone is not a poor-signal verdict. + +The applet Settings drawer independently persists Lock Scope and Diagnostics +visibility in one nested client UI object. The scope plots measured 19 kHz pilot +magnitude and the detector's actual acquire/release thresholds in relative +normalized discriminator units. It retains at most 160 observations and up to +40 seconds; hiding it clears history and stops its plotting/history work without +stopping the receiver. Receiver changes and stale observations clear the scope. +This is not a PLL, SNR, calibrated RF-power or channel-separation instrument. + +Diagnostics describe the measured pilot detector's consecutive-block hysteresis, +lock duration, loss/reacquisition counts and up to five seconds of unchanged +pilot state. Durations come from completed decoder samples. The optional C +snapshot is coherently read with bounded atomic retries, carried by the existing +receiver PCM lifetime fences and published to the model at most four times per +second plus actual status changes. Unknown or stale measurements are unavailable; +no quality percentage is invented. Configuration and processing-health readouts +are distinct from reception measurements. + +Native NRSC-5/HD FM is a separately requested next checkpoint and RFC amendment, +not covered by the existing analog architecture approval or the qualification +below. The analog checkpoint exposes no ineffective HD state or empty metadata +controls. Future HD program/ID3 metadata and analog RDS have different decoder +sources; RDS remains separate from the requested native HD implementation. + +The stereo indicator reports the last completed decoder block carried by a +current-session/current-revision independent audio packet. It is unavailable +outside receiving WFM, acquiring after adoption, and mono or stereo only after +current decoder evidence. Every accepted revision clears the previous claim, +including repair of an unchanged recipe. A 500 ms absence of accepted decoder +observations returns it to acquiring. Park, removal, mode change and disconnect +retire the corresponding stream. The model drives accessible status text; +selecting WFM alone cannot establish stereo. + +RF/de-emphasis changes prepare replacements away from acquisition and publish +new receiver and speaker epochs. Startup positions align to the final 48 kHz +sample lattice, including nonzero and coprime capture-rate origins. Each packet +declares 48 kHz, stereo layout and actual frame count. Monitor mute, gain and +balance affect the speaker mix after the independent slice tap. Queued stale PCM +and observations remain fenced by capture/session/receiver lifetime. The mixer +starts each new epoch at the same exact capture/audio lattice point as extraction; +it does not demand rounded-down samples that the extractor cannot produce. + +Nonblocking opted-in WFM prepares eight WDSP exchange slots. The seven initial +output credits cover the largest single 8192-complex-sample USB callback burst +at the admitted capture rates. This is finite headroom, not a guarantee against +arbitrary queued callbacks or worker starvation. WDSP still reports a real +underrun when exhausted, and the receiver still withdraws immediately. Output +bytes and their write cursor are complete before WDSP publishes output credit. +Blocking WFM and legacy channels retain depth two, including reused channel IDs. + +The prepared WFM exchange contributes 2048 audio frames (42.667 ms) of transport +delay, including the prior-output worker stage, versus 512 frames (10.667 ms) +at depth two. The extra delay is 32 ms; filter, device and other DSP delays are +additional. Ring storage increases from 72 to 288 KiB per opted-in channel. +The mixer's unchanged 2048-frame missing-contribution deadline is a separate +capture-position bound, not a total audio-latency limit. + +The `aether.rtl.receive` debug category is disabled by default. When explicitly +enabled, owner-thread records identify the first failing process result, +extractor reason, capture/IQ positions and exact missing mixer frames. Its fixed +queue reports overflow separately; acquisition performs no logging or allocation. +Health status also exposes the existing accepted/requested capture revisions and +trace-drop count, allowing bounded observation with detailed logging disabled. + +Accepted 48 kHz speaker PCM also drives RX device negotiation: prefer 48 kHz, +then 44.1 kHz, then 24 kHz through the shared stereo-preserving policy. A 24 kHz +fallback is usable but cannot retain 15 kHz audio, and the actual device rate +is reported independently of the producer. Deferred requests check the current +producer lease and sink generation. Same-rate epochs and brief retirement gaps +do not renegotiate the sink rate. Legacy 24 kHz policy and fixed CW/Quindar/RADE +contracts remain unchanged; speaker-only changes retain producer queues and effects. + +## Qualification evidence and limits + +`wdsp_wbfm_test` uses analytical continuous-FM IQ (75 kHz peak deviation scale, +10% pilot, independent left/right tones). Rectangular phase accumulation at the +DSP rate is not a valid reference because it cancels the discriminator defect. +It checks the documented 1/2 kHz reference against at least 40 dB separation, +plus low/high audio references, pilot acquisition/loss, mono fallback, both +de-emphasis responses, 15 kHz bandwidth, RF selection/polarity, no-signal noise, +rate/pilot offsets, output geometry, allocation scope and lifetime. + +Before the fixes, filtering had no measured effect, the 90% mono reference +peaked at 3.258 with clipping, and 50/75 µs selections had identical response. +The filter/headroom correction measured over 91 dB adjacent-channel selection, +peak about 0.815 without clipping, a 15 kHz 50/75 µs amplitude ratio of 1.483, +and over 86 dB rejection 3 kHz beyond the 60 kHz RF filter's edges. These are +generated-signal measurements, not antenna selectivity or hardware reception proof. + +A 97.5% modulation stress vector reports separation separately and requires +finite, unclipped headroom. Its separation is limited by high-order RF sidebands +outside the 192 kHz decoder input, so the 40 dB reference result does not claim +40 dB for every modulation waveform. A future remedy would require preserving +those sidebands through discrimination; neither gain nor relabeling sample rates +can recover them. Live strong/weak-station listening remains separate evidence. + +`rtl_wfm_pipeline_test` drives production extraction, immutable receiver banks, +paired 48 kHz taps and the speaker mixer with generated IQ. It verifies positions, +frame counts, independent monitor routing, parking/resume and repair epochs. +Injected-device model/runtime tests verify accepted controls, persistence, +capture guards, low-rate reconnect and stale status. `wfm_controls_test` verifies +capability gating, confirmed selection, keyboard behavior and accessible state. +No new test binds a socket or represents third-party firmware. + +The no-device `audio_engine_rates_test` admits real typed PCM, dispatches the +queued speaker-rate change, and checks actual final output at 48/44.1 kHz and +the 24 kHz fallback. It checks 15 kHz content, independent channels, stale +requests, retirement gaps and retained auxiliary queues. The injected boundary +is device opening; physical sink behavior still requires live validation. + +The opt-in `AETHER_WFM_BENCHMARK=1` fixture additionally runs production capture +DDC, the 65,536-bin display FFT at 25 FPS, extraction, WDSP, paired taps and mixer. +Four RF seconds at 2.4 MS/s completed in 1.905 seconds (2.10×), with 100 FFT frames +and waterfall rows, 192,000 frames on each 48 kHz output, and zero observed repair, +drop, late-frame or mixer-error counters. The feed is capped at 2.1×: this is a +sustained lower bound, not maximum speed. Preparation, USB byte conversion, +backend timers, AudioEngine and GUI are outside that timed measurement. + +Mutation checks demonstrate that wrong IQ-to-audio positions fail the pipeline +test and a hidden 48→24→48 bottleneck fails the actual WAV spectral test even +when rate/duration metadata remain unchanged. Source restoration is followed by +passing rebuilds and tests. + +Native validation is on Nobara in this work order. Hosted checks cover their +configured platforms; no local Mac/Windows/ARM hardware result is implied. +Production planning uses its real persistent wisdom path, never the test planner +time limit. Receiver admission remains one while multi-receiver, long-duration, +architecture-specific and operator acceptance work remains separate. + +## Future digital reception + +HD Radio/nrsc5 implementation is deferred until the current RTL sprint finishes. +Its future branch needs wide capture IQ before this analog filter, its own +station-centered extraction and 744187.5 Hz conversion, and explicit paired +44.1→48 kHz PCM conversion. RADE's demodulated 24 kHz speech input is unsuitable. +No nrsc5 dependency, speculative digital framework or automatic fallback is added. diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 217e708dbd..cffbebba7d 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -2174,8 +2174,10 @@ void AudioEngine::drainRxAudio(qsizetype freeBytes) m_rxZombieTickCount = 0; qCWarning(lcAudio) << "AudioEngine: sink appears zombie (bytesFree stuck at 0 for" << kZombieTickThreshold * 10 << "ms), restarting RX (#1361)"; - QMetaObject::invokeMethod(this, [this]() { - if (!m_audioSink) return; + QMetaObject::invokeMethod(this, [this, generation = m_rxSinkGeneration]() { + if (!m_audioSink || generation != m_rxSinkGeneration) { + return; + } stopRxStream(); startRxStream(); }, Qt::QueuedConnection); @@ -2203,8 +2205,10 @@ void AudioEngine::drainRxAudio(qsizetype freeBytes) qCWarning(lcAudio) << "AudioEngine: no audio data received for" << m_lastAudioFeedTime.elapsed() << "ms, restarting RX (#1411)"; m_lastAudioFeedTime.start(); // prevent repeated rapid restarts - QMetaObject::invokeMethod(this, [this]() { - if (!m_audioSink) return; + QMetaObject::invokeMethod(this, [this, generation = m_rxSinkGeneration]() { + if (!m_audioSink || generation != m_rxSinkGeneration) { + return; + } stopRxStream(); startRxStream(); }, Qt::QueuedConnection); @@ -2738,8 +2742,10 @@ void AudioEngine::drainRxAudio(qsizetype freeBytes) qCWarning(lcAudio) << "AudioEngine: sink appears stale (processedUSecs stuck at" << processed << "for" << kStaleTickThreshold * 10 << "ms), restarting RX (#1569)"; - QMetaObject::invokeMethod(this, [this]() { - if (!m_audioSink) return; + QMetaObject::invokeMethod(this, [this, generation = m_rxSinkGeneration]() { + if (!m_audioSink || generation != m_rxSinkGeneration) { + return; + } stopRxStream(); startRxStream(); }, Qt::QueuedConnection); @@ -4116,6 +4122,35 @@ bool AudioEngine::startRxStream() m_lastProcessedUSecs = 0; m_lastAudioFeedTime.start(); // initialize liveness watchdog (#1411) + const int producerRate = mainPcmSourceOwnsDisplay() + ? m_rxProducerRate.load() : DEFAULT_SAMPLE_RATE; + if (!openRxSink(producerRate)) { + return false; + } + // Open the dedicated sidetone + Quindar local sinks alongside RX. Cheap when + // disabled (timers write silence to a tiny primed buffer). NOTE: the old + // Windows branch returned before startQuindarLocalSink(), so the Quindar + // local monitor never opened on Windows — unifying the path fixes that. + startSidetoneStream(); + startQuindarLocalSink(); + emit rxStarted(); + return true; +} + +bool AudioEngine::openRxSink(int producerRate) +{ + ++m_rxSinkGeneration; + m_rxZombieTickCount = 0; + m_rxStaleTickCount = 0; + m_lastProcessedUSecs = 0; + const bool opened = m_rxSinkOpener + ? m_rxSinkOpener(producerRate) : openRxSinkDevice(producerRate); + m_rxSinkProducerRate = opened ? producerRate : 0; + return opened; +} + +bool AudioEngine::openRxSinkDevice(int producerRate) +{ QAudioDevice dev = QMediaDevices::defaultAudioOutput(); bool rxFallbackOccurred = false; QStringList rxFallbackReasons; @@ -4192,14 +4227,15 @@ bool AudioEngine::startRxStream() // Negotiate the output format via the consolidated factory (#3306). RX audio // is written as Float PCM, so we walk only the Float rungs of the ladder — // but the ladder supplies, in ONE place with no per-OS #ifdef: the preferred - // rate (Windows/macOS 48k to dodge the WASAPI 24k resampler artifacts #2120 - // and keep macOS A2DP devices off the HFP/telephony route; Linux native 24k), + // rate (native 48k producers first; otherwise the existing per-OS 24k + // producer policy, including Windows/macOS 48k), // the universal 44.1 kHz fallback (#3385), and the device preferredFormat // catch-all. Each rung is tried with a real start(), so reliable backends and // WASAPI's probe-at-open are handled identically. const QList rxLadder = AudioDeviceNegotiator::formatLadder( dev, AudioFormatNegotiator::Direction::Output, - AudioFormatNegotiator::ResamplerPolicy::PreservePan); + AudioFormatNegotiator::ResamplerPolicy::PreservePan, + AudioFormatNegotiator::hostTargetOs(), producerRate); m_audioSink = nullptr; m_audioDevice = nullptr; @@ -4266,8 +4302,8 @@ bool AudioEngine::startRxStream() // (#1149 / #1303). IdleState restart removed — it looped on Windows (#1405); // the zombie-sink watchdog handles stale WASAPI sessions after idle/sleep. connect(m_audioSink, &QAudioSink::stateChanged, this, - [this](QAudio::State state) { - if (state != QAudio::StoppedState) { + [this, generation = m_rxSinkGeneration](QAudio::State state) { + if (generation != m_rxSinkGeneration || state != QAudio::StoppedState) { return; } m_audioDevice = nullptr; @@ -4280,8 +4316,10 @@ bool AudioEngine::startRxStream() << error; return; } - QMetaObject::invokeMethod(this, [this]() { - if (!m_audioSink) return; + QMetaObject::invokeMethod(this, [this, generation]() { + if (!m_audioSink || generation != m_rxSinkGeneration) { + return; + } qCWarning(lcAudio) << "AudioEngine: QAudioSink stopped unexpectedly, restarting RX (#1303)"; stopRxStream(); startRxStream(); @@ -4299,16 +4337,87 @@ bool AudioEngine::startRxStream() summary.fallbackOccurred = rxFallbackOccurred; summary.fallbackReason = rxFallbackReasons.join(QStringLiteral("; ")); AudioSummaryLogger::logRxSink(summary); - // Open the dedicated sidetone + Quindar local sinks alongside RX. Cheap when - // disabled (timers write silence to a tiny primed buffer). NOTE: the old - // Windows branch returned before startQuindarLocalSink(), so the Quindar - // local monitor never opened on Windows — unifying the path fixes that. - startSidetoneStream(); - startQuindarLocalSink(); - emit rxStarted(); return true; } +void AudioEngine::requestRxSinkRate() +{ + // Device lifecycle runs only on the existing AudioEngine owner thread. + // Retirement can also be noticed by an auxiliary ingress caller. + if (QThread::currentThread() != thread()) { + QMetaObject::invokeMethod(this, &AudioEngine::requestRxSinkRate, + Qt::QueuedConnection); + return; + } + if (!m_audioDevice || m_rxSinkProducerRate == 0) { + return; // receiving PCM must not start a stopped speaker + } + const int producerRate = m_rxProducerRate.load(); + if (producerRate == m_rxSinkProducerRate) { + m_pendingRxSinkRate.reset(); + return; + } + const QList ladder = + AudioFormatNegotiator::buildLadder( + AudioFormatNegotiator::hostTargetOs(), + AudioFormatNegotiator::Direction::Output, {}, + AudioFormatNegotiator::ResamplerPolicy::PreservePan, producerRate); + if (!ladder.isEmpty() && ladder.first().rate == m_rxOutputRate.load()) { + // Windows/macOS may already have opened the preferred 48 kHz sink + // before the first native48 frame arrived. Preserve that live sink. + m_rxSinkProducerRate = producerRate; + m_pendingRxSinkRate.reset(); + return; + } + RxSinkRateRequest request{producerRate, m_rxSinkGeneration, std::nullopt}; + if (mainPcmSourceOwnsDisplay()) { + request.lease = m_mainPcmFrame->epochLease(); + } + m_pendingRxSinkRate = std::move(request); + if (!m_rxSinkRateChangeQueued) { + m_rxSinkRateChangeQueued = true; + QMetaObject::invokeMethod(this, &AudioEngine::applyPendingRxSinkRate, + Qt::QueuedConnection); + } +} + +void AudioEngine::applyPendingRxSinkRate() +{ + m_rxSinkRateChangeQueued = false; + const std::optional pending = std::move(m_pendingRxSinkRate); + m_pendingRxSinkRate.reset(); + if (!pending || !m_audioDevice || m_rxSinkProducerRate == 0 + || pending->sinkGeneration != m_rxSinkGeneration + || pending->producerRate != m_rxProducerRate.load()) { + return; + } + if (pending->lease) { + if (!pending->lease->current() || !mainPcmSourceOwnsDisplay() + || pending->lease->stream() != m_mainPcmFrame->stream()) { + return; + } + } else if (mainPcmSourceOwnsDisplay() || pending->producerRate != DEFAULT_SAMPLE_RATE) { + return; + } + // Only the speaker changes format. Dedicated CW/Quindar sinks and their + // timers remain live; setRxDeviceRate expires old device-domain queues. + const int previousRate = m_rxSinkProducerRate; + closeRxSink(); + if (openRxSink(pending->producerRate)) { + return; + } + qCWarning(lcAudio) << "AudioEngine: speaker renegotiation failed, retrying previous policy at" + << previousRate << "Hz"; + if (openRxSink(previousRate)) { + // The restored device still resamples the current producer. Remember + // its accepted domain so later epochs do not repeat this failed reopen. + m_rxSinkProducerRate = pending->producerRate; + } else { + qCWarning(lcAudio) << "AudioEngine: previous speaker policy also failed;" + " reconnect or select an output device to retry"; + } +} + void AudioEngine::stopRxStream() { stopSidetoneStream(); @@ -4340,22 +4449,32 @@ void AudioEngine::stopRxStream() m_rxBufferSampleRate.store(DEFAULT_SAMPLE_RATE); m_rxPlaybackQueuedMs.store(0, std::memory_order_relaxed); + closeRxSink(); + emit rxStopped(); +} + +void AudioEngine::closeRxSink() +{ + ++m_rxSinkGeneration; + m_pendingRxSinkRate.reset(); + m_rxSinkProducerRate = 0; + m_audioDevice = nullptr; + m_rxPlaybackQueuedMs.store(0, std::memory_order_relaxed); if (m_audioSink) { // Null out m_audioSink BEFORE stopping so that the stateChanged - // handler's "if (!m_audioSink) return" guard prevents a cascading + // handler's generation/sink guard prevents a cascading // restart loop. Without this, stop() emits stateChanged(StoppedState) // synchronously while m_audioSink is still non-null, causing the // handler to queue another stopRx+startRx — which repeats // indefinitely and prevents audio from ever playing. (#1441) auto* sink = m_audioSink; m_audioSink = nullptr; - m_audioDevice = nullptr; // Guard: same stale-device-handle crash can occur on the RX side (#1059). - if (sink->state() != QAudio::StoppedState) + if (sink->state() != QAudio::StoppedState) { sink->stop(); + } delete sink; } - emit rxStopped(); } void AudioEngine::setRxVolume(float v) @@ -4823,39 +4942,35 @@ bool AudioEngine::retireInvalidPcmSources() flushRxDevice(); updateRxBufferStats(); } + // A revoked epoch can be a brief filter/park transition. Keep the sink + // policy until an accepted replacement or actual legacy PCM adopts a rate. return retired; } void AudioEngine::setRxDeviceRate(int rate) { - // Called only after successful output negotiation. The producer rate is - // unchanged; old device-format bytes and converter history expire. The NR chain - // is deliberately not rebuilt: this runs on every sink open, including the - // #1361/#1411 watchdog recoveries on the GUI thread, and NR belongs to the - // producer domain; a model load here would freeze the UI. + // Only already-converted bytes and converter histories belong to the old + // device format. Raw queues, packet tails and effect/NR histories remain + // in each producer's domain and survive a speaker-only renegotiation. + // Full start/stop and source retirement reset their own producer state. std::lock_guard lock(m_dspMutex); m_rxOutputRate.store(rate); - resetMainPcmState(m_rxProducerRate.load(), /*rebuildDsp=*/false); + m_rxBufferSampleRate.store(rate); + m_rxOutputBuffer.clear(); + m_rxResampler.reset(); + m_rxResamplerR.reset(); m_radeRxBuffer.clear(); m_radeRxResampler.reset(); - m_kiwiSdrRxBuffer.clear(); - m_kiwiSdrRxPackets.clear(); m_kiwiSdrOutputBuffer.clear(); m_kiwiSdrRxResampler.reset(); m_kiwiSdrRxResamplerR.reset(); - resetLegacyKiwiDspState(); for (const auto& source : m_externalKiwiSources) { if (!source) { continue; } - source->rxBuffer.clear(); - source->rxPackets.clear(); source->outputBuffer.clear(); - source->nr2Output.clear(); source->rxResampler.reset(); source->rxResamplerR.reset(); - resetExternalKiwiDspState(*source); - source->prebuffering = true; } updateRxBufferStats(); } @@ -4882,6 +4997,9 @@ void AudioEngine::feedPcmFrame(const PcmFrame& frame) flushRxDevice(); } m_mainPcmFrame = frame; + if (transition) { + requestRxSinkRate(); + } const int channels = frame.stream().format.channels(); QByteArray pcm(frame.frameCount() * 2 * sizeof(float), Qt::Uninitialized); auto* output = reinterpret_cast(pcm.data()); @@ -4964,6 +5082,9 @@ void AudioEngine::feedAudioData(const QByteArray& pcm) if (m_rxProducerRate.load() != DEFAULT_SAMPLE_RATE) { resetMainPcmState(DEFAULT_SAMPLE_RATE); } + if (pcm.size() >= 2 * static_cast(sizeof(float))) { + requestRxSinkRate(); + } captureAutomationAudio(QStringLiteral("raw"), QStringLiteral("flex"), QString(), pcm, DEFAULT_SAMPLE_RATE, 2); processRxAudioData(pcm, true); diff --git a/src/core/AudioEngine.h b/src/core/AudioEngine.h index bc164c7911..2b01139599 100644 --- a/src/core/AudioEngine.h +++ b/src/core/AudioEngine.h @@ -1041,6 +1041,26 @@ private slots: // RX QAudioSink* m_audioSink{nullptr}; QPointer m_audioDevice; // sink-owned device, may vanish on hot-unplug + // A negotiated fallback still satisfies one producer-domain attempt. Keep + // this separate from m_rxOutputRate so a 48 -> 24 fallback cannot reopen on + // every packet or same-rate epoch. Zero means no managed speaker is open. + int m_rxSinkProducerRate{0}; + quint64 m_rxSinkGeneration{0}; + struct RxSinkRateRequest { + int producerRate{DEFAULT_SAMPLE_RATE}; + quint64 sinkGeneration{0}; + std::optional lease; + }; + std::optional m_pendingRxSinkRate; + bool m_rxSinkRateChangeQueued{false}; + // The existing friend fixture injects just the external device-open + // boundary. An empty callback uses the real QAudioSink opener below. + std::function m_rxSinkOpener; + bool openRxSink(int producerRate); + bool openRxSinkDevice(int producerRate); + void closeRxSink(); + void requestRxSinkRate(); + void applyPendingRxSinkRate(); // Dedicated low-latency sink for the local CW sidetone — kept separate // from the RX sink so the RX path keeps its 100 ms jitter cushion. diff --git a/src/core/AudioFormatNegotiator.cpp b/src/core/AudioFormatNegotiator.cpp index 28851e721d..6a44897fb0 100644 --- a/src/core/AudioFormatNegotiator.cpp +++ b/src/core/AudioFormatNegotiator.cpp @@ -16,9 +16,16 @@ namespace { // 44.1k rung and the device-preferred rung are appended by buildLadder() so // every sink gets the SAME complete fallback set (no more "Quindar has no // fallback / RX never tries 44.1k" divergence — #3306, #3385). -QList primaryRateOrder(TargetOs os, Direction dir, int internalRate) +QList primaryRateOrder(TargetOs os, Direction dir, + ResamplerPolicy policy, int internalRate) { if (dir == Direction::Output) { + if (internalRate == 48000 && policy == ResamplerPolicy::PreservePan) { + // Preserve a native 48 kHz stereo producer's bandwidth when possible. + // Keep 24 kHz as an explicit last fallback: an advertised preferred + // 48 kHz format does not guarantee that opening it will succeed. + return {48000, 44100, 24000}; + } switch (os) { // Windows: force 48k — WASAPI's shared-mode resampler adds artifacts at // 24k that become audible once radio-side NR removes the noise floor; @@ -29,8 +36,8 @@ QList primaryRateOrder(TargetOs os, Direction dir, int internalRate) case TargetOs::MacOS: return {48000, internalRate}; // Linux: native 24k is fine (no WASAPI resampler in the path) — avoid an // unnecessary upsample. Deliberate, documented divergence from Win/Mac. - // RX is still canonically 24 kHz, so this stays 24k-first even though - // the Linux *input* ladder now leads with 48k for the TX voice strip. + // Callers using the default 24 kHz producer retain this order even + // though the Linux input ladder leads with 48k for the TX voice strip. case TargetOs::Linux: return {internalRate, 48000}; } } else { // Input (mic / TX capture) @@ -206,7 +213,7 @@ QList buildLadder(TargetOs os, } // Main per-OS rate order × format order. - const QList rates = primaryRateOrder(os, dir, internalRate); + const QList rates = primaryRateOrder(os, dir, policy, internalRate); for (int rate : rates) { for (SampleFmt fmt : fmts) { QString reason = (rate == rates.first()) diff --git a/src/core/RtlSliceSettings.cpp b/src/core/RtlSliceSettings.cpp index 63d2ec4dff..416b4ea822 100644 --- a/src/core/RtlSliceSettings.cpp +++ b/src/core/RtlSliceSettings.cpp @@ -53,6 +53,8 @@ QJsonObject encodeSlice(const RtlSliceSettings::Slice& slice, QJsonObject previo previous.insert(QStringLiteral("audioGain"), slice.audioGain); previous.insert(QStringLiteral("audioMute"), slice.audioMute); previous.insert(QStringLiteral("audioPan"), slice.audioPan); + previous.insert(QStringLiteral("wfmDeemphasisUs"), slice.wfmDeemphasisUs); + previous.insert(QStringLiteral("wfmForceMono"), slice.wfmForceMono); return previous; } @@ -112,6 +114,16 @@ bool RtlSliceSettings::decode(const QJsonObject& object, Document& output, QStri || !entry.value(QStringLiteral("squelch")).isObject()) { return false; } + if (entry.contains(QStringLiteral("wfmDeemphasisUs"))) { + double microseconds = 0; + if (!number(entry, QStringLiteral("wfmDeemphasisUs"), 50, 75, microseconds, true) + || (microseconds != 50 && microseconds != 75)) { return false; } + slice.wfmDeemphasisUs = static_cast(microseconds); + } + if (entry.contains(QStringLiteral("wfmForceMono"))) { + if (!entry.value(QStringLiteral("wfmForceMono")).isBool()) { return false; } + slice.wfmForceMono = entry.value(QStringLiteral("wfmForceMono")).toBool(); + } const QJsonObject squelch = entry.value(QStringLiteral("squelch")).toObject(); if (!squelch.value(QStringLiteral("enabled")).isBool() || !percent(squelch, QStringLiteral("level"), slice.squelchLevel)) { diff --git a/src/core/RtlSliceSettings.h b/src/core/RtlSliceSettings.h index e6b15bddb3..f8a5d3049a 100644 --- a/src/core/RtlSliceSettings.h +++ b/src/core/RtlSliceSettings.h @@ -31,6 +31,9 @@ class RtlSliceSettings { int audioGain = 50; bool audioMute = false; int audioPan = 50; + // Optional in schema 1; old documents retain the legacy 75 us default. + int wfmDeemphasisUs = 75; + bool wfmForceMono = false; }; struct Document { double captureCenterHz = 0; diff --git a/src/core/WfmReceptionDiagnostics.h b/src/core/WfmReceptionDiagnostics.h new file mode 100644 index 0000000000..89827e17cb --- /dev/null +++ b/src/core/WfmReceptionDiagnostics.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace AetherSDR { +// Last completed WDSP decoder block, carried under the receiver PCM lifetime. +// The pilot indicator is a hysteretic coherent-magnitude detector, NOT a PLL. +// Magnitude is in normalized FM-discriminator units (75 kHz peak deviation = +// unity), measured after the 19 kHz filter and before pilot AGC. Its exact +// double value is neither RF strength, SNR nor a quality score. +// Durations use processed DSP samples, not wall-clock/audio-device time. +// Counts exclude the first acquisition, saturate at INT32_MAX, and reset on +// decoder construction/flush. Stable duration is capped at a five-second +// window and resets on either pilot-indicator transition. No pilot is normal +// mono fallback, not evidence of poor reception. Invalid snapshots carry no +// current reception claim. The backend expires them after 500 ms without data. +struct WfmReceptionDiagnostics { + bool valid = false; + double pilotMagnitude = 0.0; + bool pilotLocked = false; + std::uint32_t lockDurationMs = 0; + std::uint32_t lockLossCount = 0; + std::uint32_t reacquisitionCount = 0; + std::uint32_t observationDurationMs = 0; + std::uint32_t stableDurationMs = 0; + double pilotEngageThreshold = 0.0; + double pilotReleaseThreshold = 0.0; + std::uint32_t consecutiveHighBlocks = 0; + std::uint32_t consecutiveLowBlocks = 0; + std::uint32_t engageBlocks = 0; + std::uint32_t releaseBlocks = 0; + std::uint32_t observationSequence = 0; // changes only after a completed DSP block + bool operator==(const WfmReceptionDiagnostics&) const = default; +}; +} // namespace AetherSDR diff --git a/src/core/backends/IRadioBackend.h b/src/core/backends/IRadioBackend.h index f264105e97..0f909e0b21 100644 --- a/src/core/backends/IRadioBackend.h +++ b/src/core/backends/IRadioBackend.h @@ -233,6 +233,8 @@ class IRadioBackend : public QObject { { return ReceiveDispatch::Unsupported; } virtual ReceiveDispatch requestSliceAudio(int, const SliceAudioRequest&) { return ReceiveDispatch::Unsupported; } + virtual ReceiveDispatch requestSliceWfm(int, const SliceWfmRequest&) + { return ReceiveDispatch::Unsupported; } virtual ReceiveDispatch requestSliceSquelch(int, const SliceSquelchRequest&) { return ReceiveDispatch::Unsupported; } virtual ReceiveDispatch requestSliceRxAntenna(int, const QString&) @@ -674,6 +676,14 @@ class IRadioBackend : public QObject { { Q_UNUSED(sliceId); Q_UNUSED(on); Q_UNUSED(level); } + // BroadcastFmReceive declares supported values; accepted state returns in + // SliceDelta only after the receiver adopts the requested configuration. + virtual void setSliceWfmForceMono(int sliceId, bool forceMono) + { Q_UNUSED(sliceId); Q_UNUSED(forceMono); } + virtual void setSliceWfmDeemphasis(int sliceId, int microseconds) + { + Q_UNUSED(sliceId); Q_UNUSED(microseconds); + } // CW audio peaking filter (capabilities().hasAudioPeakingFilter): the slice's // enable and 0..100 apf_level together; the backend owns the centre (its CW // pitch) and whether it runs in the current mode. Flex does not override it diff --git a/src/core/backends/RadioCapabilities.h b/src/core/backends/RadioCapabilities.h index b63fe79375..7b63a76554 100644 --- a/src/core/backends/RadioCapabilities.h +++ b/src/core/backends/RadioCapabilities.h @@ -63,6 +63,12 @@ struct ReceiveAudioControl { SliceFrequencyControl::Authority authority{SliceFrequencyControl::Authority::Unknown}; // Both gain (0..100) and mute must act on the shared slice's RX audio. }; +// Broadcast FM controls are available only when their typed request is supported. +struct BroadcastFmReceive { + QVector deemphasisUs; + bool forceMonoControl = false; + bool receptionDiagnostics = false; +}; struct ReceivePanRangeControl { SliceFrequencyControl::Authority authority{SliceFrequencyControl::Authority::Unknown}; qint64 minimumHz{0}; @@ -288,6 +294,7 @@ struct RadioCapabilities { std::optional receiveModeControl; std::optional receiveFilterControl; std::optional receiveAudioControl; + std::optional broadcastFmReceive; std::optional receivePanCenterControl; std::optional receivePanBandwidthControl; // Engaged when the radio can deliver a wideband converter view; see the diff --git a/src/core/backends/ReceiveCommand.h b/src/core/backends/ReceiveCommand.h index b480eabae9..8bcb8f274c 100644 --- a/src/core/backends/ReceiveCommand.h +++ b/src/core/backends/ReceiveCommand.h @@ -74,6 +74,18 @@ struct SliceAudioRequest { } }; +struct SliceWfmRequest { + enum class Field { ForceMono, Deemphasis }; + Field field{Field::ForceMono}; + int value{0}; + + bool valid() const + { + return (field == Field::ForceMono && (value == 0 || value == 1)) + || (field == Field::Deemphasis && (value == 50 || value == 75)); + } +}; + struct SliceSquelchRequest { bool enabled{false}; int level{0}; @@ -91,3 +103,4 @@ Q_DECLARE_METATYPE(AetherSDR::SliceAgcRequest) Q_DECLARE_METATYPE(AetherSDR::SliceDspRequest) Q_DECLARE_METATYPE(AetherSDR::SliceAudioRequest) Q_DECLARE_METATYPE(AetherSDR::SliceSquelchRequest) +Q_DECLARE_METATYPE(AetherSDR::SliceWfmRequest) diff --git a/src/core/backends/SliceDelta.h b/src/core/backends/SliceDelta.h index f16c005112..9f8d32afee 100644 --- a/src/core/backends/SliceDelta.h +++ b/src/core/backends/SliceDelta.h @@ -1,6 +1,7 @@ #pragma once #include +#include "core/WfmReceptionDiagnostics.h" #include #include @@ -13,6 +14,10 @@ namespace AetherSDR { // Existing backends retain their historical optimistic setter behavior. enum class ReceiveControlPolicy { Optimistic, Confirmed }; +// Observed broadcast output state: forced mono is Mono even with an acquired +// pilot. Independent pilot observations live in WfmReceptionDiagnostics. +enum class WfmStereoStatus { Unavailable, Acquiring, Mono, Stereo }; + // Normalized slice-status delta (aetherd RFC 2.3). A backend populates only the // fields the wire reported (engaged optional == present); SliceModel::applyChanges // applies exactly those. Canonical value types; the FlexBackend decode owns the @@ -93,6 +98,10 @@ struct SliceDelta { std::optional agcOffLevel; std::optional squelchOn; std::optional squelchLevel; + std::optional wfmDeemphasisUs; + std::optional wfmForceMono; + std::optional wfmReceptionDiagnostics; + std::optional wfmStereoStatus; std::optional ritOn; std::optional ritFreq; std::optional xitOn; @@ -133,3 +142,5 @@ struct SliceDelta { // to a synchronous DirectConnection, but the registration keeps it correct if a // backend is ever moved to a worker thread.) Q_DECLARE_METATYPE(AetherSDR::SliceDelta) +Q_DECLARE_METATYPE(AetherSDR::WfmStereoStatus) +Q_DECLARE_METATYPE(AetherSDR::WfmReceptionDiagnostics) diff --git a/src/core/backends/anan/AnanBackend.cpp b/src/core/backends/anan/AnanBackend.cpp index 9502a86149..3943aa796c 100644 --- a/src/core/backends/anan/AnanBackend.cpp +++ b/src/core/backends/anan/AnanBackend.cpp @@ -371,6 +371,7 @@ AnanBackend::~AnanBackend() RadioCapabilities AnanBackend::capabilities() const { RadioCapabilities c; + c.broadcastFmReceive = std::nullopt; c.family = QStringLiteral("anan"); // No setTune() implementation, so no tune generator to select a mode on. c.twoToneGenerator = std::nullopt; diff --git a/src/core/backends/flex/FlexBackend.cpp b/src/core/backends/flex/FlexBackend.cpp index 21c0eb5b0e..c47cbe930d 100644 --- a/src/core/backends/flex/FlexBackend.cpp +++ b/src/core/backends/flex/FlexBackend.cpp @@ -194,6 +194,7 @@ void FlexBackend::setRadioReportedCapacity(int maxSlices, int maxPanadapters) RadioCapabilities FlexBackend::capabilities() const { RadioCapabilities caps; + caps.broadcastFmReceive = std::nullopt; // FlexLib 4.2.18 Slice.Freq delegates range refusal to firmware; its old // bounds are commented out. Do not guess coverage (including transverters). caps.sliceFrequencyControl = {SliceFrequencyControl::Authority::Radio, 0, 0}; diff --git a/src/core/backends/hl2/Hl2Backend.cpp b/src/core/backends/hl2/Hl2Backend.cpp index cd1903f67d..8a7c7d0d81 100644 --- a/src/core/backends/hl2/Hl2Backend.cpp +++ b/src/core/backends/hl2/Hl2Backend.cpp @@ -1731,6 +1731,7 @@ AetherSDR::WidebandConverterView widebandConverterViewRecord() noexcept RadioCapabilities Hl2Backend::capabilities() const { RadioCapabilities c; + c.broadcastFmReceive = std::nullopt; c.canReboot = false; c.hasRemoteOnControl = false; c.canUpgradeFirmware = false; diff --git a/src/core/backends/icom/IcomCivBackend.cpp b/src/core/backends/icom/IcomCivBackend.cpp index ac2cff5fe4..04c32183c2 100644 --- a/src/core/backends/icom/IcomCivBackend.cpp +++ b/src/core/backends/icom/IcomCivBackend.cpp @@ -289,6 +289,7 @@ RadioCapabilities IcomCivBackend::capabilities() const const IcomModel& m = *m_model; const IcomModelProfile& profile = profileFor(m); RadioCapabilities c; + c.broadcastFmReceive = std::nullopt; c.fmDtcsCodes = {}; c.family = QStringLiteral("icom"); c.manufacturer = QStringLiteral("Icom"); diff --git a/src/core/backends/rtl/RtlAudioMixer.cpp b/src/core/backends/rtl/RtlAudioMixer.cpp index 56aa1908b2..6d6414ff90 100644 --- a/src/core/backends/rtl/RtlAudioMixer.cpp +++ b/src/core/backends/rtl/RtlAudioMixer.cpp @@ -83,13 +83,20 @@ void RtlAudioMixer::drain(std::uint64_t captureClock, Sink& sink) noexcept const float level = slot.input.mute ? 0 : slot.input.gain / static_cast(m_count); const float leftGain = level * std::min(1.0f, 2 * (1 - slot.input.pan)); const float rightGain = level * std::min(1.0f, 2 * slot.input.pan); + MissingMask missing{}; for (std::size_t i = 0; i < kQuantum; ++i) { Frame& frame = slot.frames[(m_next + i) % kCapacity]; if (frame.valid && frame.position == m_next + i) { m_output[2 * i] += frame.left * leftGain; m_output[2 * i + 1] += frame.right * rightGain; frame.valid = false; - } else { ++m_late; } + } else { + ++m_late; + missing[i / 64] |= std::uint64_t{1} << (i % 64); + } + } + if (missing[0] != 0 || missing[1] != 0) { + sink.missingFrames(slot.input, m_next, captureClock, missing); } } for (float& value : m_output) { value = std::clamp(value, -1.0f, 1.0f); } diff --git a/src/core/backends/rtl/RtlAudioMixer.h b/src/core/backends/rtl/RtlAudioMixer.h index 66f78ab974..fde7b73187 100644 --- a/src/core/backends/rtl/RtlAudioMixer.h +++ b/src/core/backends/rtl/RtlAudioMixer.h @@ -22,11 +22,18 @@ class RtlAudioMixer final { float pan = 0.5f; bool mute = false; }; + using MissingMask = std::array; + static_assert(kQuantum == 128); class Sink { public: virtual ~Sink() = default; virtual void speakerBlock(std::uint64_t firstSample, std::span interleaved, bool discontinuity) noexcept = 0; + // Acquisition-owned fixed data only. Bit n names the exact missing + // contribution at firstSample+n; reporting cannot affect the deadline. + virtual void missingFrames(const Input&, std::uint64_t firstSample, + std::uint64_t captureClock, const MissingMask&) noexcept + { (void)firstSample; (void)captureClock; } }; bool configure(std::uint64_t session, std::uint64_t captureGeneration, std::span inputs, std::uint64_t firstSample) noexcept; diff --git a/src/core/backends/rtl/RtlCaptureTransaction.cpp b/src/core/backends/rtl/RtlCaptureTransaction.cpp index 03db08e814..70a83339ee 100644 --- a/src/core/backends/rtl/RtlCaptureTransaction.cpp +++ b/src/core/backends/rtl/RtlCaptureTransaction.cpp @@ -242,6 +242,7 @@ RtlCaptureTransaction::Submission RtlCaptureTransaction::submit(const Desired& d || receiver.audioGain < 0 || receiver.audioGain > 100 || receiver.audioPan < 0 || receiver.audioPan > 100 || receiver.squelchLevel < 0 || receiver.squelchLevel > 100 + || (receiver.wfmDeemphasisUs != 50 && receiver.wfmDeemphasisUs != 75) || (receiver.squelchEnabled && receiver.mode != Mode::Fm && receiver.mode != Mode::Fmn)) { return {{}, Policy::Error::InvalidNumber}; } diff --git a/src/core/backends/rtl/RtlCaptureTransaction.h b/src/core/backends/rtl/RtlCaptureTransaction.h index 0c822220ea..81b6697480 100644 --- a/src/core/backends/rtl/RtlCaptureTransaction.h +++ b/src/core/backends/rtl/RtlCaptureTransaction.h @@ -31,6 +31,8 @@ class RtlCaptureTransaction final { bool audioMute = false; bool squelchEnabled = false; int squelchLevel = 20; + int wfmDeemphasisUs = 75; // broadcast standard; validated 50 or 75 only + bool wfmForceMono = false; // selected decoder matrix policy, not observed pilot bool operator==(const Receiver&) const = default; }; struct Desired { diff --git a/src/core/backends/rtl/RtlReceivePipeline.cpp b/src/core/backends/rtl/RtlReceivePipeline.cpp index 2039a70c76..c9096a0693 100644 --- a/src/core/backends/rtl/RtlReceivePipeline.cpp +++ b/src/core/backends/rtl/RtlReceivePipeline.cpp @@ -2,9 +2,28 @@ #include #include #include +#include namespace AetherSDR::rtl { namespace { +std::optional mixerOrigin(std::uint64_t firstSample, double rateHz) noexcept +{ + // The extractor accepts only integral, bounded achieved rates. Validate + // before casting and share its alignment so these clocks cannot diverge. + if (!std::isfinite(rateHz) || rateHz != std::floor(rateHz) + || rateHz < 225001 || rateHz > 3000000) { return std::nullopt; } + const auto rate = static_cast(rateHz); + const auto captureFirst = RtlRfExtractor::alignedCaptureFirst(firstSample, rate, 48000); + if (!captureFirst) { return std::nullopt; } + const std::uint64_t common = std::gcd(rate, std::uint64_t{48000}); + const std::uint64_t periods = *captureFirst / (rate / common); + const std::uint64_t framesPerPeriod = 48000 / common; + constexpr std::uint64_t margin = RtlAudioMixer::kQuantum + RtlAudioMixer::kDeadlineFrames; + if (periods > (std::numeric_limits::max() - margin) / framesPerPeriod) { + return std::nullopt; + } + return periods * framesPerPeriod; +} WdspChannel::Mode dspMode(RtlCaptureTransaction::Mode mode) { using M = RtlCaptureTransaction::Mode; @@ -12,6 +31,7 @@ WdspChannel::Mode dspMode(RtlCaptureTransaction::Mode mode) case M::Am: return WdspChannel::Mode::Am; case M::Sam: return WdspChannel::Mode::Sam; case M::Fm: case M::Fmn: return WdspChannel::Mode::Fm; + case M::Wfm: return WdspChannel::Mode::Wbfm; case M::Lsb: return WdspChannel::Mode::Lsb; case M::Cw: return WdspChannel::Mode::Cwu; case M::Cwr: return WdspChannel::Mode::Cwl; @@ -19,8 +39,8 @@ WdspChannel::Mode dspMode(RtlCaptureTransaction::Mode mode) } } } -RtlReceivePipeline::RtlReceivePipeline(std::size_t capacity) - : m_registry({8, capacity, std::min(32, capacity * 2)}) +RtlReceivePipeline::RtlReceivePipeline(std::size_t capacity, bool enableWfm) + : m_enableWfm(enableWfm), m_registry({8, capacity, std::min(32, capacity * 2)}) { for (auto& monitor : m_monitor) { monitor.store(100 | (50 << 8)); } } @@ -34,13 +54,19 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( std::array validated{}; for (const auto& receiver : state.receivers) { const int id = receiver.passband.stableId; - if (id < 0 || id >= 8 || validated[id] || receiver.audioGain < 0 + if (id < 0 || id >= 8 || validated[id] + || receiver.mode < Transaction::Mode::Am || receiver.mode > Transaction::Mode::Cwr + || receiver.audioGain < 0 || receiver.audioGain > 100 || receiver.audioPan < 0 || receiver.audioPan > 100 - || receiver.squelchLevel < 0 || receiver.squelchLevel > 100) { return Submission::Failed; } + || receiver.squelchLevel < 0 || receiver.squelchLevel > 100 + || (receiver.wfmDeemphasisUs != 50 && receiver.wfmDeemphasisUs != 75) + || (receiver.mode == Transaction::Mode::Wfm && receiver.squelchEnabled)) { return Submission::Failed; } validated[id] = true; } + const unsigned faults = m_faults.load(std::memory_order_acquire); std::array receiving{}; bool legacy = false; + bool wfmTransition = false; std::uint8_t receivingMask = 0; for (int id : state.receivingIds) { if (id < 0 || id >= 8 || receiving[id] || !validated[id]) { return Submission::Failed; } @@ -50,12 +76,29 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( return value.passband.stableId == id; }); legacy = legacy || (receiver->mode != Transaction::Mode::Fm - && receiver->mode != Transaction::Mode::Fmn); + && receiver->mode != Transaction::Mode::Fmn + && !(m_enableWfm && receiver->mode == Transaction::Mode::Wfm)); if (legacy && receiver->squelchEnabled) { return Submission::Failed; } + const auto& prior = m_specs[id]; + const bool wide = m_enableWfm && receiver->mode == Transaction::Mode::Wfm; + if (wide || prior.dsp.wbfmReceive) { + const auto deemphasis = receiver->wfmDeemphasisUs == 50 + ? WdspChannel::WbfmReceive::Deemphasis::Us50 + : WdspChannel::WbfmReceive::Deemphasis::Us75; + wfmTransition |= (wide && (faults & (1u << id))) + || !wide || !prior.dsp.wbfmReceive + || prior.passband != receiver->passband + || prior.dsp.wbfmReceive->deemphasis != deemphasis + || prior.dsp.wbfmReceive->forceMono != receiver->wfmForceMono; + } } if (legacy && (state.receivers.size() != 1 || state.receivingIds.size() != 1)) { return Submission::Failed; } + // WFM changes the causal decoder graph even when final PCM stays 48 kHz. + // Give the speaker a discontinuity too, retiring the previous graph's + // queued waveform and client-effect history instead of appending its tail. + // A withdrawn WFM receiver has lost that graph even if its recipe is unchanged. const bool advanceCaptureEpoch = resetCapture || legacy != m_legacy - || receivingMask != m_receivingMask; + || receivingMask != m_receivingMask || wfmTransition; if (advanceCaptureEpoch && m_nextEpoch == std::numeric_limits::max()) { return Submission::Failed; } auto epochs = m_epochs; auto specs = m_specs; @@ -64,7 +107,6 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( m_reader = m_registry.attachReader(); if (!m_session || !m_reader) { return Submission::Failed; } } - const unsigned faults = m_faults.load(std::memory_order_acquire); std::array desired; std::array used{}; std::size_t count = 0; @@ -88,8 +130,21 @@ RtlReceivePipeline::Submission RtlReceivePipeline::prepareDetailed( spec.handle = *m_handles[id]; spec.passband = receiver.passband; spec.capture = state.capture; spec.extractRf = true; spec.dsp.mode = dspMode(receiver.mode); - spec.dsp.fmReceive = WdspChannel::FmReceive{}; - spec.dsp.fmDeviationHz = receiver.mode == Transaction::Mode::Fmn ? 2500.0 : 5000.0; + if (receiver.mode == Transaction::Mode::Wfm) { + spec.dsp.inputSampleRate = 384000; + spec.dsp.inputBlockSize = 2048; + spec.dsp.dspSampleRate = 192000; + spec.dsp.dspBlockSize = 1024; + spec.dsp.outputSampleRate = 48000; + spec.dsp.wbfmReceive = WdspChannel::WbfmReceive{}; + spec.dsp.wbfmReceive->forceMono = receiver.wfmForceMono; + spec.dsp.wbfmReceive->deemphasis = receiver.wfmDeemphasisUs == 50 + ? WdspChannel::WbfmReceive::Deemphasis::Us50 + : WdspChannel::WbfmReceive::Deemphasis::Us75; + } else { + spec.dsp.fmReceive = WdspChannel::FmReceive{}; + spec.dsp.fmDeviationHz = receiver.mode == Transaction::Mode::Fmn ? 2500.0 : 5000.0; + } spec.dsp.filterLowHz = receiver.passband.filterLowHz; spec.dsp.filterHighHz = receiver.passband.filterHighHz; if (resetCapture || (faults & (1u << id)) || m_specs[id].handle != spec.handle @@ -130,6 +185,9 @@ RtlReceivePipeline::Preparation RtlReceivePipeline::service() bool RtlReceivePipeline::adopt() noexcept { if (!m_reader.adoptPrepared(m_session, m_nextCapture, m_prepared)) { return false; } + if (m_token.session != m_nextToken.session || m_captureEpoch != m_nextEpoch) { + m_mixerOrigin.reset(); + } const bool resetSquelch = m_captureEpoch != m_nextEpoch; for (std::size_t slot = 0; slot < m_monitor.size(); ++slot) { m_monitor[slot].store(m_nextMonitor[slot], std::memory_order_relaxed); @@ -170,18 +228,24 @@ void RtlReceivePipeline::process(const RtlReceiverRegistry::SampleBlock& block, for (std::size_t i = 0; i < views.size(); ++i) { const auto& spec = *views[i].spec; const unsigned monitor = m_monitor[spec.handle.slot].load(std::memory_order_relaxed); + m_traceStableIds[spec.handle.slot] = spec.passband.stableId; inputs[i] = {spec.handle.slot, spec.handle.instance, spec.epoch, (monitor & 255) / 100.0f, ((monitor >> 8) & 255) / 100.0f, (monitor & (1 << 16)) != 0}; } const auto clock = [this](std::uint64_t sample) { return static_cast(static_cast(sample) * 48000 / m_capture.achievedSampleRateHz); }; - if (!m_mixer.configure(m_token.session, m_captureEpoch, - std::span(inputs).first(views.size()), clock(block.firstSample))) { + m_traceCaptureFirst = block.firstSample; + m_traceCaptureFrames = block.samples.size(); + m_traceCaptureClock = clock(block.firstSample + block.samples.size()); + if (!m_mixerOrigin) { m_mixerOrigin = mixerOrigin(block.firstSample, m_capture.achievedSampleRateHz); } + if (!m_mixerOrigin || !m_mixer.configure(m_token.session, m_captureEpoch, + std::span(inputs).first(views.size()), *m_mixerOrigin)) { // No stale map/audio may survive a rejected configuration. This is a // defensive invariant failure: normal registry handles and validated // transaction tokens cannot reach it. Repair through the existing owner. m_mixer.reset(); + m_mixerOrigin.reset(); m_mixerConfigurationFailures.fetch_add(1, std::memory_order_relaxed); m_observed.store(true, std::memory_order_release); m_faults.fetch_or(0xff, std::memory_order_release); @@ -194,7 +258,7 @@ void RtlReceivePipeline::process(const RtlReceiverRegistry::SampleBlock& block, gate.configure(m_squelchConfig[slot].enabled, m_squelchConfig[slot].level, true); m_squelchEpoch[slot] = view.spec->epoch; } - if (m_spectrumFresh) { + if (m_spectrumFresh && !view.spec->dsp.wbfmReceive) { const auto& passband = view.spec->passband; const double binHz = m_capture.achievedSampleRateHz / m_spectrum.size(); const double offset = passband.carrierHz - m_capture.centerHz; @@ -212,11 +276,19 @@ void RtlReceivePipeline::process(const RtlReceiverRegistry::SampleBlock& block, gate.observe(peak, clock(m_spectrumFirstSample)); } if (!view.receiver->processCapture(block, *this)) { - m_faults.fetch_or(1u << view.spec->handle.slot, std::memory_order_release); + const unsigned bit = 1u << slot; + if (!(m_faults.fetch_or(bit, std::memory_order_release) & bit)) { + TraceEvent event = traceContext(); + event.slot = slot; event.stableId = view.spec->passband.stableId; + event.instance = view.spec->handle.instance; + event.receiverEpoch = view.spec->epoch; + event.failure = view.receiver->processingFailure(); + enqueueTrace(event); + } } } m_spectrumFresh = false; - m_mixer.drain(clock(block.firstSample + block.samples.size()), *this); + m_mixer.drain(m_traceCaptureClock, *this); m_mixerLate.store(m_mixer.lateFrames(), std::memory_order_relaxed); m_mixerRejected.store(m_mixer.rejectedBlocks(), std::memory_order_relaxed); m_observed.store(true, std::memory_order_release); @@ -226,20 +298,34 @@ RtlReceivePipeline::Diagnostics RtlReceivePipeline::diagnostics() const noexcept return {m_observed.load(std::memory_order_acquire), m_drops.load(std::memory_order_relaxed), m_mixerLate.load(std::memory_order_relaxed), m_mixerRejected.load(std::memory_order_relaxed), - m_mixerConfigurationFailures.load(std::memory_order_relaxed)}; + m_mixerConfigurationFailures.load(std::memory_order_relaxed), + m_traceDrops.load(std::memory_order_relaxed)}; } void RtlReceivePipeline::audioBlock(const RtlReceiverRegistry::ReceiverSpec& spec, std::uint64_t first, std::span left, std::span right, bool discontinuity) noexcept +{ + audioBlockWithStatus(spec, first, left, right, discontinuity, std::nullopt); +} +void RtlReceivePipeline::audioBlockWithStatus(const RtlReceiverRegistry::ReceiverSpec& spec, + std::uint64_t first, std::span left, std::span right, + bool discontinuity, std::optional reception) noexcept { Packet packet; packet.token = m_token; packet.captureEpoch = m_captureEpoch; packet.instance = spec.handle.instance; packet.receiverEpoch = spec.epoch; packet.slot = spec.handle.slot; packet.firstSample = first; packet.frames = left.size(); packet.discontinuity = discontinuity; + packet.wfmReception = reception; + if (reception) { + packet.wfmStereoDetected = reception->pilotLocked + && !(spec.dsp.wbfmReceive && spec.dsp.wbfmReceive->forceMono); + } if (left.size() != right.size() || left.size() > 1024) { return; } std::array gatedLeft{}, gatedRight{}; for (std::size_t i = 0; i < left.size(); ++i) { - const float gain = m_squelch[packet.slot].gain(first + i); + // WBFM already applies WDSP's internal automatic squelch and the + // qualified paired-channel gain before this independent tap. + const float gain = spec.dsp.wbfmReceive ? 1.0f : m_squelch[packet.slot].gain(first + i); gatedLeft[i] = left[i] * gain; gatedRight[i] = right[i] * gain; packet.samples[2 * i] = gatedLeft[i]; packet.samples[2 * i + 1] = gatedRight[i]; } @@ -255,6 +341,44 @@ void RtlReceivePipeline::speakerBlock(std::uint64_t first, std::span wfmStereoDetected; + std::optional wfmReception; std::array samples{}; }; - explicit RtlReceivePipeline(std::size_t capacity = 1); + // Bounded acquisition observations; no formatting, clock reads, allocation, + // or logging on the sample callback. The owner drains across bank adoption + // so repair cannot erase the fault that requested it. + struct TraceEvent { + enum class Kind { ReceiverFailure, MixerMissing }; + Kind kind = Kind::ReceiverFailure; + Transaction::Token token; + std::uint64_t hardwareGeneration = 0; + std::uint64_t captureEpoch = 0; + int slot = -1; + int stableId = -1; + std::uint64_t instance = 0; + std::uint64_t receiverEpoch = 0; + std::uint64_t captureFirst = 0; + std::uint64_t captureFrames = 0; + std::uint64_t captureClock = 0; // end of current capture block, at 48 kHz + std::uint64_t quantumFirst = 0; + RtlAudioMixer::MissingMask missingMask{}; + std::optional failure; + }; + static constexpr unsigned kTraceEvents = 64; + bool takeTraceEvent(TraceEvent& output) noexcept; + // One internal build gate: qualification fixtures opt in explicitly. The + // backend uses the same constant for its controls and legacy publication. + static constexpr bool kQualifiedWfmEnabled = true; + explicit RtlReceivePipeline(std::size_t capacity = 1, + bool enableWfm = kQualifiedWfmEnabled); enum class Submission { Accepted, RetryRetiringSlot, RetryPlannerBusy, Failed }; Submission prepareDetailed(const Transaction::State& state, bool resetCapture = false, bool verifiedRollback = false); @@ -63,8 +96,15 @@ class RtlReceivePipeline final : private RtlReceiverRegistry::BlockProcessor, void process(const RtlReceiverRegistry::SampleBlock&, std::span) noexcept override; void audioBlock(const RtlReceiverRegistry::ReceiverSpec&, std::uint64_t, std::span, std::span, bool) noexcept override; + void audioBlockWithStatus(const RtlReceiverRegistry::ReceiverSpec&, std::uint64_t, + std::span, std::span, bool, std::optional) noexcept override; void speakerBlock(std::uint64_t, std::span, bool) noexcept override; bool enqueue(const Packet&) noexcept; + void missingFrames(const RtlAudioMixer::Input&, std::uint64_t, std::uint64_t, + const RtlAudioMixer::MissingMask&) noexcept override; + void enqueueTrace(const TraceEvent&) noexcept; + TraceEvent traceContext() const noexcept; + const bool m_enableWfm; RtlReceiverRegistry m_registry; RtlReceiverRegistry::SampleReader m_reader; std::uint64_t m_session = 0; @@ -84,6 +124,18 @@ class RtlReceivePipeline final : private RtlReceiverRegistry::BlockProcessor, bool m_legacy = true; std::uint8_t m_receivingMask = 0; RtlAudioMixer m_mixer; + // Set on the first capture block of a new mixer epoch, then retained. + // Unchanged epochs must keep their existing sample coordinate and buffers. + std::optional m_mixerOrigin; + std::array m_traceStableIds{}; + std::uint64_t m_traceCaptureFirst = 0; + std::uint64_t m_traceCaptureFrames = 0; + std::uint64_t m_traceCaptureClock = 0; + // Independent SPSC trace queue: overflow is counted and never delays PCM. + std::array m_traceEvents; + alignas(64) std::atomic m_traceWrite{0}; + alignas(64) std::atomic m_traceRead{0}; + std::atomic m_traceDrops{0}; std::atomic m_faults{0}; std::array, 8> m_monitor; std::array m_nextMonitor{}; diff --git a/src/core/backends/rtl/RtlReceiverRegistry.cpp b/src/core/backends/rtl/RtlReceiverRegistry.cpp index 651b270552..63fa225872 100644 --- a/src/core/backends/rtl/RtlReceiverRegistry.cpp +++ b/src/core/backends/rtl/RtlReceiverRegistry.cpp @@ -39,6 +39,18 @@ bool validCapture(const Registry::Capture& capture) bool boundedDsp(const WdspChannel::Config& config) { + if (config.wbfmReceive) { + const auto& wide = *config.wbfmReceive; + if (config.mode != WdspChannel::Mode::Wbfm || config.fmReceive + || config.inputSampleRate != 384000 || config.inputBlockSize != 2048 + || config.dspSampleRate != 192000 || config.dspBlockSize != 1024 + || config.outputSampleRate != 48000 + || !std::isfinite(wide.outputGain) || wide.outputGain < 0 || wide.outputGain > 1 + || (wide.deemphasis != WdspChannel::WbfmReceive::Deemphasis::Us50 + && wide.deemphasis != WdspChannel::WbfmReceive::Deemphasis::Us75) + || config.filterLowHz < -0.45 * config.inputSampleRate + || config.filterHighHz > 0.45 * config.inputSampleRate) { return false; } + } if (config.fmReceive && (config.mode != WdspChannel::Mode::Fm || !std::isfinite(config.fmDeviationHz) || config.fmDeviationHz <= 0 || config.fmDeviationHz >= config.dspSampleRate / 2.0)) { return false; } @@ -71,8 +83,8 @@ bool boundedDsp(const WdspChannel::Config& config) static_cast(config.mode) <= static_cast(WdspChannel::Mode::Wbfm) && std::isfinite(config.filterLowHz) && std::isfinite(config.filterHighHz) && config.filterLowHz < config.filterHighHz && - config.filterLowHz >= -config.dspSampleRate / 2.0 && - config.filterHighHz <= config.dspSampleRate / 2.0 && + config.filterLowHz >= -(config.wbfmReceive ? config.inputSampleRate : config.dspSampleRate) / 2.0 && + config.filterHighHz <= (config.wbfmReceive ? config.inputSampleRate : config.dspSampleRate) / 2.0 && config.filterTaps >= 64 && config.filterTaps <= 16384 && (config.filterTaps & (config.filterTaps - 1)) == 0 && // WDSP derives again = rate / (fmDeviationHz * TWOPI) from this, so an @@ -100,29 +112,60 @@ class WdspReceiver final : public Registry::Receiver, private RtlRfExtractor::Si { if (spec.extractRf) { m_extractor = std::make_unique(RtlRfExtractor::Config{ - spec.capture, spec.passband, spec.dsp.inputSampleRate, spec.dsp.inputBlockSize}); + spec.capture, spec.passband, spec.dsp.inputSampleRate, spec.dsp.inputBlockSize, + spec.dsp.outputSampleRate}); } } bool valid() const noexcept { return !m_extractor || m_extractor->valid(); } bool processCapture(const Registry::SampleBlock& block, Registry::AudioSink& sink) noexcept override { - if (!m_extractor || block.session != m_spec.handle.session) { return false; } + if (!m_extractor || block.session != m_spec.handle.session) { + if (!m_failure) { + m_failure.emplace(); + m_failure->reason = !m_extractor ? Registry::ProcessingFailureReason::NoExtractor + : Registry::ProcessingFailureReason::SessionMismatch; + m_failure->captureFirst = block.firstSample; + m_failure->captureFrames = block.samples.size(); + } + return false; + } + const bool hadFailure = m_failure.has_value(); m_sink = &sink; const bool success = m_extractor->process(block.capture, block.firstSample, block.samples, *this, block.discontinuity); m_sink = nullptr; + if (!success && !hadFailure) { + if (!m_failure) { m_failure.emplace(); } + m_failure->extraction = m_extractor->failure(); + if (m_failure->extraction) { + const auto& failure = *m_failure->extraction; + m_failure->expectedCaptureFirst = failure.expectedCaptureFirst; + m_failure->captureFirst = failure.captureFirst; + m_failure->captureFrames = failure.captureFrames; + m_failure->hasExpectedCaptureFirst = failure.hasExpectedCaptureFirst; + m_failure->iqFirst = failure.iqFirst; + m_failure->iqFrames = failure.iqFrames; + m_failure->hasIqFirst = failure.hasIqFirst; + } + } return success; } WdspChannel::ProcessResult processIq(std::span i, std::span q) noexcept override { - if (i.data() == nullptr || q.data() == nullptr) { - return WdspChannel::ProcessResult::InvalidBuffer; + const WdspChannel::ProcessResult result = i.data() == nullptr || q.data() == nullptr + ? WdspChannel::ProcessResult::InvalidBuffer : m_channel->processIq(i, q, m_left, m_right); + if (result != WdspChannel::ProcessResult::Ok && !m_failure) { + m_failure.emplace(); + m_failure->reason = Registry::ProcessingFailureReason::DspProcess; + m_failure->processResult = result; + m_failure->iqFrames = i.size(); } - return m_channel->processIq(i, q, m_left, m_right); + return result; } std::span left() const noexcept override { return m_left; } std::span right() const noexcept override { return m_right; } + std::optional processingFailure() const noexcept override { return m_failure; } private: std::unique_ptr m_channel; std::vector m_left; @@ -131,14 +174,38 @@ class WdspReceiver final : public Registry::Receiver, private RtlRfExtractor::Si std::unique_ptr m_extractor; Registry::AudioSink* m_sink = nullptr; bool m_first = true; + std::optional m_failure; bool iqBlock(std::span i, std::span q, std::uint64_t firstSample) noexcept override { // Nonblocking WDSP fexchange2 advances its ring even on underrun. // Withdraw this instance rather than later publishing a stale ring // position as current audio. Its replacement is prepared off-thread. - if (processIq(i, q) != WdspChannel::ProcessResult::Ok) { return false; } - m_sink->audioBlock(m_spec, firstSample, m_left, m_right, m_first); + const bool hadFailure = m_failure.has_value(); + if (processIq(i, q) != WdspChannel::ProcessResult::Ok) { + if (!hadFailure) { + m_failure->iqFirst = firstSample; + m_failure->hasIqFirst = true; + } + return false; + } + // The extractor positions name IQ frames. The sink and mixer name + // final audio frames. Preparation aligns the first IQ block with that + // slower lattice; every fixed block advances it by an exact ratio. + const auto ratio = static_cast( + m_spec.dsp.inputSampleRate / m_spec.dsp.outputSampleRate); + if (ratio == 0 || firstSample % ratio != 0) { + if (!m_failure) { + m_failure.emplace(); + m_failure->reason = Registry::ProcessingFailureReason::AudioLattice; + m_failure->iqFirst = firstSample; + m_failure->iqFrames = i.size(); + m_failure->hasIqFirst = true; + } + return false; + } + m_sink->audioBlockWithStatus(m_spec, firstSample / ratio, m_left, m_right, m_first, + m_channel->wbfmReceptionDiagnostics()); m_first = false; return true; } @@ -594,9 +661,17 @@ RtlReceiverRegistry::Result RtlReceiverRegistry::submitImpl(const Capture& captu if (slot < 0 || static_cast(slot) >= state.limits.slotCount || used[slot] || spec.handle.session != state.session.load() || state.reservations[slot] != spec.handle || spec.passband.stableId != slot || !boundedDsp(spec.dsp) - || (spec.extractRf && (spec.dsp.inputSampleRate != 48000 - || spec.dsp.dspSampleRate != 48000 || spec.dsp.outputSampleRate != 48000 - || spec.dsp.inputBlockSize != 1024 || spec.dsp.dspBlockSize != 1024))) { + || (spec.dsp.wbfmReceive + && (spec.passband.guardLowHz < WdspChannel::WbfmReceive::kRfTransitionGuardHz + || spec.passband.guardHighHz < WdspChannel::WbfmReceive::kRfTransitionGuardHz)) + || (spec.extractRf && !((spec.dsp.inputSampleRate == 48000 + && spec.dsp.dspSampleRate == 48000 && spec.dsp.outputSampleRate == 48000 + && spec.dsp.inputBlockSize == 1024 && spec.dsp.dspBlockSize == 1024 + && !spec.dsp.wbfmReceive) + || (spec.dsp.inputSampleRate == 384000 && spec.dsp.dspSampleRate == 192000 + && spec.dsp.outputSampleRate == 48000 && spec.dsp.inputBlockSize == 2048 + && spec.dsp.dspBlockSize == 1024 && spec.dsp.mode == WdspChannel::Mode::Wbfm + && spec.dsp.wbfmReceive && !spec.dsp.fmReceive)))) { return Result::Invalid; } used[slot] = true; diff --git a/src/core/backends/rtl/RtlReceiverRegistry.h b/src/core/backends/rtl/RtlReceiverRegistry.h index 517cfe52a9..ca227377df 100644 --- a/src/core/backends/rtl/RtlReceiverRegistry.h +++ b/src/core/backends/rtl/RtlReceiverRegistry.h @@ -1,6 +1,7 @@ #pragma once #include "core/SharedCapturePolicy.h" +#include "core/backends/rtl/RtlRfExtractor.h" #include "core/dsp/WdspChannel.h" #include @@ -53,6 +54,21 @@ class RtlReceiverRegistry final bool operator==(const ReceiverSpec&) const = default; }; + enum class ProcessingFailureReason { NoExtractor, SessionMismatch, Extraction, DspProcess, AudioLattice }; + struct ProcessingFailure { + ProcessingFailureReason reason = ProcessingFailureReason::Extraction; + WdspChannel::ProcessResult processResult = WdspChannel::ProcessResult::Ok; + std::uint64_t expectedCaptureFirst = 0; + std::uint64_t captureFirst = 0; + std::uint64_t captureFrames = 0; + std::uint64_t iqFirst = 0; + std::uint64_t iqFrames = 0; + bool hasExpectedCaptureFirst = false; + bool hasIqFirst = false; + std::optional extraction; + bool operator==(const ProcessingFailure&) const = default; + }; + // Prepared fixed-block DSP, with no allocating setters exposed. M1 owns // the RF extraction/conversion feeding these exact-size planar blocks. // Returned output spans are borrowed until the next processIq() call. @@ -64,12 +80,24 @@ class RtlReceiverRegistry final virtual std::span left() const noexcept = 0; virtual std::span right() const noexcept = 0; virtual bool processCapture(const SampleBlock&, AudioSink&) noexcept { return false; } + // Fixed-size first-failure observation; acquisition context only, or + // after acquisition has stopped. Reading does not acknowledge/reset it. + virtual std::optional processingFailure() const noexcept { return std::nullopt; } }; class AudioSink { public: virtual ~AudioSink() = default; virtual void audioBlock(const ReceiverSpec& spec, std::uint64_t firstSample, std::span left, std::span right, bool discontinuity) noexcept = 0; + // Optional decoder observation for the same live receiver. The decoder + // publishes its latest completed DSP block, not an RF arrival timestamp. + virtual void audioBlockWithStatus(const ReceiverSpec& spec, std::uint64_t firstSample, + std::span left, std::span right, bool discontinuity, + std::optional reception) noexcept + { + (void)reception; + audioBlock(spec, firstSample, left, right, discontinuity); + } }; // Injection is for deterministic preparation failures/delays. The default // constructs real WDSP channels and output buffers on the Qt worker pool. diff --git a/src/core/backends/rtl/RtlRfExtractor.cpp b/src/core/backends/rtl/RtlRfExtractor.cpp index 73e65f80ac..d802c09d44 100644 --- a/src/core/backends/rtl/RtlRfExtractor.cpp +++ b/src/core/backends/rtl/RtlRfExtractor.cpp @@ -16,8 +16,12 @@ RtlRfExtractor::RtlRfExtractor(Config config) : m_config(std::move(config)) if (!std::isfinite(capture.achievedSampleRateHz) || capture.achievedSampleRateHz != std::floor(capture.achievedSampleRateHz) || capture.achievedSampleRateHz < 225001 || capture.achievedSampleRateHz > 3000000 - || m_config.outputRateHz < 48000 || m_config.outputRateHz > 192000 - || m_config.outputRateHz > capture.achievedSampleRateHz + || m_config.outputRateHz < 48000 || m_config.outputRateHz > 384000 + || m_config.outputRateHz > 2 * capture.achievedSampleRateHz + || m_config.alignmentRateHz < 0 + || (m_config.alignmentRateHz != 0 + && (m_config.alignmentRateHz > m_config.outputRateHz + || m_config.outputRateHz % m_config.alignmentRateHz != 0)) || m_config.blockSize < 64 || m_config.blockSize > 4096 || (m_config.blockSize & (m_config.blockSize - 1)) != 0 || slice.filterLowHz < -0.45 * m_config.outputRateHz @@ -35,28 +39,66 @@ RtlRfExtractor::RtlRfExtractor(Config config) : m_config(std::move(config)) // The 10% transition leaves the declared +/-45% DSP-rate passband intact. m_i = std::make_unique(capture.achievedSampleRateHz, m_config.outputRateHz, kInputChunk, 10.0); m_q = std::make_unique(capture.achievedSampleRateHz, m_config.outputRateHz, kInputChunk, 10.0); - // Downsampling only; room includes r8brain's burst output from its staging. + // At most 2x upsampling; room includes r8brain's staging burst. The RF + // fit still uses actual capture coverage, never the interpolated IQ rate. m_convertedI.reserve(16384 * sizeof(float)); m_convertedQ.reserve(16384 * sizeof(float)); m_blockI.resize(m_config.blockSize); m_blockQ.resize(m_config.blockSize); m_valid = true; } +std::optional RtlRfExtractor::alignedCaptureFirst(std::uint64_t firstSample, + std::uint64_t captureRateHz, std::uint64_t alignmentRateHz) noexcept +{ + if (captureRateHz < 225001 || captureRateHz > 3000000 + || alignmentRateHz == 0 || alignmentRateHz > 384000) { return std::nullopt; } + const std::uint64_t period = captureRateHz / std::gcd(captureRateHz, alignmentRateHz); + const std::uint64_t skip = (period - firstSample % period) % period; + if (firstSample > std::numeric_limits::max() - skip) { return std::nullopt; } + return firstSample + skip; +} RtlRfExtractor::~RtlRfExtractor() = default; int RtlRfExtractor::groupDelayInputFrames() const noexcept { return m_i ? m_i->groupDelayInputFrames() : 0; } bool RtlRfExtractor::process(const SharedCapturePolicy::CaptureDescriptor& capture, std::uint64_t firstSample, std::span> input, Sink& sink, bool discontinuity) noexcept { - if (!m_valid || m_withdrawn || capture != m_config.capture) { return false; } - if (input.empty() || !input.data() || input.size() > kMaxInput - || firstSample > std::numeric_limits::max() - input.size() - || (m_seenInput && (discontinuity || firstSample != m_nextInput)) - || !std::ranges::all_of(input, [](const auto& sample) { - return std::isfinite(sample.real()) && std::isfinite(sample.imag()); - })) { - m_withdrawn = true; + const std::uint64_t captureFrames = input.size(); + const std::uint64_t expectedCaptureFirst = m_nextInput; + const bool hasExpectedCaptureFirst = m_seenInput; + const auto fail = [&](FailureReason reason, bool withdraw = true, + int convertedI = 0, int convertedQ = 0) { + if (!m_failure) { + Failure failure; + failure.reason = reason; + failure.expectedCaptureFirst = expectedCaptureFirst; + failure.captureFirst = firstSample; + failure.captureFrames = captureFrames; + failure.hasExpectedCaptureFirst = hasExpectedCaptureFirst; + failure.iqFrames = m_stagedOutput; + failure.hasIqFirst = m_started + && m_outputOrigin <= std::numeric_limits::max() - m_outputFrames; + if (failure.hasIqFirst) { failure.iqFirst = m_outputOrigin + m_outputFrames; } + failure.convertedI = convertedI; + failure.convertedQ = convertedQ; + m_failure = failure; + } + if (withdraw) { m_withdrawn = true; } return false; + }; + if (!m_valid) { return fail(FailureReason::InvalidConfiguration, false); } + if (m_withdrawn) { return false; } + if (capture != m_config.capture) { return fail(FailureReason::CaptureMismatch, false); } + if (input.empty()) { return fail(FailureReason::EmptyInput); } + if (!input.data()) { return fail(FailureReason::NullInput); } + if (input.size() > kMaxInput) { return fail(FailureReason::InputTooLarge); } + if (firstSample > std::numeric_limits::max() - input.size()) { + return fail(FailureReason::CapturePositionOverflow); } + if (m_seenInput && discontinuity) { return fail(FailureReason::Discontinuity); } + if (m_seenInput && firstSample != m_nextInput) { return fail(FailureReason::CapturePositionMismatch); } + if (!std::ranges::all_of(input, [](const auto& sample) { + return std::isfinite(sample.real()) && std::isfinite(sample.imag()); + })) { return fail(FailureReason::NonFiniteInput); } if (!m_seenInput) { // New receivers join the SAME rational sample lattice as siblings. // Wait for the next capture/output coincidence, rather than rounding @@ -64,24 +106,29 @@ bool RtlRfExtractor::process(const SharedCapturePolicy::CaptureDescriptor& captu // All supported rates are integral hardware readbacks. This wait is // bounded by one second even for coprime rates; it consumes no storage. const auto rate = static_cast(capture.achievedSampleRateHz); - const std::uint64_t period = rate / std::gcd(rate, static_cast(m_config.outputRateHz)); - const std::uint64_t skip = (period - firstSample % period) % period; - if (firstSample > std::numeric_limits::max() - skip) { - m_withdrawn = true; return false; - } - m_startInput = firstSample + skip; + const int alignmentRate = m_config.alignmentRateHz != 0 + ? m_config.alignmentRateHz : m_config.outputRateHz; + const auto start = alignedCaptureFirst(firstSample, rate, static_cast(alignmentRate)); + if (!start) { return fail(FailureReason::AlignmentOverflow); } + m_startInput = *start; m_seenInput = true; } m_nextInput = firstSample + input.size(); if (!m_started) { if (m_nextInput <= m_startInput) { return true; } input = input.subspan(static_cast(m_startInput - firstSample)); - const long double origin = static_cast(m_startInput) - * m_config.outputRateHz / capture.achievedSampleRateHz; - if (origin > std::numeric_limits::max() - kMaxInput) { - m_withdrawn = true; return false; + const auto rate = static_cast(capture.achievedSampleRateHz); + const int alignmentRate = m_config.alignmentRateHz != 0 + ? m_config.alignmentRateHz : m_config.outputRateHz; + const std::uint64_t common = std::gcd(rate, static_cast(alignmentRate)); + const std::uint64_t periods = m_startInput / (rate / common); + const std::uint64_t framesPerPeriod = static_cast(m_config.outputRateHz) / common; + if (periods > (std::numeric_limits::max() - kMaxInput) / framesPerPeriod) { + return fail(FailureReason::OutputOriginOverflow); } - m_outputOrigin = static_cast(std::round(origin)); + // Exact integer time mapping also holds beyond floating-point's exact + // integer range. The RF oscillator separately retains its phase model. + m_outputOrigin = periods * framesPerPeriod; const long double phase = std::remainder(-2 * std::numbers::pi_v * (m_config.slice.carrierHz + m_config.slice.translationHz - capture.centerHz) * m_startInput / capture.achievedSampleRateHz, 2 * std::numbers::pi_v); @@ -98,18 +145,23 @@ bool RtlRfExtractor::process(const SharedCapturePolicy::CaptureDescriptor& captu const int countI = m_i->process(m_inputI.data(), kInputChunk, m_convertedI); const int countQ = m_q->process(m_inputQ.data(), kInputChunk, m_convertedQ); m_stagedInput = 0; - if (countI != countQ || countI < 0 || countI > 16384) { m_withdrawn = true; return false; } + if (countI != countQ) { return fail(FailureReason::ConvertedCountMismatch, true, countI, countQ); } + if (countI < 0 || countI > 16384) { + return fail(FailureReason::ConvertedCountOutOfRange, true, countI, countQ); + } for (int index = 0; index < countI; ++index) { std::memcpy(&m_blockI[m_stagedOutput], m_convertedI.constData() + index * sizeof(float), sizeof(float)); std::memcpy(&m_blockQ[m_stagedOutput], m_convertedQ.constData() + index * sizeof(float), sizeof(float)); if (!std::isfinite(m_blockI[m_stagedOutput]) || !std::isfinite(m_blockQ[m_stagedOutput])) { - m_withdrawn = true; return false; + return fail(FailureReason::NonFiniteOutput, true, countI, countQ); } if (++m_stagedOutput != m_config.blockSize) { continue; } if (m_outputFrames > std::numeric_limits::max() - m_config.blockSize - || m_outputOrigin > std::numeric_limits::max() - m_outputFrames - m_config.blockSize - || !sink.iqBlock(m_blockI, m_blockQ, m_outputOrigin + m_outputFrames)) { - m_withdrawn = true; return false; + || m_outputOrigin > std::numeric_limits::max() - m_outputFrames - m_config.blockSize) { + return fail(FailureReason::OutputPositionOverflow); + } + if (!sink.iqBlock(m_blockI, m_blockQ, m_outputOrigin + m_outputFrames)) { + return fail(FailureReason::SinkRejected); } m_outputFrames += m_config.blockSize; m_stagedOutput = 0; diff --git a/src/core/backends/rtl/RtlRfExtractor.h b/src/core/backends/rtl/RtlRfExtractor.h index e1b947dd90..5683e2ec95 100644 --- a/src/core/backends/rtl/RtlRfExtractor.h +++ b/src/core/backends/rtl/RtlRfExtractor.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -20,11 +21,35 @@ namespace rtl { // the callback. Configuration and capture readback are immutable for its life. class RtlRfExtractor final { public: + enum class FailureReason { + InvalidConfiguration, CaptureMismatch, EmptyInput, NullInput, InputTooLarge, + CapturePositionOverflow, Discontinuity, CapturePositionMismatch, NonFiniteInput, + AlignmentOverflow, OutputOriginOverflow, ConvertedCountMismatch, + ConvertedCountOutOfRange, NonFiniteOutput, OutputPositionOverflow, SinkRejected + }; + struct Failure { + FailureReason reason = FailureReason::InvalidConfiguration; + std::uint64_t expectedCaptureFirst = 0; + std::uint64_t captureFirst = 0; + std::uint64_t captureFrames = 0; + // The next/attempted IQ block, including any samples already staged. + // hasIqFirst is false before an exact output origin is established. + std::uint64_t iqFirst = 0; + std::uint64_t iqFrames = 0; + bool hasExpectedCaptureFirst = false; + bool hasIqFirst = false; + int convertedI = 0; + int convertedQ = 0; + bool operator==(const Failure&) const = default; + }; struct Config { SharedCapturePolicy::CaptureDescriptor capture; SharedCapturePolicy::SliceDescriptor slice; int outputRateHz = 48000; std::size_t blockSize = 1024; + // The final audio lattice can be slower than the extracted IQ clock. + // Zero preserves the existing outputRateHz alignment. + int alignmentRateHz = 0; }; class Sink { public: @@ -32,12 +57,19 @@ class RtlRfExtractor final { virtual bool iqBlock(std::span i, std::span q, std::uint64_t firstSample) noexcept = 0; }; + // First integral capture sample coinciding with the requested sample + // lattice. Fixed integer arithmetic; nullopt preserves overflow refusal. + static std::optional alignedCaptureFirst(std::uint64_t firstSample, + std::uint64_t captureRateHz, std::uint64_t alignmentRateHz) noexcept; static constexpr std::size_t kInputChunk = 256; static constexpr std::size_t kMaxInput = 65536; explicit RtlRfExtractor(Config config); ~RtlRfExtractor(); bool valid() const noexcept { return m_valid; } bool withdrawn() const noexcept { return m_withdrawn; } + // Acquisition context only, or after it stops. The first failure survives + // later calls, including attempts to use an already withdrawn instance. + std::optional failure() const noexcept { return m_failure; } const Config& config() const noexcept { return m_config; } int groupDelayInputFrames() const noexcept; std::uint64_t outputFrames() const noexcept { return m_outputFrames; } @@ -51,6 +83,7 @@ class RtlRfExtractor final { const Config m_config; bool m_valid = false; bool m_withdrawn = false; + std::optional m_failure; bool m_started = false; bool m_seenInput = false; std::uint64_t m_startInput = 0; diff --git a/src/core/backends/rtl/RtlSdrBackend.cpp b/src/core/backends/rtl/RtlSdrBackend.cpp index 83df945b61..2a9c96795a 100644 --- a/src/core/backends/rtl/RtlSdrBackend.cpp +++ b/src/core/backends/rtl/RtlSdrBackend.cpp @@ -21,6 +21,8 @@ namespace AetherSDR::rtl { namespace { +Q_LOGGING_CATEGORY(lcRtlReceive, "aether.rtl.receive", QtWarningMsg) + constexpr double kMinTuneHz = 24'000.0; constexpr double kMaxTuneHz = 1'766'000'000.0; @@ -208,6 +210,12 @@ RadioCapabilities RtlSdrBackend::capabilities() const c.receiveFilterControl = ReceiveFilterControl{SliceFrequencyControl::Authority::Engine, {{QStringLiteral("FM"), -21600, -1, 1, 21600, 2, 43200}, {QStringLiteral("FMN"), -21600, -1, 1, 21600, 2, 43200}}}; + c.broadcastFmReceive = std::nullopt; + if (RtlReceivePipeline::kQualifiedWfmEnabled) { + c.receiveFilterControl->modes.append({QStringLiteral("WFM"), + -100000, -15000, 15000, 100000, 30000, 200000}); + c.broadcastFmReceive = BroadcastFmReceive{{50, 75}, true, true}; + } c.receiveAudioControl = ReceiveAudioControl{SliceFrequencyControl::Authority::Engine}; c.panSpanModel = PanSpanModel{false, false}; if (m_lastPublished && hasReceivingNarrowFm(*m_lastPublished)) { @@ -378,16 +386,34 @@ void RtlSdrBackend::connectRadio(const RadioConnectRequest& request) } } m_requested.hardware.gainTenths = nearestGainTenths(m_tunerGainsTenths, requestedGain * 10); - m_requested.receivers = {{{0, double(m_requested.hardware.centerHz), - double(m_sliceFilterLow), double(m_sliceFilterHigh), 0, 0, 0}, captureMode(m_sliceMode)}}; + m_requested.receivers = {initialReceiver()}; m_modelName = m_product; startCapture(std::make_unique(m_device, nullptr, m_receiverCapacity)); } +RtlCaptureTransaction::Receiver RtlSdrBackend::initialReceiver() const +{ + const double guard = (m_sliceMode == QLatin1String("FM") || m_sliceMode == QLatin1String("FMN") + || (RtlReceivePipeline::kQualifiedWfmEnabled && m_sliceMode == QLatin1String("WFM"))) ? 3000.0 : 0.0; + RtlCaptureTransaction::Receiver receiver{{0, double(m_requested.hardware.centerHz), + double(m_sliceFilterLow), double(m_sliceFilterHigh), 0, guard, guard}, captureMode(m_sliceMode)}; + // Saved receiver restoration prepares asynchronously after this provisional + // receiver connects. Keep its speaker muted until the actual saved monitor + // state adopts; a saved muted session must not briefly play bootstrap audio. + receiver.audioMute = m_savedSettings.status == RtlSliceSettings::ReadStatus::Ready + && !m_savedSettings.document.slices.isEmpty(); + return receiver; +} + void RtlSdrBackend::startCapture(std::unique_ptr worker) { m_worker = std::move(worker); m_diagnostics = {}; + m_wfmStatus.fill(WfmStereoStatus::Unavailable); + m_wfmReception.fill({}); + m_wfmLastObservation.fill(std::nullopt); + for (auto& age : m_wfmPublicationAge) { age.invalidate(); } + for (auto& age : m_wfmObservationAge) { age.invalidate(); } m_capture.beginSession(); m_published = {}; m_lastPublished.reset(); @@ -459,6 +485,11 @@ void RtlSdrBackend::disconnectRadio() { retirePcmStreams(); retireNativeAudio(); + m_wfmStatus.fill(WfmStereoStatus::Unavailable); + m_wfmReception.fill({}); + m_wfmLastObservation.fill(std::nullopt); + for (auto& age : m_wfmPublicationAge) { age.invalidate(); } + for (auto& age : m_wfmObservationAge) { age.invalidate(); } m_captureTimer.stop(); m_capture.endSession(); m_published = {}; @@ -509,18 +540,20 @@ IRadioBackend::HealthSnapshot RtlSdrBackend::healthSnapshot() const if (!m_connected) { return {}; } HealthSnapshot snapshot; snapshot.order = {QStringLiteral("rtlQueueDrops"), QStringLiteral("rtlMixerLateFrames"), - QStringLiteral("rtlMixerRejectedBlocks"), QStringLiteral("rtlMixerConfigurationFailures")}; + QStringLiteral("rtlMixerRejectedBlocks"), QStringLiteral("rtlMixerConfigurationFailures"), + QStringLiteral("rtlReceiveTraceDrops")}; snapshot.sections.insert(snapshot.order.front(), tr("RTL receive pipeline (since connect)")); snapshot.labels = {{snapshot.order[0], tr("Audio queue dropped packets")}, {snapshot.order[1], tr("Mixer missing receiver frames at deadline")}, {snapshot.order[2], tr("Mixer rejected audio blocks")}, - {snapshot.order[3], tr("Mixer configuration failures")}}; + {snapshot.order[3], tr("Mixer configuration failures")}, + {snapshot.order[4], tr("Receive diagnostic records dropped")}}; // Legacy-only sessions have not observed this pipeline. Missing values // report "not reported", never a fabricated successful zero measurement. if (m_diagnostics.observed) { - const std::array counters{m_diagnostics.droppedPackets, + const std::array counters{m_diagnostics.droppedPackets, m_diagnostics.mixerLateFrames, m_diagnostics.mixerRejectedBlocks, - m_diagnostics.mixerConfigurationFailures}; + m_diagnostics.mixerConfigurationFailures, m_diagnostics.droppedTraceEvents}; for (int i = 0; i < snapshot.order.size(); ++i) { snapshot.values.insert(snapshot.order[i], QVariant::fromValue(counters[i])); } @@ -529,7 +562,9 @@ IRadioBackend::HealthSnapshot RtlSdrBackend::healthSnapshot() const const auto& capture = m_lastPublished->capture; const QStringList keys{QStringLiteral("rtlCaptureCenterHz"), QStringLiteral("rtlCaptureRateHz"), QStringLiteral("rtlCaptureLowHz"), QStringLiteral("rtlCaptureHighHz"), - QStringLiteral("rtlCaptureDcClear"), QStringLiteral("rtlCaptureRequest")}; + QStringLiteral("rtlCaptureDcClear"), QStringLiteral("rtlCaptureRequest"), + QStringLiteral("rtlCaptureSession"), QStringLiteral("rtlCaptureRevision"), + QStringLiteral("rtlCaptureRequestedRevision")}; snapshot.sections.insert(keys.front(), tr("RTL accepted capture")); snapshot.order.append(keys); snapshot.labels.insert(keys[0], tr("Capture center / converter DC (Hz)")); @@ -538,6 +573,9 @@ IRadioBackend::HealthSnapshot RtlSdrBackend::healthSnapshot() const snapshot.labels.insert(keys[3], tr("Usable capture high edge (Hz)")); snapshot.labels.insert(keys[4], tr("FM receivers clear of converter DC")); snapshot.labels.insert(keys[5], tr("Last capture request")); + snapshot.labels.insert(keys[6], tr("Capture session")); + snapshot.labels.insert(keys[7], tr("Accepted capture revision")); + snapshot.labels.insert(keys[8], tr("Requested capture revision")); snapshot.values.insert(keys[0], capture.centerHz); snapshot.values.insert(keys[1], capture.achievedSampleRateHz); snapshot.values.insert(keys[2], std::max(0.0, capture.centerHz - capture.usableLeftHz)); @@ -546,6 +584,11 @@ IRadioBackend::HealthSnapshot RtlSdrBackend::healthSnapshot() const ? QVariant(RtlCaptureTransaction::dcClear(*m_lastPublished)) : QVariant(tr("No receiving FM or FM-N slice"))); snapshot.values.insert(keys[5], m_captureStatus); + // Read existing owner-thread tokens so steady-state validation can see + // repair requests/publications even when detailed logging is disabled. + snapshot.values.insert(keys[6], QVariant::fromValue(m_published.session)); + snapshot.values.insert(keys[7], QVariant::fromValue(m_published.revision)); + snapshot.values.insert(keys[8], QVariant::fromValue(m_capture.requested().revision)); } return snapshot; } @@ -664,12 +707,21 @@ void RtlSdrBackend::setSliceMode(int sliceId, const QString& mode) // passband; restores and ordinary filter requests never resize it. receiver->passband.filterLowHz = -8000; receiver->passband.filterHighHz = 8000; } + const bool nativeWfm = RtlReceivePipeline::kQualifiedWfmEnabled + && modeValue == RtlCaptureTransaction::Mode::Wfm; + if (nativeWfm && (receiver->passband.filterLowHz > -15000 + || receiver->passband.filterHighHz < 15000)) { + // Entering broadcast FM from a narrow mode selects its existing + // 200 kHz RF width. Ordinary edits and saved restores are never resized. + receiver->passband.filterLowHz = -100000; + receiver->passband.filterHighHz = 100000; + } receiver->mode = modeValue; desired.followReceiverId = sliceId; desired.avoidDc = narrowFm; if (!narrowFm) { receiver->squelchEnabled = false; } - receiver->passband.guardLowHz = narrowFm ? 3000 : 0; - receiver->passband.guardHighHz = narrowFm ? 3000 : 0; + receiver->passband.guardLowHz = (narrowFm || nativeWfm) ? 3000 : 0; + receiver->passband.guardHighHz = (narrowFm || nativeWfm) ? 3000 : 0; requestCapture(desired); } @@ -680,9 +732,9 @@ void RtlSdrBackend::setSliceFilter(int sliceId, int lowHz, int highHz) const auto receiver = std::ranges::find_if(desired.receivers, [sliceId](const auto& value) { return value.passband.stableId == sliceId; }); if (receiver == desired.receivers.end()) { return; } if (receiver->mode == RtlCaptureTransaction::Mode::Wfm) { - // Legacy WFM does not consume these edges. Do not acknowledge or save - // a cosmetic filter change while its qualified DSP path is deferred. - return; + if (!RtlReceivePipeline::kQualifiedWfmEnabled || lowHz > -15000 || highHz < 15000) { + return; + } } if ((receiver->mode == RtlCaptureTransaction::Mode::Fm || receiver->mode == RtlCaptureTransaction::Mode::Fmn) @@ -692,6 +744,31 @@ void RtlSdrBackend::setSliceFilter(int sliceId, int lowHz, int highHz) requestCapture(desired); } +void RtlSdrBackend::setSliceWfmForceMono(int sliceId, bool forceMono) +{ + if (!RtlReceivePipeline::kQualifiedWfmEnabled || !hasAcceptedSlice(sliceId)) { return; } + auto desired = m_requested; + const auto receiver = std::ranges::find_if(desired.receivers, + [sliceId](const auto& value) { return value.passband.stableId == sliceId; }); + if (receiver == desired.receivers.end() || receiver->mode != RtlCaptureTransaction::Mode::Wfm + || receiver->wfmForceMono == forceMono) { return; } + receiver->wfmForceMono = forceMono; + requestCapture(desired); // Same prepare/adopt/rollback contract as the filter. +} + +void RtlSdrBackend::setSliceWfmDeemphasis(int sliceId, int microseconds) +{ + if (!RtlReceivePipeline::kQualifiedWfmEnabled || !hasAcceptedSlice(sliceId) + || (microseconds != 50 && microseconds != 75)) { return; } + auto desired = m_requested; + const auto receiver = std::ranges::find_if(desired.receivers, + [sliceId](const auto& value) { return value.passband.stableId == sliceId; }); + if (receiver == desired.receivers.end() || receiver->mode != RtlCaptureTransaction::Mode::Wfm + || receiver->wfmDeemphasisUs == microseconds) { return; } + receiver->wfmDeemphasisUs = microseconds; + requestCapture(desired); // Only adopted state is published and persisted. +} + void RtlSdrBackend::setSliceAgc(int sliceId, const QString& mode, int thresholdDb) { Q_UNUSED(sliceId); @@ -836,6 +913,27 @@ ReceiveDispatch RtlSdrBackend::requestSliceAudio(int sliceId, const SliceAudioRe return ReceiveDispatch::Dispatched; } +ReceiveDispatch RtlSdrBackend::requestSliceWfm(int sliceId, const SliceWfmRequest& request) +{ + if (!request.valid() || !RtlReceivePipeline::kQualifiedWfmEnabled + || !hasAcceptedSlice(sliceId)) { + return ReceiveDispatch::Unsupported; + } + const auto receiver = std::ranges::find_if(m_requested.receivers, [sliceId](const auto& value) { + return value.passband.stableId == sliceId; + }); + if (receiver == m_requested.receivers.end() + || receiver->mode != RtlCaptureTransaction::Mode::Wfm) { + return ReceiveDispatch::Unsupported; + } + if (request.field == SliceWfmRequest::Field::ForceMono) { + setSliceWfmForceMono(sliceId, request.value != 0); + } else { + setSliceWfmDeemphasis(sliceId, request.value); + } + return ReceiveDispatch::Dispatched; +} + ReceiveDispatch RtlSdrBackend::requestSliceSquelch(int sliceId, const SliceSquelchRequest& request) { if (!hasAcceptedSlice(sliceId) || !request.valid()) { @@ -998,6 +1096,8 @@ QVector RtlSdrBackend::acceptedSettings() const } slice.audioGain = m_monitors[id].gain; slice.audioMute = m_monitors[id].mute; slice.audioPan = m_monitors[id].pan; slice.squelchEnabled = receiver.squelchEnabled; slice.squelchLevel = receiver.squelchLevel; + slice.wfmDeemphasisUs = receiver.wfmDeemphasisUs; + slice.wfmForceMono = receiver.wfmForceMono; output.append(slice); } return output; @@ -1042,29 +1142,43 @@ void RtlSdrBackend::restoreAcceptedSlices() QSet seen; auto desired = m_requested; desired.receivers.clear(); + bool unsupportedWfmFilter = false; for (const auto& slice : saved.slices) { const bool wide = slice.mode != QLatin1String("FM") && slice.mode != QLatin1String("FMN"); + const bool nativeWfm = RtlReceivePipeline::kQualifiedWfmEnabled + && slice.mode == QLatin1String("WFM"); + const double guard = (!wide || nativeWfm) ? 3000.0 : 0.0; const SharedCapturePolicy::SliceDescriptor descriptor{slice.id, slice.frequencyHz, - slice.filterLowHz, slice.filterHighHz, 0, wide ? 0.0 : 3000.0, - wide ? 0.0 : 3000.0}; + slice.filterLowHz, slice.filterHighHz, 0, guard, guard}; m_omittedSettings.insert(slice.id); if (slice.id < 0 || slice.id >= 8 || seen.contains(slice.id) || !SharedCapturePolicy::occupiedInterval(descriptor).interval || !isKnownMode(slice.mode) || desired.receivers.size() >= static_cast(m_receiverCapacity)) { continue; } seen.insert(slice.id); + if (nativeWfm && (slice.filterLowHz < -100000 || slice.filterHighHz > 100000 + || slice.filterLowHz > -15000 || slice.filterHighHz < 15000)) { + unsupportedWfmFilter = true; + continue; + } if ((!wide && (slice.filterLowHz < -21600 || slice.filterHighHz > 21600 || slice.filterLowHz >= 0 || slice.filterHighHz <= 0)) || (wide && !desired.receivers.empty()) || (!desired.receivers.empty() && desired.receivers.front().mode != RtlCaptureTransaction::Mode::Fm && desired.receivers.front().mode != RtlCaptureTransaction::Mode::Fmn)) { continue; } desired.receivers.push_back({descriptor, captureMode(slice.mode), slice.audioGain, slice.audioPan, - slice.audioMute, !wide && slice.squelchEnabled, slice.squelchLevel}); + slice.audioMute, !wide && slice.squelchEnabled, slice.squelchLevel, slice.wfmDeemphasisUs, slice.wfmForceMono}); } // Preserve a valid configured receiver even when it is parked. The // transaction derives DSP membership from the confirmed capture; a // reconnect never silently retunes or loses an out-of-capture station. - if (desired.receivers.empty()) { return; } + if (desired.receivers.empty()) { + if (unsupportedWfmFilter) { + emit configurationWarning(tr("Saved WFM filter is not supported by the broadcast decoder. " + "The saved filter is preserved. Select a supported WFM filter and unmute to resume audio.")); + } + return; + } if (requestCapture(desired)) { m_restoreToken = m_capture.requested(); } } @@ -1126,10 +1240,19 @@ void RtlSdrBackend::applyRestoredState(const RestoredRadioState& state) m_sampleRateHz = static_cast(saved.sampleRateHz); } m_sliceFreqHz = m_panCenterHz; - // Use a valid initial receiver before fitting the saved set. At VLF a - // 200 kHz wide initial receiver would extend through negative RF. - const bool narrowInitial = m_panCenterHz < 100000; - m_sliceMode = narrowInitial ? QStringLiteral("FM") : QStringLiteral("WFM"); + // A saved native receiver may use a sparse ID. A provisional native + // ID 0 would consume the sole reservation and prevent preparing it. + // Bootstrap with a muted legacy receiver (an empty native bank), then + // adopt the original saved recipe/ID through the normal transaction. + // AM also avoids FM's automatic converter-DC capture displacement. + const bool nativeRestore = RtlReceivePipeline::kQualifiedWfmEnabled + && !saved.slices.isEmpty(); + const double initialHalfWidth = RtlReceivePipeline::kQualifiedWfmEnabled ? 103000 : 100000; + const bool narrowInitial = nativeRestore || m_panCenterHz < initialHalfWidth + || 0.45 * m_sampleRateHz < initialHalfWidth; + m_sliceMode = narrowInitial + ? (RtlReceivePipeline::kQualifiedWfmEnabled ? QStringLiteral("AM") : QStringLiteral("FM")) + : QStringLiteral("WFM"); m_sliceFilterLow = narrowInitial ? -8000 : -100000; m_sliceFilterHigh = narrowInitial ? 8000 : 100000; } @@ -1230,7 +1353,35 @@ void RtlSdrBackend::serviceCapture() if (!m_worker) { return; } const QPointer producer(m_worker.get()); m_worker->serviceCancellation(); + const std::uint64_t previousTraceDrops = m_diagnostics.droppedTraceEvents; m_diagnostics = m_worker->diagnostics(); + if (m_diagnostics.droppedTraceEvents != previousTraceDrops) { + qCDebug(lcRtlReceive).nospace() << "RtlReceive ms=" << QDateTime::currentMSecsSinceEpoch() + << " kind=trace_overflow trace_drops=" << m_diagnostics.droppedTraceEvents; + } + RtlReceivePipeline::TraceEvent trace; + for (unsigned count = 0; count < RtlReceivePipeline::kTraceEvents + && m_worker->takeTraceEvent(trace); ++count) { + const auto failure = trace.failure.value_or(RtlReceiverRegistry::ProcessingFailure{}); + const auto extraction = failure.extraction.value_or(RtlRfExtractor::Failure{}); + qCDebug(lcRtlReceive).nospace() << "RtlReceive ms=" << QDateTime::currentMSecsSinceEpoch() + << " kind=" << (trace.kind == RtlReceivePipeline::TraceEvent::Kind::ReceiverFailure ? "failure" : "missing") + << " session=" << trace.token.session << " revision=" << trace.token.revision + << " generation=" << trace.hardwareGeneration << " capture_epoch=" << trace.captureEpoch + << " slot=" << trace.slot << " receiver=" << trace.stableId + << " instance=" << trace.instance << " receiver_epoch=" << trace.receiverEpoch + << " capture_first=" << trace.captureFirst << " capture_frames=" << trace.captureFrames + << " capture_clock=" << trace.captureClock << " quantum_first=" << trace.quantumFirst + << " missing_lo=" << QString::number(trace.missingMask[0], 16) + << " missing_hi=" << QString::number(trace.missingMask[1], 16) + << " has_failure=" << trace.failure.has_value() << " reason=" << int(failure.reason) + << " process_result=" << int(failure.processResult) + << " has_expected=" << failure.hasExpectedCaptureFirst << " expected_capture=" << failure.expectedCaptureFirst + << " has_iq=" << failure.hasIqFirst << " iq_first=" << failure.iqFirst << " iq_frames=" << failure.iqFrames + << " has_extraction=" << failure.extraction.has_value() << " extraction_reason=" << int(extraction.reason) + << " converted_i=" << extraction.convertedI << " converted_q=" << extraction.convertedQ + << " trace_drops=" << m_diagnostics.droppedTraceEvents; + } if (const auto result = m_worker->takeResult()) { const auto completion = m_capture.complete(*result); qCDebug(lcPerf).nospace() << "RtlCapture phase=complete ms=" << QDateTime::currentMSecsSinceEpoch() @@ -1271,7 +1422,11 @@ void RtlSdrBackend::serviceCapture() } if (m_worker) { drainAudio(); + if (!producer || producer.data() != m_worker.get()) { return; } + expireWfmObservations(); + if (!producer || producer.data() != m_worker.get()) { return; } if (!m_capture.busy() && m_worker->needsRepair()) { requestCapture(m_requested); } + if (!producer || producer.data() != m_worker.get()) { return; } if (const auto work = m_capture.takeWork()) { if (!m_worker->submit(*work)) { qFatal("RTL-SDR transaction mailbox ownership violated"); } } @@ -1293,7 +1448,9 @@ void RtlSdrBackend::publishLegacyPcm(const QByteArray& pcm, const QByteArray& pr || m_lastPublished->receivingIds.front() != m_lastPublished->receivers.front().passband.stableId || m_lastPublished->receivers.front().mode == RtlCaptureTransaction::Mode::Fm - || m_lastPublished->receivers.front().mode == RtlCaptureTransaction::Mode::Fmn) { return; } + || m_lastPublished->receivers.front().mode == RtlCaptureTransaction::Mode::Fmn + || (RtlReceivePipeline::kQualifiedWfmEnabled + && m_lastPublished->receivers.front().mode == RtlCaptureTransaction::Mode::Wfm)) { return; } const int id = m_lastPublished->receivers.front().passband.stableId; const auto token = m_published; for (int slot : {-1, id}) { @@ -1318,7 +1475,8 @@ void RtlSdrBackend::drainAudio() for (int count = 0; count < 128 && worker && worker.data() == m_worker.get() && worker->takeAudio(packet); ++count) { if (!acceptsFrame(packet.token.session, packet.token.revision) - || packet.frames == 0 || packet.frames > 1024 || packet.slot < -1 || packet.slot >= 8) { continue; } + || packet.frames == 0 || packet.frames > 1024 || packet.slot < -1 || packet.slot >= 8 + || packet.sampleRateHz != 48000 || packet.channelCount != 2) { continue; } if (!m_lastPublished || m_lastPublished->receivingIds.empty() || (packet.slot >= 0 && std::ranges::find(m_lastPublished->receivingIds, packet.slot) == m_lastPublished->receivingIds.end())) { continue; } @@ -1329,7 +1487,7 @@ void RtlSdrBackend::drainAudio() output = {}; output.producer = std::make_unique(); output.producer->start(packet.slot < 0 ? PcmPurpose::Speaker : PcmPurpose::Slice, - packet.slot, {48000, PcmLayout::Stereo}, packet.token.session, packet.instance); + packet.slot, {packet.sampleRateHz, PcmLayout::Stereo}, packet.token.session, packet.instance); output.captureEpoch = packet.captureEpoch; output.instance = packet.instance; output.receiverEpoch = packet.receiverEpoch; } @@ -1339,11 +1497,76 @@ void RtlSdrBackend::drainAudio() packet.discontinuity || packet.firstSample != output.nextSample); output.nextSample = packet.firstSample + packet.frames; if (frame) { + observeWfm(packet); + if (!worker || worker.data() != m_worker.get() + || !acceptsFrame(packet.token.session, packet.token.revision)) { return; } if (packet.slot < 0) { emit audioFrameReady(*frame); } else { emit sliceAudioFrameReady(packet.slot, *frame); } } } } +// Decoder observations travel with accepted PCM. A requested mode or a stale +// packet can never light the stereo indicator. Silence or a stopped producer +// expires the observation; there is no indefinitely retained pilot claim. +void RtlSdrBackend::observeWfm(const RtlReceivePipeline::Packet& packet) +{ + if (!RtlReceivePipeline::kQualifiedWfmEnabled || !packet.wfmReception + || !packet.wfmReception->valid || !packet.wfmStereoDetected + || packet.slot < 0 || packet.slot >= 8 || !m_lastPublished + || !acceptsFrame(packet.token.session, packet.token.revision)) { return; } + const auto receiver = std::ranges::find_if(m_lastPublished->receivers, + [&packet](const auto& value) { return value.passband.stableId == packet.slot; }); + if (receiver == m_lastPublished->receivers.end() || receiver->mode != RtlCaptureTransaction::Mode::Wfm + || std::ranges::find(m_lastPublished->receivingIds, packet.slot) + == m_lastPublished->receivingIds.end()) { return; } + const auto& reception = *packet.wfmReception; + if (!std::isfinite(reception.pilotMagnitude) || reception.pilotMagnitude < 0.0 + || !std::isfinite(reception.pilotEngageThreshold) || !std::isfinite(reception.pilotReleaseThreshold) + || reception.pilotReleaseThreshold < 0.0 + || reception.pilotEngageThreshold <= reception.pilotReleaseThreshold + || reception.engageBlocks == 0 || reception.releaseBlocks == 0 + || reception.stableDurationMs > 5000 + || reception.lockDurationMs > reception.observationDurationMs + || reception.stableDurationMs > reception.observationDurationMs + || (!reception.pilotLocked && reception.lockDurationMs != 0) + || (reception.pilotLocked && !receiver->wfmForceMono) != *packet.wfmStereoDetected) { return; } + const WfmObservationIdentity identity{packet.instance, packet.receiverEpoch, reception.observationSequence}; + if (m_wfmLastObservation[packet.slot] == identity) { return; } + m_wfmLastObservation[packet.slot] = identity; + m_wfmObservationAge[packet.slot].restart(); + const WfmStereoStatus status = *packet.wfmStereoDetected + ? WfmStereoStatus::Stereo : WfmStereoStatus::Mono; + const bool statusChanged = m_wfmStatus[packet.slot] != status + || (m_wfmReception[packet.slot].valid + && m_wfmReception[packet.slot].pilotLocked != reception.pilotLocked); + m_wfmStatus[packet.slot] = status; + if (packet.wfmReception && packet.wfmReception->valid) { + m_wfmReception[packet.slot] = *packet.wfmReception; + } + auto& publicationAge = m_wfmPublicationAge[packet.slot]; + if (!statusChanged && publicationAge.isValid() && publicationAge.elapsed() < 250) { return; } + publicationAge.restart(); + SliceDelta delta; delta.wfmStereoStatus = status; + delta.wfmReceptionDiagnostics = m_wfmReception[packet.slot]; + emit sliceChanged(packet.slot, delta); +} + +void RtlSdrBackend::expireWfmObservations() +{ + const auto token = m_published; + for (int id = 0; id < 8; ++id) { + if (!acceptsFrame(token.session, token.revision)) { return; } + if (!m_wfmObservationAge[id].isValid() || m_wfmObservationAge[id].elapsed() <= 500) { continue; } + m_wfmObservationAge[id].invalidate(); + m_wfmStatus[id] = WfmStereoStatus::Acquiring; + m_wfmReception[id] = {}; + m_wfmPublicationAge[id].invalidate(); + SliceDelta delta; delta.wfmStereoStatus = WfmStereoStatus::Acquiring; + delta.wfmReceptionDiagnostics = WfmReceptionDiagnostics{}; + emit sliceChanged(id, delta); + } +} + void RtlSdrBackend::emitSliceState(const RtlCaptureTransaction::Receiver& receiver) { const int id = receiver.passband.stableId; @@ -1356,6 +1579,10 @@ void RtlSdrBackend::emitSliceState(const RtlCaptureTransaction::Receiver& receiv delta.panId = QStringLiteral("0xe1000000"); delta.active = id == m_requested.receivers.front().passband.stableId; delta.inCapture = m_lastPublished && std::ranges::find(m_lastPublished->receivingIds, id) != m_lastPublished->receivingIds.end(); + delta.wfmDeemphasisUs = receiver.wfmDeemphasisUs; + delta.wfmForceMono = receiver.wfmForceMono; + delta.wfmReceptionDiagnostics = m_wfmReception[id]; + delta.wfmStereoStatus = m_wfmStatus[id]; delta.modeList = capabilities().receiveModeControl->modes; emit sliceChanged(id, delta); } @@ -1381,13 +1608,17 @@ void RtlSdrBackend::publishCapture() for (const auto& previous : m_lastPublished->receivers) { const int id = previous.passband.stableId; const auto current = std::ranges::find_if(state.receivers, [id](const auto& value) { return value.passband.stableId == id; }); - if (current == state.receivers.end() || current->passband != previous.passband || current->mode != previous.mode) { m_sliceAudio[id] = {}; } + if (current == state.receivers.end() || current->passband != previous.passband || current->mode != previous.mode + || current->wfmDeemphasisUs != previous.wfmDeemphasisUs + || current->wfmForceMono != previous.wfmForceMono) { m_sliceAudio[id] = {}; } } if (std::ranges::any_of(state.receivers, [&state](const auto& value) { return std::ranges::find(state.receivingIds, value.passband.stableId) != state.receivingIds.end() && value.mode != RtlCaptureTransaction::Mode::Fm - && value.mode != RtlCaptureTransaction::Mode::Fmn; + && value.mode != RtlCaptureTransaction::Mode::Fmn + && !(RtlReceivePipeline::kQualifiedWfmEnabled + && value.mode == RtlCaptureTransaction::Mode::Wfm); })) { retireNativeAudio(); } else { retirePcmStreams(); } } @@ -1417,6 +1648,24 @@ void RtlSdrBackend::publishCapture() } } } + for (int id = 0; id < 8; ++id) { + const auto receiver = std::ranges::find_if(state.receivers, + [id](const auto& value) { return value.passband.stableId == id; }); + const bool active = RtlReceivePipeline::kQualifiedWfmEnabled + && receiver != state.receivers.end() && receiver->mode == RtlCaptureTransaction::Mode::Wfm + && std::ranges::find(state.receivingIds, id) != state.receivingIds.end(); + // A newly accepted revision may repair an unchanged receiver recipe. + // Its first current-token PCM packet is the proof of decoder state; + // never carry a pilot claim across that publication boundary. + m_wfmStatus[id] = active ? WfmStereoStatus::Acquiring : WfmStereoStatus::Unavailable; + m_wfmReception[id] = {}; + // Retain the last observed decoder identity across control-only + // revisions: cached credits must not refresh a stalled decoder. + // A new prepared graph differs by instance/epoch even if its local + // publication sequence restarts at the same value. + m_wfmPublicationAge[id].invalidate(); + m_wfmObservationAge[id].invalidate(); + } m_lastPublished = state; for (const auto& receiver : state.receivers) { m_monitors[receiver.passband.stableId] = {receiver.audioGain, receiver.audioPan, receiver.audioMute}; diff --git a/src/core/backends/rtl/RtlSdrBackend.h b/src/core/backends/rtl/RtlSdrBackend.h index b987901eb9..ae2c5782a8 100644 --- a/src/core/backends/rtl/RtlSdrBackend.h +++ b/src/core/backends/rtl/RtlSdrBackend.h @@ -13,6 +13,7 @@ #include #include #include +#include #include // librtlsdr forward @@ -74,10 +75,13 @@ class RtlSdrBackend : public IRadioBackend { void setPanWeightedAverage(const QString& panId, bool on) override; void setSliceAudioMute(int sliceId, bool mute) override; ReceiveDispatch requestSliceAudio(int sliceId, const SliceAudioRequest& request) override; + ReceiveDispatch requestSliceWfm(int sliceId, const SliceWfmRequest& request) override; ReceiveDispatch requestSliceSquelch(int sliceId, const SliceSquelchRequest& request) override; void setSliceAudioGain(int sliceId, int gainPercent) override; void setSliceAudioPan(int sliceId, int panPercent) override; void setSliceSquelch(int sliceId, bool enabled, int level) override; + void setSliceWfmDeemphasis(int sliceId, int microseconds) override; + void setSliceWfmForceMono(int sliceId, bool forceMono) override; void setKeying(bool key, const AetherSDR::TxCoordinator::Operation& operation, const AetherSDR::TxCoordinator::Completion& completion = {}) override; void invokeExtension(const QString& ns, const QString& verb, quint64 requestId, const QVariant& arg = {}) override; @@ -114,6 +118,7 @@ class RtlSdrBackend : public IRadioBackend { int deviceIndexFromParams(const QVariantMap& params) const; QString serialFromParams(const QVariantMap& params) const; friend struct RtlCaptureBackendTestAccess; + RtlCaptureTransaction::Receiver initialReceiver() const; void startCapture(std::unique_ptr worker); void serviceCapture(); bool requestCapture(const RtlCaptureTransaction::Desired& desired, @@ -121,6 +126,8 @@ class RtlSdrBackend : public IRadioBackend { bool fromDrag = false); void publishCapture(); void drainAudio(); + void observeWfm(const RtlReceivePipeline::Packet& packet); + void expireWfmObservations(); void publishLegacyPcm(const QByteArray& pcm, const QByteArray& preMonitor); void retireNativeAudio(); void emitSliceState(const RtlCaptureTransaction::Receiver& receiver); @@ -218,6 +225,17 @@ class RtlSdrBackend : public IRadioBackend { // Worker thread (owns async USB reader & RtlSdrDdc engine) std::unique_ptr m_worker; + std::array m_wfmStatus{}; + std::array m_wfmObservationAge; + std::array m_wfmPublicationAge; + std::array m_wfmReception; + struct WfmObservationIdentity { + std::uint64_t instance = 0; + std::uint64_t epoch = 0; + std::uint32_t sequence = 0; + bool operator==(const WfmObservationIdentity&) const = default; + }; + std::array, 8> m_wfmLastObservation; RtlReceivePipeline::Diagnostics m_diagnostics; // owner-thread health cache RtlSdrDdc* ddc(); }; diff --git a/src/core/backends/rtl/RtlSdrWorker.h b/src/core/backends/rtl/RtlSdrWorker.h index e9a67fff88..d7dbad3e91 100644 --- a/src/core/backends/rtl/RtlSdrWorker.h +++ b/src/core/backends/rtl/RtlSdrWorker.h @@ -46,6 +46,7 @@ class RtlSdrWorker : public QThread { void serviceCancellation(); bool takeAudio(RtlReceivePipeline::Packet& packet) { return m_pipeline->takePacket(packet); } RtlReceivePipeline::Diagnostics diagnostics() const { return m_pipeline->diagnostics(); } + bool takeTraceEvent(RtlReceivePipeline::TraceEvent& event) { return m_pipeline->takeTraceEvent(event); } bool needsRepair() const { return m_pipeline->needsRepair(); } void setMonitor(int slot, int gain, int pan, bool mute) { m_pipeline->setMonitor(slot, gain, pan, mute); } diff --git a/src/core/backends/sim/SimBackend.cpp b/src/core/backends/sim/SimBackend.cpp index 59e81460ec..a62b3ef83c 100644 --- a/src/core/backends/sim/SimBackend.cpp +++ b/src/core/backends/sim/SimBackend.cpp @@ -247,6 +247,7 @@ QString SimBackend::familyName() { return QStringLiteral("sim"); } RadioCapabilities SimBackend::capabilities() const { RadioCapabilities caps; + caps.broadcastFmReceive = std::nullopt; // Synthetic receiver: this is the API's bounded numeric domain, not an // advertised hardware tuning range. Off-scene signals simply become silent. caps.sliceFrequencyControl = {SliceFrequencyControl::Authority::Engine, diff --git a/src/core/dsp/WdspChannel.cpp b/src/core/dsp/WdspChannel.cpp index bc1bc94a97..303db46a22 100644 --- a/src/core/dsp/WdspChannel.cpp +++ b/src/core/dsp/WdspChannel.cpp @@ -294,7 +294,10 @@ std::unique_ptr WdspChannel::create(const Config& config, return nullptr; } --reservation.m_count; - channel->open(); + if (!channel->open()) { + setError(error, "WDSP refused the prepared exchange depth"); + return nullptr; + } return channel; } @@ -496,8 +499,20 @@ WdspChannel::ProcessResult WdspChannel::processIq(std::span inputI, outputRight[sample] *= static_cast(m_wbfm->gain); } } - const bool stereo = wdspError == 0 && m_running.load(std::memory_order_relaxed) - && GetRXAWBFMStereoIndicator(m_channelId) != 0; + m_wbfmReception.reset(); + AetherWdspWbfmObservation observation{}; + if (wdspError == 0 && m_running.load(std::memory_order_relaxed) + && GetRXAWBFMReception(m_channelId, &observation) && observation.valid) { + m_wbfmReception = AetherSDR::WfmReceptionDiagnostics{ + true, observation.pilotMagnitude, observation.pilotLocked != 0, + observation.lockDurationMs, observation.lockLossCount, observation.reacquisitionCount, + observation.observationDurationMs, observation.stableDurationMs, + observation.pilotEngageThreshold, observation.pilotReleaseThreshold, + observation.consecutiveHighBlocks, observation.consecutiveLowBlocks, + observation.engageBlocks, observation.releaseBlocks, observation.observationSequence}; + } + const bool stereo = m_wbfmReception && m_wbfmReception->pilotLocked + && !m_config.wbfmReceive->forceMono; m_wbfmStereo.store(stereo ? 1 : 0, std::memory_order_relaxed); } const uint64_t allocationsAfter = wdspPortThreadAllocationSequence(); @@ -549,6 +564,7 @@ bool WdspChannel::setRunning(bool running) noexcept if (m_wbfm) { if (running) { flush_bps(m_wbfm->filter.get()); } m_wbfmStereo.store(0, std::memory_order_relaxed); + m_wbfmReception.reset(); } SetChannelState(m_channelId, running ? 1 : 0, 0); m_running.store(running, std::memory_order_relaxed); @@ -569,7 +585,11 @@ bool WdspChannel::reconfigure(const Config& config, std::string* error) noexcept close(); m_config = config; m_outputBlockSize = computeOutputBlockSize(m_config); - open(); + if (!open()) { + setError(error, "WDSP refused the prepared exchange depth"); + endControlOperation(); + return false; + } if (!wasRunning) { // Restore the state found, like WDSP's own rebuilds (channel.c // SetDSPBuffsize); open() always starts, so a stopped channel must be stopped @@ -1134,6 +1154,36 @@ void WdspChannel::setWorkerHandoffPauseForTest(unsigned microseconds) noexcept wdspPortSetHandoffPauseForTest(microseconds); } +void WdspChannel::setWorkerHandoffHoldForTest(bool enabled) noexcept +{ + wdspPortSetHandoffHoldForTest(enabled ? 1 : 0); +} + +bool WdspChannel::workerHandoffHeldForTest() noexcept +{ + return wdspPortHandoffHeldForTest() != 0; +} + +void WdspChannel::setWorkerOutputCopyHoldForTest(bool enabled) noexcept +{ + wdspPortSetOutputCopyHoldForTest(enabled ? 1 : 0); +} + +bool WdspChannel::workerOutputCopyHeldForTest() noexcept +{ + return wdspPortOutputCopyHeldForTest() != 0; +} + +int WdspChannel::outputSamplesReadyForTest() const noexcept +{ + return GetChannelOutputSamplesForTest(m_channelId); +} + +void WdspChannel::synchronizeWorkerForTest() const noexcept +{ + SynchronizeChannelWorkerForTest(m_channelId); +} + std::unique_lock WdspChannel::fftwSetupLock() { // Forwards, and keeps its name so Hl2Spectrum, AnanPanAnalyzer and @@ -1326,11 +1376,21 @@ void WdspChannel::setNoiseBlankerHold(bool hold) noexcept m_nbHold.store(hold, std::memory_order_relaxed); } -void WdspChannel::open() noexcept +bool WdspChannel::open() noexcept { const std::scoped_lock setupLock(g_setupMutex); loadWisdomOnce(); // import cached FFTW wisdom so PATIENT plans don't re-measure - OpenChannel(m_channelId, + // Seven exchanges can emerge from one approved 8192-sample RTL callback. + // Depth 8 prepares seven output credits plus the existing worker stage: + // 2048 frames / 42.667 ms exchange-stage delay, 32 ms more than depth 2. + // It does not cover arbitrary starvation; every non-OK still withdraws. + const bool preparedWbfm = m_config.wbfmReceive && !m_config.blockForOutput; + const auto openChannel = [preparedWbfm](auto... args) { + if (preparedWbfm) { return OpenChannelWithExchangeDepth(args..., 8) != 0; } + OpenChannel(args...); // Explicit legacy depth 2, including reused slots. + return true; + }; + if (!openChannel(m_channelId, static_cast(m_config.inputBlockSize), static_cast(m_config.dspBlockSize), m_config.inputSampleRate, @@ -1345,7 +1405,9 @@ void WdspChannel::open() noexcept 0, m_config.muteDelayUpSec, m_config.muteSlewUpSec, m_config.muteDelayDownSec, m_config.muteSlewDownSec, - m_config.blockForOutput ? 1 : 0); + m_config.blockForOutput ? 1 : 0)) { + return false; + } if (m_config.direction == Direction::Receive) { SetRXAMode(m_channelId, wdspMode(m_config.mode)); if (m_config.fmReceive) { @@ -1360,9 +1422,11 @@ void WdspChannel::open() noexcept if (m_config.wbfmReceive) { m_wbfm = std::make_unique(m_config); SetRXAWBFMDiscriminatorCompensation(m_channelId, 1); + SetRXAWBFMForceMono(m_channelId, m_config.wbfmReceive->forceMono ? 1 : 0); SetRXAWBFMdmph(m_channelId, 1, m_config.wbfmReceive->deemphasis == WbfmReceive::Deemphasis::Us50 ? 1 : 0); m_wbfmStereo.store(0, std::memory_order_relaxed); + m_wbfmReception.reset(); } else { SetRXABandpassFreqs(m_channelId, m_config.filterLowHz, m_config.filterHighHz); RXANBPSetFreqs(m_channelId, m_config.filterLowHz, m_config.filterHighHz); @@ -1411,6 +1475,7 @@ void WdspChannel::open() noexcept SetChannelState(m_channelId, 1, 0); m_running.store(true, std::memory_order_relaxed); m_open = true; + return true; } void WdspChannel::close() noexcept @@ -1438,6 +1503,7 @@ void WdspChannel::close() noexcept closeNoiseBlanker(); m_wbfm.reset(); m_wbfmStereo.store(-1, std::memory_order_relaxed); + m_wbfmReception.reset(); } m_open = false; } diff --git a/src/core/dsp/WdspChannel.h b/src/core/dsp/WdspChannel.h index 67ee24b360..0fbff3537f 100644 --- a/src/core/dsp/WdspChannel.h +++ b/src/core/dsp/WdspChannel.h @@ -3,6 +3,7 @@ #include #include +#include "core/WfmReceptionDiagnostics.h" #include #include #include @@ -58,6 +59,7 @@ class WdspChannel final enum class Deemphasis { Us75, Us50 }; Deemphasis deemphasis = Deemphasis::Us75; double outputGain = 0.25; + bool forceMono = false; static constexpr double kRfTransitionGuardHz = 3000.0; bool operator==(const WbfmReceive&) const = default; }; @@ -261,10 +263,15 @@ class WdspChannel final bool setMode(Mode mode) noexcept; bool setFilter(double lowHz, double highHz) noexcept; bool setWbfmDeemphasis(WbfmReceive::Deemphasis deemphasis) noexcept; - // Lock-free latest completed decoder observation, sampled by processIq(). + // Lock-free latest completed output-mode observation, sampled by processIq(). + // Forced Mono is false independently of reception diagnostics.pilotLocked. // It is not an exact PCM-ring timestamp. False after stop/open; absent for // channels that do not own the broadcast recipe. Caller owns the lifetime. [[nodiscard]] std::optional wbfmStereoDetected() const noexcept; + // Acquisition thread only, immediately after processIq; control may read + // after joining/quiescing acquisition. Never reaches into mutable WDSP. + [[nodiscard]] const std::optional& + wbfmReceptionDiagnostics() const noexcept { return m_wbfmReception; } // Runtime RX AGC change. agcMode is the WDSP RXA AGC mode (0 off, 1 long, // 2 slow, 3 medium, 4 fast); maximumGainDb is the AGC "top", the ceiling on // how much gain the AGC may apply. Receive channels only — returns false on @@ -434,6 +441,21 @@ class WdspChannel final // overwrote the input block the worker had not yet copied out. 0 restores // the shipping path. static void setWorkerHandoffPauseForTest(unsigned microseconds) noexcept; + // Test-only deterministic counterpart: arm the next worker handoff, then + // acknowledge that one worker is held until explicitly released. Fixtures + // serialize use, keep other channels idle, and release BEFORE destruction + // or reconfiguration. The default is inactive; this never holds acquisition. + static void setWorkerHandoffHoldForTest(bool enabled) noexcept; + [[nodiscard]] static bool workerHandoffHeldForTest() noexcept; + // Same serialized-fixture lifetime contract, before output bytes/credit. + static void setWorkerOutputCopyHoldForTest(bool enabled) noexcept; + [[nodiscard]] static bool workerOutputCopyHeldForTest() noexcept; + // Test/control thread only: locks existing exchange/output-count sections. The + // caller owns this open channel and excludes control/destruction. + [[nodiscard]] int outputSamplesReadyForTest() const noexcept; + // After output-count readiness, wait for the last hook and DSP body before + // arming another hold. Test/control only; NEVER call while a hold is armed. + void synchronizeWorkerForTest() const noexcept; // Test only: the next `count` control operations on THIS channel are // refused exactly as a racing processIq() callback would refuse them, so // a caller's refused-then-converges path can be driven deterministically. @@ -465,7 +487,7 @@ class WdspChannel final void applySquelchLocked(Mode mode) noexcept; static std::size_t computeOutputBlockSize(const Config& config) noexcept; - void open() noexcept; + [[nodiscard]] bool open() noexcept; void close() noexcept; // Create/destroy the ANB stage alongside the channel. The blanker's id IS // the channel id: nob.c keeps its own table of 32, WDSP's channel table is @@ -481,6 +503,7 @@ class WdspChannel final struct WbfmState; std::unique_ptr m_wbfm; std::atomic m_wbfmStereo {-1}; + std::optional m_wbfmReception; int m_channelId = -1; Config m_config; // Fixed for a given Config; cached at open()/reconfigure() so the real-time diff --git a/src/gui/AppletPanel.cpp b/src/gui/AppletPanel.cpp index f3f7a65c6d..da0e5be9c9 100644 --- a/src/gui/AppletPanel.cpp +++ b/src/gui/AppletPanel.cpp @@ -23,6 +23,7 @@ #include "PhoneApplet.h" #include "EqApplet.h" #include "AetherClockApplet.h" +#include "WfmApplet.h" #include "MiniPanApplet.h" #include "WaveApplet.h" #include "ClientEqApplet.h" @@ -164,7 +165,7 @@ constexpr int kStackBottomMargin = 8; } // namespace const QStringList AppletPanel::kDefaultOrder = { - "PWR", "RX", "TUN", "AMP", "TX", "PHNE", "P/CW", "EQ", "WAVE", "TXDSP", "CAT", "DAX", "TCI", "IQ", "MTR", "PROF", "KSDR", "HLTH", "AG", "SS", "GHE", "CLOCK" + "PWR", "RX", "WFM", "TUN", "AMP", "TX", "PHNE", "P/CW", "EQ", "WAVE", "TXDSP", "CAT", "DAX", "TCI", "IQ", "MTR", "PROF", "KSDR", "HLTH", "AG", "SS", "GHE", "CLOCK" }; // ── Drop-aware scroll area ────────────────────────────────────────────────── @@ -627,6 +628,9 @@ AppletPanel::AppletPanel(QWidget* parent) : QWidget(parent) // lowering = hide it. The manager owns the window // so we just toggle the container's visibility. if (c->isFloating()) { + if (c->isPresentationManaged()) { + c->setContainerVisible(checked); + } if (auto* w = c->window()) w->setVisible(checked); return; @@ -754,6 +758,16 @@ AppletPanel::AppletPanel(QWidget* parent) : QWidget(parent) m_rxApplet = new RxApplet; m_appletOrder.append(makeEntry("RX", "RX Controls", m_rxApplet, true, m_drawer, m_drawerLayout)); + // A cohesive broadcast receiver tile follows the selected slice. The + // container owns docking, floating, layout and the operator's open/closed + // choice; temporary mode/capability loss must not overwrite that choice. + m_wfmApplet = new WfmApplet; + m_appletOrder.append(makeEntry("WFM", "WFM", m_wfmApplet, true, + m_drawer, m_drawerLayout)); + markHardwareConditional("WFM"); + connect(m_wfmApplet, &WfmApplet::availabilityChanged, + this, &AppletPanel::setWfmAvailable); + // Tuner / Amp entries use makeEntry like everything else; // MainWindow toggles tray-button visibility via setTunerVisible / // setAmpVisible once the hardware reports its presence. Until @@ -1124,6 +1138,7 @@ AppletPanel::AppletPanel(QWidget* parent) : QWidget(parent) // Place the drawer toggle into the favorites row, then apply the // saved (or default) favorites layout to populate both strips. loadButtonLayout(); + setWfmAvailable(m_wfmApplet->isAvailable()); applyBarLayout(); // Restore drawer open/closed state (default closed). Signals blocked @@ -1397,6 +1412,7 @@ QList AppletPanel::appletCatalog() const // a category keep panel order. static const QMap kCategory = { {QStringLiteral("RX"), QStringLiteral("Receive")}, + {QStringLiteral("WFM"), QStringLiteral("Receive")}, {QStringLiteral("MPAN"), QStringLiteral("Receive")}, {QStringLiteral("KSDR"), QStringLiteral("Receive")}, {QStringLiteral("DEMO"), QStringLiteral("Receive")}, @@ -1507,7 +1523,7 @@ void AppletPanel::setAppletVisible(const QString& id, bool visible) w->setVisible(visible); // Keep the container itself shown: it is the window's content, // and the window is what visibility means for a floating tile. - c->setContainerVisible(true); + c->setContainerVisible(c->isPresentationManaged() ? visible : true); if (entry.btn) { QSignalBlocker b(entry.btn); entry.btn->setChecked(visible); @@ -1731,6 +1747,30 @@ void AppletPanel::applyCapabilityVisibility(const QString& id, applyBarLayout(); } +void AppletPanel::setWfmAvailable(bool available) +{ + // Availability suppresses presentation, never workspace membership or the + // requested open state. Generic capability hiding emits visibilityChanged + // and would incorrectly mark this temporarily unavailable tile as closed + // in the active workspace. The container/window gate also survives recall. + for (BarButton& button : m_barButtons) { + if (button.id != QLatin1String("WFM")) { + continue; + } + button.hardwareAvailable = available; + if (available && !m_buttonOrder.contains(button.id) + && !m_hiddenButtons.contains(button.id)) { + m_buttonOrder.append(button.id); + saveButtonLayout(); + } + break; + } + if (ContainerWidget* container = m_containerMgr->container(QStringLiteral("WFM"))) { + container->setPresentationAvailable(available); + } + applyBarLayout(); +} + void AppletPanel::setRadioFilterWidths(const QList& widthsHz) { if (m_rxApplet) @@ -1878,6 +1918,9 @@ void AppletPanel::setControlsLocked(bool locked) void AppletPanel::setSlice(SliceModel* slice) { m_rxApplet->setSlice(slice); + if (m_wfmApplet) { + m_wfmApplet->setSlice(slice); + } if (m_aetherClockApplet) m_aetherClockApplet->setSlice(slice); diff --git a/src/gui/AppletPanel.h b/src/gui/AppletPanel.h index 9f7be1f8cc..b9a2a744c0 100644 --- a/src/gui/AppletPanel.h +++ b/src/gui/AppletPanel.h @@ -46,6 +46,7 @@ class PhoneApplet; class EqApplet; class WaveApplet; class AetherClockApplet; +class WfmApplet; class MiniPanApplet; class ClientEqApplet; class ClientCompApplet; @@ -118,6 +119,7 @@ class AppletPanel : public QWidget { EqApplet* eqApplet() { return m_eqApplet; } WaveApplet* waveApplet() const { return m_waveApplet; } AetherClockApplet* aetherClockApplet() const { return m_aetherClockApplet; } + WfmApplet* wfmApplet() const { return m_wfmApplet; } MiniPanApplet* miniPanApplet() const { return m_miniPanApplet; } // Phase 7.1: each side has its own CEQ applet — clientEqTxApplet() // is the original "ceq" tile bound to TX, clientEqRxApplet() is @@ -385,6 +387,7 @@ class AppletPanel : public QWidget { const QString& appletKey, bool available); void markHardwareConditional(const QString& id); + void setWfmAvailable(bool available); void persistVuMeterSettings() const; void showStandardMeterContextMenu(QWidget* source, const QPoint& position); static const int kFavoriteCount = 5; @@ -425,6 +428,7 @@ class AppletPanel : public QWidget { EqApplet* m_eqApplet{nullptr}; WaveApplet* m_waveApplet{nullptr}; AetherClockApplet* m_aetherClockApplet{nullptr}; + WfmApplet* m_wfmApplet{nullptr}; MiniPanApplet* m_miniPanApplet{nullptr}; ClientEqApplet* m_clientEqTxApplet{nullptr}; ClientEqApplet* m_clientEqRxApplet{nullptr}; diff --git a/src/gui/ControlAvailabilityRegistry.cpp b/src/gui/ControlAvailabilityRegistry.cpp index aae227c468..da07996e7a 100644 --- a/src/gui/ControlAvailabilityRegistry.cpp +++ b/src/gui/ControlAvailabilityRegistry.cpp @@ -60,13 +60,15 @@ ControlAvailabilityRegistry::ControlAvailabilityRegistry(RadioModel& model, QObj void ControlAvailabilityRegistry::registerWidget(QWidget* widget, QString reason, AvailabilityPredicate available, - EngagedPredicate engaged) + EngagedPredicate engaged, + bool availableWhenDisconnected) { if (!widget || !available) { return; } Entry entry; entry.widget = widget; + entry.availableWhenDisconnected = availableWhenDisconnected; entry.reason = std::move(reason); entry.available = std::move(available); entry.engaged = std::move(engaged); @@ -111,11 +113,10 @@ void ControlAvailabilityRegistry::applyOne(Entry& entry, bool connected, const RadioCapabilities& caps) { - // PERMISSIVE ON DISCONNECT, matching every gate in applyCapabilitiesToUi: - // with no radio attached there is nothing to be honest about, and leaving a - // control dimmed after unplugging reads as a fault rather than as an absent - // capability. - const bool available = !connected || entry.available(connected, caps); + // Preserve the historical disconnected treatment unless a control explicitly + // requires a live receiver (for example observed broadcast-FM settings). + const bool available = connected ? entry.available(connected, caps) + : entry.availableWhenDisconnected; const bool engaged = connected && available && entry.engaged && entry.engaged(); entry.state = !available ? ControlAvailability::Unavailable : engaged ? ControlAvailability::Active diff --git a/src/gui/ControlAvailabilityRegistry.h b/src/gui/ControlAvailabilityRegistry.h index 0953f40cc2..88088d2b45 100644 --- a/src/gui/ControlAvailabilityRegistry.h +++ b/src/gui/ControlAvailabilityRegistry.h @@ -52,10 +52,13 @@ class ControlAvailabilityRegistry : public QObject { // with no reason is the accessibility defect this milestone exists to close // (#4896). Applied immediately, so a widget built after connect is correct // without a second push. + // Pass availableWhenDisconnected=false for controls requiring a live + // receiver; existing controls retain their permissive disconnected state. void registerWidget(QWidget* widget, QString reason, AvailabilityPredicate available, - EngagedPredicate engaged = {}); + EngagedPredicate engaged = {}, + bool availableWhenDisconnected = true); // Same contract for a menu entry or toolbar action. void registerAction(QAction* action, @@ -82,6 +85,7 @@ class ControlAvailabilityRegistry : public QObject { AvailabilityPredicate available; EngagedPredicate engaged; ControlAvailability state{ControlAvailability::Unavailable}; + bool availableWhenDisconnected = true; }; void applyAll(bool connected, const RadioCapabilities& caps); diff --git a/src/gui/MainWindow_Wiring.cpp b/src/gui/MainWindow_Wiring.cpp index d7eca147a9..5746db148d 100644 --- a/src/gui/MainWindow_Wiring.cpp +++ b/src/gui/MainWindow_Wiring.cpp @@ -33,6 +33,7 @@ #include "RadioSetupDialog.h" #include "GpsLocationDialog.h" #include "RxApplet.h" +#include "WfmApplet.h" #include "AmpApplet.h" #include "AcomApplet.h" #include "SpeApplet.h" @@ -7339,6 +7340,7 @@ void MainWindow::wireMeters() m_appletPanel->txApplet()->setRadioModel(&m_radioModel); m_appletPanel->txApplet()->setBandPlanManager(m_bandPlanMgr); m_appletPanel->rxApplet()->setRadioModel(&m_radioModel); + m_appletPanel->wfmApplet()->setRadioModel(&m_radioModel); m_appletPanel->rxApplet()->setKiwiSdrManager(m_kiwiSdrManager); m_appletPanel->rxApplet()->setTransmitModel(&m_radioModel.transmitModel()); connect(m_appletPanel->rxApplet(), &RxApplet::kiwiRxAntennaSelected, diff --git a/src/gui/ModeFilterPresets.cpp b/src/gui/ModeFilterPresets.cpp index 010651cb7d..dcac0b4910 100644 --- a/src/gui/ModeFilterPresets.cpp +++ b/src/gui/ModeFilterPresets.cpp @@ -15,15 +15,21 @@ bool isFmMode(const QString& mode) } namespace { +QString canonicalFmMode(const QString& mode) +{ + if (mode == "NFM") { return QStringLiteral("FMN"); } + if (mode == "WBFM") { return QStringLiteral("WFM"); } + return mode; +} + const ReceiveFilterMode* fmControlFor(const QString& mode, const ReceiveFilterControl* control) { if (!control || !isFmMode(mode)) { return nullptr; } - const QString canonical = mode == "NFM" ? QStringLiteral("FMN") : mode; + const QString canonical = canonicalFmMode(mode); for (const ReceiveFilterMode& candidate : control->modes) { - const QString candidateMode = candidate.mode == "NFM" - ? QStringLiteral("FMN") : candidate.mode; + const QString candidateMode = canonicalFmMode(candidate.mode); if (candidateMode == canonical) { return &candidate; } @@ -86,11 +92,16 @@ bool squelchAvailableInMode(const QString& mode, const SquelchLevelScale* exclus QVector widthsForMode(const QString& mode, const ReceiveFilterControl* control) { - if (mode != "FM" && mode != "FMN" && mode != "NFM") { + if (!isFmMode(mode)) { return widthsForMode(mode); } + // Broadcast FM uses RF channel widths, not the narrow-FM audio ladder. + // These are choices only; observed and saved valid edges remain intact. + static const QVector broadcast{100000, 120000, 140000, 160000, 180000, 200000}; + const QVector& candidates = canonicalFmMode(mode) == "WFM" + ? broadcast : widthsForMode(QStringLiteral("DFM")); QVector widths; - for (int width : widthsForMode(QStringLiteral("DFM"))) { + for (int width : candidates) { if (acceptsFmEdges(mode, control, edgesForWidth(mode, width, {}))) { widths.append(width); } diff --git a/src/gui/ModeFilterPresets.h b/src/gui/ModeFilterPresets.h index 8977157751..b1ef79d735 100644 --- a/src/gui/ModeFilterPresets.h +++ b/src/gui/ModeFilterPresets.h @@ -31,7 +31,8 @@ struct Edges { const QVector& widthsForMode(const QString& mode); // FM defaults above describe fixed radio filters. A receiver which declares -// adjustable FM edges can use the existing DFM ladder within those bounds. +// adjustable FM edges can use the DFM ladder, or the broadcast-width ladder +// for WFM/WBFM, intersected with its declared bounds. // The conservative headless record does not gate unrelated desktop modes. QVector widthsForMode(const QString& mode, const ReceiveFilterControl* control); // Analog FM spellings. DFM retains its existing separate preset behavior. diff --git a/src/gui/WfmApplet.cpp b/src/gui/WfmApplet.cpp new file mode 100644 index 0000000000..e29e6c6aa7 --- /dev/null +++ b/src/gui/WfmApplet.cpp @@ -0,0 +1,479 @@ +#include "WfmApplet.h" +#include "ComboStyle.h" +#include "ControlAvailabilityRegistry.h" +#include "GuardedSlider.h" +#include "ModeFilterPresets.h" +#include "WfmLockScope.h" +#include "core/AppSettings.h" +#include "core/ThemeManager.h" +#include "models/RadioModel.h" +#include "models/PanadapterModel.h" +#include "models/SliceModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace AetherSDR { +namespace { +QJsonObject preferences() +{ + return QJsonDocument::fromJson(AppSettings::instance() + .value(QStringLiteral("WfmApplet"), QString{}).toString().toUtf8()).object(); +} + +void setReadout(QLabel* label, const QString& text, const QString& name) +{ + if (label->text() == text && label->accessibleName() == name) { return; } + label->setText(text); + label->setAccessibleName(name); + QAccessibleEvent event(label, QAccessible::NameChanged); + QAccessible::updateAccessibility(&event); +} + +void announceCombo(QComboBox* combo, const QString& previous) +{ + if (previous != combo->currentText()) { + QAccessibleValueChangeEvent event(combo, combo->currentText()); + QAccessible::updateAccessibility(&event); + } +} + +void styleButton(QPushButton* button) +{ + ThemeManager::instance().applyStyleSheet(button, + "QPushButton { background: {{color.background.1}};" + " border: 1px solid {{color.border.subtle}}; border-radius: 3px;" + " color: {{color.text.primary}}; font-size: 10px; font-weight: bold; padding: 2px 4px; }" + "QPushButton:hover { background: {{color.background.2}}; }" + "QPushButton:disabled { color: {{color.control.unavailable}}; }"); +} +} // namespace + +WfmApplet::WfmApplet(QWidget* parent) : QWidget(parent) +{ + setObjectName(QStringLiteral("WfmApplet")); + setAccessibleName(tr("Broadcast FM")); + theme::setContainer(this, QStringLiteral("applet/wfm")); + auto* root = new QVBoxLayout(this); + root->setContentsMargins(2, 2, 2, 2); + root->setSpacing(3); + m_scope = new WfmLockScope(this); + m_scope->setObjectName(QStringLiteral("wfmLockScope")); + root->addWidget(m_scope); + + auto* statusRow = new QHBoxLayout; + statusRow->setSpacing(5); + m_audioMode = new QPushButton(this); + m_audioMode->setObjectName(QStringLiteral("wfmAudioMode")); + m_audioMode->setFocusPolicy(Qt::StrongFocus); + m_audioMode->setMinimumHeight(22); + styleButton(m_audioMode); + statusRow->addWidget(m_audioMode); + m_status = new QLabel(this); + m_status->setObjectName(QStringLiteral("wfmStereoStatus")); + m_status->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + ThemeManager::instance().applyStyleSheet(m_status, + "QLabel { color: {{color.text.secondary}}; font-size: 11px; }"); + statusRow->addWidget(m_status, 1); + root->addLayout(statusRow); + + auto* settings = new QGridLayout; + settings->setHorizontalSpacing(5); + settings->setVerticalSpacing(3); + m_deemphasis = new GuardedComboBox(this); + m_deemphasis->setObjectName(QStringLiteral("wfmDeemphasis")); + m_deemphasis->setAccessibleName(tr("Broadcast FM deemphasis")); + m_deemphasis->setPlaceholderText(tr("Unknown")); + m_deemphasis->setFocusPolicy(Qt::StrongFocus); + applyComboStyle(m_deemphasis); + m_bandwidth = new GuardedComboBox(this); + m_bandwidth->setObjectName(QStringLiteral("wfmBandwidth")); + m_bandwidth->setAccessibleName(tr("Broadcast FM bandwidth")); + m_bandwidth->setPlaceholderText(tr("Unavailable")); + m_bandwidth->setFocusPolicy(Qt::StrongFocus); + applyComboStyle(m_bandwidth); + auto* deemphasisLabel = new QLabel(tr("De-emphasis"), this); + deemphasisLabel->setBuddy(m_deemphasis); + auto* bandwidthLabel = new QLabel(tr("Bandwidth"), this); + bandwidthLabel->setBuddy(m_bandwidth); + for (QLabel* label : {deemphasisLabel, bandwidthLabel}) { + ThemeManager::instance().applyStyleSheet(label, + "QLabel { color: {{color.text.secondary}}; font-size: 11px; }"); + } + settings->addWidget(deemphasisLabel, 0, 0); + settings->addWidget(m_deemphasis, 0, 1); + settings->addWidget(bandwidthLabel, 1, 0); + settings->addWidget(m_bandwidth, 1, 1); + settings->setColumnStretch(1, 1); + root->addLayout(settings); + + m_settingsToggle = new QPushButton(tr("▸ Settings"), this); + m_settingsToggle->setObjectName(QStringLiteral("wfmSettingsToggle")); + m_settingsToggle->setCheckable(true); + m_settingsToggle->setAccessibleName(tr("Broadcast FM settings")); + m_settingsToggle->setFocusPolicy(Qt::StrongFocus); + styleButton(m_settingsToggle); + root->addWidget(m_settingsToggle); + m_settingsDrawer = new QFrame(this); + m_settingsDrawer->setObjectName(QStringLiteral("wfmSettingsDrawer")); + auto* drawer = new QVBoxLayout(m_settingsDrawer); + drawer->setContentsMargins(5, 4, 5, 5); + drawer->setSpacing(3); + m_showScope = new QCheckBox(tr("Show Lock Scope"), m_settingsDrawer); + m_showScope->setObjectName(QStringLiteral("wfmShowLockScope")); + m_showScope->setAccessibleName(tr("Show Broadcast FM lock scope")); + m_showDiagnostics = new QCheckBox(tr("Show Diagnostics"), m_settingsDrawer); + m_showDiagnostics->setObjectName(QStringLiteral("wfmShowDiagnostics")); + m_showDiagnostics->setAccessibleName(tr("Show Broadcast FM diagnostics")); + const QJsonObject ui = preferences().value(QStringLiteral("ui")).toObject(); + m_showScope->setChecked(ui.value(QStringLiteral("showLockScope")).toBool(true)); + m_showDiagnostics->setChecked(ui.value(QStringLiteral("showDiagnostics")).toBool(false)); + drawer->addWidget(m_showScope); + drawer->addWidget(m_showDiagnostics); + root->addWidget(m_settingsDrawer); + m_diagnostics = new QLabel(this); + m_diagnostics->setObjectName(QStringLiteral("wfmDiagnostics")); + m_diagnostics->setWordWrap(true); + m_diagnostics->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); + ThemeManager::instance().applyStyleSheet(m_diagnostics, + "QLabel { color: {{color.text.secondary}}; font-size: 10px;" + " background: {{color.background.0}}; border: 1px solid {{color.border.subtle}};" + " border-radius: 3px; padding: 4px; }"); + root->addWidget(m_diagnostics); + + connect(m_audioMode, &QPushButton::clicked, this, [this] { + if (m_available && m_slice && m_audioMode->isEnabled()) { + m_slice->setWfmForceMono(!m_slice->wfmForceMono()); + } + refresh(); + }); + connect(m_deemphasis, &QComboBox::activated, this, [this](int index) { + if (m_available && m_slice && m_deemphasis->isEnabled()) { + m_slice->setWfmDeemphasis(m_deemphasis->itemData(index).toInt()); + } + refresh(); + }); + connect(m_bandwidth, &QComboBox::activated, this, [this](int index) { + if (m_available && m_slice && m_bandwidth->isEnabled()) { + const int width = m_bandwidth->itemData(index).toInt(); + const ModeFilters::Edges edges = ModeFilters::edgesForWidth(QStringLiteral("WFM"), width, {}); + const RadioCapabilities caps = m_model->backendCapabilities(); + if (ModeFilters::acceptsFmEdges(QStringLiteral("WFM"), + caps.receiveFilterControl ? &*caps.receiveFilterControl : nullptr, edges)) { + m_slice->setFilterWidth(edges.lo, edges.hi); + } + } + refresh(); + }); + connect(m_settingsToggle, &QPushButton::toggled, this, &WfmApplet::setSettingsExpanded); + for (QCheckBox* option : {m_showScope, m_showDiagnostics}) { + connect(option, &QCheckBox::toggled, this, [this] { + applyUiPreferences(); + saveUiPreferences(); + }); + } + setSettingsExpanded(false); + applyUiPreferences(); + refresh(); +} + +WfmApplet::~WfmApplet() = default; +QSize WfmApplet::sizeHint() const { return {260, layout()->sizeHint().height()}; } +QSize WfmApplet::minimumSizeHint() const { return {220, layout()->minimumSize().height()}; } + +bool WfmApplet::ownsWfmSlice() const +{ + return m_model && m_slice && m_model->slice(m_slice->sliceId()) == m_slice + && m_slice->mode() == QLatin1String("WFM"); +} + +void WfmApplet::setRadioModel(RadioModel* model) +{ + for (const auto& connection : m_modelConnections) { disconnect(connection); } + m_modelConnections.clear(); + m_availability.reset(); + m_scope->clear(); + m_model = model; + m_connected = model && model->isConnected(); + m_caps = model ? model->backendCapabilities() : RadioCapabilities{}; + if (model) { + m_modelConnections.append(connect(model, &RadioModel::capabilitiesChanged, this, + [this](bool connected, const RadioCapabilities& caps) { + m_connected = connected; + m_caps = caps; + refresh(); + })); + m_modelConnections.append(connect(model, &RadioModel::connectionStateChanged, this, + [this](bool connected) { + m_connected = connected; + m_caps = m_model ? m_model->backendCapabilities() : RadioCapabilities{}; + refresh(); + })); + m_modelConnections.append(connect(model, &RadioModel::sliceRemoved, this, + [this](int id) { + if (m_slice && m_slice->sliceId() == id) { setSlice(nullptr); } + })); + m_modelConnections.append(connect(model, &QObject::destroyed, this, [this] { + m_availability.reset(); + m_model = nullptr; + m_connected = false; + m_caps = {}; + refresh(); + })); + registerControls(); + } + refresh(); +} + +void WfmApplet::setSlice(SliceModel* slice) +{ + for (const auto& connection : m_sliceConnections) { disconnect(connection); } + m_sliceConnections.clear(); + m_scope->clear(); + m_slice = slice; + if (slice) { + m_sliceConnections.append(connect(slice, &SliceModel::modeChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &SliceModel::filterChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &SliceModel::wfmDeemphasisChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &SliceModel::wfmForceMonoChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &SliceModel::wfmStereoStatusChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &SliceModel::wfmReceptionDiagnosticsChanged, this, [this] { + refreshDiagnostics(); + appendScopeSample(); + })); + m_sliceConnections.append(connect(slice, &SliceModel::frequencyChanged, this, [this] { + m_scope->clear(); + refreshDiagnostics(); + })); + m_sliceConnections.append(connect(slice, &SliceModel::inCaptureChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &SliceModel::externalReceiveReplacementChanged, this, &WfmApplet::refresh)); + m_sliceConnections.append(connect(slice, &QObject::destroyed, this, [this] { + m_slice = nullptr; + refresh(); + })); + } + refresh(); +} + +void WfmApplet::registerControls() +{ + m_availability = std::make_unique(*m_model, this); + const auto supported = [this](bool connected, const RadioCapabilities& caps) { + return connected && caps.broadcastFmReceive && ownsWfmSlice() + && !m_slice->externalReceiveReplacementActive(); + }; + m_availability->registerWidget(m_audioMode, + tr("Mono selection is unavailable for this receiver or replacement audio source"), + [supported](bool connected, const RadioCapabilities& caps) { + return supported(connected, caps) && caps.broadcastFmReceive->forceMonoControl; + }, [] { return true; }, false); + m_availability->registerWidget(m_deemphasis, + tr("De-emphasis is unavailable for this receiver or replacement audio source"), + [supported](bool connected, const RadioCapabilities& caps) { + return supported(connected, caps) && !caps.broadcastFmReceive->deemphasisUs.isEmpty(); + }, [] { return true; }, false); + m_availability->registerWidget(m_bandwidth, + tr("Adjustable WFM bandwidth is unavailable for this receiver or replacement audio source"), + [supported](bool connected, const RadioCapabilities& caps) { + return supported(connected, caps) && !ModeFilters::widthsForMode(QStringLiteral("WFM"), + caps.receiveFilterControl ? &*caps.receiveFilterControl : nullptr).isEmpty(); + }, [] { return true; }, false); + for (QCheckBox* control : {m_showScope, m_showDiagnostics}) { + m_availability->registerWidget(control, tr("This receiver does not provide pilot measurements"), + [this](bool connected, const RadioCapabilities& caps) { + return connected && caps.broadcastFmReceive && caps.broadcastFmReceive->receptionDiagnostics + && ownsWfmSlice(); + }, [] { return true; }, false); + } +} + +void WfmApplet::refresh() +{ + const bool available = m_connected && m_caps.broadcastFmReceive && ownsWfmSlice(); + if (available != m_available) { + m_available = available; + if (!available) { m_scope->clear(); } + emit availabilityChanged(available); + } + if (m_availability) { + m_availability->refreshEngaged(); + } else { + for (QWidget* control : {static_cast(m_audioMode), static_cast(m_deemphasis), + static_cast(m_bandwidth), static_cast(m_showScope), + static_cast(m_showDiagnostics)}) { + control->setEnabled(false); + control->setAccessibleDescription(tr("Connect a broadcast FM receiver and select WFM")); + } + } + const bool forceMono = m_available && m_slice->wfmForceMono(); + const QString modeText = forceMono ? tr("Mono") : tr("Auto Stereo"); + if (m_audioMode->text() != modeText) { + m_audioMode->setText(modeText); + m_audioMode->setAccessibleName(tr("Broadcast FM audio mode: %1").arg(modeText)); + QAccessibleEvent event(m_audioMode, QAccessible::NameChanged); + QAccessible::updateAccessibility(&event); + } + if (m_audioMode->isEnabled()) { + m_audioMode->setAccessibleDescription(tr("Cycle between forced Mono and Auto Stereo. Auto Stereo falls back to mono when no stereo pilot is detected.")); + m_audioMode->setToolTip(m_audioMode->accessibleDescription()); + } + const QString oldDeemphasis = m_deemphasis->currentText(); + const QString oldBandwidth = m_bandwidth->currentText(); + { + const QSignalBlocker blocker(m_deemphasis); + m_deemphasis->clear(); + if (m_caps.broadcastFmReceive) { + for (int value : m_caps.broadcastFmReceive->deemphasisUs) { + m_deemphasis->addItem(tr("%1 µs").arg(value), value); + } + } + m_deemphasis->setCurrentIndex(m_available ? m_deemphasis->findData(m_slice->wfmDeemphasisUs()) : -1); + } + { + const QSignalBlocker blocker(m_bandwidth); + m_bandwidth->clear(); + const QVector widths = ModeFilters::widthsForMode(QStringLiteral("WFM"), + m_caps.receiveFilterControl ? &*m_caps.receiveFilterControl : nullptr); + for (int width : widths) { m_bandwidth->addItem(tr("%1 kHz").arg(width / 1000), width); } + int selected = -1; + if (m_available) { + const int low = m_slice->filterLow(); + const int high = m_slice->filterHigh(); + const int width = high - low; + if (low == -width / 2 && high == width / 2) { selected = m_bandwidth->findData(width); } + if (selected < 0) { + // Accepted custom edges remain exact; a display refresh must + // never round them to a nearby symmetric preset or send intent. + m_bandwidth->addItem(tr("%1 kHz (custom)").arg(width / 1000.0, 0, 'g', 5), 0); + selected = m_bandwidth->count() - 1; + } + } + m_bandwidth->setCurrentIndex(selected); + } + announceCombo(m_deemphasis, oldDeemphasis); + announceCombo(m_bandwidth, oldBandwidth); + refreshDiagnostics(); +} + +void WfmApplet::refreshDiagnostics() +{ + const bool receiving = m_available && m_slice->inCapture() + && !m_slice->externalReceiveReplacementActive(); + const WfmReceptionDiagnostics diagnostics = receiving && m_caps.broadcastFmReceive->receptionDiagnostics + ? m_slice->wfmReceptionDiagnostics() : WfmReceptionDiagnostics{}; + const bool forceMono = m_available && m_slice->wfmForceMono(); + const WfmStereoStatus status = receiving ? m_slice->wfmStereoStatus() : WfmStereoStatus::Unavailable; + QString text = tr("Unavailable"); + QString token = QStringLiteral("color.text.secondary"); + if (diagnostics.valid) { + if (forceMono) { + text = diagnostics.pilotLocked ? tr("Pilot detected") : tr("No pilot"); + } else if (status == WfmStereoStatus::Stereo && diagnostics.pilotLocked) { + text = tr("Stereo"); + if (!forceMono) { token = QStringLiteral("color.accent.success"); } + } else if (status == WfmStereoStatus::Acquiring) { + text = tr("Acquiring"); + if (!forceMono) { token = QStringLiteral("color.accent.warning"); } + } else if (status == WfmStereoStatus::Mono) { + text = tr("Mono fallback"); + } + if (!forceMono && diagnostics.lockLossCount > 0 && diagnostics.stableDurationMs < 5000) { + text += tr(" · Unstable"); + token = QStringLiteral("color.accent.warning"); + } + } else { + m_scope->clear(); + if (receiving && status == WfmStereoStatus::Acquiring) { + text = tr("Acquiring"); + if (!forceMono) { token = QStringLiteral("color.accent.warning"); } + } + } + setReadout(m_status, text, tr("Broadcast FM observed pilot status: %1").arg(text)); + m_status->setAccessibleDescription(tr("Measured stereo pilot indicator, independent of the selected audio mode.")); + ThemeManager::instance().setWidgetForegroundToken(m_status, token); + QString details = tr("No current receive measurements"); + if (diagnostics.valid) { + details = tr("Pilot indicator: %1\nPilot magnitude: %2 relative\nAcquire / release: %3 / %4 relative\nHigh blocks: %5 / %6 · Low blocks: %7 / %8\nLock duration: %9 s\nLock losses: %10 · Reacquisitions: %11\nObservation: %12 s\nPilot state unchanged for %13 s (5 s window)") + .arg(diagnostics.pilotLocked ? tr("Detected") : tr("Not detected")) + .arg(diagnostics.pilotMagnitude, 0, 'g', 4) + .arg(diagnostics.pilotEngageThreshold, 0, 'g', 4) + .arg(diagnostics.pilotReleaseThreshold, 0, 'g', 4) + .arg(diagnostics.consecutiveHighBlocks).arg(diagnostics.engageBlocks) + .arg(diagnostics.consecutiveLowBlocks).arg(diagnostics.releaseBlocks) + .arg(diagnostics.lockDurationMs / 1000.0, 0, 'f', 1) + .arg(diagnostics.lockLossCount).arg(diagnostics.reacquisitionCount) + .arg(diagnostics.observationDurationMs / 1000.0, 0, 'f', 1) + .arg(diagnostics.stableDurationMs / 1000.0, 0, 'f', 1); + } + if (m_available) { + const QString frequency = m_slice->frequencyReportedKnown() + ? tr("%1 MHz").arg(m_slice->reportedFrequency(), 0, 'f', 6) : tr("Unavailable"); + details += tr("\nFrequency: %1\nFilter: %2 to %3 kHz\nDe-emphasis: %4 µs") + .arg(frequency).arg(m_slice->filterLow() / 1000.0, 0, 'g', 5) + .arg(m_slice->filterHigh() / 1000.0, 0, 'g', 5).arg(m_slice->wfmDeemphasisUs()); + if (const PanadapterModel* pan = m_model->panadapter(m_slice->panId())) { + details += tr("\nRF gain: %1%2").arg(pan->rfGain()).arg(pan->rfGainUnitSuffix()); + } + } + setReadout(m_diagnostics, details, tr("Broadcast FM diagnostics: %1").arg(details)); + m_diagnostics->setAccessibleDescription(tr("Pilot magnitude uses relative discriminator units. The pilot indicator is not PLL lock, SNR, or an audio quality score. Measurements reset with the decoder and clear when stale.")); + m_diagnostics->setToolTip(m_diagnostics->accessibleDescription()); +} + +void WfmApplet::appendScopeSample() +{ + if (!m_available || !m_slice || !m_slice->inCapture() || m_slice->externalReceiveReplacementActive() + || !m_caps.broadcastFmReceive->receptionDiagnostics || !m_slice->wfmReceptionDiagnostics().valid) { + m_scope->clear(); + return; + } + if (!m_showScope->isChecked() || !m_scope->isVisible()) { return; } + const WfmReceptionDiagnostics& diagnostics = m_slice->wfmReceptionDiagnostics(); + m_scope->appendSample(diagnostics.pilotMagnitude, diagnostics.pilotEngageThreshold, + diagnostics.pilotReleaseThreshold, m_slice->wfmStereoStatus(), m_slice->wfmForceMono()); +} + +void WfmApplet::setSettingsExpanded(bool expanded) +{ + m_settingsDrawer->setVisible(expanded); + m_settingsToggle->setText(expanded ? tr("▾ Settings") : tr("▸ Settings")); + m_settingsToggle->setAccessibleDescription(expanded ? tr("Expanded") : tr("Collapsed")); + setMinimumHeight(minimumSizeHint().height()); + updateGeometry(); + if (QWidget* parent = parentWidget()) { parent->updateGeometry(); } +} + +void WfmApplet::applyUiPreferences() +{ + m_scope->setVisible(m_showScope->isChecked()); + if (!m_showScope->isChecked()) { m_scope->clear(); } + m_diagnostics->setVisible(m_showDiagnostics->isChecked()); + setMinimumHeight(minimumSizeHint().height()); + updateGeometry(); + if (QWidget* parent = parentWidget()) { parent->updateGeometry(); } +} + +void WfmApplet::saveUiPreferences() +{ + // Client presentation only. Accepted receiver controls persist through + // their model/backend path, never through this UI preference object. + QJsonObject document = preferences(); + QJsonObject ui = document.value(QStringLiteral("ui")).toObject(); + ui.insert(QStringLiteral("showLockScope"), m_showScope->isChecked()); + ui.insert(QStringLiteral("showDiagnostics"), m_showDiagnostics->isChecked()); + document.insert(QStringLiteral("ui"), ui); + AppSettings::instance().setValue(QStringLiteral("WfmApplet"), + QString::fromUtf8(QJsonDocument(document).toJson(QJsonDocument::Compact))); + AppSettings::instance().save(); +} +} // namespace AetherSDR diff --git a/src/gui/WfmApplet.h b/src/gui/WfmApplet.h new file mode 100644 index 0000000000..71a3910062 --- /dev/null +++ b/src/gui/WfmApplet.h @@ -0,0 +1,64 @@ +#pragma once + +#include "core/backends/RadioCapabilities.h" +#include +#include +#include +#include + +class QCheckBox; +class QComboBox; +class QFrame; +class QLabel; +class QPushButton; + +namespace AetherSDR { +class ControlAvailabilityRegistry; +class RadioModel; +class SliceModel; +class WfmLockScope; + +// Selected-slice broadcast FM controls. AppletPanel owns the visibility of +// the complete tile and floating window; this widget never hides itself. +class WfmApplet : public QWidget { + Q_OBJECT +public: + explicit WfmApplet(QWidget* parent = nullptr); + ~WfmApplet() override; + void setRadioModel(RadioModel* model); + void setSlice(SliceModel* slice); + bool isAvailable() const { return m_available; } + QSize sizeHint() const override; + QSize minimumSizeHint() const override; +signals: + void availabilityChanged(bool available); +private: + bool ownsWfmSlice() const; + void refresh(); + void refreshDiagnostics(); + void appendScopeSample(); + void registerControls(); + void setSettingsExpanded(bool expanded); + void applyUiPreferences(); + void saveUiPreferences(); + + QPointer m_model; + QPointer m_slice; + QVector m_modelConnections; + QVector m_sliceConnections; + std::unique_ptr m_availability; + RadioCapabilities m_caps; + bool m_connected{false}; + bool m_available{false}; + WfmLockScope* m_scope{nullptr}; + QPushButton* m_audioMode{nullptr}; + QLabel* m_status{nullptr}; + QComboBox* m_deemphasis{nullptr}; + QComboBox* m_bandwidth{nullptr}; + QPushButton* m_settingsToggle{nullptr}; + QFrame* m_settingsDrawer{nullptr}; + QCheckBox* m_showScope{nullptr}; + QCheckBox* m_showDiagnostics{nullptr}; + QLabel* m_diagnostics{nullptr}; +}; +} // namespace AetherSDR diff --git a/src/gui/WfmLockScope.cpp b/src/gui/WfmLockScope.cpp new file mode 100644 index 0000000000..ad790af98c --- /dev/null +++ b/src/gui/WfmLockScope.cpp @@ -0,0 +1,224 @@ +#include "WfmLockScope.h" + +#include "core/ThemeManager.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace AetherSDR { +namespace { +class WfmLockScopeAccessible final : public QAccessibleWidget { +public: + explicit WfmLockScopeAccessible(QWidget* widget) + : QAccessibleWidget(widget, QAccessible::Indicator) {} + QString text(QAccessible::Text kind) const override + { + if (kind == QAccessible::Value && widget()) { + return widget()->accessibleDescription(); + } + return QAccessibleWidget::text(kind); + } +}; + +QAccessibleInterface* scopeAccessibleFactory(const QString& key, QObject* object) +{ + if (key == QLatin1String("AetherSDR::WfmLockScope")) { + return new WfmLockScopeAccessible(qobject_cast(object)); + } + return nullptr; +} + +void publishAccessibleValue(QWidget* widget, const QString& value) +{ + if (widget->accessibleDescription() == value) { return; } + widget->setAccessibleDescription(value); + // Observation publication is bounded to 4 Hz plus real state changes. + QAccessibleValueChangeEvent event(widget, value); + QAccessible::updateAccessibility(&event); +} + +QString stateText(WfmStereoStatus state, bool forceMono) +{ + if (forceMono) { + return QStringLiteral("Forced mono"); + } + switch (state) { + case WfmStereoStatus::Stereo: return QStringLiteral("Stereo locked"); + case WfmStereoStatus::Mono: return QStringLiteral("Mono fallback"); + case WfmStereoStatus::Acquiring: return QStringLiteral("Acquiring"); + case WfmStereoStatus::Unavailable: return QStringLiteral("Unavailable"); + } + return QStringLiteral("Unavailable"); +} +} + +WfmLockScope::WfmLockScope(QWidget* parent) : QWidget(parent) +{ + static const bool factoryInstalled = [] { + QAccessible::installFactory(scopeAccessibleFactory); + return true; + }(); + Q_UNUSED(factoryInstalled); + setObjectName(QStringLiteral("wfmLockScope")); + setAccessibleName(QStringLiteral("WFM pilot lock scope")); + setToolTip(QStringLiteral( + "Measured 19 kHz pilot magnitude and the decoder's acquire/release " + "thresholds, in relative detector units. Stereo also requires consecutive " + "decoder blocks above or below the threshold. Up to 40 seconds of current " + "receiver observations; state strip shows actual stereo/mono output. " + "This is not SNR or calibrated RF power.")); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + setMinimumSize(minimumSizeHint()); + connect(&ThemeManager::instance(), &ThemeManager::themeChanged, + this, qOverload<>(&WfmLockScope::update)); + clear(); +} + +QSize WfmLockScope::sizeHint() const { return {260, 136}; } +QSize WfmLockScope::minimumSizeHint() const { return {210, 116}; } + +void WfmLockScope::appendSample(double pilotMagnitude, double acquireThreshold, + double releaseThreshold, WfmStereoStatus status, + bool forceMono) +{ + // Hidden scope means no history growth, timer reads or repaint requests. + // A show starts with fresh telemetry, never a retained green strip. + if (!isVisible()) { + return; + } + if (!std::isfinite(pilotMagnitude) || pilotMagnitude < 0.0 + || !std::isfinite(acquireThreshold) || acquireThreshold <= 0.0 + || !std::isfinite(releaseThreshold) || releaseThreshold < 0.0 + || releaseThreshold > acquireThreshold + || status == WfmStereoStatus::Unavailable) { + clear(); + return; + } + if (!m_clock.isValid()) { + m_clock.start(); + } + const qint64 now = m_clock.elapsed(); + while (!m_samples.isEmpty() + && (m_samples.size() >= kCapacity + || now - m_samples.first().milliseconds > kWindowMs)) { + m_samples.removeFirst(); + } + m_samples.append({now, pilotMagnitude, acquireThreshold, releaseThreshold, + status, forceMono}); + publishAccessibleValue(this, + QStringLiteral("19 kHz pilot %1 relative units. Acquire threshold %2; " + "release threshold %3. %4. %5 current receiver observations. " + "Threshold crossings require consecutive decoder blocks.") + .arg(pilotMagnitude, 0, 'g', 4).arg(acquireThreshold, 0, 'g', 4) + .arg(releaseThreshold, 0, 'g', 4).arg(stateText(status, forceMono)) + .arg(m_samples.size())); + update(); +} + +void WfmLockScope::clear() +{ + m_samples.clear(); + m_clock.invalidate(); + publishAccessibleValue(this, QStringLiteral("Awaiting current receiver pilot telemetry.")); + if (isVisible()) { + update(); + } +} + +void WfmLockScope::hideEvent(QHideEvent* event) +{ + clear(); + QWidget::hideEvent(event); +} + +void WfmLockScope::paintEvent(QPaintEvent*) +{ + QPainter painter(this); + ThemeManager& theme = ThemeManager::instance(); + const QColor text = theme.color(this, "color.text.secondary"); + const QColor trace = theme.color(this, "color.text.primary"); + const QColor success = theme.color(this, "color.accent.success"); + const QColor warning = theme.color(this, "color.accent.warning"); + const QColor neutral = theme.color(this, "color.text.disabled"); + painter.fillRect(rect(), theme.brush(this, "color.background.0", rect())); + painter.setPen(theme.color(this, "color.border.subtle")); + painter.drawRect(rect().adjusted(0, 0, -1, -1)); + painter.setPen(text); + const int lineHeight = fontMetrics().height(); + painter.drawText(QRect(5, 3, width() - 10, lineHeight), Qt::AlignLeft, + QStringLiteral("19 kHz pilot · relative units")); + if (m_samples.isEmpty()) { + painter.drawText(rect().adjusted(5, lineHeight + 5, -5, -5), + Qt::AlignCenter, QStringLiteral("Awaiting pilot telemetry")); + return; + } + const Sample& latest = m_samples.last(); + // Fixed, disjoint legend cells keep nearly equal thresholds readable; + // the guide lines below still use their actual measured y positions. + const int legendWidth = (width() - 12) / 2; + const QRect acquireLegend(6, lineHeight + 5, legendWidth, lineHeight); + const QRect releaseLegend(6 + legendWidth, lineHeight + 5, legendWidth, lineHeight); + painter.setPen(success); + painter.drawText(acquireLegend, Qt::AlignLeft | Qt::AlignVCenter, + fontMetrics().elidedText(QStringLiteral("Acquire %1").arg(latest.acquire, 0, 'g', 3), + Qt::ElideRight, legendWidth - 4)); + painter.setPen(warning); + painter.drawText(releaseLegend, Qt::AlignLeft | Qt::AlignVCenter, + fontMetrics().elidedText(QStringLiteral("Release %1").arg(latest.release, 0, 'g', 3), + Qt::ElideRight, legendWidth - 4)); + const QRectF plot(6, 2 * lineHeight + 11, width() - 12, + std::max(20, height() - 4 * lineHeight - 32)); + double maximum = latest.acquire; + for (const Sample& sample : m_samples) { + maximum = std::max(maximum, sample.magnitude); + maximum = std::max(maximum, sample.acquire); + } + const auto yOf = [&](double value) { + return plot.bottom() - std::clamp(value / maximum, 0.0, 1.0) * plot.height(); + }; + const auto xOf = [&](const Sample& sample) { + return plot.right() - double(latest.milliseconds - sample.milliseconds) + / double(kWindowMs) * plot.width(); + }; + // Threshold labels are textual as well as differently styled lines. + painter.setPen(QPen(success, 1, Qt::DashLine)); + painter.drawLine(QPointF(plot.left(), yOf(latest.acquire)), + QPointF(plot.right(), yOf(latest.acquire))); + painter.setPen(QPen(warning, 1, Qt::DotLine)); + painter.drawLine(QPointF(plot.left(), yOf(latest.release)), + QPointF(plot.right(), yOf(latest.release))); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(trace, 1.5)); + QPolygonF points; + points.reserve(m_samples.size()); + for (const Sample& sample : m_samples) { + points.append(QPointF(xOf(sample), yOf(sample.magnitude))); + } + painter.drawPolyline(points); + painter.drawEllipse(points.last(), 2.0, 2.0); + // Status is observed decoder output, not inferred from one amplitude. + const qreal stripY = plot.bottom() + 4; + for (qsizetype i = 0; i < m_samples.size(); ++i) { + const Sample& sample = m_samples[i]; + const QColor color = !sample.forceMono && sample.status == WfmStereoStatus::Stereo + ? success : (!sample.forceMono && sample.status == WfmStereoStatus::Acquiring + ? warning : neutral); + const qreal left = xOf(sample); + const qreal right = i + 1 < m_samples.size() + ? xOf(m_samples[i + 1]) : plot.right(); + painter.fillRect(QRectF(left, stripY, std::max(2.0, right - left), 4), color); + } + painter.setPen(text); + painter.drawText(QRect(6, int(stripY) + 8, width() - 12, lineHeight), + Qt::AlignLeft, stateText(latest.status, latest.forceMono)); + painter.drawText(QRect(6, height() - lineHeight - 2, width() - 12, lineHeight), + Qt::AlignRight, QStringLiteral("← up to 40 s · now")); +} + +} // namespace AetherSDR diff --git a/src/gui/WfmLockScope.h b/src/gui/WfmLockScope.h new file mode 100644 index 0000000000..86e9b7ad1e --- /dev/null +++ b/src/gui/WfmLockScope.h @@ -0,0 +1,47 @@ +#pragma once + +#include "models/SliceModel.h" + +#include +#include +#include + +namespace AetherSDR { + +// Low-rate, measured pilot history. No decoder access, sampling timer or FFT. +// The applet clears it whenever its selected receiver observation is retired. +class WfmLockScope : public QWidget { + Q_OBJECT +public: + explicit WfmLockScope(QWidget* parent = nullptr); + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + + // Magnitude and both thresholds share the decoder's relative scale. This + // is not calibrated RF power, SNR, PLL phase error or an audio level. + void appendSample(double pilotMagnitude, double acquireThreshold, + double releaseThreshold, WfmStereoStatus status, + bool forceMono); + void clear(); + int sampleCount() const { return int(m_samples.size()); } + +protected: + void paintEvent(QPaintEvent* event) override; + void hideEvent(QHideEvent* event) override; + +private: + struct Sample { + qint64 milliseconds; + double magnitude; + double acquire; + double release; + WfmStereoStatus status; + bool forceMono; + }; + static constexpr int kCapacity = 160; + static constexpr qint64 kWindowMs = 40000; + QList m_samples; + QElapsedTimer m_clock; +}; + +} // namespace AetherSDR diff --git a/src/gui/containers/ContainerWidget.cpp b/src/gui/containers/ContainerWidget.cpp index d1654853f8..712d1ca27b 100644 --- a/src/gui/containers/ContainerWidget.cpp +++ b/src/gui/containers/ContainerWidget.cpp @@ -136,10 +136,31 @@ void ContainerWidget::setContainerVisible(bool visible) emit visibilityChanged(visible); } +void ContainerWidget::setVisible(bool visible) +{ + m_requestedVisible = visible; + QWidget::setVisible(visible && (!m_presentationManaged || m_visible) + && (m_presentationAvailable || isFloating())); +} + +void ContainerWidget::setPresentationAvailable(bool available) +{ + if (m_presentationManaged && available == m_presentationAvailable) { return; } + m_presentationManaged = true; + m_presentationAvailable = available; + // Bypass the override: suppressing presentation must not become a new + // explicit hide request. Ancestor hide/show similarly leaves intent alone. + QWidget::setVisible(m_visible && m_requestedVisible && (available || isFloating())); + emit presentationAvailabilityChanged(available); +} + void ContainerWidget::setDockMode(DockMode mode) { if (mode == m_dockMode) return; m_dockMode = mode; + if (!m_presentationAvailable) { + QWidget::setVisible(m_visible && m_requestedVisible && isFloating()); + } if (m_titleBar) { // Order matters: setFloatingState() repaints the docked/floating // visuals, and setCanvasState() overrides them only when the new diff --git a/src/gui/containers/ContainerWidget.h b/src/gui/containers/ContainerWidget.h index 9b4289436a..f65f9f6ae8 100644 --- a/src/gui/containers/ContainerWidget.h +++ b/src/gui/containers/ContainerWidget.h @@ -83,6 +83,15 @@ class ContainerWidget : public QWidget { void setContainerVisible(bool visible); bool isContainerVisible() const { return m_visible; } + // Transient presentation only: unavailable applets retain their logical + // open/closed intent and workspace home. Direct show() calls from layout + // recall obey this gate too. Floating content stays shown inside its + // independently gated outer window. Other containers default available. + void setPresentationAvailable(bool available); + bool isPresentationAvailable() const { return m_presentationAvailable; } + bool isPresentationManaged() const { return m_presentationManaged; } + void setVisible(bool visible) override; + // Access to the titlebar — callers can hide its close button // for root containers or customise the title dynamically. ContainerTitleBar* titleBar() { return m_titleBar; } @@ -129,6 +138,9 @@ class ContainerWidget : public QWidget { // Fired after setContainerVisible() changes state. void visibilityChanged(bool visible); + // Does not emit visibilityChanged: availability is never a user close. + void presentationAvailabilityChanged(bool available); + // Fired after dockMode() changes — manager uses this to keep the // titlebar button label in sync. void dockModeChanged(DockMode mode); @@ -163,6 +175,9 @@ private slots: QWidget* m_content{nullptr}; DockMode m_dockMode{DockMode::PanelDocked}; bool m_visible{true}; + bool m_presentationAvailable{true}; + bool m_presentationManaged{false}; + bool m_requestedVisible{true}; QSize m_defaultFloatingSize; QHash m_savedMaxWidths; // child → docked maximumWidth (#3451) }; diff --git a/src/gui/containers/FloatingContainerWindow.cpp b/src/gui/containers/FloatingContainerWindow.cpp index 6af03e43f6..be4af91461 100644 --- a/src/gui/containers/FloatingContainerWindow.cpp +++ b/src/gui/containers/FloatingContainerWindow.cpp @@ -53,27 +53,54 @@ void FloatingContainerWindow::takeContainer(ContainerWidget* container) if (m_container == container) return; if (m_container) { - m_layout->removeWidget(m_container); - m_container->setParent(nullptr); + releaseContainer(); } m_container = container; if (m_container) { + m_presentationConnection = connect(m_container, + &ContainerWidget::presentationAvailabilityChanged, this, + [this](bool) { applyPresentation(); }); + m_containerVisibilityConnection = connect(m_container, + &ContainerWidget::visibilityChanged, this, [this](bool visible) { + if (m_container && m_container->isPresentationManaged()) { + setVisible(visible); + } + }); + m_containerDestroyedConnection = connect(m_container, &QObject::destroyed, + this, [this] { m_container = nullptr; }); m_container->setParent(this); m_layout->addWidget(m_container, 1); - m_container->show(); m_container->setDockMode(ContainerWidget::DockMode::Floating); + m_container->show(); setWindowTitle(m_container->title()); } + applyPresentation(); +} + +void FloatingContainerWindow::setVisible(bool visible) +{ + m_requestedVisible = visible; + applyPresentation(); +} + +void FloatingContainerWindow::applyPresentation() +{ + QWidget::setVisible(m_requestedVisible && (!m_container + || (m_container->isPresentationAvailable() + && (!m_container->isPresentationManaged() || m_container->isContainerVisible())))); } ContainerWidget* FloatingContainerWindow::releaseContainer() { ContainerWidget* c = m_container; if (c) { + QObject::disconnect(m_presentationConnection); + QObject::disconnect(m_containerVisibilityConnection); + QObject::disconnect(m_containerDestroyedConnection); + m_container = nullptr; m_layout->removeWidget(c); c->setParent(nullptr); c->setDockMode(ContainerWidget::DockMode::PanelDocked); - m_container = nullptr; } return c; } @@ -153,6 +180,7 @@ void FloatingContainerWindow::prepareShutdown() void FloatingContainerWindow::setFramelessMode(bool on) { const bool wasVisible = isVisible(); + const bool requestedVisible = m_requestedVisible; const QRect geom = geometry(); Qt::WindowFlags flags = windowFlags(); if (on) { @@ -161,6 +189,7 @@ void FloatingContainerWindow::setFramelessMode(bool on) flags &= ~Qt::FramelessWindowHint; } setWindowFlags(flags); + m_requestedVisible = requestedVisible; if (wasVisible) setGeometry(geom); if (wasVisible) show(); } @@ -170,6 +199,7 @@ void FloatingContainerWindow::setAlwaysOnTop(bool on) if (m_alwaysOnTop == on) return; m_alwaysOnTop = on; const bool wasVisible = isVisible(); + const bool requestedVisible = m_requestedVisible; const QRect geom = geometry(); Qt::WindowFlags flags = windowFlags(); if (on) { @@ -178,6 +208,7 @@ void FloatingContainerWindow::setAlwaysOnTop(bool on) flags &= ~Qt::WindowStaysOnTopHint; } setWindowFlags(flags); + m_requestedVisible = requestedVisible; if (wasVisible) setGeometry(geom); if (wasVisible) show(); } diff --git a/src/gui/containers/FloatingContainerWindow.h b/src/gui/containers/FloatingContainerWindow.h index 084bcdef53..0d30769b5b 100644 --- a/src/gui/containers/FloatingContainerWindow.h +++ b/src/gui/containers/FloatingContainerWindow.h @@ -32,6 +32,10 @@ class FloatingContainerWindow : public QWidget { ContainerWidget* releaseContainer(); ContainerWidget* container() const { return m_container; } + // Retain the outer window's explicit show/hide request while the hosted + // container is temporarily unavailable. The content itself stays shown. + void setVisible(bool visible) override; + // AppSettings key under which to store geometry (serialized as // base64 of QByteArray returned by saveGeometry()). Empty = no // persistence. @@ -71,8 +75,13 @@ class FloatingContainerWindow : public QWidget { private: void saveGeometryToKey() const; + void applyPresentation(); ContainerWidget* m_container{nullptr}; + QMetaObject::Connection m_presentationConnection; + QMetaObject::Connection m_containerVisibilityConnection; + QMetaObject::Connection m_containerDestroyedConnection; + bool m_requestedVisible{false}; QVBoxLayout* m_layout{nullptr}; QString m_geometryKey; bool m_restoring{false}; diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index 52970c243e..f07a2a72c7 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -2003,12 +2003,15 @@ RadioModel::RadioModel(QObject* parent) qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); + qRegisterMetaType(); + qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); + qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); @@ -6942,6 +6945,10 @@ void RadioModel::stageSessionModelsForReconnect() if (slice) { slice->invalidateSquelchState(); slice->invalidateFrequencyObservation(); + SliceDelta unavailableWfm; + unavailableWfm.wfmStereoStatus = WfmStereoStatus::Unavailable; + unavailableWfm.wfmReceptionDiagnostics = WfmReceptionDiagnostics{}; + slice->applyChanges(unavailableWfm); m_staleSlices.insert(slice->sliceId(), slice); } } @@ -9839,6 +9846,10 @@ void RadioModel::wireSliceReceiveIntentsToBackend(SliceModel* s) this, &RadioModel::dispatchSliceDsp, type); connect(s, &SliceModel::receiveAudioRequested, this, &RadioModel::dispatchSliceAudio, type); + connect(s, &SliceModel::wfmForceMonoRequested, + this, &RadioModel::dispatchSliceWfmForceMono, type); + connect(s, &SliceModel::wfmDeemphasisRequested, + this, &RadioModel::dispatchSliceWfmDeemphasis, type); connect(s, &SliceModel::receiveSquelchRequested, this, &RadioModel::dispatchSliceSquelch, type); connect(s, &SliceModel::receiveRxAntennaRequested, @@ -9931,6 +9942,31 @@ void RadioModel::dispatchSliceAudio(const SliceAudioRequest& request) } } +void RadioModel::dispatchSliceWfmForceMono(bool forceMono) +{ + dispatchSliceWfm({SliceWfmRequest::Field::ForceMono, int(forceMono)}); +} + +void RadioModel::dispatchSliceWfmDeemphasis(int microseconds) +{ + dispatchSliceWfm({SliceWfmRequest::Field::Deemphasis, microseconds}); +} + +void RadioModel::dispatchSliceWfm(const SliceWfmRequest& request) +{ + if (SliceModel* source = receiveCommandSource()) { + const std::optional feature = m_backend->capabilities().broadcastFmReceive; + const bool available = request.valid() && feature + && source->mode() == QLatin1String("WFM") + && !source->externalReceiveReplacementActive() + && (request.field == SliceWfmRequest::Field::ForceMono + ? feature->forceMonoControl : feature->deemphasisUs.contains(request.value)); + reportReceiveDispatch(available + ? m_backend->requestSliceWfm(source->sliceId(), request) + : ReceiveDispatch::Unsupported, QStringLiteral("broadcast FM")); + } +} + void RadioModel::dispatchSliceSquelch(const SliceSquelchRequest& request) { if (SliceModel* source = receiveCommandSource()) { diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index d986c52252..247c43a496 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -1746,6 +1746,9 @@ private slots: void dispatchSliceAgc(const SliceAgcRequest& request); void dispatchSliceDsp(const SliceDspRequest& request); void dispatchSliceAudio(const SliceAudioRequest& request); + void dispatchSliceWfmForceMono(bool forceMono); + void dispatchSliceWfmDeemphasis(int microseconds); + void dispatchSliceWfm(const SliceWfmRequest& request); void dispatchSliceSquelch(const SliceSquelchRequest& request); void dispatchSliceRxAntenna(const QString& antenna); void dispatchSliceLock(bool locked); diff --git a/src/models/SliceModel.cpp b/src/models/SliceModel.cpp index e58f0943a2..60ecf34e21 100644 --- a/src/models/SliceModel.cpp +++ b/src/models/SliceModel.cpp @@ -767,6 +767,19 @@ void SliceModel::setAgcOffLevel(int value) m_agcThreshold, value}); } +void SliceModel::setWfmForceMono(bool forceMono) +{ + // Selection is intent until the prepared decoder revision is accepted. + emit wfmForceMonoRequested(forceMono); +} + +void SliceModel::setWfmDeemphasis(int microseconds) +{ + if (microseconds != 50 && microseconds != 75) { return; } + // Requests never update observed state, even on optimistic legacy slices. + emit wfmDeemphasisRequested(microseconds); +} + void SliceModel::setSquelch(bool on, int level) { if (refuseOffThread(__func__)) { return; } @@ -1154,6 +1167,7 @@ void SliceModel::setExternalReceiveAudioReplacementMute(bool active, bool restoreMute) { const QPointer alive(this); + const bool previousReplacement = m_externalReceiveAudioReplacement; const bool previousVisibleMute = audioMute(); const float previousVisibleGain = audioGain(); const int previousVisiblePan = audioPan(); @@ -1238,6 +1252,9 @@ void SliceModel::setExternalReceiveAudioReplacementMute(bool active, if (m_externalReceiveAutoSquelch != previousExternalAutoSquelch) { emit externalReceiveAutoSquelchChanged(m_externalReceiveAutoSquelch); } + if (m_externalReceiveAudioReplacement != previousReplacement) { + emit externalReceiveReplacementChanged(m_externalReceiveAudioReplacement); + } } void SliceModel::prepareExternalReceiveAudioReplacementBandRecall( @@ -1768,6 +1785,46 @@ void SliceModel::applyChanges(const SliceDelta& d) const int v = *d.agcOffLevel; if (m_agcOffLevel != v) { m_agcOffLevel = v; emit agcOffLevelChanged(v); } } + if (d.wfmDeemphasisUs && (*d.wfmDeemphasisUs == 50 || *d.wfmDeemphasisUs == 75) + && m_wfmDeemphasisUs != *d.wfmDeemphasisUs) { + m_wfmDeemphasisUs = *d.wfmDeemphasisUs; + emit wfmDeemphasisChanged(m_wfmDeemphasisUs); + } + if (d.wfmForceMono && m_wfmForceMono != *d.wfmForceMono) { + m_wfmForceMono = *d.wfmForceMono; + emit wfmForceMonoChanged(m_wfmForceMono); + } + if (d.wfmStereoStatus) { + const WfmStereoStatus status = *d.wfmStereoStatus; + switch (status) { + case WfmStereoStatus::Unavailable: + case WfmStereoStatus::Acquiring: + case WfmStereoStatus::Mono: + case WfmStereoStatus::Stereo: + if (m_wfmStereoStatus != status) { + m_wfmStereoStatus = status; + emit wfmStereoStatusChanged(status); + } + break; + } + } + if (d.wfmReceptionDiagnostics) { + WfmReceptionDiagnostics value = *d.wfmReceptionDiagnostics; + if (!value.valid || !std::isfinite(value.pilotMagnitude) + || value.pilotMagnitude < 0.0 + || !std::isfinite(value.pilotEngageThreshold) || !std::isfinite(value.pilotReleaseThreshold) + || value.pilotReleaseThreshold < 0.0 + || value.pilotEngageThreshold <= value.pilotReleaseThreshold + || value.engageBlocks == 0 || value.releaseBlocks == 0 + || value.stableDurationMs > 5000 + || value.lockDurationMs > value.observationDurationMs + || value.stableDurationMs > value.observationDurationMs + || (!value.pilotLocked && value.lockDurationMs != 0)) { value = {}; } + if (m_wfmReceptionDiagnostics != value) { + m_wfmReceptionDiagnostics = value; + emit wfmReceptionDiagnosticsChanged(value); + } + } if (d.squelchOn.has_value() || d.squelchLevel.has_value()) { m_squelchOnKnown |= d.squelchOn.has_value(); m_squelchLevelKnown |= d.squelchLevel.has_value(); diff --git a/src/models/SliceModel.h b/src/models/SliceModel.h index 949b137fbc..28041f9fe5 100644 --- a/src/models/SliceModel.h +++ b/src/models/SliceModel.h @@ -175,6 +175,11 @@ class SliceModel : public QObject { return m_externalReceiveAutoSquelch; } int squelchLevel()const { return m_squelchLevel; } + // Zero means not observed; never present a default as adopted DSP state. + int wfmDeemphasisUs() const { return m_wfmDeemphasisUs; } + bool wfmForceMono() const { return m_wfmForceMono; } + const WfmReceptionDiagnostics& wfmReceptionDiagnostics() const { return m_wfmReceptionDiagnostics; } + WfmStereoStatus wfmStereoStatus() const { return m_wfmStereoStatus; } int flexSquelchLevel() const { return m_squelchLevel; } int receiveSquelchLevel() const { return m_externalReceiveAudioReplacement ? m_externalReceiveSquelchLevel @@ -315,6 +320,8 @@ class SliceModel : public QObject { void setAgcThreshold(int value); void setAgcOffLevel(int value); void setSquelch(bool on, int level); + void setWfmDeemphasis(int microseconds); + void setWfmForceMono(bool forceMono); // For genuine operator-driven manual squelch input only (a VFO flag's // own SQL controls, a controller-mapped squelch knob) — setSquelch() // plus recording the level as the operator's manual choice, in one @@ -486,7 +493,14 @@ class SliceModel : public QObject { void externalReceiveAgcThresholdChanged(int value); void externalReceiveAgcOffLevelChanged(int value); void externalReceiveAutoSquelchChanged(bool on); + void externalReceiveReplacementChanged(bool active); void squelchChanged(bool on, int level); + void wfmDeemphasisChanged(int microseconds); + void wfmForceMonoChanged(bool forceMono); + void wfmForceMonoRequested(bool forceMono); + void wfmReceptionDiagnosticsChanged(const AetherSDR::WfmReceptionDiagnostics& diagnostics); + void wfmStereoStatusChanged(AetherSDR::WfmStereoStatus status); + void wfmDeemphasisRequested(int microseconds); void externalReceiveSquelchChanged(bool on, int level); void stepChanged(int hz, const QVector& stepList); void ritChanged(bool on, int hz); @@ -673,6 +687,10 @@ class SliceModel : public QObject { bool m_squelchLevelKnown{false}; bool m_squelchOn{false}; int m_squelchLevel{20}; + int m_wfmDeemphasisUs{0}; + bool m_wfmForceMono{false}; + WfmReceptionDiagnostics m_wfmReceptionDiagnostics; + WfmStereoStatus m_wfmStereoStatus{WfmStereoStatus::Unavailable}; int m_manualSquelchLevel{20}; bool m_squelchEchoIsManual{true}; int m_stepHz{100}; diff --git a/tests/audio_engine_pcm_lifetime_test.cpp b/tests/audio_engine_pcm_lifetime_test.cpp index 342655e3e6..eaa1d17dd6 100644 --- a/tests/audio_engine_pcm_lifetime_test.cpp +++ b/tests/audio_engine_pcm_lifetime_test.cpp @@ -137,19 +137,21 @@ class AudioEngineRatesTestAccess { engine.m_rxPackets.push_back(queued(producerRate, 3)); engine.m_rxOutputBuffer = queued(deviceRate, 7); engine.m_kiwiSdrRxBuffer = queued(24000, 11); - engine.m_kiwiSdrRxPackets.push_back(queued(24000, 13)); + // Each invocation replaces this synthetic snapshot. A sink-rate + // change correctly preserves producer packets and historical peaks. + engine.m_kiwiSdrRxPackets = {queued(24000, 13)}; engine.m_kiwiSdrOutputBuffer = queued(deviceRate, 17); AudioEngine::ExternalRxAudioSourceState* first = engine.externalKiwiSource(QStringLiteral("duration-first"), true); first->enabled = true; first->rxBuffer = queued(24000, 19); - first->rxPackets.push_back(queued(24000, 23)); + first->rxPackets = {queued(24000, 23)}; first->outputBuffer = queued(deviceRate, 29); AudioEngine::ExternalRxAudioSourceState* second = engine.externalKiwiSource(QStringLiteral("duration-second"), true); second->enabled = true; second->rxBuffer = queued(24000, 31); - second->rxPackets.push_back(queued(24000, 37)); + second->rxPackets = {queued(24000, 37)}; second->outputBuffer = queued(deviceRate, 41); engine.m_radeRxBuffer = queued(deviceRate, 43); engine.updateRxBufferStats(); diff --git a/tests/audio_engine_rates_test.cpp b/tests/audio_engine_rates_test.cpp index 6d4da1756b..71451e3d48 100644 --- a/tests/audio_engine_rates_test.cpp +++ b/tests/audio_engine_rates_test.cpp @@ -2,6 +2,7 @@ // output device. No QAudioSink, socket peer, radio connection, or RF is used. #include "TestSettingsProfile.h" #include "core/AudioEngine.h" +#include "core/AudioFormatNegotiator.h" #include "core/ClientComp.h" #include "core/ClientDeEss.h" #include "core/ClientEq.h" @@ -13,6 +14,7 @@ #include #include +#include #include #include #include @@ -35,6 +37,20 @@ class AudioEngineRatesTestAccess { engine.setRxDeviceRate(deviceRate); } static void detach(AudioEngine& engine) { engine.m_audioDevice = nullptr; } + static void manageSink(AudioEngine& engine, std::function opener) + { + engine.m_rxSinkOpener = std::move(opener); + engine.m_rxSinkProducerRate = 24000; + ++engine.m_rxSinkGeneration; + } + static void dispatchRateChange(AudioEngine& engine) + { + QCoreApplication::sendPostedEvents(&engine, QEvent::MetaCall); + } + static void closeSink(AudioEngine& engine) { engine.closeRxSink(); } + static void replaceSinkGeneration(AudioEngine& engine) { ++engine.m_rxSinkGeneration; } + static bool openSink(AudioEngine& engine, int rate) { return engine.openRxSink(rate); } + static bool hasSink(const AudioEngine& engine) { return engine.m_audioDevice != nullptr; } static void drain(AudioEngine& engine, qsizetype bytes) { engine.drainRxAudio(bytes); } static qsizetype rawBytes(const AudioEngine& engine) { @@ -283,6 +299,7 @@ void bandwidthAndMono() { for (int deviceRate : {24000, 44100, 48000}) { Fixture fixture(deviceRate); + fixture.capture(); PcmProducer producer; producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); for (int tick = 0; tick < 100; ++tick) { @@ -290,14 +307,17 @@ void bandwidthAndMono() fixture.tick(); } fixture.finish(); - check(amplitude(fixture.output.data(), deviceRate, 1, 1000) > 0.15, - "wide producer preserves low-band stereo channel"); + const QByteArray final = fixture.captured(QStringLiteral("final"), QStringLiteral("mix")); + check(!final.isEmpty() && final == fixture.output.data(), + "final wideband capture is the exact PCM written to the speaker device"); + check(amplitude(final, deviceRate, 1, 1000) > 0.15, + "wide producer preserves low-band stereo channel at the final speaker output"); if (deviceRate >= 44100) { - check(amplitude(fixture.output.data(), deviceRate, 0, 15000) > 0.27, - "48 kHz producer retains 15 kHz audio bandwidth"); + check(amplitude(final, deviceRate, 0, 15000) > 0.27, + "48 kHz producer retains 15 kHz at the final speaker output"); } else { - check(amplitude(fixture.output.data(), deviceRate, 0, 9000) < 0.003, - "48 to 24 conversion rejects the 15 kHz alias"); + check(amplitude(final, deviceRate, 0, 9000) < 0.003, + "48 to 24 fallback rejects the 15 kHz alias at the final speaker output"); } } for (int producerRate : {24000, 48000}) { @@ -322,6 +342,253 @@ void bandwidthAndMono() } } +struct NegotiatingFixture : Fixture { + QList supportedRates; + QList requestedRates; + int streamEvents = 0; + int failedOpens = 0; + + explicit NegotiatingFixture(QList rates, int initialRate = 24000) + : Fixture(initialRate), supportedRates(std::move(rates)) + { + QObject::connect(&engine, &AudioEngine::rxStarted, &engine, + [this]() { ++streamEvents; }); + QObject::connect(&engine, &AudioEngine::rxStopped, &engine, + [this]() { ++streamEvents; }); + AudioEngineRatesTestAccess::manageSink(engine, [this](int producerRate) { + requestedRates.append(producerRate); + if (failedOpens > 0) { + --failedOpens; + return false; + } + AudioFormatNegotiator::DeviceCaps caps; + caps.supportedRates = supportedRates; + caps.supportedFormats = {AudioFormatNegotiator::SampleFmt::Float32}; + caps.preferredRate = 48000; + const auto selected = AudioFormatNegotiator::negotiate( + AudioFormatNegotiator::hostTargetOs(), + AudioFormatNegotiator::Direction::Output, caps, + AudioFormatNegotiator::ResamplerPolicy::PreservePan, producerRate); + if (!selected.ok) { + return false; + } + deviceRate = selected.rate; + AudioEngineRatesTestAccess::attach(engine, output, deviceRate); + return true; + }); + } + ~NegotiatingFixture() + { + // AudioEngine stops streams in its destructor, after this fixture's + // counters have ended their lifetime. + QObject::disconnect(&engine, nullptr, &engine, nullptr); + } + void dispatch() { AudioEngineRatesTestAccess::dispatchRateChange(engine); } +}; + +void negotiatedSpeakerOutput() +{ + for (int workingRate : {48000, 44100, 24000}) { + NegotiatingFixture fixture({workingRate}); + PcmProducer producer; + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, QVector(960, 0.0f)); + check(fixture.requestedRates.isEmpty() && fixture.deviceRate == 24000, + "accepted native48 queues device negotiation without opening inline"); + fixture.dispatch(); + check(fixture.requestedRates == QList{48000} + && fixture.deviceRate == workingRate + && AudioEngineRatesTestAccess::producerRate(fixture.engine) == 48000, + "accepted producer drives explicit native48 speaker negotiation and fallback"); + fixture.finish(); + fixture.output.buffer().clear(); + fixture.output.seek(0); + fixture.capture(); + for (int tick = 0; tick < 100; ++tick) { + feed(fixture.engine, producer, tone(48000, 480, tick * 480, 15000, 1000)); + fixture.tick(); + } + fixture.finish(); + const QByteArray final = fixture.captured(QStringLiteral("final"), QStringLiteral("mix")); + check(final == fixture.output.data() && finite(final) + && std::abs(final.size() / kFrameBytes - workingRate) <= 512, + "negotiated speaker writes one second of finite PCM at its actual rate"); + check(amplitude(final, workingRate, 1, 1000) > 0.15, + "negotiated speaker retains the independent right audio channel"); + if (workingRate >= 44100) { + check(amplitude(final, workingRate, 0, 15000) > 0.27 + && amplitude(final, workingRate, 1, 15000) < 0.002, + "native48 adoption retains 15 kHz at final speaker output without channel folding"); + } else { + check(amplitude(final, workingRate, 0, 9000) < 0.003, + "refused high-rate speaker falls back to24 with correct anti-aliasing"); + } + // Same-rate discontinuities and fresh epochs are legitimate source + // transitions, but a refused 48 kHz sink must not be retried each time. + fixture.engine.feedPcmFrame(*producer.produce(QVector(960, 0.0f), + std::nullopt, true)); + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, QVector(960, 0.0f)); + fixture.dispatch(); + check(fixture.requestedRates.size() == 1 && fixture.streamEvents == 0, + "same-rate epochs do not retry fallbacks or restart the whole RX stream"); + } + NegotiatingFixture alreadyNative({48000}, 48000); + PcmProducer producer; + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(alreadyNative.engine, producer, tone(48000, 480)); + alreadyNative.dispatch(); + check(alreadyNative.requestedRates.isEmpty() && alreadyNative.deviceRate == 48000, + "an already-native speaker is retained without a needless reopen"); +} + +void negotiationOpenFailure() +{ + for (bool recoverable : {true, false}) { + NegotiatingFixture fixture(recoverable ? QList{24000} : QList{}); + fixture.failedOpens = recoverable ? 1 : 0; + PcmProducer producer; + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(48000, 480)); + const qsizetype queued = AudioEngineRatesTestAccess::rawBytes(fixture.engine); + fixture.dispatch(); + check(fixture.requestedRates == QList({48000, 24000}), + "failed speaker renegotiation retries the previous policy exactly once"); + check(AudioEngineRatesTestAccess::hasSink(fixture.engine) == recoverable + && AudioEngineRatesTestAccess::producerRate(fixture.engine) == 48000 + && AudioEngineRatesTestAccess::rawBytes(fixture.engine) == queued + && fixture.engine.clientEqRx()->sampleRate() == 48000 + && fixture.streamEvents == 0, + "speaker failure recovery preserves producer PCM, DSP and stream lifecycle"); + if (recoverable) { + fixture.tick(); + for (int tick = 1; tick < 100; ++tick) { + feed(fixture.engine, producer, tone(48000, 480, tick * 480)); + fixture.tick(); + } + fixture.finish(); + check(finite(fixture.output.data()) + && amplitude(fixture.output.data(), 24000, 0, 1000) > 0.27 + && amplitude(fixture.output.data(), 24000, 1, 2300) > 0.15, + "restored speaker renders the current producer at the actual device rate"); + } + // Neither a working fallback nor a completely unavailable endpoint + // may turn each new source epoch into another reopen attempt. + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(48000, 480)); + fixture.dispatch(); + check(fixture.requestedRates.size() == 2, + "failed negotiation has no repeated retry on a same-rate source epoch"); + } +} + +void negotiationLifetimeAndCoalescing() +{ + for (int staleCase = 0; staleCase < 4; ++staleCase) { + NegotiatingFixture fixture({24000, 48000}); + PcmProducer producer; + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(48000, 480)); + if (staleCase == 0) { + producer.invalidate(); + } else if (staleCase == 1) { + producer.setFormat({24000, PcmLayout::Stereo}); + } else if (staleCase == 2) { + AudioEngineRatesTestAccess::replaceSinkGeneration(fixture.engine); + } else { + AudioEngineRatesTestAccess::closeSink(fixture.engine); + } + fixture.dispatch(); + check(fixture.requestedRates.isEmpty(), + "revoked lease, changed format, replaced sink or stop cancels queued negotiation"); + } + NegotiatingFixture fixture({24000, 48000}); + PcmProducer producer; + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(48000, 480)); + producer.setFormat({24000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(24000, 240)); + producer.setFormat({48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(48000, 480)); + fixture.dispatch(); + check(fixture.requestedRates == QList{48000} && fixture.deviceRate == 48000, + "format churn coalesces to one current native48 request"); + + const qsizetype nativeAttempts = fixture.requestedRates.size(); + producer.invalidate(); + fixture.tick(); // actual retirement can precede the replacement by an event turn + fixture.dispatch(); + fixture.engine.feedAudioData({}); + fixture.dispatch(); + check(fixture.deviceRate == 48000 && fixture.requestedRates.size() == nativeAttempts, + "retirement and empty ingress do not bounce a native48 speaker to24 during a source gap"); + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, producer, tone(48000, 480)); + fixture.dispatch(); + check(fixture.deviceRate == 48000 && fixture.requestedRates.size() == nativeAttempts, + "replacement native48 epoch after a drained gap reuses the same speaker sink"); + + producer.invalidate(); + fixture.engine.feedAudioData(bytes(tone(24000, 240))); + fixture.dispatch(); + const auto legacy = AudioFormatNegotiator::buildLadder( + AudioFormatNegotiator::hostTargetOs(), AudioFormatNegotiator::Direction::Output, + {}, AudioFormatNegotiator::ResamplerPolicy::PreservePan).first().rate; + check(AudioEngineRatesTestAccess::producerRate(fixture.engine) == 24000 + && fixture.deviceRate == legacy, + "retired native48 route returns to the unchanged legacy24 speaker policy"); + + AudioEngineRatesTestAccess::closeSink(fixture.engine); + producer.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + const qsizetype attempts = fixture.requestedRates.size(); + feed(fixture.engine, producer, tone(48000, 480)); + fixture.dispatch(); + check(fixture.requestedRates.size() == attempts, + "PCM arrival cannot reopen an explicitly stopped speaker"); + check(AudioEngineRatesTestAccess::openSink(fixture.engine, 48000) + && fixture.deviceRate == 48000, + "an explicit fresh sink open can retry the accepted producer domain"); +} + +void auxiliaryAcrossNegotiation() +{ + NegotiatingFixture fixture({24000, 48000}); + fixture.engine.setKiwiSdrAudioSourceEnabled(kKiwiId, true); + check(AudioEngineRatesTestAccess::prepareKiwi(fixture.engine), + "auxiliary processing initializes before speaker transition"); + PcmProducer auxiliary; + auxiliary.start(PcmPurpose::Auxiliary); + quint64 offset = 0; + for (int packet = 0; packet < 40; ++packet) { + fixture.engine.feedKiwiPcmFrame(kKiwiId, + *auxiliary.produce(tone(24000, 240, offset, 700, 3200))); + offset += 240; + } + const qsizetype queued = AudioEngineRatesTestAccess::kiwiBytes(fixture.engine, kKiwiId); + PcmProducer main; + main.start(PcmPurpose::Speaker, -1, {48000, PcmLayout::Stereo}); + feed(fixture.engine, main, QVector(960, 0.0f)); + fixture.dispatch(); + check(queued > 0 && AudioEngineRatesTestAccess::kiwiBytes(fixture.engine, kKiwiId) == queued, + "speaker rate change preserves admitted auxiliary24 producer samples"); + fixture.capture(); + for (int tick = 0; tick < 100; ++tick) { + feed(fixture.engine, main, tone(48000, 480, tick * 480, 15000, 1000)); + fixture.engine.feedKiwiPcmFrame(kKiwiId, + *auxiliary.produce(tone(24000, 240, offset, 700, 3200))); + offset += 240; + fixture.tick(); + } + const QByteArray aux = fixture.captured(QStringLiteral("post"), QStringLiteral("kiwi"), kKiwiId); + check(finite(aux) && amplitude(aux, 48000, 0, 700) > 0.27 + && amplitude(aux, 48000, 1, 3200) > 0.15 + && amplitude(aux, 48000, 1, 700) < 0.002, + "admitted auxiliary24 retains stereo layout and frequencies at renegotiated48"); + check(AudioEngineRatesTestAccess::recordedCwRate(fixture.engine) == 24000 + && fixture.streamEvents == 0, + "speaker-only reopen leaves fixed CW recording and whole-stream lifecycle unchanged"); +} + void transitionsAndRejection() { Fixture fixture(48000); @@ -423,15 +690,17 @@ void queueBudgetsAndDeviceTransitions() check(fixture.engine.receivePresentationAudioQueues().flexRawBufferMs >= 100, "presentation delay retains the configured producer duration"); + const qsizetype producerBytes = AudioEngineRatesTestAccess::rawBytes(fixture.engine); AudioEngineRatesTestAccess::deviceRate(fixture.engine, 24000); fixture.deviceRate = 24000; - check(AudioEngineRatesTestAccess::rawBytes(fixture.engine) == 0 + check(AudioEngineRatesTestAccess::rawBytes(fixture.engine) == producerBytes && AudioEngineRatesTestAccess::outputBytes(fixture.engine) == 0, - "device format transition discards queued old-rate samples"); + "device format transition preserves producer bytes and discards old device bytes"); check(AudioEngineRatesTestAccess::producerRate(fixture.engine) == producerRate && AudioEngineRatesTestAccess::outputRate(fixture.engine) == 24000, "device renegotiation retains the producer rate"); fixture.engine.setReceivePresentationDelays(0, 0); + fixture.finish(); // consume the producer-domain samples preserved above const qsizetype before = fixture.output.data().size(); for (int tick = 0; tick < 100; ++tick) { feed(fixture.engine, producer, tone(producerRate, producerRate / 100, @@ -831,6 +1100,10 @@ int main(int argc, char** argv) check(settings.isValid(), "isolated settings profile"); rateMatrix(); bandwidthAndMono(); + negotiatedSpeakerOutput(); + negotiationOpenFailure(); + negotiationLifetimeAndCoalescing(); + auxiliaryAcrossNegotiation(); transitionsAndRejection(); queueBudgetsAndDeviceTransitions(); concurrentSourcesAndEffects(); diff --git a/tests/audio_format_negotiation_test.cpp b/tests/audio_format_negotiation_test.cpp index 882a5295e6..e75cad2cf6 100644 --- a/tests/audio_format_negotiation_test.cpp +++ b/tests/audio_format_negotiation_test.cpp @@ -83,6 +83,83 @@ DeviceCaps dev(QList rates, return c; } +// The RX speaker caller can supply a native 48 kHz producer. Device preference +// is not proof that an open succeeds: retain a 24 kHz fallback even when the +// device advertises 48 kHz but accepts only 24 kHz. All inputs remain injected. +void native48OutputPolicy() +{ + const auto floatRates = [](TargetOs os, Direction direction, + const DeviceCaps& caps, int internalRate, + ResamplerPolicy policy = ResamplerPolicy::PreservePan) { + QList rates; + for (const FormatCandidate& candidate : buildLadder( + os, direction, caps, policy, internalRate)) { + if (candidate.fmt == SampleFmt::Float32) { + rates.append(candidate.rate); + } + } + return rates; + }; + for (TargetOs os : {TargetOs::Windows, TargetOs::MacOS, TargetOs::Linux}) { + const std::string prefix = std::string("native48 output / ") + toString(os); + DeviceCaps caps = dev({48000, 44100, 24000}); + caps.preferredRate = 48000; + report(prefix + " Float open order retains 48 -> 44.1 -> 24 without retrying preferred48", + floatRates(os, Direction::Output, caps, 48000) + == QList{48000, 44100, 24000}); + + // Exercise reliable capability checks and the try-at-open policy used + // by WASAPI. Each row removes the previous successful rate while the + // advertised preferred format remains 48 kHz throughout. + for (bool reliable : {true, false}) { + caps.isFormatSupportedReliable = reliable; + for (int workingRate : {48000, 44100, 24000}) { + caps.supportedRates = workingRate == 48000 + ? QList{24000, 44100, 48000} + : workingRate == 44100 ? QList{24000, 44100} + : QList{24000}; + const NegotiatedFormat selected = negotiate( + os, Direction::Output, caps, ResamplerPolicy::PreservePan, 48000); + const ResamplerKind expectedResampler = workingRate == 48000 + ? ResamplerKind::None : ResamplerKind::PreservePan; + report(prefix + (reliable ? " reliable" : " try-at-open") + + " chooses " + std::to_string(workingRate) + + " with advertised preferred48", + selected.ok && selected.rate == workingRate + && selected.fmt == SampleFmt::Float32 && selected.channels == 2 + && selected.resampler == expectedResampler + && selected.fellBack == (workingRate != 48000), + fmtOf(selected)); + } + } + + // Freeze the existing orders outside this narrow output case. In + // particular, passing internalRate=48000 must not give input capture + // the new output fallback sequence. + const QList legacyOutput = os == TargetOs::Linux + ? QList{24000, 48000, 44100} : QList{48000, 24000, 44100}; + report(prefix + " preserves default24 output order", + floatRates(os, Direction::Output, caps, kInternalRate) == legacyOutput); + for (ResamplerPolicy policy : {ResamplerPolicy::RegenerateAtRate, + ResamplerPolicy::MonoCollapse}) { + const std::string policyName = policy == ResamplerPolicy::RegenerateAtRate + ? "RegenerateAtRate" : "MonoCollapse"; + report(prefix + " preserves native48 " + policyName + " output order", + floatRates(os, Direction::Output, caps, 48000, policy) + == QList{48000, 44100}); + } + for (int internalRate : {24000, 48000}) { + const QList inputOrder = os == TargetOs::Linux + ? (internalRate == 24000 ? QList{48000, 24000, 44100} + : QList{48000, 44100}) + : (internalRate == 24000 ? QList{48000, 44100, 24000, 16000} + : QList{48000, 44100, 16000}); + report(prefix + " preserves input" + std::to_string(internalRate) + " order", + floatRates(os, Direction::Input, caps, internalRate) == inputOrder); + } + } +} + } // namespace int main() @@ -98,6 +175,8 @@ int main() const auto Mac = TargetOs::MacOS; const auto Lin = TargetOs::Linux; + native48OutputPolicy(); + // ── Standard 48k-only DAC, RX speaker (PreservePan) ─────────────────────── runRow({"std 48k DAC / Win / RX", Win, Out, Pan, dev({48000}), true, 48000, F, ResamplerKind::PreservePan, 2, false}); diff --git a/tests/container_widget_test.cpp b/tests/container_widget_test.cpp index 69eb081238..ba152e40d1 100644 --- a/tests/container_widget_test.cpp +++ b/tests/container_widget_test.cpp @@ -1,8 +1,8 @@ // Phase 1 smoke tests for the container system — exercises the // ContainerWidget <-> FloatingContainerWindow float/dock cycle. // -// Headless-ish: uses QApplication so the widgets can instantiate and -// process signals, but never calls show() (no X11 / display needed). +// Uses QApplication and the offscreen platform to exercise real show/hide +// and reparent behavior without a radio, sound device or visible desktop. // Run: ./build/container_widget_test #include "TestSettingsProfile.h" @@ -91,6 +91,134 @@ void testVisibilitySignal() spy.count() == 0); } +void testTransientPresentation() +{ + QWidget panel; + auto* layout = new QVBoxLayout(&panel); + ContainerWidget c("conditional", "Conditional"); + c.setContent(new QLabel("payload")); + layout->addWidget(&c); + QSignalSpy visibility(&c, &ContainerWidget::visibilityChanged); + + // makeEntry's default-open call is a logical no-op on a fresh container. + // The first availability transition must still retain that open intent. + c.setContainerVisible(true); + c.setPresentationAvailable(false); + panel.show(); + c.setPresentationAvailable(true); + report("default logical open survives the first construction gate", c.isVisible()); + c.setPresentationAvailable(false); + QWidget* generic = &c; + generic->show(); + report("direct show cannot expose a suppressed docked container", + c.isContainerVisible() && c.isHidden() && !c.isVisible()); + panel.hide(); + panel.show(); + report("ancestor show cannot bypass presentation suppression", c.isHidden()); + c.setPresentationAvailable(true); + report("availability restores the requested docked presentation", c.isVisible()); + panel.hide(); + c.setPresentationAvailable(false); + c.setPresentationAvailable(true); + panel.show(); + report("ancestor hiding does not become an explicit child hide", c.isVisible()); + report("availability emits no logical close or open", visibility.isEmpty()); + + c.setPresentationAvailable(false); + generic->hide(); + c.setPresentationAvailable(true); + report("explicit direct hide remains hidden after availability returns", + c.isHidden() && c.isContainerVisible()); + c.setContainerVisible(false); + c.setPresentationAvailable(false); + c.setContainerVisible(true); + report("logical reopen under suppression stays physically hidden", + c.isContainerVisible() && c.isHidden()); + c.setPresentationAvailable(true); + report("logical reopen restores when available", c.isVisible()); + c.setContainerVisible(false); + c.setPresentationAvailable(false); + c.setPresentationAvailable(true); + report("logical close remains closed across availability changes", + !c.isContainerVisible() && c.isHidden()); +} + +void testFloatingPresentation() +{ + ContainerWidget c("conditional-float", "Conditional float"); + c.setContent(new QLabel("payload")); + QSignalSpy visibility(&c, &ContainerWidget::visibilityChanged); + FloatingContainerWindow win; + c.setPresentationAvailable(false); + win.takeContainer(&c); + QWidget* generic = &win; + generic->show(); + report("suppressed float hides its outer window but keeps content shown", + win.isHidden() && !c.isHidden() && c.isContainerVisible()); + c.setPresentationAvailable(true); + report("float availability returns without blank content", + win.isVisible() && c.isVisible() && c.content()->isVisible()); + c.setPresentationAvailable(false); + win.setAlwaysOnTop(true); + win.setFramelessMode(false); + report("window flag changes cannot reveal a suppressed float", win.isHidden()); + c.setPresentationAvailable(true); + report("window flag recreation retains the suppressed show request", + win.isVisible() && c.content()->isVisible()); + c.setPresentationAvailable(false); + generic->hide(); + c.setPresentationAvailable(true); + report("explicitly hidden float is not reopened by availability", win.isHidden()); + generic->show(); + c.setPresentationAvailable(false); + c.setContainerVisible(false); + c.setPresentationAvailable(true); + report("logical close during suppressed float cannot restore a blank window", + win.isHidden() && !c.isContainerVisible()); + generic->show(); + report("unconditional restore show cannot override a managed logical close", win.isHidden()); + c.setContainerVisible(true); + report("logical reopen restores both managed window and content", + win.isVisible() && c.isVisible() && c.content()->isVisible()); + c.setContainerVisible(false); + report("logical close also suppresses an available managed float", win.isHidden()); + c.setPresentationAvailable(false); + c.setContainerVisible(true); + report("logical reopen while unavailable remains suppressed", win.isHidden()); + c.setPresentationAvailable(true); + report("availability then restores the new logical reopen", win.isVisible()); + visibility.clear(); + c.setPresentationAvailable(false); + win.releaseContainer(); + generic->hide(); + c.setPresentationAvailable(true); + report("released container cannot toggle its former window", win.isHidden()); + c.setPresentationAvailable(false); + QWidget panel; + auto* layout = new QVBoxLayout(&panel); + layout->addWidget(&c); + c.show(); + panel.show(); + report("docking a suppressed float reapplies the container gate", + c.isPanelDocked() && c.isHidden() && c.isContainerVisible()); + c.setPresentationAvailable(true); + report("docked content recovers without losing its body", c.content()->isVisible()); + report("floating availability never emits a persisted visibility change", + visibility.isEmpty()); + c.setParent(nullptr); // stack-owned; panel must not destroy it first + + ContainerWidget replacement("replacement", "Replacement"); + replacement.setContent(new QLabel("new payload")); + win.takeContainer(&c); + generic->show(); + win.takeContainer(&replacement); + c.setPresentationAvailable(false); + report("replaced container cannot suppress the new window owner", win.isVisible()); + replacement.setPresentationAvailable(false); + report("only the current hosted container gates its window", win.isHidden()); + win.releaseContainer(); +} + void testFloatDockCycle() { ContainerWidget c("id", "T"); @@ -116,6 +244,12 @@ void testFloatDockCycle() report("dockMode change signal fired", modeSpy.count() >= 1); + win.show(); + c.setContainerVisible(false); + report("unmanaged float retains its existing outer-window behavior", + win.isVisible() && !c.isPresentationManaged()); + c.setContainerVisible(true); + // Release → back to docked state. ContainerWidget* released = win.releaseContainer(); report("releaseContainer returns the same container", @@ -222,6 +356,8 @@ int main(int argc, char** argv) testContainerBasics(); testSetContent(); testVisibilitySignal(); + testTransientPresentation(); + testFloatingPresentation(); testFloatDockCycle(); testFloatingWidthPolicy(); testDefaultFloatingSize(); diff --git a/tests/mode_filter_presets_test.cpp b/tests/mode_filter_presets_test.cpp index 02edfd6f78..b978eae6b4 100644 --- a/tests/mode_filter_presets_test.cpp +++ b/tests/mode_filter_presets_test.cpp @@ -24,6 +24,7 @@ private slots: void unknownModesGetTheSsbLadder(); void fmLaddersRequireDeclaredControl(); void fmPresetsRespectDeclaredEdges(); + void broadcastFmPresetsRespectDeclaredEdges(); }; void ModeFilterPresetsTest::ssbPinsItsLowCutAndDerivesTheHigh() @@ -158,5 +159,25 @@ void ModeFilterPresetsTest::fmPresetsRespectDeclaredEdges() QVERIFY(acceptsFmEdges(QStringLiteral("FMN"), &control, {-4000, 6000})); } +void ModeFilterPresetsTest::broadcastFmPresetsRespectDeclaredEdges() +{ + const AetherSDR::ReceiveFilterControl control{ + AetherSDR::SliceFrequencyControl::Authority::Engine, + {{QStringLiteral("WFM"), -90000, -15000, 15000, 80000, 120000, 180000}}}; + const QVector expected{120000, 140000, 160000}; + for (const char* spelling : {"WFM", "WBFM"}) { + const QString mode = QString::fromLatin1(spelling); + QVERIFY(widthsForMode(mode).isEmpty()); + QVERIFY(widthsForMode(mode, nullptr).isEmpty()); + QCOMPARE(widthsForMode(mode, &control), expected); + for (int width : expected) { + QVERIFY(acceptsFmEdges(mode, &control, edgesForWidth(mode, width, {}))); + } + QVERIFY(acceptsFmEdges(mode, &control, {-90000, 80000})); + QVERIFY(!acceptsFmEdges(mode, &control, {-90000, 90000})); + } + QVERIFY(widthsForMode(QStringLiteral("FM"), &control).isEmpty()); +} + QTEST_MAIN(ModeFilterPresetsTest) #include "mode_filter_presets_test.moc" diff --git a/tests/qso_recorder_rates_test.cpp b/tests/qso_recorder_rates_test.cpp index 73d1a39096..1873bc20ba 100644 --- a/tests/qso_recorder_rates_test.cpp +++ b/tests/qso_recorder_rates_test.cpp @@ -12,10 +12,12 @@ #include #include +#include #include #include #include #include +#include #include #include @@ -127,6 +129,23 @@ qint16 sampleAt(const QByteArray& wav, int frame, int channel) return qFromLittleEndian(wav.constData() + offset); } +double wavAmplitude(const QByteArray& wav, int rate, int channel, double frequency) +{ + const int frames = static_cast((wav.size() - 44) / 4); + if (frames <= 0) { + return 0.0; + } + double real = 0.0; + double imaginary = 0.0; + for (int frame = 0; frame < frames; ++frame) { + const double angle = 2.0 * std::numbers::pi * frequency * frame / rate; + const double sample = sampleAt(wav, frame, channel) / 32767.0; + real += sample * std::cos(angle); + imaginary += sample * std::sin(angle); + } + return 2.0 * std::hypot(real, imaginary) / frames; +} + void verifyIsolatedSegment(const QByteArray& wav, int firstFrame, int frames, int activeChannel, const char* message) { @@ -204,6 +223,43 @@ void knownProducerRateAndDuration() } } +void writtenWideStereoSpectrum() +{ + // Inspect actual production WAV samples: a 48 kHz header alone cannot + // reveal a hidden 24 kHz bottleneck or a collapsed stereo recording path. + constexpr int kRate = 48000; + QTemporaryDir directory; + check(directory.isValid(), "create wide-stereo recording directory"); + QsoRecorder recorder; + configure(recorder, directory.path()); + PcmProducer producer; + check(producer.start(PcmPurpose::Speaker, -1, {kRate, PcmLayout::Stereo}), + "start current 48 kHz stereo recording source"); + recorder.feedRxFrame(produce(producer, stereo(1, 0.0f, 0.0f))); + recorder.startRecording(); + check(recorder.isRecording(), "wide-stereo recording starts after current metadata"); + const QString path = recorder.recordingFilePath(); + QVector samples(kRate * 2); + for (int frame = 0; frame < kRate; ++frame) { + const double angle = 2.0 * std::numbers::pi * frame / kRate; + samples[2 * frame] = static_cast(0.3 * std::sin(15000.0 * angle)); + samples[2 * frame + 1] = static_cast(0.17 * std::sin(1700.0 * angle)); + } + recorder.feedRxFrame(produce(producer, std::move(samples))); + check(recorder.stopRecording() == 1, "written wide-stereo duration is one second"); + const QByteArray wav = readFile(path); + verifyWav(wav, kRate, kRate); + check(std::abs(wavAmplitude(wav, kRate, 0, 15000) - 0.3) < 0.001, + "actual 48 kHz WAV retains the left 15 kHz tone amplitude"); + check(std::abs(wavAmplitude(wav, kRate, 1, 1700) - 0.17) < 0.001, + "actual 48 kHz WAV retains the independent right tone amplitude"); + check(wavAmplitude(wav, kRate, 0, 1700) < 0.001 + && wavAmplitude(wav, kRate, 1, 15000) < 0.001, + "actual WAV preserves stereo without cross-channel tone leakage"); + check(wavAmplitude(wav, kRate, 0, 9000) < 0.001, + "actual WAV does not contain a 9 kHz alias from a hidden 24 kHz path"); +} + void legacyAndMonoQuantization() { QTemporaryDir directory; @@ -660,6 +716,7 @@ int main(int argc, char** argv) AppSettings::instance().save(); finalizedStopDuration(); knownProducerRateAndDuration(); + writtenWideStereoSpectrum(); legacyAndMonoQuantization(); earlyAndTxFirstRateSelection(); replacementReconnectAndReplay(); diff --git a/tests/rtl_audio_mixer_test.cpp b/tests/rtl_audio_mixer_test.cpp index cbd8650582..8aa9d1954a 100644 --- a/tests/rtl_audio_mixer_test.cpp +++ b/tests/rtl_audio_mixer_test.cpp @@ -14,6 +14,16 @@ struct Sink final : Mixer::Sink { std::vector samples; std::uint64_t next = 0; bool valid = true; + struct Missing { + Mixer::Input input; + std::uint64_t first; + std::uint64_t clock; + Mixer::MissingMask mask; + }; + std::vector missing; + void missingFrames(const Mixer::Input& input, std::uint64_t first, + std::uint64_t clock, const Mixer::MissingMask& mask) noexcept override + { missing.push_back({input, first, clock, mask}); } void speakerBlock(std::uint64_t first, std::span input, bool discontinuity) noexcept override { valid &= first == next && discontinuity == samples.empty() && input.size() == Mixer::kQuantum * 2; @@ -26,7 +36,7 @@ int main() std::array inputs{{{0, 1, 1}, {3, 2, 1}}}; std::array left, right; left.fill(0.8f); right.fill(-0.8f); - Sink sink; sink.samples.reserve(40000); + Sink sink; sink.samples.reserve(40000); sink.missing.reserve(64); check(mixer->configure(1, 1, inputs, 0), "sparse independent inputs configured"); check(mixer->push(0, 1, 1, 0, left, right), "first slice queues capture positions"); mixer->drain(128, sink); @@ -42,6 +52,13 @@ int main() mixer->drain(256 + Mixer::kDeadlineFrames, sink); check(sink.next == 256 && sink.samples[256] == 0.4f && sink.samples[257] == -0.4f, "deadline substitutes silence without stalling or shifting healthy audio"); + check(sink.missing.size() == 1 && sink.missing.front().input.slot == 3 + && sink.missing.front().input.instance == 2 && sink.missing.front().input.epoch == 1 + && sink.missing.front().first == 128 + && sink.missing.front().clock == 256 + Mixer::kDeadlineFrames + && sink.missing.front().mask[0] == std::numeric_limits::max() + && sink.missing.front().mask[1] == std::numeric_limits::max(), + "diagnostics distinguish the expired sparse receiver and exact 128-frame hole"); check(mixer->lateFrames() == 128 && !mixer->push(3, 2, 1, 128, left, right), "expired receiver audio cannot reappear at current time"); inputs[0].gain = 0.5f; inputs[0].pan = 0; inputs[1].mute = true; @@ -70,6 +87,20 @@ int main() check(mixer->configure(2, 1, inputs, 0) && mixer->nextSample() == 0, "reconnect starts new source clock"); const auto rejected = mixer->rejectedBlocks(); check(rejected >= 7, "queue refusals are observable"); + // Two isolated absent contributions are not a contiguous two-frame gap. + auto sparseMixer = std::make_unique(); + const std::array single{{{4, 99, 7}}}; + Sink sparse; sparse.samples.reserve(256); sparse.missing.reserve(1); + check(sparseMixer->configure(7, 9, single, 0), "sparse diagnostic input configured"); + left.fill(.2f); right.fill(-.2f); + check(sparseMixer->push(4, 99, 7, 1, std::span(left).first(63), std::span(right).first(63)), + "queue audio after first missing frame"); + check(sparseMixer->push(4, 99, 7, 65, std::span(left).first(63), std::span(right).first(63)), + "queue audio after separated missing frame"); + sparseMixer->drain(128 + Mixer::kDeadlineFrames, sparse); + check(sparseMixer->lateFrames() == 2 && sparse.missing.size() == 1 + && sparse.missing.front().mask[0] == 1 && sparse.missing.front().mask[1] == 1, + "missing masks retain exact separated sample positions across a 64-bit boundary"); std::fprintf(stderr, "rtl_audio_mixer_test: %d failures\n", failures); return failures ? 1 : 0; } diff --git a/tests/rtl_capture_worker_test.cpp b/tests/rtl_capture_worker_test.cpp index 511aad3c39..ed329f234b 100644 --- a/tests/rtl_capture_worker_test.cpp +++ b/tests/rtl_capture_worker_test.cpp @@ -27,7 +27,8 @@ struct RtlCaptureBackendTestAccess { static void start(RtlSdrBackend& backend, std::unique_ptr device, int capacity = 1) { backend.m_requested.hardware = {100'000'000, 2'400'000, 0, 0, 0, 240}; - backend.m_requested.receivers = {{{0, 100'000'000, -100'000, 100'000, 0, 0, 0}, T::Mode::Wfm}}; + const double wfmGuard = RtlReceivePipeline::kQualifiedWfmEnabled ? 3000 : 0; + backend.m_requested.receivers = {{{0, 100'000'000, -100'000, 100'000, 0, wfmGuard, wfmGuard}, T::Mode::Wfm}}; if (capacity > 1) { backend.m_receiverCapacity = capacity; backend.m_capture = RtlCaptureTransaction({8, static_cast(capacity)}); @@ -112,12 +113,25 @@ static void sustainedPanProgress(bool narrow) const int beforeFrames = frames; const int beforeWrites = device->writes; const int beforeCallbacks = device->callbacks; - for (int block = 0; block < 7; ++block) { device->block(); } + // Each injected block represents real sample time (3.4 ms here). + // Bursting all seven drains native WFM's nonblocking WDSP output ring + // and correctly requests receiver repair, superseding this pan. Pace + // the fixture so it tests held-pan ordering through the native graph. + const unsigned long callbackMicroseconds = static_cast(std::ceil( + 1.0e6 * (device->callbackBytes.load() / 2.0) / accepted.hardware.sampleRateHz)); + const auto clockBlock = [&] { + const int completed = device->callbacks + 1; + device->block(); + check(waitFor([&] { return device->callbacks >= completed; }), + "paced capture callback completes"); + QThread::usleep(callbackMicroseconds); + }; + for (int block = 0; block < 7; ++block) { clockBlock(); } check(waitFor([&] { return device->callbacks >= beforeCallbacks + 7; }), "fresh capture receives seven partial FFT blocks"); check(frames == beforeFrames && device->writes == beforeWrites, "next pan waits for a whole fresh observation, not a partial window"); - device->block(); + clockBlock(); check(waitFor([&] { return frames > beforeFrames; }), "fresh RF frame escapes while continuous pointer motion is pending"); check(std::abs(framedCenter - accepted.hardware.centerHz) < 1, @@ -332,7 +346,7 @@ int main(int argc, char** argv) const int beforeFilter = changes; backend.setSliceFilter(0, -8000, 8000); check(changes == beforeFilter && !rtl::RtlCaptureBackendTestAccess::busy(backend), - "unimplemented WFM filter request neither publishes nor queues work"); + "invalid narrow WFM filter request neither publishes nor queues work"); const int beforeMode = changes; backend.setSliceMode(0, QStringLiteral("FM")); check(waitFor([&] { state->block(); QThread::msleep(5); return changes > beforeMode; }), "mode adopted at callback boundary"); diff --git a/tests/rtl_model_acceptance_test.cpp b/tests/rtl_model_acceptance_test.cpp index 297545927d..49e66f8b11 100644 --- a/tests/rtl_model_acceptance_test.cpp +++ b/tests/rtl_model_acceptance_test.cpp @@ -12,9 +12,12 @@ #include #include #include +#include #include #include +#include #include +#include using namespace AetherSDR; using T = rtl::RtlCaptureTransaction; @@ -38,15 +41,44 @@ struct RtlCaptureBackendTestAccess { { backend.m_receiverCapacity = capacity; backend.m_capture = T({8, static_cast(capacity)}); - backend.m_requested.hardware = {100'000'000, 2'400'000, 0, 0, 0, 240}; - backend.m_requested.receivers = {{{0, 100'000'000, -100'000, 100'000, 0, 0, 0}, T::Mode::Wfm}}; + backend.m_requested = {}; + backend.m_requested.hardware = {static_cast(backend.m_panCenterHz), + backend.m_sampleRateHz, 0, 0, backend.m_ppmCorrection, 240}; + backend.m_requested.dcSuppression = backend.m_dcSuppression; + // Keep the production bootstrap selected by handRestoredStateToBackend; + // replace only the USB entry point. In particular, do not inject a + // different WFM graph or discard the provisional monitor mute here. + backend.m_requested.receivers = {backend.initialReceiver()}; if (capacity > 1) { + // The separate membership fixture deliberately admits several FM + // receivers; it has no saved receiver state or production capacity. + backend.m_requested.hardware = {100'000'000, 2'400'000, 0, 0, 0, 240}; backend.m_requested.receivers = {{{0, 100'000'000, -8000, 8000, 0, 3000, 3000}, T::Mode::Fm}}; } backend.startCapture(std::make_unique(std::move(device), nullptr, capacity)); } static bool idle(const RtlSdrBackend& backend) { return !backend.m_capture.busy() && !backend.m_pendingDrag; } static T::State state(const RtlSdrBackend& backend) { return *backend.m_capture.confirmed(); } + static void pilot(RtlSdrBackend& backend, int id, T::Token token, bool stereo, + std::uint32_t sequence = 2, double magnitude = 0.1) + { + RtlReceivePipeline::Packet packet; + const auto& receivers = backend.m_lastPublished->receivers; + const auto receiver = std::ranges::find_if(receivers, + [id](const auto& value) { return value.passband.stableId == id; }); + packet.slot = id; packet.token = token; + packet.wfmStereoDetected = stereo && !receiver->wfmForceMono; + packet.wfmReception = WfmReceptionDiagnostics{true, magnitude, stereo, + stereo ? 5000U : 0U, 0, 0, 6000, 5000, 0.06, 0.03, 100, 0, 93, 187, sequence}; + backend.observeWfm(packet); + } + static void expirePilot(RtlSdrBackend& backend, int id) + { + backend.m_wfmObservationAge[id] = QElapsedTimer(); + backend.m_wfmObservationAge[id].start(); + QThread::msleep(510); + backend.expireWfmObservations(); + } static void spectrum(RtlSdrBackend& backend, const QByteArray& frame, T::Token token) { emit backend.m_worker->spectrumFrameReady(token.session, token.revision, 0, frame); } }; @@ -64,6 +96,31 @@ template static bool waitFor(F predicate) } return predicate(); } +// This fixture's capture remains 2.4 MS/s with 8192 complex frames per +// callback. Queue at most one callback at a time and no faster than real time. +// Accumulating callbacks during off-thread planning would replay a burst into +// nonblocking WDSP and test artificial output underruns instead of adoption. +class CaptureClock { +public: + explicit CaptureClock(std::shared_ptr device) + : m_device(std::move(device)) + { m_clock.start(); } + void advance() + { + const qint64 now = m_clock.elapsed(); + if (m_device->starts.load() == 0 || m_device->callbacks.load() < m_queued + || now < m_nextBlockMs) { return; } + m_device->block(); + ++m_queued; + m_nextBlockMs = now + 4; + } +private: + std::shared_ptr m_device; + QElapsedTimer m_clock; + int m_queued = 0; + qint64 m_nextBlockMs = 0; +}; + int main(int argc, char** argv) { TestSettingsProfile profile(QStringLiteral("rtl-model-acceptance")); @@ -83,10 +140,11 @@ int main(int argc, char** argv) RadioModelSliceLifecycleTestAccess::restore(model, "model-accepted"); auto& backend = *static_cast(model.backend()); auto device = std::make_shared(); + CaptureClock captureClock(device); rtl::RtlCaptureBackendTestAccess::start(backend, std::make_unique(device)); device->releaseReadback(); check(waitFor([&] { - device->block(); + captureClock.advance(); return model.slice(3) && !model.slice(0) && rtl::RtlCaptureBackendTestAccess::idle(backend); }), "capacity-one restore materializes stable ID 3 instead of zero"); QPointer slice = model.slice(3); @@ -131,7 +189,7 @@ int main(int argc, char** argv) RadioModelSliceLifecycleTestAccess::flush(model); check(scope.featureExact("RtlSlices") == initial, "forced pending flush preserves accepted document"); check(waitFor([&] { - device->block(); + captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend) && slice->frequency() == 100.1 && slice->filterLow() == -7000 && slice->audioGain() == 37 && slice->audioMute() && slice->audioPan() == 73 && slice->squelchLevel() == 61; @@ -151,14 +209,14 @@ int main(int argc, char** argv) check(scope.featureExact("RtlSlices") == accepted, "refused edits never persist"); // A return to the already observed value must still supersede pending intent. slice->setFrequency(100.2); slice->setFrequency(100.1); - check(waitFor([&] { device->block(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }), + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }), "observed-value request cancels a pending different value"); check(slice->frequency() == 100.1 && !frequencies.contains(100.2), "superseded receiver-only value never becomes observable"); const int modeBefore = observed; slice->setMode("FMN"); check(slice->mode() == "FM" && observed == modeBefore, "mode waits for adopted DSP"); - check(waitFor([&] { device->block(); return slice->mode() == "FMN" && rtl::RtlCaptureBackendTestAccess::idle(backend); }), + check(waitFor([&] { captureClock.advance(); return slice->mode() == "FMN" && rtl::RtlCaptureBackendTestAccess::idle(backend); }), "accepted mode reaches model through backend status"); // Fail the center write, then hold the verified compensation readback. { @@ -183,7 +241,7 @@ int main(int argc, char** argv) slice->setFrequency(107.0); check(slice->frequency() == 100.1, "both hardware requests remain invisible while pending"); device->releaseReadback(); - check(waitFor([&] { device->block(); return rtl::RtlCaptureBackendTestAccess::idle(backend) && slice->frequency() == 107.0; }), + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend) && slice->frequency() == 107.0; }), "latest hardware state publishes after compensation"); check(!frequencies.contains(106.0), "superseded hardware state never publishes"); // Display geometry must never become a hardware/sample-rate/slice command. @@ -213,14 +271,14 @@ int main(int argc, char** argv) slice->setFrequency(107.05); model.requestPanCenter(pan->panId(), 107.05); check(slice->frequency() == 107.0, "typed frequency waits for receiver adoption"); - check(waitFor([&] { device->block(); return slice->frequency() == 107.05 + check(waitFor([&] { captureClock.advance(); return slice->frequency() == 107.05 && rtl::RtlCaptureBackendTestAccess::idle(backend); }), "typed in-window tune adopted"); check(std::abs(pan->centerMhz() - 107.05) < 0.002 && device->writes == beforeWrites, "typed centering moves only the view when capture already fits"); check(model.requestConfirmedReceiveTune(3, 107.08, IRadioBackend::ReceiveTuneView::Center), "confirmed GUI tune admits receiver and view as one intent"); check(slice->frequency() == 107.05, "joint typed intent remains unobserved until adoption"); - check(waitFor([&] { device->block(); return slice->frequency() == 107.08 + check(waitFor([&] { captureClock.advance(); return slice->frequency() == 107.08 && rtl::RtlCaptureBackendTestAccess::idle(backend); }), "joint typed intent adopted"); check(std::abs(pan->centerMhz() - 107.08) < 0.002 && device->writes == beforeWrites, "joint in-window typed intent centers display without USB writes"); @@ -228,7 +286,7 @@ int main(int argc, char** argv) check(model.requestConfirmedReceiveTune(3, 107.2, IRadioBackend::ReceiveTuneView::Center) && model.requestConfirmedReceiveTune(3, 107.08, IRadioBackend::ReceiveTuneView::Preserve), "newer preserve-view tune supersedes an unadopted centering intent"); - check(waitFor([&] { device->block(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }) + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }) && slice->frequency() == 107.08 && pan->centerMhz() == acceptedView, "superseded frequency and centering never become observations"); check(!model.requestConfirmedReceiveTune(3, 3000.0, IRadioBackend::ReceiveTuneView::Center) @@ -252,12 +310,12 @@ int main(int argc, char** argv) const auto captured = rtl::RtlCaptureBackendTestAccess::state(backend); check(model.requestConfirmedReceiveTune(3, captured.capture.centerHz / 1e6, IRadioBackend::ReceiveTuneView::Center), "operator can tune onto DC without an implicit capture retune"); - check(waitFor([&] { device->block(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }), + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }), "on-DC receiver tune adopted"); check(!backend.healthSnapshot().values.value("rtlCaptureDcClear").toBool(), "DC overlap is operator-visible rather than hidden by a removed bin"); check(model.requestReceiveCaptureRecenter(pan->panId()), "explicit DC placement admitted"); - check(waitFor([&] { device->block(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }), + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend); }), "explicit DC placement adopted"); const auto displaced = rtl::RtlCaptureBackendTestAccess::state(backend); check(displaced.hardware.centerHz != captured.hardware.centerHz @@ -322,7 +380,7 @@ int main(int argc, char** argv) QObject::disconnect(frameConnection); QObject::disconnect(waterfallConnection); slice->setFrequency(107.0); - check(waitFor([&] { device->block(); return slice->frequency() == 107.0 + check(waitFor([&] { captureClock.advance(); return slice->frequency() == 107.0 && rtl::RtlCaptureBackendTestAccess::idle(backend); }), "test receiver returned after viewport checks"); } // A staged object cannot control an otherwise-connected backend. @@ -339,33 +397,102 @@ int main(int argc, char** argv) if (value == 107.1) { ++reentrantEdits; slice->setAudioGain(41); } }); backend.setSliceFrequency(3, 107'100'000); - check(waitFor([&] { device->block(); return model.slice(3) == slice && slice->frequency() == 107.1 + check(waitFor([&] { captureClock.advance(); return model.slice(3) == slice && slice->frequency() == 107.1 && slice->audioGain() == 41 && rtl::RtlCaptureBackendTestAccess::idle(backend); }), "accepted state reclaims the staged object and adopts a reentrant edit"); check(reentrantEdits == 1, "reclaimed binding publishes the accepted tune once"); QObject::disconnect(reentrant); slice->setMode("WFM"); - check(waitFor([&] { device->block(); return slice->mode() == "WFM" + check(waitFor([&] { captureClock.advance(); return slice->mode() == "WFM" && rtl::RtlCaptureBackendTestAccess::idle(backend); }), - "legacy WFM mode adopted before unsupported filter request"); - const int wfmLow = slice->filterLow(); - const int wfmHigh = slice->filterHigh(); - const int wfmObserved = observed; - RadioModelSliceLifecycleTestAccess::flush(model); - const auto wfmSettings = scope.featureExact("RtlSlices"); - slice->setFilterWidth(-90'000, 90'000); - slice->setSquelch(true, 75); - check(rtl::RtlCaptureBackendTestAccess::idle(backend) - && slice->filterLow() == wfmLow && slice->filterHigh() == wfmHigh - && observed == wfmObserved && !slice->squelchOn(), - "unsupported WFM filter neither queues work nor changes observed state"); - RadioModelSliceLifecycleTestAccess::flush(model); - check(scope.featureExact("RtlSlices") == wfmSettings, - "unsupported WFM filter does not persist a cosmetic passband"); + "WFM mode adopted before control checks"); + if constexpr (rtl::RtlReceivePipeline::kQualifiedWfmEnabled) { + check(slice->filterLow() == -100000 && slice->filterHigh() == 100000, + "entering native WFM selects the established broadcast RF width"); + RadioModelSliceLifecycleTestAccess::flush(model); + const auto acceptedWfmSettings = scope.featureExact("RtlSlices"); + slice->setFilterWidth(-90000, 90000); + slice->setWfmDeemphasis(50); + slice->setWfmForceMono(true); + check(!slice->wfmForceMono(), "forced Mono request is not optimistic accepted state"); + check(slice->filterLow() == -100000 && slice->wfmDeemphasisUs() == 75 + && scope.featureExact("RtlSlices") == acceptedWfmSettings, + "WFM filter and deemphasis requests remain intent until adopted"); + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend) + && slice->filterLow() == -90000 && slice->filterHigh() == 90000 + && slice->wfmDeemphasisUs() == 50 && slice->wfmForceMono(); }), + "WFM filter, deemphasis and forced Mono adopt together"); + slice->setSquelch(true, 75); + slice->setWfmDeemphasis(60); + check(rtl::RtlCaptureBackendTestAccess::idle(backend) && !slice->squelchOn() + && slice->wfmDeemphasisUs() == 50, + "manual WFM squelch and unsupported deemphasis remain refused"); + RadioModelSliceLifecycleTestAccess::flush(model); + check(RtlSliceSettings(scope).load().document.slices.value(3).wfmDeemphasisUs == 50, + "only accepted WFM deemphasis reaches persisted receiver state"); + check(RtlSliceSettings(scope).load().document.slices.value(3).wfmForceMono, + "only adopted forced Mono reaches persisted settings"); + const auto token = rtl::RtlCaptureBackendTestAccess::state(backend).token; + rtl::RtlCaptureBackendTestAccess::pilot(backend, 3, token, true); + check(slice->wfmStereoStatus() == WfmStereoStatus::Mono, + "current forced Mono output is reported independently of acquired pilot"); + check(slice->wfmForceMono() && slice->wfmReceptionDiagnostics().valid + && slice->wfmReceptionDiagnostics().pilotLocked, + "actual pilot observation is distinct from selected forced Mono"); + rtl::RtlCaptureBackendTestAccess::pilot(backend, 3, {token.session, token.revision - 1}, false); + rtl::RtlCaptureBackendTestAccess::pilot(backend, 3, {token.session + 1, token.revision}, false); + check(slice->wfmStereoStatus() == WfmStereoStatus::Mono + && slice->wfmReceptionDiagnostics().pilotLocked, + "old revision and foreign session cannot change WFM observation"); + rtl::RtlCaptureBackendTestAccess::expirePilot(backend, 3); + check(slice->wfmStereoStatus() == WfmStereoStatus::Acquiring + && !slice->wfmReceptionDiagnostics().valid, + "stopped decoder observations expire rather than retaining stereo"); + rtl::RtlCaptureBackendTestAccess::pilot(backend, 3, token, true); + check(!slice->wfmReceptionDiagnostics().valid, + "cached decoder sequence cannot refresh expired reception"); + rtl::RtlCaptureBackendTestAccess::pilot(backend, 3, token, true, 4, + std::numeric_limits::quiet_NaN()); + check(!slice->wfmReceptionDiagnostics().valid, "invalid magnitude cannot refresh reception"); + rtl::RtlCaptureBackendTestAccess::pilot(backend, 3, token, false, 4); + check(slice->wfmStereoStatus() == WfmStereoStatus::Mono, + "current no-pilot observation reports mono"); + slice->setWfmForceMono(false); + check(slice->wfmForceMono(), "Auto Stereo remains intent until decoder adoption"); + check(waitFor([&] { captureClock.advance(); return rtl::RtlCaptureBackendTestAccess::idle(backend) + && !slice->wfmForceMono(); }), "Auto Stereo adoption confirms the selection"); + check(!slice->wfmReceptionDiagnostics().valid + || slice->wfmReceptionDiagnostics().observationDurationMs < 6000, + "new decoder revision cannot retain the old reception history"); + RadioModelSliceLifecycleTestAccess::flush(model); + check(!RtlSliceSettings(scope).load().document.slices.value(3).wfmForceMono, + "accepted Auto Stereo replaces only the chosen persistent policy"); + } else { + const int wfmLow = slice->filterLow(); + const int wfmHigh = slice->filterHigh(); + const int wfmObserved = observed; + RadioModelSliceLifecycleTestAccess::flush(model); + const auto wfmSettings = scope.featureExact("RtlSlices"); + slice->setFilterWidth(-90'000, 90'000); + slice->setSquelch(true, 75); + check(rtl::RtlCaptureBackendTestAccess::idle(backend) + && slice->filterLow() == wfmLow && slice->filterHigh() == wfmHigh + && observed == wfmObserved && !slice->squelchOn(), + "unsupported WFM filter neither queues work nor changes observed state"); + RadioModelSliceLifecycleTestAccess::flush(model); + check(scope.featureExact("RtlSlices") == wfmSettings, + "unsupported WFM filter does not persist a cosmetic passband"); + } slice->setMode("FMN"); - check(waitFor([&] { device->block(); return slice->mode() == "FMN" + check(waitFor([&] { captureClock.advance(); return slice->mode() == "FMN" && rtl::RtlCaptureBackendTestAccess::idle(backend); }), - "FMN restored after legacy WFM refusal check"); + "FMN restored after WFM control checks"); + check(slice->wfmStereoStatus() == WfmStereoStatus::Unavailable, + "leaving WFM invalidates the pilot observation"); + if constexpr (rtl::RtlReceivePipeline::kQualifiedWfmEnabled) { + check(slice->wfmDeemphasisUs() == 50, + "mode changes retain the selected broadcast deemphasis"); + } RadioModelSliceLifecycleTestAccess::flush(model); const auto beforeDisconnect = scope.featureExact("RtlSlices"); slice->setFrequency(107.2); slice->setAudioGain(80); diff --git a/tests/rtl_receive_pipeline_test.cpp b/tests/rtl_receive_pipeline_test.cpp index 3b4877635f..36ae3bca6c 100644 --- a/tests/rtl_receive_pipeline_test.cpp +++ b/tests/rtl_receive_pipeline_test.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #ifdef AETHER_BACKEND_RTL #include "core/backends/rtl/RtlSdrDdc.h" @@ -21,6 +22,194 @@ using T = AetherSDR::rtl::RtlCaptureTransaction; using namespace std::chrono_literals; namespace AetherSDR::rtl { struct RtlReceivePipelineTestAccess { + // Observation seam only: the receiver supplies a fixed failure; this does + // not claim to reproduce the underlying WDSP underrun. + static bool traceObservation(RtlReceivePipeline& pipeline) + { + struct FailingReceiver final : RtlReceiverRegistry::Receiver { + RtlReceiverRegistry::ProcessingFailure failure; + mutable unsigned observations = 0; + WdspChannel::ProcessResult processIq(std::span, std::span) noexcept override + { return WdspChannel::ProcessResult::Ok; } + std::span left() const noexcept override { return {}; } + std::span right() const noexcept override { return {}; } + bool processCapture(const RtlReceiverRegistry::SampleBlock&, RtlReceiverRegistry::AudioSink&) noexcept override + { return false; } + std::optional processingFailure() const noexcept override + { ++observations; return failure; } + } receiver; + receiver.failure.reason = RtlReceiverRegistry::ProcessingFailureReason::DspProcess; + receiver.failure.processResult = WdspChannel::ProcessResult::Underrun; + receiver.failure.captureFirst = receiver.failure.expectedCaptureFirst = 5000; + receiver.failure.captureFrames = 6400; + receiver.failure.iqFirst = 800; receiver.failure.iqFrames = 2048; + receiver.failure.hasExpectedCaptureFirst = receiver.failure.hasIqFirst = true; + receiver.failure.extraction.emplace(); + receiver.failure.extraction->reason = RtlRfExtractor::FailureReason::SinkRejected; + receiver.failure.extraction->iqFirst = 800; + receiver.failure.extraction->iqFrames = 2048; + const auto originalFailure = receiver.failure; + pipeline.m_legacy = false; pipeline.m_token = {42, 19}; pipeline.m_captureEpoch = 91; + pipeline.m_capture = {88, 37, 100000000, 2400000, 1080000, 1080000}; + RtlReceiverRegistry::ReceiverSpec spec; + spec.handle = {999, 0xabcdef, 3}; spec.epoch = 77; spec.passband.stableId = 6; + const std::array views{{{&spec, &receiver}}}; + std::array, 6400> iq{}; + RtlReceiverRegistry::SampleBlock block{999, pipeline.m_capture, 5000, false, iq}; + pipeline.process(block, views); + if (!pipeline.needsRepair() || receiver.observations != 1) { return false; } + // The first queued event must own its failure, not borrow this value. + receiver.failure.processResult = WdspChannel::ProcessResult::Busy; + receiver.failure.captureFirst = 999999; + std::array present; present.fill(0.25f); + if (!pipeline.m_mixer.push(3, spec.handle.instance, spec.epoch, 101, present, present)) { return false; } + block.firstSample = 107400; // end clock 2276: first quantum's exact deadline + pipeline.process(block, views); + if (receiver.observations != 1) { return false; } // one report per faulted bank + RtlReceivePipeline::TraceEvent event; + if (!pipeline.takeTraceEvent(event) + || event.kind != RtlReceivePipeline::TraceEvent::Kind::ReceiverFailure + || event.token != pipeline.m_token || event.hardwareGeneration != 37 || event.captureEpoch != 91 + || event.slot != 3 || event.stableId != 6 || event.instance != 0xabcdef || event.receiverEpoch != 77 + || event.captureFirst != 5000 || event.captureFrames != 6400 || event.captureClock != 228 + || event.failure != originalFailure) { return false; } + if (!pipeline.takeTraceEvent(event) + || event.kind != RtlReceivePipeline::TraceEvent::Kind::MixerMissing + || event.token != pipeline.m_token || event.hardwareGeneration != 37 || event.captureEpoch != 91 + || event.slot != 3 || event.stableId != 6 || event.instance != 0xabcdef || event.receiverEpoch != 77 + || event.captureFirst != 107400 || event.captureFrames != 6400 || event.captureClock != 2276 + || event.quantumFirst != 100 || event.missingMask != RtlAudioMixer::MissingMask{1, std::uint64_t{1} << 63} + || event.failure || pipeline.takeTraceEvent(event)) { return false; } + RtlReceivePipeline::Packet packet; + if (!pipeline.takePacket(packet) || packet.slot != -1 || packet.firstSample != 100 || packet.frames != 128 + || packet.samples[0] != 0 || packet.samples[2] != 0.25f || packet.samples[254] != 0 + || pipeline.takePacket(packet) || pipeline.diagnostics().mixerLateFrames != 2) { return false; } + + // Fill the independent trace ring, then partially drain and wrap it. + // Every accepted event must survive in FIFO order; only new ones drop. + constexpr unsigned capacity = RtlReceivePipeline::kTraceEvents - 1; + constexpr unsigned offered = RtlReceivePipeline::kTraceEvents + 4; + constexpr unsigned drained = 17; + event.failure = originalFailure; + for (unsigned n = 0; n < offered; ++n) { + event.captureFirst = n; + pipeline.enqueueTrace(event); + } + if (pipeline.diagnostics().droppedTraceEvents != offered - capacity) { return false; } + for (unsigned n = 0; n < drained; ++n) { + if (!pipeline.takeTraceEvent(event) || event.captureFirst != n || event.failure != originalFailure) { return false; } + } + for (unsigned n = 0; n < drained; ++n) { + event.captureFirst = offered + n; + pipeline.enqueueTrace(event); + } + pipeline.enqueueTrace(event); // full again; cannot overwrite oldest + if (pipeline.diagnostics().droppedTraceEvents != offered - capacity + 1) { return false; } + std::array audio; audio.fill(0.5f); + pipeline.speakerBlock(333, audio, false); + if (!pipeline.takePacket(packet) || packet.firstSample != 333 || packet.frames != 128 + || packet.token != pipeline.m_token || packet.samples[0] != 0.5f || packet.samples[255] != 0.5f + || pipeline.droppedPackets() != 0) { return false; } + for (unsigned n = drained; n < capacity; ++n) { + if (!pipeline.takeTraceEvent(event) || event.captureFirst != n || event.failure != originalFailure) { return false; } + } + for (unsigned n = 0; n < drained; ++n) { + if (!pipeline.takeTraceEvent(event) || event.captureFirst != offered + n || event.failure != originalFailure) { return false; } + } + if (pipeline.takeTraceEvent(event)) { return false; } + event.captureFirst = 123456; + pipeline.enqueueTrace(event); + return pipeline.takeTraceEvent(event) && event.captureFirst == 123456 + && !pipeline.takeTraceEvent(event) && !pipeline.takePacket(packet) + && pipeline.diagnostics().droppedTraceEvents == offered - capacity + 1; + } + // Exercise the production extractor, pipeline publication, mixer and + // queues. Fixed PCM replaces only demodulation: no async WDSP scheduling + // is involved in this sample-coordinate regression. + static bool commonLatticeOrigin(RtlReceivePipeline& pipeline, std::uint64_t rate, + std::uint64_t expectedOrigin) + { + struct ExtractedPcm final : RtlReceiverRegistry::Receiver, RtlRfExtractor::Sink { + RtlReceiverRegistry::ReceiverSpec spec; + RtlRfExtractor extractor; + RtlReceiverRegistry::AudioSink* sink = nullptr; + std::array pcm{}; + bool first = true; + explicit ExtractedPcm(RtlReceiverRegistry::ReceiverSpec value) + : spec(value), extractor({value.capture, value.passband, 384000, 2048, 48000}) + { pcm.fill(0.25f); } + WdspChannel::ProcessResult processIq(std::span, std::span) noexcept override + { return WdspChannel::ProcessResult::Ok; } + std::span left() const noexcept override { return pcm; } + std::span right() const noexcept override { return pcm; } + bool processCapture(const RtlReceiverRegistry::SampleBlock& block, + RtlReceiverRegistry::AudioSink& target) noexcept override + { + sink = ⌖ + const bool accepted = extractor.process(block.capture, block.firstSample, block.samples, *this); + sink = nullptr; + return accepted; + } + bool iqBlock(std::span i, std::span q, std::uint64_t position) noexcept override + { + if (i.size() != 2048 || q.size() != 2048 || position % 8) { return false; } + sink->audioBlock(spec, position / 8, pcm, pcm, first); + first = false; + return true; + } + }; + pipeline.m_legacy = false; pipeline.m_token = {47, 1}; pipeline.m_captureEpoch = 17; + pipeline.m_capture = {47, 1, 100000000, double(rate), rate * 0.45, rate * 0.45}; + RtlReceiverRegistry::ReceiverSpec spec; + spec.handle = {47, 93, 3}; spec.epoch = 11; spec.capture = pipeline.m_capture; + spec.passband = {3, 100000000, -90000, 90000, 0, 1000, 1000}; + spec.dsp.inputSampleRate = 384000; spec.dsp.inputBlockSize = 2048; + spec.dsp.dspSampleRate = 192000; spec.dsp.dspBlockSize = 1024; + spec.dsp.outputSampleRate = 48000; + spec.dsp.wbfmReceive.emplace(); // preserve native fixed PCM on the observation path + ExtractedPcm receiver(spec); + if (!receiver.extractor.valid()) { return false; } + const std::array views{{{&receiver.spec, &receiver}}}; + std::array, 257> input{}; + RtlReceivePipeline::Packet packet; + std::array seen{}; + std::array first{}, next{}; + bool continuous = true; + // Every tested origin is an exact known lattice point. Feed through + // the coprime wait, then far enough past the existing 2048-frame deadline. + const std::uint64_t end = expectedOrigin * rate / 48000 + rate / 12; + for (std::uint64_t position = 25; position < end;) { + const std::size_t count = std::min(input.size(), end - position); + const RtlReceiverRegistry::SampleBlock block{47, pipeline.m_capture, position, false, + std::span(input).first(count)}; + inCallback = true; + pipeline.process(block, views); + inCallback = false; + position += count; + while (pipeline.takePacket(packet)) { + const int stream = packet.slot < 0 ? 1 : 0; + if (!seen[stream]) { first[stream] = packet.firstSample; seen[stream] = true; } + else { continuous &= packet.firstSample == next[stream]; } + next[stream] = packet.firstSample + packet.frames; + } + RtlReceivePipeline::TraceEvent event; + while (pipeline.takeTraceEvent(event)) {} // retain production counters, avoid observation overflow + } + const auto diagnostic = pipeline.diagnostics(); + std::printf("LATTICE rate=%llu tap=%llu speaker=%llu late=%llu\n", + static_cast(rate), static_cast(first[0]), + static_cast(first[1]), static_cast(diagnostic.mixerLateFrames)); + // Same epoch configure is allowed to update inputs, never to relocate + // already queued healthy audio or its next speaker position. + const std::array mixerInputs{{{3, 93, 11}}}; + const std::uint64_t preserved = pipeline.m_mixer.nextSample(); + const bool retained = pipeline.m_mixer.configure(47, 17, mixerInputs, preserved + 10000) + && pipeline.m_mixer.nextSample() == preserved; + return retained && seen[0] && seen[1] && continuous && first[0] == expectedOrigin + && first[1] == first[0] && diagnostic.mixerLateFrames == 0 + && diagnostic.mixerRejectedBlocks == 0 && diagnostic.droppedPackets == 0 + && diagnostic.mixerConfigurationFailures == 0 && !pipeline.needsRepair(); + } static void exhaustEpoch(RtlReceivePipeline& pipeline) { pipeline.m_nextEpoch = std::numeric_limits::max(); } static std::uint64_t requested(RtlReceivePipeline& pipeline) @@ -250,6 +439,20 @@ static void measureParkAndResume() int main(int argc, char** argv) { QCoreApplication app(argc, argv); + { + auto traced = std::make_unique(); + inCallback = true; + const bool observed = AetherSDR::rtl::RtlReceivePipelineTestAccess::traceObservation(*traced); + inCallback = false; + check(observed, "first-failure identity/copy, exact missing mask, trace FIFO/full/wrap and independent audio delivery"); + check(callbackAllocations == 0, "trace capture, overflow and delivery perform no callback allocation"); + } + for (const auto [rate, origin] : std::array, 3>{{ + {2400000, 1}, {1843200, 5}, {225001, 48000}}}) { + auto aligned = std::make_unique(); + check(AetherSDR::rtl::RtlReceivePipelineTestAccess::commonLatticeOrigin(*aligned, rate, origin), + "new speaker epoch starts at the actual extractor origin without premature missing contributions"); + } measureParkAndResume(); measureSingleFmReceiver(T::Mode::Fm); measureSingleFmReceiver(T::Mode::Fmn); diff --git a/tests/rtl_receiver_registry_test.cpp b/tests/rtl_receiver_registry_test.cpp index 4015572656..722ea58b2b 100644 --- a/tests/rtl_receiver_registry_test.cpp +++ b/tests/rtl_receiver_registry_test.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include using Registry = AetherSDR::rtl::RtlReceiverRegistry; @@ -691,6 +692,58 @@ void retainedRfHistoryAndAdoption() check(stats->callbackDestruction == 0, "reused receiver never destructs on callback"); } +void actualExtractionFailure() +{ + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_trivially_copyable_v>); + Registry registry({1, 1, 2}); + const std::uint64_t session = registry.beginSession(capture()); + const auto handle = registry.reserveSlot(); + if (!handle) { check(false, "extraction failure handle"); return; } + Registry::ReceiverSpec desired = spec(*handle); + desired.extractRf = true; + auto reader = registry.attachReader(); + check(registry.submit(capture(), std::span(&desired, 1)) == Registry::Result::Accepted, + "real extraction failure fixture admitted"); + check(ready(registry, 1), "real extraction failure fixture prepared"); + struct FailureProbe final : Registry::BlockProcessor, Registry::AudioSink { + bool valid = false; + int audioCalls = 0; + void audioBlock(const Registry::ReceiverSpec&, std::uint64_t, std::span, + std::span, bool) noexcept override { ++audioCalls; } + void process(const Registry::SampleBlock& value, std::span views) noexcept override + { + if (views.size() != 1) { return; } + Registry::Receiver& receiver = *views[0].receiver; + if (receiver.processingFailure() || !receiver.processCapture(value, *this)) { return; } + Registry::SampleBlock gap = value; + gap.firstSample = 90; + gap.discontinuity = false; + if (receiver.processCapture(gap, *this)) { return; } + const auto failure = receiver.processingFailure(); + if (!failure || failure->reason != Registry::ProcessingFailureReason::Extraction + || !failure->extraction + || failure->extraction->reason != AetherSDR::rtl::RtlRfExtractor::FailureReason::CapturePositionMismatch + || !failure->hasExpectedCaptureFirst || failure->expectedCaptureFirst != 89 + || failure->captureFirst != 90 || failure->captureFrames != 64 + || !failure->hasIqFirst || failure->iqFirst != 1 || failure->iqFrames != 0) { return; } + gap.firstSample = 89; + if (receiver.processCapture(gap, *this)) { return; } + // A different later failure must not replace the original cause. + if (receiver.processIq({}, {}) != WdspChannel::ProcessResult::InvalidBuffer) { return; } + valid = receiver.processingFailure() == failure && audioCalls == 0; + } + } probe; + Registry::SampleBlock delivery = block(capture(), 25); + delivery.session = session; + check(countedProcessBlock(reader, probe, delivery) && probe.valid, + "production receiver retains exact extraction failure across withdrawal and a different later error"); + check(totalCallbackAllocations == 0, "failure observation is allocation-free on acquisition"); + reader.stop(); + check(until(registry, [](const auto& status) { return status.residentReceivers == 0; }), + "failed extraction receiver retires off acquisition"); +} + void actualPreparedDsp() { Registry registry({1, 1, 2}); // production factory, real reserved WDSP channel @@ -707,11 +760,17 @@ void actualPreparedDsp() { std::array silence {}; if (views.size() != 1) { return; } + if (views[0].receiver->processingFailure()) { return; } if (views[0].receiver->processIq({}, silence) != WdspChannel::ProcessResult::InvalidBuffer) { return; } + const auto failure = views[0].receiver->processingFailure(); + if (!failure || failure->reason != Registry::ProcessingFailureReason::DspProcess + || failure->processResult != WdspChannel::ProcessResult::InvalidBuffer + || failure->hasIqFirst || failure->hasExpectedCaptureFirst || failure->extraction) { return; } const WdspChannel::ProcessResult result = views[0].receiver->processIq(silence, silence); valid = (result == WdspChannel::ProcessResult::Ok || result == WdspChannel::ProcessResult::Underrun) && - views[0].receiver->left().size() == 1024 && views[0].receiver->right().size() == 1024; + views[0].receiver->left().size() == 1024 && views[0].receiver->right().size() == 1024 + && views[0].receiver->processingFailure() == failure; } } probe; Registry::SampleBlock delivery = block(); @@ -747,6 +806,7 @@ int main(int argc, char** argv) reconnectAndRegistryBound(); drain(); retainedRfHistoryAndAdoption(); drain(); actualPreparedDsp(); drain(); + actualExtractionFailure(); drain(); check(totalCallbackAllocations == 0, "all acquisition callback paths remained free of ordinary C++ allocations"); std::cout << "RTL receiver registry: " << checks << " checks, " << failures << " failures\n"; return failures == 0 ? 0 : 1; diff --git a/tests/rtl_rf_extractor_test.cpp b/tests/rtl_rf_extractor_test.cpp index c689dfd1db..986c4d35f3 100644 --- a/tests/rtl_rf_extractor_test.cpp +++ b/tests/rtl_rf_extractor_test.cpp @@ -1,11 +1,14 @@ #include "core/backends/rtl/RtlRfExtractor.h" #include "CallbackAllocationProbe.h" +#include "CDSPResampler.h" +#include #include #include #include #include #include +#include #include using Extractor = AetherSDR::rtl::RtlRfExtractor; @@ -58,8 +61,163 @@ static double toneMagnitude(std::span samples, double hz) } return std::abs(sum) / samples.size(); } +static void wfmUsbCallbackBursts() +{ + constexpr std::size_t kUsbIqFrames = 8192; // 16384 interleaved RTL U8 bytes. + constexpr std::size_t kWfmIqBlock = 2048; + constexpr int kWfmIqRate = 384000; + constexpr std::array rates{ + 225001, 250000, 300000, 1000000, 1536000, 1843200, 2000000, 2400000, 3000000}; + static_assert(kUsbIqFrames % Extractor::kInputChunk == 0); + struct CountingSink final : Extractor::Sink { + std::size_t callbackBlocks = 0; + std::uint64_t next = 0; + bool continuous = true; + bool iqBlock(std::span i, std::span q, + std::uint64_t first) noexcept override + { + if (i.size() != kWfmIqBlock || q.size() != i.size() || first != next || first % 8 != 0) { + continuous = false; + return false; + } + ++callbackBlocks; + next += i.size(); + return true; + } + }; + std::array, kUsbIqFrames> input; + input.fill({0.25f, -0.125f}); + for (const int rate : rates) { + const Extractor::Config cfg{{1, 1, 100e6, double(rate), 0.45 * rate, 0.45 * rate}, + {0, 100e6, -90000, 90000, 0, 1000, 1000}, kWfmIqRate, kWfmIqBlock, 48000}; + Extractor extractor(cfg); + check(extractor.valid(), "WFM callback observation uses an admitted full passband"); + if (!extractor.valid()) { continue; } + + // Same converter recipe as the production extractor/Resampler. r8brain + // composes each stage's getMaxOutLen() into this prepared bound; it is + // independent of startup state and of the observations below. A full + // USB callback performs 32 conversions, even with a partial input + // chunk already staged, and can begin with 2047 staged output frames. + r8b::CDSPResampler24 converterBound(rate, kWfmIqRate, Extractor::kInputChunk, 10.0); + const int maxChunkOutput = converterBound.getMaxOutLen(Extractor::kInputChunk); + check(maxChunkOutput > 0 && maxChunkOutput <= 16384, "converter output bound fits extractor storage"); + if (maxChunkOutput <= 0 || maxChunkOutput > 16384) { continue; } + const std::size_t boundBlocks = (kWfmIqBlock - 1 + + (kUsbIqFrames / Extractor::kInputChunk) * std::size_t(maxChunkOutput)) / kWfmIqBlock; + CountingSink sink; + std::size_t callbacks = 0; + std::size_t maxBlocks = 0; + std::uint64_t maxCaptureFirst = 0; + std::uint64_t captureFrames = 0; + bool accepted = true; + // Two seconds rounded up to whole production USB callbacks. This is + // sample time, with no sleeps, elapsed-time assertions, WDSP or radio. + while (captureFrames < 2 * std::uint64_t(rate)) { + sink.callbackBlocks = 0; + inCallback = true; + accepted = extractor.process(cfg.capture, captureFrames, input, sink); + inCallback = false; + if (!accepted) { break; } + if (sink.callbackBlocks > maxBlocks) { + maxBlocks = sink.callbackBlocks; + maxCaptureFirst = captureFrames; + } + ++callbacks; + captureFrames += input.size(); + } + check(accepted && sink.continuous && !extractor.withdrawn(), + "WFM USB callbacks preserve exact fixed-block IQ and final-audio positions"); + check(sink.next > 0 && sink.next == extractor.outputFrames(), "WFM burst observation emitted real blocks"); + check(maxBlocks <= boundBlocks, "observed callback burst respects composed converter bound"); + // This bound covers ONE callback, not consecutive queued callbacks + // without WDSP progress, and is not a realtime/latency qualification. + std::printf("WFM_EXTRACTOR_BURST rate=%d callbacks=%zu captureFrames=%llu iqFrames=%llu " + "maxBlocks=%zu maxCaptureFirst=%llu chunkOutputBound=%d callbackBlockBound=%zu " + "maxAudioFrames=%zu audioFrameBound=%zu continuous=%d\n", + rate, callbacks, static_cast(captureFrames), + static_cast(sink.next), maxBlocks, + static_cast(maxCaptureFirst), maxChunkOutput, boundBlocks, + maxBlocks * 256, boundBlocks * 256, accepted && sink.continuous); + } +} +static void firstFailureObservation() +{ + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_trivially_copyable_v>); + struct RejectingSink final : Extractor::Sink { + int calls = 0; + bool iqBlock(std::span, std::span, std::uint64_t) noexcept override + { ++calls; return false; } + } sink; + const auto process = [&sink](Extractor& extractor, std::uint64_t first, + std::span> input, bool discontinuity = false) { + inCallback = true; + const bool result = extractor.process(config().capture, first, input, sink, discontinuity); + inCallback = false; + return result; + }; + std::array, 64> shortInput{}; + Extractor gap(config()); + check(!gap.failure(), "new extractor has no failure observation"); + check(process(gap, 25, shortInput), "nonzero origin starts without a complete DSP block"); + check(!process(gap, 90, shortInput) && gap.withdrawn(), "one missing capture sample still withdraws"); + const auto gapFailure = gap.failure(); + check(gapFailure && gapFailure->reason == Extractor::FailureReason::CapturePositionMismatch + && gapFailure->hasExpectedCaptureFirst && gapFailure->expectedCaptureFirst == 89 + && gapFailure->captureFirst == 90 && gapFailure->captureFrames == 64 + && gapFailure->hasIqFirst && gapFailure->iqFirst == 1 && gapFailure->iqFrames == 0, + "gap observation names exact expected/actual capture and next IQ positions"); + check(!process(gap, 89, shortInput) && gap.failure() == gapFailure, + "withdrawn retry cannot erase or replace the first gap"); + + Extractor discontinuous(config()); + check(process(discontinuous, 25, shortInput), "explicit-discontinuity fixture starts"); + check(!process(discontinuous, 89, shortInput, true), "explicit discontinuity remains a refusal"); + check(discontinuous.failure() + && discontinuous.failure()->reason == Extractor::FailureReason::Discontinuity + && discontinuous.failure()->captureFirst == discontinuous.failure()->expectedCaptureFirst, + "explicit discontinuity differs from a positional gap"); + + Extractor invalid(config()); + shortInput[3] = {std::numeric_limits::quiet_NaN(), 0}; + check(!process(invalid, 50, shortInput), "nonfinite input remains refused"); + const auto invalidFailure = invalid.failure(); + check(invalidFailure && invalidFailure->reason == Extractor::FailureReason::NonFiniteInput + && invalidFailure->captureFirst == 50 && invalidFailure->captureFrames == 64 + && !invalidFailure->hasExpectedCaptureFirst && !invalidFailure->hasIqFirst, + "pre-conversion failure does not invent an IQ or previous capture position"); + shortInput[3] = {}; + check(!process(invalid, 50, shortInput) && invalid.failure() == invalidFailure, + "later finite input retains the nonfinite first cause"); + + Extractor overflow(config()); + check(!process(overflow, std::numeric_limits::max() - 31, shortInput), + "capture end overflow remains refused"); + check(overflow.failure() && overflow.failure()->reason == Extractor::FailureReason::CapturePositionOverflow, + "capture overflow has its own precise reason"); + + Extractor empty(config()); + check(!process(empty, 10, {}) && empty.failure() + && empty.failure()->reason == Extractor::FailureReason::EmptyInput, + "empty input has a distinct first-failure reason"); + + Extractor rejected(config()); + std::vector> input(65536); + check(!process(rejected, 0, input) && rejected.withdrawn(), "sink rejection still withdraws immediately"); + const auto rejectedFailure = rejected.failure(); + check(rejectedFailure && rejectedFailure->reason == Extractor::FailureReason::SinkRejected + && rejectedFailure->captureFirst == 0 && rejectedFailure->captureFrames == input.size() + && rejectedFailure->hasIqFirst && rejectedFailure->iqFirst == 0 && rejectedFailure->iqFrames == 1024 + && rejected.outputFrames() == 0 && sink.calls == 1, + "sink failure names the attempted full IQ block without counting it as emitted"); + check(!process(rejected, input.size(), shortInput) && rejected.failure() == rejectedFailure && sink.calls == 1, + "withdrawal preserves sink cause and never calls it again"); +} int main() { + firstFailureObservation(); + wfmUsbCallbackBursts(); Extractor extractor(config()); check(extractor.valid(), "valid readback and full passband prepare extraction"); std::vector> input(65536, {0.5f, 0}); @@ -118,7 +276,11 @@ int main() auto stale = config().capture; ++stale.generation; check(!changed.process(stale, 0, input, output) && !changed.withdrawn(), "stale capture generation cannot consume current history"); + const auto mismatch = changed.failure(); + check(mismatch && mismatch->reason == Extractor::FailureReason::CaptureMismatch, + "non-withdrawing metadata rejection is observable"); check(changed.process(config().capture, 0, input, output), "matching generation still works"); + check(changed.failure() == mismatch, "success does not reset the first observation"); check(!changed.process(config().capture, input.size() + 1, input, output) && changed.withdrawn(), "gap withdraws history without realtime reset"); check(!changed.process(config().capture, input.size(), input, output), "withdrawn extractor cannot resume stale history"); diff --git a/tests/rtl_runtime_settings_test.cpp b/tests/rtl_runtime_settings_test.cpp index 1ab8a4d527..e9aea2d84a 100644 --- a/tests/rtl_runtime_settings_test.cpp +++ b/tests/rtl_runtime_settings_test.cpp @@ -8,6 +8,9 @@ #include "core/backends/rtl/RtlSdrBackend.h" #include #include +#include +#include +#include #include using namespace AetherSDR; namespace AetherSDR { @@ -49,14 +52,33 @@ struct RtlCaptureBackendTestAccess { backend.m_receiverCapacity = 4; backend.m_capture = RtlCaptureTransaction({8, 4}); backend.m_requested.hardware = {100000000, 2400000, 0, 0, 0, 240}; - backend.m_requested.receivers = {{{0, 100000000, -100000, 100000, 0, 0, 0}, RtlCaptureTransaction::Mode::Wfm}}; + const double wfmGuard = RtlReceivePipeline::kQualifiedWfmEnabled ? 3000 : 0; + backend.m_requested.receivers = {{{0, 100000000, -100000, 100000, 0, wfmGuard, wfmGuard}, RtlCaptureTransaction::Mode::Wfm}}; backend.startCapture(std::make_unique(std::move(device), nullptr, 4)); } + static void startRestored(RtlSdrBackend& backend, std::unique_ptr device) + { + // Keep applyRestoredState's real bootstrap choice. Replace only the USB + // entry, then use the production transaction, worker and restore path. + backend.m_requested = {}; + backend.m_requested.hardware = {static_cast(backend.m_panCenterHz), + backend.m_sampleRateHz, 0, 0, backend.m_ppmCorrection, 240}; + backend.m_requested.dcSuppression = backend.m_dcSuppression; + backend.m_requested.receivers = {backend.initialReceiver()}; + backend.startCapture(std::make_unique(std::move(device))); + } + static bool bootstrapMuted(const RtlSdrBackend& backend) + { return backend.m_requested.receivers.front().audioMute; } static bool restored(const RtlSdrBackend& backend) { return backend.m_settingsActive && backend.m_restoreAttempted && !backend.m_restoreToken.revision && backend.m_lastPublished && backend.m_lastPublished->receivers.front().passband.stableId == 1; } + static bool restoreFinished(const RtlSdrBackend& backend) + { + return backend.m_settingsActive && backend.m_restoreAttempted && !backend.m_restoreToken.revision + && backend.m_lastPublished && !backend.m_capture.busy(); + } static std::optional state(const RtlSdrBackend& backend) { return backend.m_lastPublished; } static bool busy(const RtlSdrBackend& backend) { return backend.m_capture.busy(); } @@ -73,6 +95,180 @@ static QJsonObject legacy() {"filterHighHz", 8000}, {"sampleRateHz", 2400000}, {"ext", QJsonObject{{"rfGain", QJsonObject{{"gainDb", 24}}}}}, {"future", "preserve"}}; } +static void reconnectAtCaptureLimits() +{ + struct SavedCapture { + std::uint32_t centerHz; + std::uint32_t rateHz; + const char* mode; + int filterHz; + }; + // The first two need a smaller provisional receiver at the lowest legal + // rate. The others straddle the WFM RF+transition guard's lower RF edge. + constexpr std::array cases{{ + {100'000'000, 225'001, "WFM", 90'000}, + {100'000'000, 225'001, "FM", 7'000}, + {100'000, 2'400'000, "WFM", 90'000}, + {102'999, 2'400'000, "FM", 7'000}, + {103'000, 2'400'000, "WFM", 90'000} + }}; + for (const SavedCapture& capture : cases) { + const QString serial = QStringLiteral("bootstrap-%1-%2-%3") + .arg(capture.centerHz).arg(capture.rateHz).arg(QLatin1String(capture.mode)); + const RadioSettingsScope scope("rtl", serial); + RtlSliceSettings::Slice saved; + saved.id = 1; saved.frequencyHz = capture.centerHz; + saved.mode = QLatin1String(capture.mode); + saved.filterLowHz = -capture.filterHz; saved.filterHighHz = capture.filterHz; + saved.wfmDeemphasisUs = 50; + saved.wfmForceMono = true; + check(RtlSliceSettings(scope).patch(capture.centerHz, capture.rateHz, {saved}), + "capture-limit reconnect fixture preserves a valid saved receiver"); + const QJsonObject original = scope.featureExact("RtlSlices"); + rtl::RtlSdrBackend receiver; + for (int attempt = 0; attempt < 2; ++attempt) { + std::fprintf(stderr, "RTL_RECONNECT center=%u rate=%u mode=%s attempt=%d\n", + capture.centerHz, capture.rateHz, capture.mode, attempt); + receiver.configureSettingsScope(scope, {serial, false}); + receiver.applyRestoredState({}); + check(receiver.currentOperatingState().sampleRateHz == int(capture.rateHz), + "real bootstrap retains the saved capture rate before USB startup"); + auto device = std::make_shared(); + rtl::RtlCaptureBackendTestAccess::startRestored(receiver, + std::make_unique(device)); + check(rtl::RtlCaptureBackendTestAccess::bootstrapMuted(receiver), + "saved restoration keeps the provisional speaker muted until adoption"); + device->releaseReadback(); + QElapsedTimer deadline; deadline.start(); + int queued = 0; + qint64 nextBlockMs = 0; + const int callbackMs = static_cast(std::ceil(8192.0 * 1000 / capture.rateHz)); + while (deadline.elapsed() < 8000 && !device->destroyed + && !(rtl::RtlCaptureBackendTestAccess::restored(receiver) + && !rtl::RtlCaptureBackendTestAccess::busy(receiver))) { + // Never burst queued callbacks while WDSP is still preparing. + if (device->starts > 0 && device->callbacks >= queued + && deadline.elapsed() >= nextBlockMs) { + device->block(); ++queued; + nextBlockMs = deadline.elapsed() + callbackMs; + } + QCoreApplication::processEvents(); QThread::msleep(1); + } + const auto accepted = rtl::RtlCaptureBackendTestAccess::state(receiver); + const bool restored = receiver.isConnected() + && rtl::RtlCaptureBackendTestAccess::restored(receiver) + && !rtl::RtlCaptureBackendTestAccess::busy(receiver); + check(restored && accepted && accepted->hardware.centerHz == capture.centerHz + && accepted->hardware.sampleRateHz == capture.rateHz + && accepted->receivers.size() == 1 && accepted->receivingIds == std::vector{1}, + "low-rate and RF-boundary reconnect preserves capture and restores the receiving sparse slice"); + if (restored && accepted) { + const auto& actual = accepted->receivers.front(); + const auto expectedMode = saved.mode == QLatin1String("WFM") + ? rtl::RtlCaptureTransaction::Mode::Wfm : rtl::RtlCaptureTransaction::Mode::Fm; + check(actual.mode == expectedMode && actual.passband.carrierHz == saved.frequencyHz + && actual.passband.filterLowHz == saved.filterLowHz + && actual.passband.filterHighHz == saved.filterHighHz + && actual.wfmDeemphasisUs == 50 && actual.wfmForceMono + && actual.audioMute == saved.audioMute, + "provisional bootstrap does not resize or replace saved receiver choices"); + check(device->starts == 1 && device->writes == 6 && device->cancels == 0, + "accepted saved receivers restore without a second hardware capture transaction"); + check(receiver.storeOperatingState(scope, receiver.currentOperatingState()).value_or(false), + "accepted restored receiver remains owned by the RTL settings writer"); + } + check(scope.featureExact("RtlSlices") == original, + "reconnect and accepted persistence preserve the complete saved slice document"); + receiver.disconnectRadio(); + } + } +} +static void unsupportedStoredWfmRemainsIntact() +{ + if (!rtl::RtlReceivePipeline::kQualifiedWfmEnabled) { return; } + const QString serial = QStringLiteral("legacy-wfm-narrow-filter"); + const RadioSettingsScope scope("rtl", serial); + RtlSliceSettings::Slice saved; + // Old WFM ignored these stored RF edges. They must not acquire new RF + // meaning or be silently migrated when the native decoder is enabled. + saved.id = 0; saved.frequencyHz = 100'100'000; saved.mode = QStringLiteral("WFM"); + saved.filterLowHz = -4000; saved.filterHighHz = 4000; + saved.audioMute = false; saved.audioGain = 23; saved.audioPan = 71; + saved.agcMode = QStringLiteral("slow"); saved.agcThreshold = 17; + saved.squelchEnabled = true; saved.squelchLevel = 42; + check(RtlSliceSettings(scope).patch(100'000'000, 2'400'000, {saved}), + "seed schema-valid legacy WFM with unsupported native RF edges"); + QJsonObject original = scope.featureExact("RtlSlices"); + original.insert(QStringLiteral("futureDocument"), QJsonObject{{"keep", "document extension"}}); + QJsonObject slices = original.value(QStringLiteral("slices")).toObject(); + QJsonObject entry = slices.value(QStringLiteral("0")).toObject(); + entry.remove(QStringLiteral("wfmDeemphasisUs")); // Genuine old schema-one entry. + entry.insert(QStringLiteral("futureSlice"), QJsonObject{{"keep", "slice extension"}}); + QJsonObject squelch = entry.value(QStringLiteral("squelch")).toObject(); + squelch.insert(QStringLiteral("futureSquelch"), 91); + entry.insert(QStringLiteral("squelch"), squelch); + slices.insert(QStringLiteral("0"), entry); + original.insert(QStringLiteral("slices"), slices); + check(scope.setFeature("RtlSlices", RtlSliceSettings::kSchemaVersion, original), + "retain old optional-field absence and unknown members in the legacy document"); + + RadioModel model; + check(model.rebuildBackendForTest("rtl"), "legacy WFM restore uses the real model settings owner"); + auto& receiver = *static_cast(model.backend()); + QStringList warnings; + QObject::connect(&receiver, &IRadioBackend::configurationWarning, &model, + [&](const QString& warning) { warnings.append(warning); }); + RadioModelSliceLifecycleTestAccess::restore(model, serial); + check(receiver.currentOperatingState().mode == QLatin1String("AM"), + "real restored-state application chooses the provisional AM bootstrap"); + auto device = std::make_shared(); + rtl::RtlCaptureBackendTestAccess::startRestored(receiver, + std::make_unique(device)); + check(rtl::RtlCaptureBackendTestAccess::bootstrapMuted(receiver), + "unsupported saved WFM never starts audible provisional AM"); + device->releaseReadback(); + QElapsedTimer deadline; deadline.start(); + int queued = 0; + qint64 nextBlockMs = 0; + while (deadline.elapsed() < 8000 && !device->destroyed + && !(device->starts > 0 && rtl::RtlCaptureBackendTestAccess::restoreFinished(receiver))) { + if (device->starts > 0 && device->callbacks >= queued && deadline.elapsed() >= nextBlockMs) { + device->block(); ++queued; + nextBlockMs = deadline.elapsed() + 4; // 8192 / 2.4 MS/s, rounded up. + } + QCoreApplication::processEvents(); QThread::msleep(1); + } + const auto accepted = rtl::RtlCaptureBackendTestAccess::state(receiver); + check(receiver.isConnected() && rtl::RtlCaptureBackendTestAccess::restoreFinished(receiver) + && accepted && accepted->hardware.centerHz == 100'000'000 + && accepted->hardware.sampleRateHz == 2'400'000 + && accepted->receivers.size() == 1 + && accepted->receivers.front().passband.stableId == 0 + && accepted->receivers.front().mode == rtl::RtlCaptureTransaction::Mode::Am + && accepted->receivers.front().audioMute, + "unsupported WFM remains unadopted while the confirmed provisional AM receiver stays muted"); + bool explained = false; + for (const QString& warning : warnings) { + const QString text = warning.toLower(); + explained |= text.contains(QStringLiteral("wfm")) && text.contains(QStringLiteral("filter")) + && (text.contains(QStringLiteral("saved")) || text.contains(QStringLiteral("stored"))) + && (text.contains(QStringLiteral("unsupported")) || text.contains(QStringLiteral("not supported"))); + } + check(explained, "typed configuration warning explains the unsupported stored WFM filter"); + check(device->starts == 1 && device->writes == 6 && device->cancels == 0, + "unsupported stored WFM does not trigger a replacement hardware capture"); + check(scope.featureExact("RtlSlices") == original, + "bootstrap and refused WFM restore preserve the complete saved document"); + check(receiver.storeOperatingState(scope, receiver.currentOperatingState()).value_or(false), + "RTL settings owner handles the accepted provisional-state store"); + RadioModelSliceLifecycleTestAccess::forcePendingFlush(model); + check(scope.featureExact("RtlSlices") == original, + "explicit store and model flush cannot overwrite or resize the omitted legacy WFM entry"); + RadioModelSliceLifecycleTestAccess::queueDisconnectFlush(model); + receiver.disconnectRadio(); + check(!receiver.isConnected() && scope.featureExact("RtlSlices") == original, + "disconnect flush preserves all legacy WFM choices and unknown document members"); +} int main(int argc, char** argv) { TestSettingsProfile profile(QStringLiteral("rtl-runtime-settings")); @@ -260,6 +456,8 @@ int main(int argc, char** argv) "drag persists the new capture center without changing saved slice RF or monitor settings"); live.disconnectRadio(); } + reconnectAtCaptureLimits(); + unsupportedStoredWfmRemainsIntact(); std::fprintf(stderr, "rtl_runtime_settings_test: %d failures\n", failures); return failures ? 1 : 0; } diff --git a/tests/rtl_slice_settings_test.cpp b/tests/rtl_slice_settings_test.cpp index 92bbc2df63..5c51c7fd27 100644 --- a/tests/rtl_slice_settings_test.cpp +++ b/tests/rtl_slice_settings_test.cpp @@ -188,6 +188,63 @@ int main(int argc, char** argv) check(!RtlSliceSettings::decode(bad, decoded, reason) && decoded.captureCenterHz == 42, "malformed field rejects entire document without partial output"); } + { + const RadioSettingsScope wfmScope("rtl", "wfm-deemphasis"); + RtlSliceSettings wfm(wfmScope); + auto selected = slice(3); + selected.mode = QStringLiteral("WFM"); + selected.filterLowHz = -90000; selected.filterHighHz = 90000; + selected.wfmDeemphasisUs = 50; + selected.wfmForceMono = true; + check(wfm.patch(100'000'000, 2'400'000, {selected}) + && wfm.load().document.slices.value(3).wfmDeemphasisUs == 50, + "accepted 50 us deemphasis persists per stable slice"); + check(wfm.load().document.slices.value(3).wfmForceMono, + "accepted forced Mono persists under the existing per-slice owner"); + selected.mode = QStringLiteral("FM"); + check(wfm.patch(100'000'000, 2'400'000, {selected}) + && wfm.load().document.slices.value(3).wfmDeemphasisUs == 50, + "leaving WFM retains the accepted broadcast deemphasis choice"); + selected.mode = QStringLiteral("WFM"); selected.wfmDeemphasisUs = 75; + check(wfm.patch(100'000'000, 2'400'000, {selected}) + && wfm.load().document.slices.value(3).wfmDeemphasisUs == 75, + "accepted 75 us selection replaces only its slice setting"); + QJsonObject old = wfmScope.featureExact(feature); + QJsonObject oldEntries = old.value("slices").toObject(); + QJsonObject oldEntry = oldEntries.value("3").toObject(); + oldEntry.remove("wfmDeemphasisUs"); oldEntry.remove("wfmForceMono"); oldEntries.insert("3", oldEntry); + old.insert("slices", oldEntries); + RtlSliceSettings::Document restored; + check(RtlSliceSettings::decode(old, restored, reason) + && restored.slices.value(3).wfmDeemphasisUs == 75, + "existing schema-one documents retain the legacy 75 us response"); + check(!restored.slices.value(3).wfmForceMono, "old documents retain Auto Stereo"); + for (const QJsonValue& invalid : {QJsonValue(0), QJsonValue(1), QJsonValue("false"), + QJsonValue(QJsonValue::Null), QJsonValue(QJsonObject{})}) { + QJsonObject malformed = old; + QJsonObject items = oldEntries, item = oldEntry; + item.insert("wfmForceMono", invalid); items.insert("3", item); + malformed.insert("slices", items); restored.captureCenterHz = 42; + check(!RtlSliceSettings::decode(malformed, restored, reason) && restored.captureCenterHz == 42, + "malformed Mono selection refuses the whole document without partial adoption"); + } + for (const QJsonValue& invalid : {QJsonValue(0), QJsonValue(60), QJsonValue(75.5), + QJsonValue("50"), QJsonValue(QJsonValue::Null)}) { + QJsonObject malformed = old; + QJsonObject items = oldEntries, item = oldEntry; + item.insert("wfmDeemphasisUs", invalid); items.insert("3", item); + malformed.insert("slices", items); + restored.captureCenterHz = 42; + check(!RtlSliceSettings::decode(malformed, restored, reason) + && restored.captureCenterHz == 42, + "invalid deemphasis refuses the complete document atomically"); + } + const auto beforeInvalid = wfmScope.featureExact(feature); + selected.wfmDeemphasisUs = 60; + check(!wfm.patch(100'000'000, 2'400'000, {selected}) + && wfmScope.featureExact(feature) == beforeInvalid, + "invalid deemphasis cannot overwrite accepted settings"); + } QJsonObject bad = valid; entries = bad.value("slices").toObject(); entries.insert("00", entries.take("0")); diff --git a/tests/rtl_usb_controls_test.cpp b/tests/rtl_usb_controls_test.cpp index 0e560e31e0..8da51b3d30 100644 --- a/tests/rtl_usb_controls_test.cpp +++ b/tests/rtl_usb_controls_test.cpp @@ -79,7 +79,8 @@ int main() T owner({8, 1}); owner.beginSession(); T::Desired desired; desired.hardware = device.hardware; - desired.receivers = {{{0, 100'000'000, -100'000, 100'000, 0, 0, 0}, T::Mode::Wfm}}; + const double wfmGuard = AetherSDR::rtl::RtlReceivePipeline::kQualifiedWfmEnabled ? 3000 : 0; + desired.receivers = {{{0, 100'000'000, -100'000, 100'000, 0, wfmGuard, wfmGuard}, T::Mode::Wfm}}; check(bool(owner.submit(desired)), "initial request admitted"); auto work = owner.takeWork(); check(work && owner.complete(T::execute(*work, usb)) == T::Completion::Published, diff --git a/tests/rtl_wfm_pipeline_test.cpp b/tests/rtl_wfm_pipeline_test.cpp new file mode 100644 index 0000000000..4dcc7b2bac --- /dev/null +++ b/tests/rtl_wfm_pipeline_test.cpp @@ -0,0 +1,638 @@ +#include "core/backends/rtl/RtlReceivePipeline.h" +#include "core/backends/rtl/RtlRfExtractor.h" +#include "core/backends/rtl/RtlSdrDdc.h" +#include "core/dsp/WdspChannel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using Pipeline = AetherSDR::rtl::RtlReceivePipeline; +using Extractor = AetherSDR::rtl::RtlRfExtractor; +using Transaction = AetherSDR::rtl::RtlCaptureTransaction; +using namespace std::chrono_literals; +namespace { +constexpr double kTau = 2 * std::numbers::pi; +int failures = 0; +void check(bool condition, const char* description) +{ + if (!condition) { ++failures; std::fprintf(stderr, "FAIL: %s\n", description); } +} +bool ready(Pipeline& pipeline) +{ + const auto deadline = std::chrono::steady_clock::now() + 20s; + while (std::chrono::steady_clock::now() < deadline) { + const auto result = pipeline.service(); + if (result == Pipeline::Preparation::Ready) { return true; } + if (result == Pipeline::Preparation::Failed) { return false; } + std::this_thread::sleep_for(1ms); + } + return false; +} +Transaction::State state() +{ + Transaction::State result; + result.token = {47, 1}; + result.hardware.centerHz = 100'000'000; + result.capture = {47, 1, 100'000'000, 2'400'000, 1'080'000, 1'080'000}; + result.receivers = {{{3, 100'000'000, -100'000, 100'000, 0, 3000, 3000}, Transaction::Mode::Wfm}}; + result.receivingIds = {3}; + return result; +} +class ClockSink final : public Extractor::Sink { +public: + bool iqBlock(std::span i, std::span q, + std::uint64_t position) noexcept override + { + check(i.size() == 2048 && q.size() == i.size(), "WFM extracted IQ uses complete paired blocks"); + check(position % 8 == 0, "384 kHz IQ starts on the final 48 kHz sample lattice"); + if (blocks == 0) { first = position; } + else { check(position == next, "extracted IQ positions advance without partition-dependent rounding"); } + next = position + i.size(); + ++blocks; + return true; + } + std::uint64_t first = 0; + std::uint64_t next = 0; + unsigned blocks = 0; +}; +void outputClockAlignment() +{ + // At 2.4 MS/s, capture sample 25 is on the 384 kHz lattice but halfway + // between final 48 kHz frames. Coprime rates also exercise the bounded wait. + for (std::uint64_t rate : {225001u, 1843200u, 2400000u, 2400001u}) { + const std::uint64_t first = 25; + const std::uint64_t period = rate / std::gcd(rate, std::uint64_t{48000}); + const std::uint64_t start = first + (period - first % period) % period; + Extractor::Config config{{1, 1, 100'000'000, double(rate), rate * 0.45, rate * 0.45}, + {3, 100'000'000, -100'000, 100'000, 0, 1000, 1000}, 384000, 2048, 48000}; + Extractor extractor(config); + check(extractor.valid(), "WFM extraction accepts achieved unusual rates and bounded interpolation"); + if (!extractor.valid()) { continue; } + ClockSink sink; + std::array, 257> input; + input.fill({0.3f, 0}); + const std::uint64_t end = start + rate / 4; + for (std::uint64_t position = first; position < end;) { + const std::size_t count = std::min(input.size(), end - position); + check(extractor.process(config.capture, position, std::span(input).first(count), sink), + "partitioned input preserves the capture sample clock"); + position += count; + } + check(sink.blocks > 4, "clock fixture observes actual paired resampler output"); + check(sink.first == start / period * (384000 / std::gcd(rate, std::uint64_t{48000})), + "first IQ position is the exact capture-derived origin"); + check(sink.first / 8 == start / period * (48000 / std::gcd(rate, std::uint64_t{48000})), + "audio origin converts exactly to the shared 48 kHz timeline"); + check(!extractor.process(config.capture, end + 1, input, sink), + "a capture gap withdraws the extractor instead of relabeling old history"); + } +} + +struct Audio { + std::vector left; + std::vector right; + std::uint64_t next = 0; + std::uint64_t instance = 0; + std::uint64_t receiverEpoch = 0; + std::uint64_t captureEpoch = 0; + bool seen = false; + bool stereo = false; + bool pilot = false; + bool discontinuity = false; + double peak = 0; + std::size_t clipped = 0; + void accept(const Pipeline::Packet& packet) + { + check(packet.sampleRateHz == 48000 && packet.channelCount == 2, + "native audio declares 48 kHz paired stereo"); + check(packet.frames == (packet.slot < 0 ? 128u : 256u), + "speaker and slice packet frame counts describe their actual output blocks"); + if (seen) { check(packet.firstSample == next, "native output positions are contiguous at 48 kHz"); } + else { + instance = packet.instance; + receiverEpoch = packet.receiverEpoch; + captureEpoch = packet.captureEpoch; + discontinuity = packet.discontinuity; + } + seen = true; + next = packet.firstSample + packet.frames; + stereo |= packet.wfmStereoDetected.value_or(false); + pilot |= packet.wfmReception && packet.wfmReception->pilotLocked; + for (std::size_t i = 0; i < packet.frames; ++i) { + const float l = packet.samples[2 * i]; + const float r = packet.samples[2 * i + 1]; + check(std::isfinite(l) && std::isfinite(r), "native WFM samples remain finite"); + left.push_back(l); right.push_back(r); + peak = std::max({peak, std::abs(double(l)), std::abs(double(r))}); + clipped += std::abs(l) >= 0.999999f || std::abs(r) >= 0.999999f; + } + } +}; +// Integrate the published broadcast multiplex analytically. No firmware peer: +// L=.35*sin(1k), R=.20*sin(2k), pilot=.1*sin(19k), difference at sin(38k). +// Pilot/subcarrier phase follows the same public broadcast reference as the +// low-level WDSP fixture; changing only one to cosine would test quadrature. +std::complex referenceIq(std::uint64_t position, double rate) +{ + const double time = position / rate; + const auto sineIntegral = [time](double hz) { return -std::cos(kTau * hz * time) / hz; }; + const auto cosineIntegral = [time](double hz) { return std::sin(kTau * hz * time) / hz; }; + const double phase = 75000 * (0.1575 * sineIntegral(1000) + 0.09 * sineIntegral(2000) + + 0.07875 * (cosineIntegral(37000) - cosineIntegral(39000)) + - 0.045 * (cosineIntegral(36000) - cosineIntegral(40000)) + 0.1 * sineIntegral(19000)); + return {float(0.3 * std::cos(phase)), float(0.3 * std::sin(phase))}; +} +std::array run(Pipeline& pipeline, const Transaction::State& current, + std::uint64_t& position, double seconds) +{ + std::array result; + for (Audio& audio : result) { + audio.left.reserve(std::size_t(seconds * 48000) + 1024); + audio.right.reserve(std::size_t(seconds * 48000) + 1024); + } + const auto started = std::chrono::steady_clock::now(); + const std::uint64_t origin = position; + const double rate = current.capture.achievedSampleRateHz; + const std::uint64_t end = position + std::uint64_t(seconds * rate); + std::array, 8192> iq; + while (position < end) { + const std::size_t count = std::min(iq.size(), end - position); + for (std::size_t i = 0; i < count; ++i) { iq[i] = referenceIq(position + i, rate); } + if (!pipeline.process(position, std::span(iq).first(count))) { + check(false, "WFM acquisition accepts continuous generated IQ"); break; + } + position += count; + Pipeline::Packet packet; + while (pipeline.takePacket(packet)) { + check(packet.token == current.token, "PCM and decoder status retain the accepted capture token"); + check(packet.slot == -1 || packet.slot == 3, "native routing preserves sparse slice identity"); + if (packet.slot < 0) { + check(!packet.wfmStereoDetected && !packet.wfmReception, + "speaker mix does not claim an independent receiver observation"); + } else { + // Prefilled D8 PCM can precede the first completed decoder + // observation. Absence is truthful; status must not be invented. + check(packet.wfmStereoDetected.has_value() == packet.wfmReception.has_value(), + "slice output status is published only with a decoder observation"); + if (packet.wfmReception) { + check(packet.wfmReception->valid && packet.wfmStereoDetected + == (packet.wfmReception->pilotLocked && !current.receivers[0].wfmForceMono), + "observed output status honors the accepted mono selection and independent pilot"); + } + } + result[packet.slot < 0 ? 1 : 0].accept(packet); + } + std::this_thread::sleep_until(started + std::chrono::microseconds( + std::uint64_t((position - origin) * 1'000'000.0 / rate))); + } + check(!pipeline.needsRepair(), "real WDSP processing does not withdraw the receiver"); + return result; +} +double amplitude(const std::vector& samples, double hz) +{ + constexpr std::size_t window = 48000; + if (samples.size() < window) { return 0; } + std::complex sum{}; + const std::size_t first = samples.size() - window; + for (std::size_t i = 0; i < window; ++i) { + const double phase = -kTau * hz * i / 48000; + sum += double(samples[first + i]) * std::complex(std::cos(phase), std::sin(phase)); + } + return 2 * std::abs(sum) / window; +} +void nativeRouting() +{ + auto pipeline = std::make_unique(1, true); + Transaction::State current = state(); + check(pipeline->prepare(current, true) && ready(*pipeline) && pipeline->adopt(), + "qualified WFM prepares and adopts the production native graph"); + if (pipeline->legacy()) { pipeline->stop(); return; } + std::uint64_t position = 25; + const auto output = run(*pipeline, current, position, 4.0); + for (const Audio& audio : output) { + const double left = amplitude(audio.left, 1000); + const double leftLeak = amplitude(audio.left, 2000); + const double right = amplitude(audio.right, 2000); + const double rightLeak = amplitude(audio.right, 1000); + std::printf("WFM_PIPELINE samples=%zu peak=%.6f L1k=%.6f L2k=%.6f R2k=%.6f R1k=%.6f clipped=%zu\n", + audio.left.size(), audio.peak, left, leftLeak, right, rightLeak, audio.clipped); + check(audio.left.size() > 3 * 48000 && audio.left.size() <= 4 * 48000, + "native duration follows the 48 kHz output clock"); + check(audio.discontinuity, "a newly installed WFM graph starts discontinuously"); + check(left > 0.001 && right > 0.001, "both reference stereo channels reach native output"); + check(left > 100 * leftLeak && right > 100 * rightLeak, + "native WFM preserves correct L/R with at least 40 dB separation"); + check(audio.peak < 1 && audio.clipped == 0, "normalization precedes the independent tap and final speaker limiter"); + } + check(output[0].stereo, "actual WDSP pilot acquisition reaches slice metadata"); + + current.token.revision++; + current.receivers[0].audioMute = true; + check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "monitor mute adopts without replacing the receiver"); + const auto muted = run(*pipeline, current, position, 0.5); + check(muted[0].peak > 0.001 && muted[1].peak == 0, "monitor mute silences speakers while the independent tap remains live"); + check(muted[0].receiverEpoch == output[0].receiverEpoch, "monitor edits retain the decoder epoch"); + + current.token.revision++; + current.receivers[0].audioMute = false; + current.receivers[0].wfmDeemphasisUs = 50; + check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "50 us deemphasis prepares a fresh immutable decoder recipe"); + const auto changed = run(*pipeline, current, position, 1.0); + check(changed[0].seen && changed[1].seen && changed[0].discontinuity && changed[1].discontinuity, + "deemphasis changes mark both slice and speaker discontinuities"); + check(changed[0].receiverEpoch != output[0].receiverEpoch && changed[1].captureEpoch != output[1].captureEpoch, + "deemphasis replaces both decoder and speaker epochs"); + + current.token.revision++; + current.receivers[0].wfmForceMono = true; + check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "Force Mono prepares an accepted decoder recipe"); + const auto mono = run(*pipeline, current, position, 4.0); + for (const Audio& audio : mono) { + check(audio.seen && audio.peak > 0.001 && audio.left == audio.right, + "Force Mono is identical nonzero L/R at independent tap and speaker"); + check(audio.discontinuity, "Force Mono retires both audio lifetimes"); + } + check(!mono[0].stereo && mono[0].pilot && mono[0].receiverEpoch != changed[0].receiverEpoch + && mono[1].captureEpoch != changed[1].captureEpoch, + "Force Mono reports mono output with real pilot detection while replacing decoder and speaker epochs"); + current.token.revision++; + current.receivers[0].wfmForceMono = false; + check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "Auto Stereo readopts"); + const auto automatic = run(*pipeline, current, position, 4.0); + check(amplitude(automatic[0].left, 1000) > 100 * amplitude(automatic[0].right, 1000) + && amplitude(automatic[1].right, 2000) > 100 * amplitude(automatic[1].left, 2000), + "Auto Stereo restores separated native tap and speaker audio"); + current.token.revision++; + current.receivingIds.clear(); + check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), "parking admits an empty active bank"); + const auto parked = run(*pipeline, current, position, 0.05); + check(!parked[0].seen && !parked[1].seen, "parked WFM emits neither stale audio nor pilot observations"); + current.token.revision++; + current.receivingIds = {3}; + Pipeline::Submission resume = Pipeline::Submission::RetryRetiringSlot; + const auto deadline = std::chrono::steady_clock::now() + 20s; + while ((resume == Pipeline::Submission::RetryRetiringSlot + || resume == Pipeline::Submission::RetryPlannerBusy) + && std::chrono::steady_clock::now() < deadline) { + resume = pipeline->prepareDetailed(current); + if (resume != Pipeline::Submission::Accepted) { + pipeline->service(); + std::this_thread::sleep_for(1ms); + } + } + check(resume == Pipeline::Submission::Accepted && ready(*pipeline) && pipeline->adopt(), + "resuming waits for safe retirement before constructing a fresh WFM receiver"); + const auto resumed = run(*pipeline, current, position, 1.5); + check(resumed[0].seen && resumed[0].instance != changed[0].instance + && resumed[0].discontinuity && resumed[1].discontinuity, + "resumed WFM cannot inherit the retired receiver's PCM or pilot lifetime"); + // A gap withdraws the immutable receiver. Repair has the same recipe, + // but a fresh decoder/filter history must also retire the speaker history. + std::array, 257> gapIq; + ++position; + for (std::size_t i = 0; i < gapIq.size(); ++i) { + gapIq[i] = referenceIq(position + i, current.capture.achievedSampleRateHz); + } + check(pipeline->process(position, gapIq) && pipeline->needsRepair(), + "capture gaps withdraw WFM and request the existing owner repair path"); + position += gapIq.size(); + Pipeline::Packet stale; + while (pipeline->takePacket(stale)) {} // consume only the old accepted revision + current.token.revision++; + check(pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(), + "an unchanged WFM recipe can repair its withdrawn decoder"); + const auto repaired = run(*pipeline, current, position, 1.0); + check(repaired[0].seen && repaired[1].seen + && repaired[0].receiverEpoch != resumed[0].receiverEpoch + && repaired[1].captureEpoch != resumed[1].captureEpoch + && repaired[0].discontinuity && repaired[1].discontinuity, + "WFM repair retires both decoder and speaker histories"); + const auto diagnostics = pipeline->diagnostics(); + check(diagnostics.droppedPackets == 0 && diagnostics.mixerRejectedBlocks == 0 + && diagnostics.mixerConfigurationFailures == 0, + "paced WFM integration keeps bounded queues and valid mixer positions"); + pipeline->stop(); +} +void workerUnderrunTrace() +{ + using Registry = AetherSDR::rtl::RtlReceiverRegistry; + auto pipeline = std::make_unique(1, true); + Transaction::State current = state(); + const bool installed = pipeline->prepare(current, true) && ready(*pipeline) && pipeline->adopt(); + check(installed && !pipeline->legacy(), "underrun fixture prepares the actual nonblocking WFM receiver"); + if (!installed || pipeline->legacy()) { pipeline->stop(); return; } + + // The first worker to reach the existing handoff point is the sole WFM + // worker in this fixture. Destruction order releases it before the pipeline + // can join/retire WDSP, including every failed-assertion/early-return path. + struct HandoffHold { + HandoffHold() { WdspChannel::setWorkerHandoffHoldForTest(true); } + ~HandoffHold() { release(); } + void release() { WdspChannel::setWorkerHandoffHoldForTest(false); } + } hold; + std::uint64_t position = 0; + std::uint64_t failedCaptureFirst = 0; + std::size_t slicePackets = 0; + Pipeline::Packet firstSlice; + std::array, 256> iq; + const auto feed = [&] { + for (std::size_t i = 0; i < iq.size(); ++i) { + iq[i] = referenceIq(position + i, current.capture.achievedSampleRateHz); + } + check(pipeline->process(position, iq), "continuous capture remains admitted while WDSP is held"); + if (pipeline->needsRepair()) { failedCaptureFirst = position; } + position += iq.size(); + Pipeline::Packet packet; + while (pipeline->takePacket(packet)) { + if (packet.slot == 3) { + if (slicePackets == 0) { firstSlice = packet; } + check(packet.firstSample == slicePackets * 256, + "successful pre-fault slice PCM retains its exact 48 kHz positions"); + ++slicePackets; + } + } + }; + // Small capture partitions produce at most one WDSP block per call. Stop + // after the first output, then wait for an explicit worker acknowledgement; + // no elapsed sleep is assumed to imply that the worker has been scheduled. + for (unsigned chunk = 0; chunk < 512 && slicePackets == 0 && !pipeline->needsRepair(); ++chunk) { feed(); } + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (!WdspChannel::workerHandoffHeldForTest() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(1ms); + } + const bool held = WdspChannel::workerHandoffHeldForTest(); + check(held && slicePackets == 1 && !pipeline->needsRepair(), + "first real worker handoff is acknowledged before the next exchange"); + if (!held || slicePackets != 1 || pipeline->needsRepair()) { hold.release(); pipeline->stop(); return; } + + // An 8192-sample USB callback can produce seven WFM IQ blocks at the + // slowest admitted capture rates. Prepared headroom must accept that finite + // burst without needing the held worker to run. This fails on the old D2 + // exchange, independently of host speed or an arbitrary sleep duration. + for (unsigned chunk = 0; chunk < 512 && slicePackets < 7 && !pipeline->needsRepair(); ++chunk) { feed(); } + check(slicePackets == 7 && !pipeline->needsRepair(), + "nonblocking WFM accepts the bounded seven-block USB burst while its worker is held"); + // D8 has seven prefilled output blocks plus the first acknowledged worker + // handoff. Exhaustion therefore occurs on the ninth IQ block. It remains a + // real fexchange2 -2; treating -2 as Ok must still fail this withdrawal. + for (unsigned chunk = 0; chunk < 512 && !pipeline->needsRepair(); ++chunk) { feed(); } + check(pipeline->needsRepair() && slicePackets == 8, + "real nonblocking WFM exchange withdraws beyond its bounded eight-block credit"); + std::printf("WFM_HELD_BURST successful_blocks=%zu repair=%d\n", slicePackets, pipeline->needsRepair()); + const std::uint64_t faultCaptureFirst = failedCaptureFirst; + // Subsequent continuous RF cannot publish stale decoder output or a second + // first-fault record from the withdrawn receiver. + for (unsigned chunk = 0; chunk < 8; ++chunk) { feed(); } + check(slicePackets == 8, "withdrawn WFM receiver emits no stale independent PCM"); + unsigned faultEvents = 0; + Pipeline::TraceEvent event; + while (pipeline->takeTraceEvent(event)) { + if (event.kind != Pipeline::TraceEvent::Kind::ReceiverFailure) { continue; } + ++faultEvents; + check(event.token == current.token && event.hardwareGeneration == current.capture.generation + && event.captureEpoch == firstSlice.captureEpoch && event.slot == 3 && event.stableId == 3 + && event.instance == firstSlice.instance && event.receiverEpoch == firstSlice.receiverEpoch, + "real underrun trace retains the accepted receiver and capture identity"); + check(event.captureFirst == faultCaptureFirst && event.captureFrames == 256, + "real underrun trace names the exact failing capture callback"); + check(event.failure.has_value(), "real underrun trace carries a typed first failure"); + if (event.failure) { + const auto& failure = *event.failure; + check(failure.reason == Registry::ProcessingFailureReason::DspProcess + && failure.processResult == WdspChannel::ProcessResult::Underrun, + "first fault reports actual WDSP Underrun rather than a capture gap"); + check(failure.hasExpectedCaptureFirst && failure.expectedCaptureFirst == faultCaptureFirst + && failure.captureFirst == faultCaptureFirst && failure.captureFrames == 256 + && failure.hasIqFirst && failure.iqFirst == 16384 && failure.iqFrames == 2048, + "first fault proves continuous capture and identifies the ninth exact IQ block"); + check(failure.extraction && failure.extraction->reason == Extractor::FailureReason::SinkRejected, + "RF extraction reports downstream rejection of the actual failing WDSP block"); + } + } + check(faultEvents == 1 && pipeline->diagnostics().droppedTraceEvents == 0, + "one retained first-fault event survives later RF without diagnostic loss"); + hold.release(); // mandatory before control-side repair can retire the held worker + current.token.revision++; + const bool repaired = pipeline->prepare(current) && ready(*pipeline) && pipeline->adopt(); + check(repaired, "unchanged WFM recipe repairs after the real worker is released"); + if (repaired) { + const auto audio = run(*pipeline, current, position, 2.0); + check(audio[0].seen && audio[1].seen && audio[0].receiverEpoch != firstSlice.receiverEpoch + && audio[1].captureEpoch != firstSlice.captureEpoch + && audio[0].discontinuity && audio[1].discontinuity, + "underrun repair replaces decoder and speaker histories"); + check(audio[0].stereo && amplitude(audio[0].left, 1000) > 0.001 + && amplitude(audio[0].right, 2000) > 0.001 + && amplitude(audio[1].left, 1000) > 0.001 && amplitude(audio[1].right, 2000) > 0.001, + "released-worker repair delivers actual paired reference audio and pilot acquisition"); + } + pipeline->stop(); +} +void boundedBenchmark() +{ + // Opt-in pipeline plus production DDC/spectrum throughput qualification. + // USB byte conversion, backend timers, AudioEngine and GUI are absent. + // The 65536-bin display FFT at 25 RF frames/second, squelch detector, + // extraction, WDSP worker, independent tap, mixer and packet drain run + // sequentially on the same capture IQ. No synthetic firmware peer. + constexpr std::uint64_t kCaptureRate = 2'400'000; + constexpr std::uint64_t kRfSeconds = 4; + constexpr std::size_t kChunk = 8192; + constexpr double kFeedSpeed = 2.1; + constexpr int kSpectrumFps = 25; + constexpr std::uint64_t kNominalFrames = kRfSeconds * 48000; + const Transaction::State current = state(); + std::vector> iq(kRfSeconds * kCaptureRate); + // All fixture frequencies are integer kHz, so this analytically generated + // one-millisecond period repeats exactly. Generation is outside the timer. + std::array, kCaptureRate / 1000> period; + for (std::size_t i = 0; i < period.size(); ++i) { + period[i] = referenceIq(i, kCaptureRate); + } + for (std::size_t offset = 0; offset < iq.size(); offset += period.size()) { + std::copy(period.begin(), period.end(), iq.begin() + offset); + } + + // Determine the exact finite-run frame count, including resampler startup + // and the incomplete final IQ block, without involving WDSP or timing. + // This prevents an apparently fast run from qualifying by dropping audio. + const Extractor::Config extractorConfig{current.capture, + current.receivers.front().passband, 384000, 2048, 48000}; + Extractor oracle(extractorConfig); + check(oracle.valid(), "benchmark frame-count extractor prepares"); + if (!oracle.valid()) { return; } + ClockSink expected; + for (std::size_t offset = 0; offset < iq.size(); offset += kChunk) { + const std::size_t count = std::min(kChunk, iq.size() - offset); + if (!oracle.process(current.capture, offset, std::span(iq).subspan(offset, count), expected)) { + check(false, "benchmark frame-count extraction accepts the complete RF vector"); + return; + } + } + const std::uint64_t expectedFrames = std::uint64_t(expected.blocks) * 256; + check(expectedFrames <= kNominalFrames && expectedFrames > kNominalFrames - 1024, + "finite benchmark output approaches four seconds at the declared 48 kHz rate"); + using Ddc = AetherSDR::rtl::RtlSdrDdc; + Ddc ddc; // FFT plans, window preparation and signal wiring are untimed. + ddc.applyCapture(kCaptureRate, current.capture.centerHz, + current.receivers.front().passband.carrierHz, Transaction::Mode::Wfm, -100000, 100000, + current.capture.usableLeftHz, current.capture.usableRightHz); + ddc.setSpectrumRateFps(kSpectrumFps); + std::uint64_t spectrumFrames = 0; + std::uint64_t detectorFrames = 0; + unsigned legacyAudioPackets = 0; + QObject::connect(&ddc, &Ddc::spectrumFrameReady, &ddc, + [&](int panId, const QByteArray& frame) { + ++spectrumFrames; + check(panId == 0 && frame.size() == Ddc::kSpectrumBinCount * qsizetype(sizeof(float)), + "benchmark computes complete production display FFT frames"); + bool finite = true; + float peak = -std::numeric_limits::infinity(); + for (qsizetype offset = 0; offset + qsizetype(sizeof(float)) <= frame.size(); offset += sizeof(float)) { + float value; + std::memcpy(&value, frame.constData() + offset, sizeof(value)); + finite &= std::isfinite(value); + peak = std::max(peak, value); + } + check(finite && peak > -100, + "benchmark display FFT contains finite measured RF energy"); + }); + QObject::connect(&ddc, &Ddc::audioFrameReady, &ddc, + [&](const QByteArray&, const QByteArray&) { ++legacyAudioPackets; }); + const std::uint64_t expectedSpectrumFrames = 1 + + (iq.size() - Ddc::kSpectrumBinCount) / (kCaptureRate / kSpectrumFps); + QVector> captureBlock(kChunk); + auto pipeline = std::make_unique(1, true); + const bool installed = pipeline->prepare(current, true) && ready(*pipeline) && pipeline->adopt(); + check(installed && !pipeline->legacy(), "benchmark prepares the real native WFM graph before timing"); + if (!installed || pipeline->legacy()) { pipeline->stop(); return; } + std::array output; + for (Audio& audio : output) { + audio.left.reserve(kNominalFrames); + audio.right.reserve(kNominalFrames); + } + std::uint64_t position = 0; + const auto started = std::chrono::steady_clock::now(); + while (position < iq.size()) { + const std::size_t count = std::min(kChunk, iq.size() - position); + captureBlock.resize(count); // Retains the preallocated chunk capacity. + std::copy_n(iq.data() + position, count, captureBlock.data()); + // Match the worker's detector/display/audio ordering with DC suppression + // off, and disable legacy DDC audio while native WFM owns reception. + ddc.processSquelchSpectrum(captureBlock); + ddc.processIqData(captureBlock, false, false); + const auto spectrum = ddc.takeSquelchSpectrum(); + if (!spectrum.empty()) { + ++detectorFrames; + check(spectrum.size() == 2048, "benchmark retains the real squelch detector workload"); + pipeline->observeSpectrum(spectrum, position); + } + if (!pipeline->process(position, std::span(captureBlock.constData(), captureBlock.size())) + || pipeline->needsRepair()) { + check(false, "benchmark sustains real nonblocking WDSP processing without withdrawal"); + break; + } + position += count; + Pipeline::Packet packet; + while (pipeline->takePacket(packet)) { + check(packet.token == current.token && (packet.slot == -1 || packet.slot == 3), + "benchmark output preserves current token and sparse receiver identity"); + output[packet.slot < 0 ? 1 : 0].accept(packet); + } + // An unlimited burst can outrun the nonblocking WDSP exchange worker. + // Exercise a sustained >2x RF clock instead, and measure ALL feed and + // drain work plus scheduling waits. The measured speed must still pass; + // this cap is a throughput lower bound, not a maximum-speed claim. + std::this_thread::sleep_until(started + std::chrono::duration( + double(position) / (kCaptureRate * kFeedSpeed))); + if (std::chrono::steady_clock::now() - started > 5s) { + check(false, "benchmark completes within its bounded feed interval"); + break; + } + } + const double elapsed = std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + const double rfSeconds = double(position) / kCaptureRate; + const double speed = rfSeconds / elapsed; + const auto diagnostics = pipeline->diagnostics(); + std::printf("WFM_BENCHMARK scope=pipeline_ddc_fft25 capture_rate=%llu rf_seconds=%.6f elapsed_seconds=%.6f " + "speed=%.3fx feed_cap=%.1fx expected_frames=%llu slice_frames=%zu speaker_frames=%zu " + "repair=%d drops=%llu late_frames=%llu mixer_rejections=%llu mixer_config_failures=%llu " + "fft_fps=%d expected_fft_frames=%llu fft_frames=%llu detector_frames=%llu " + "ddc_legacy_audio_packets=%u\n", + static_cast(kCaptureRate), rfSeconds, elapsed, speed, kFeedSpeed, + static_cast(expectedFrames), output[0].left.size(), output[1].left.size(), + pipeline->needsRepair(), static_cast(diagnostics.droppedPackets), + static_cast(diagnostics.mixerLateFrames), + static_cast(diagnostics.mixerRejectedBlocks), + static_cast(diagnostics.mixerConfigurationFailures), kSpectrumFps, + static_cast(expectedSpectrumFrames), static_cast(spectrumFrames), + static_cast(detectorFrames), + legacyAudioPackets); + check(spectrumFrames == expectedSpectrumFrames + && detectorFrames > 0 && legacyAudioPackets == 0, + "benchmark includes every scheduled 25 FPS display FFT and excludes duplicate legacy audio"); + check(position == iq.size() && rfSeconds >= 4.0 && speed >= 2.0, + "benchmark completes at least four RF seconds at measured >=2x real time"); + check(diagnostics.observed && !pipeline->needsRepair() && diagnostics.droppedPackets == 0 + && diagnostics.mixerLateFrames == 0 && diagnostics.mixerRejectedBlocks == 0 + && diagnostics.mixerConfigurationFailures == 0, + "benchmark has no receiver faults, packet-mailbox loss or mixer failure"); + for (const Audio& audio : output) { + check(audio.left.size() == expectedFrames && audio.right.size() == expectedFrames + && audio.next == expectedFrames, + "benchmark delivers every expected contiguous 48 kHz frame to slice and speaker"); + check(amplitude(audio.left, 1000) > 0.001 && amplitude(audio.right, 2000) > 0.001 + && audio.peak < 1 && audio.clipped == 0, + "benchmark output contains finite unclipped reference audio rather than empty packets"); + } + check(output[0].stereo, "benchmark preserves actual WFM pilot acquisition under the faster RF clock"); + pipeline->stop(); +} +void admission() +{ + auto legacy = std::make_unique(); + const Transaction::State current = state(); + check(legacy->prepare(current, true) && ready(*legacy) && legacy->adopt() + && legacy->legacy() == !Pipeline::kQualifiedWfmEnabled, + "default pipeline follows the explicit qualification gate"); + legacy->stop(); + auto native = std::make_unique(1, true); + Transaction::State invalid = current; + invalid.receivers[0].wfmDeemphasisUs = 51; + check(!native->prepare(invalid), "unsupported deemphasis is refused before preparation"); + invalid = current; + invalid.receivers[0].squelchEnabled = true; + check(!native->prepare(invalid), "manual narrow-FM squelch cannot masquerade as WFM internal squelch"); + invalid = current; + invalid.receivers.push_back(invalid.receivers[0]); + invalid.receivers.back().passband.stableId = 4; + invalid.receivingIds.push_back(4); + check(!native->prepare(invalid), "WFM qualification does not increase production receiver admission"); + native->stop(); +} +} +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + outputClockAlignment(); + admission(); + nativeRouting(); + workerUnderrunTrace(); + if (qEnvironmentVariableIntValue("AETHER_WFM_BENCHMARK") == 1) { boundedBenchmark(); } + QThreadPool::globalInstance()->waitForDone(); + std::printf("rtl_wfm_pipeline_test: %d failures\n", failures); + return failures == 0 ? 0 : 1; +} diff --git a/tests/tests.cmake b/tests/tests.cmake index 424e13384f..157f3bd362 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -1623,6 +1623,12 @@ add_executable(wdsp_allocation_scope_test tests/wdsp_allocation_scope_test.cpp) target_link_libraries(wdsp_allocation_scope_test PRIVATE aether_wdsp Threads::Threads) add_test(NAME wdsp_allocation_scope_test COMMAND wdsp_allocation_scope_test) +add_executable(rtl_wfm_pipeline_test tests/rtl_wfm_pipeline_test.cpp) +target_include_directories(rtl_wfm_pipeline_test PRIVATE src) +target_link_libraries(rtl_wfm_pipeline_test PRIVATE aethercore aether_wdsp Qt6::Core) +add_test(NAME rtl_wfm_pipeline_test COMMAND rtl_wfm_pipeline_test) +set_tests_properties(rtl_wfm_pipeline_test PROPERTIES TIMEOUT 120) + add_executable(rtl_receive_pipeline_test tests/rtl_receive_pipeline_test.cpp) target_include_directories(rtl_receive_pipeline_test PRIVATE src) target_link_libraries(rtl_receive_pipeline_test PRIVATE aethercore aether_wdsp Qt6::Core) @@ -7400,6 +7406,36 @@ add_test(NAME fm_filter_controls_test set_tests_properties(fm_filter_controls_test PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +add_executable(wfm_controls_test + tests/wfm_controls_test.cpp + src/gui/WfmApplet.cpp + src/gui/WfmLockScope.cpp + src/gui/RxApplet.cpp + src/gui/ControlAvailabilityRegistry.cpp + src/gui/VfoWidget.cpp + src/gui/ModeFilterPresets.cpp + src/gui/VfoDisplayDefaults.cpp + src/gui/FrequencyEntryParser.cpp + src/gui/DragValuePopup.cpp + src/gui/FilterPassbandWidget.cpp + src/gui/SliceColorManager.cpp + src/gui/SliceLabel.cpp + src/gui/PhaseKnob.cpp + src/gui/SmartMtrWidget.cpp + src/gui/SmartMtrConfig.cpp + src/gui/MeterViewController.cpp + src/gui/AdaptiveFilterControls.cpp + src/gui/GuardedSlider.h +) +target_include_directories(wfm_controls_test PRIVATE src) +target_link_libraries(wfm_controls_test PRIVATE + aethercore Qt6::Widgets Qt6::Test +) +add_test(NAME wfm_controls_test + COMMAND wfm_controls_test) +set_tests_properties(wfm_controls_test PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + # The VFO flag's AetherRX / AetherTX launchers stay the same width on every # mode's DSP grid. Same socket-free build as the squelch test above. add_executable(vfo_dsp_launcher_width_test @@ -7693,6 +7729,7 @@ set(AETHER_SETTINGS_CONSUMERS gui_nested_lifetime_test rx_applet_squelch_reconciliation_test fm_filter_controls_test + wfm_controls_test spectrum_confirmed_geometry_test flex_slice_mode_intent_test rtl_slice_settings_test diff --git a/tests/wdsp_wbfm_test.cpp b/tests/wdsp_wbfm_test.cpp index 06d6557db2..584bb24090 100644 --- a/tests/wdsp_wbfm_test.cpp +++ b/tests/wdsp_wbfm_test.cpp @@ -1,7 +1,9 @@ #include "core/dsp/WdspChannel.h" +#include "../third_party/wdsp/include/aether_wdsp.h" #include #include +#include #include #include #include @@ -11,6 +13,7 @@ #include #include #include +#include #include namespace { @@ -266,6 +269,17 @@ bool stereoAndPilot() ok = require(stereo.finite && leftSeparation >= 40 && rightSeparation >= 40, "reference stereo separates correctly oriented L/R >=40dB") && ok; ok = require(channel->wbfmStereoDetected() == true, "real pilot acquires stereo indication") && ok; + const auto acquired = channel->wbfmReceptionDiagnostics(); + ok = require(acquired && acquired->valid && acquired->pilotLocked + && acquired->pilotMagnitude > acquired->pilotEngageThreshold + && acquired->pilotEngageThreshold == 0.06 && acquired->pilotReleaseThreshold == 0.03 + && acquired->engageBlocks == 93 && acquired->releaseBlocks == 187 + && acquired->consecutiveHighBlocks >= acquired->engageBlocks + && acquired->consecutiveLowBlocks == 0 + && acquired->lockLossCount == 0 && acquired->reacquisitionCount == 0 + && acquired->observationDurationMs >= 5900 && acquired->lockDurationMs >= 5000 + && acquired->stableDurationMs == 5000, + "completed decoder snapshot reports real pilot thresholds, hysteresis and sample-clock duration") && ok; ok = require(stereo.peak < 0.95 && stereo.clipped == 0, "stereo reference preserves unclipped headroom") && ok; for (const Vector vector : {Vector::StereoLowAudio, Vector::StereoHighAudio, Vector::StereoHighModulation}) { @@ -289,6 +303,13 @@ bool stereoAndPilot() ok = require(channel->wbfmStereoDetected() == false && lost.finite && std::sqrt(lost.differenceEnergy / lost.frames) < 1.0e-6, "pilot loss returns truthful mono with identical L/R") && ok; + const auto loss = channel->wbfmReceptionDiagnostics(); + ok = require(loss && loss->valid && !loss->pilotLocked && loss->lockDurationMs == 0 + && loss->lockLossCount == 1 && loss->reacquisitionCount == 0 + && acquired && loss->observationSequence != acquired->observationSequence + && loss->pilotMagnitude < loss->pilotReleaseThreshold + && loss->consecutiveLowBlocks >= loss->releaseBlocks, + "pilot loss is counted in DSP even between UI snapshots") && ok; // RF amplitude changes must not change valid FM audio gain or stereo // orientation; these clean vectors are not a weak-noisy-station claim. const Measurement weak = run(*channel, Vector::Stereo, 5.0, 2.0, 0.0, 0.001); @@ -296,6 +317,10 @@ bool stereoAndPilot() && std::abs(weak.left(0) / stereo.left(0) - 1.0) < 0.01 && std::abs(weak.right(1) / stereo.right(1) - 1.0) < 0.01, "clean40dB lower RF amplitude retains FM level and stereo") && ok; + const auto reacquired = channel->wbfmReceptionDiagnostics(); + ok = require(reacquired && reacquired->pilotLocked && reacquired->lockLossCount == 1 + && reacquired->reacquisitionCount == 1, + "reacquisition excludes first acquisition and retains measured loss") && ok; const Measurement noise = run(*channel, Vector::Noise, 4.0, 2.0); std::cout << "No-signal noise peak=" << noise.peak << " pilot=" << channel->wbfmStereoDetected().value_or(true) << '\n'; @@ -304,11 +329,41 @@ bool stereoAndPilot() "internal automatic squelch suppresses no-signal noise without a false pilot") && ok; ok = require(channel->setRunning(false) && channel->wbfmStereoDetected() == false, "stop immediately invalidates previous stereo indication") && ok; + ok = require(!channel->wbfmReceptionDiagnostics(), "stop invalidates completed-block diagnostics") && ok; + // A stop only arms WDSP's down-slew. An immediate restart cancels it, + // retaining decoder history because no flush occurred. Clock its 480-frame + // fade and 256-frame zero tail through three blocking 256-frame exchanges; + // setRunning(true) then waits for the completed fade's real flush worker. + std::array drainInput{}; + std::vector drainLeft(channel->outputBlockSize()), drainRight(channel->outputBlockSize()); + for (int block = 0; block < 3; ++block) { + if (!require(channel->processIq(drainInput, drainInput, drainLeft, drainRight) + == WdspChannel::ProcessResult::Ok, + "stop clocks the real down-slew before reception-history flush")) { return false; } + ok = require(!channel->wbfmReceptionDiagnostics(), + "clocking the stopped channel does not republish reception") && ok; + } if (!require(channel->setRunning(true), "WFM restarts")) { return false; } const Measurement resumed = run(*channel, Vector::Mono, 3.0, 2.0); ok = require(resumed.finite && channel->wbfmStereoDetected() == false && resumed.peak < 0.95 && resumed.clipped == 0, "restart on mono never retains old station's stereo flag") && ok; + const auto fresh = channel->wbfmReceptionDiagnostics(); + ok = require(fresh && !fresh->pilotLocked && fresh->lockLossCount == 0 + && fresh->reacquisitionCount == 0 && fresh->observationDurationMs < 3100, + "flush starts new reception counters and sample-clock history") && ok; + auto forced = config(); forced.wbfmReceive->forceMono = true; + if (!require(channel->reconfigure(forced, &error), error.c_str())) { return false; } + const Measurement mono = run(*channel, Vector::Stereo, 4.0); + const auto monoReception = channel->wbfmReceptionDiagnostics(); + ok = require(mono.finite && mono.peak > 0.001 && mono.differenceEnergy == 0.0 + && channel->wbfmStereoDetected() == false && monoReception && monoReception->pilotLocked, + "Force Mono produces identical nonzero L/R while truthfully observing the actual pilot") && ok; + if (!require(channel->reconfigure(config(), &error), error.c_str())) { return false; } + const Measurement automatic = run(*channel, Vector::Stereo, 4.0); + ok = require(automatic.finite && automatic.left(0) > 100 * automatic.right(0) + && automatic.right(1) > 100 * automatic.left(1), + "return to Auto Stereo restores the decoder matrix") && ok; return ok; } @@ -359,6 +414,247 @@ bool clockPilotAndNoise() return ok; } +// These barriers are test-thread observations, never acquisition policy. A +// deadline only detects a stuck worker; accepted exchange count is independent +// of CPU timing. Holds are released before any return can destroy the channel. +template +bool awaitWorker(Predicate&& predicate) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!predicate()) { + if (std::chrono::steady_clock::now() >= deadline) { return false; } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return true; +} + +struct WorkerHold { + explicit WorkerHold(bool beforeCopy = false) : copy(beforeCopy) + { + if (copy) { WdspChannel::setWorkerOutputCopyHoldForTest(true); } + else { WdspChannel::setWorkerHandoffHoldForTest(true); } + } + ~WorkerHold() { release(); } + void release() + { + if (copy) { WdspChannel::setWorkerOutputCopyHoldForTest(false); } + else { WdspChannel::setWorkerHandoffHoldForTest(false); } + } + bool entered() const + { + return copy ? WdspChannel::workerOutputCopyHeldForTest() + : WdspChannel::workerHandoffHeldForTest(); + } + bool copy; +}; + +struct ExchangeVector { + static constexpr std::size_t kBlocks = 480; + static constexpr std::size_t kAudioBlock = 256; + std::vector i = std::vector(kBlocks * kInputBlock); + std::vector q = std::vector(kBlocks * kInputBlock); + ExchangeVector() + { + for (std::size_t frame = 0; frame < i.size(); ++frame) { + const double time = static_cast(frame) / kInputRate; + const auto sineIntegral = [time](double hz) { + return -std::cos(kTwoPi * hz * time) / hz; + }; + const auto stereoIntegral = [time](double hz) { + return 0.5 * (std::sin(kTwoPi * (38000.0 - hz) * time) / (38000.0 - hz) + - std::sin(kTwoPi * (38000.0 + hz) * time) / (38000.0 + hz)); + }; + // Non-harmonic-to-block L/R tones distinguish temporal order as + // well as channel order. Analytically integrated continuous FM. + const double phase = 75000.0 * (0.225 * (sineIntegral(731.0) + + sineIntegral(1279.0) + stereoIntegral(731.0) - stereoIntegral(1279.0)) + + 0.1 * sineIntegral(19000.0)); + i[frame] = static_cast(0.1 * std::cos(phase)); + q[frame] = static_cast(0.1 * std::sin(phase)); + } + } + WdspChannel::ProcessResult process(WdspChannel& channel, std::size_t block, + std::span left, std::span right) const + { + return channel.processIq(std::span(i).subspan(block * kInputBlock, kInputBlock), + std::span(q).subspan(block * kInputBlock, kInputBlock), left, right); + } +}; + +bool exchangeOrder() +{ + const ExchangeVector signal; + constexpr std::size_t audioBlock = ExchangeVector::kAudioBlock; + constexpr std::size_t extraBlocks = 6; + constexpr std::size_t heldBlock = 384; // >2 seconds of real pilot acquisition. + constexpr int readySamples = 7 * audioBlock; + std::string error; + std::unique_ptr channel = WdspChannel::create(config(), &error); + if (!require(channel != nullptr, error.c_str())) { return false; } + bool ok = require(channel->outputSamplesReadyForTest() == audioBlock, + "blocking WFM retains depth2 startup geometry"); + std::vector referenceLeft(signal.kBlocks * audioBlock); + std::vector referenceRight(referenceLeft.size()); + for (std::size_t block = 0; block < signal.kBlocks; ++block) { + if (!require(signal.process(*channel, block, + std::span(referenceLeft).subspan(block * audioBlock, audioBlock), + std::span(referenceRight).subspan(block * audioBlock, audioBlock)) + == WdspChannel::ProcessResult::Ok, "blocking ordered-audio reference succeeds")) { + return false; + } + } + WdspChannel::Config native = config(); + native.blockForOutput = false; + if (!require(channel->reconfigure(native, &error), error.c_str())) { return false; } + std::array left{}, right{}; + // Reuse the same owner/slot twice; each preparation must restore the + // exact prefix, and all wraps and queued input after release are compared. + for (int preparation = 0; preparation < 2; ++preparation) { + if (preparation != 0 && !require(channel->reconfigure(native, &error), error.c_str())) { + return false; + } + if (!require(channel->outputSamplesReadyForTest() == readySamples, + "nonblocking WFM prepares seven credits on every rebuild")) { return false; } + double maximumError = 0.0; + bool finite = true; + double burstEnergy = 0.0; + double stereoEnergy = 0.0; + const std::uint64_t allocations = WdspChannel::allocationSequenceForTest(); + for (std::size_t block = 0; block < signal.kBlocks; ++block) { + static_assert(heldBlock + 8 < ExchangeVector::kBlocks); + std::optional hold; + if (block == heldBlock) { + // Ready count alone precedes the previous handoff's hook. Do + // not let the new hold accidentally claim that previous block. + channel->synchronizeWorkerForTest(); + hold.emplace(); + } + const std::size_t count = hold ? 8 : 1; + for (std::size_t part = 0; part < count; ++part) { + const std::size_t current = block + part; + if (!require(signal.process(*channel, current, left, right) + == WdspChannel::ProcessResult::Ok, + "prepared nonblocking WFM accepts ordered bounded burst")) { return false; } + if (block == heldBlock && part == 0 + && !require(awaitWorker([] { return WdspChannel::workerHandoffHeldForTest(); }), + "ordered burst starts after acknowledged worker handoff")) { return false; } + finite = finite && std::ranges::all_of(left, [](float sample) { return std::isfinite(sample); }) + && std::ranges::all_of(right, [](float sample) { return std::isfinite(sample); }); + if (current >= extraBlocks) { + for (std::size_t frame = 0; frame < audioBlock; ++frame) { + const std::size_t expected = (current - extraBlocks) * audioBlock + frame; + maximumError = std::max({maximumError, + std::abs(static_cast(left[frame]) - referenceLeft[expected]), + std::abs(static_cast(right[frame]) - referenceRight[expected])}); + if (block == heldBlock) { + burstEnergy += left[frame] * left[frame] + right[frame] * right[frame]; + stereoEnergy += (left[frame] - right[frame]) * (left[frame] - right[frame]); + } + } + } + } + if (hold) { hold->release(); } + if (!require(awaitWorker([&] { return channel->outputSamplesReadyForTest() == readySamples; }), + "worker publishes all completed burst output without extra input")) { return false; } + block += count - 1; + } + std::cout << "WFM_EXCHANGE_ORDER preparation=" << preparation << " extra_frames=" + << extraBlocks * audioBlock << " max_error=" << maximumError + << " burst_energy=" << burstEnergy << " stereo_energy=" << stereoEnergy << '\n'; + ok = require(finite && maximumError < 2.0e-5 && burstEnergy > 1.0 && stereoEnergy > 0.5, + "nonzero paired output matches blocking reference with exactly1536 added frames") && ok; + ok = require(WdspChannel::allocationSequenceForTest() == allocations, + "prepared bursts allocate no WDSP memory") && ok; + } + return ok; +} + +// A held copy must not grant credit for bytes it has not written. This also +// tests the cold zero prefix after a genuine clocked stop/flush. A faulted +// channel is never fed again: release, then reprepare off the acquisition path. +bool unpublishedOutput(WdspChannel& channel, const ExchangeVector& signal) +{ + constexpr int audioBlock = ExchangeVector::kAudioBlock; + std::array left{}, right{}; + channel.synchronizeWorkerForTest(); + WorkerHold hold(true); + bool ok = true; + for (std::size_t block = 0; block < 7; ++block) { + if (!require(signal.process(channel, block, left, right) == WdspChannel::ProcessResult::Ok, + "seven seeded output blocks remain available while next copy is held")) { return false; } + if (block == 0) { + if (!require(awaitWorker([&] { return hold.entered(); }), + "worker acknowledged before actual output memcpy")) { return false; } + ok = require(channel.outputSamplesReadyForTest() == 6 * audioBlock, + "unpublished output never grants a premature credit") && ok; + } + ok = require(std::ranges::all_of(left, [](float sample) { return sample == 0.0f; }) + && std::ranges::all_of(right, [](float sample) { return sample == 0.0f; }), + "prepared or flushed prefix contains actual paired silence") && ok; + } + ok = require(signal.process(channel, 7, left, right) == WdspChannel::ProcessResult::Underrun, + "eighth exchange underruns while output memcpy is unpublished") && ok; + return ok; +} + +bool exchangePreparation() +{ + const ExchangeVector signal; + WdspChannel::Config native = config(); native.blockForOutput = false; + std::string error; + std::unique_ptr channel = WdspChannel::create(native, &error); + if (!require(channel != nullptr, error.c_str())) { return false; } + bool ok = true; + const std::uint64_t allocations = WdspChannel::allocationSequenceForTest(); + for (const int depth : {-1, 0, 1, 9, std::numeric_limits::max()}) { + ok = require(OpenChannelWithExchangeDepth(channel->channelId(), 2048, 1024, + 384000, 192000, 48000, 0, 0, 0.01, 0.025, 0.0, 0.01, 0, depth) == 0, + "invalid prepared depth refuses before changing a live channel") && ok; + } + for (const int id : {-1, 32}) { + ok = require(OpenChannelWithExchangeDepth(id, 2048, 1024, + 384000, 192000, 48000, 0, 0, 0.01, 0.025, 0.0, 0.01, 0, 8) == 0, + "prepared creation rejects invalid channel identity") && ok; + } + ok = require(WdspChannel::allocationSequenceForTest() == allocations + && channel->outputSamplesReadyForTest() == 7 * 256, + "refused preparation leaves existing ring and allocations intact") && ok; + ok = unpublishedOutput(*channel, signal) && ok; + for (int round = 0; round < 2; ++round) { + if (!require(channel->reconfigure(native, &error), error.c_str())) { return false; } + std::array left{}, right{}; + for (std::size_t block = 0; block < 64; ++block) { + if (!require(signal.process(*channel, block, left, right) == WdspChannel::ProcessResult::Ok + && awaitWorker([&] { return channel->outputSamplesReadyForTest() == 7 * 256; }), + "flush fixture warms actual worker with bounded exchanges")) { return false; } + } + if (!require(channel->setRunning(false), "clocked flush stop accepted")) { return false; } + // Config's 480-frame down-slew plus the WDSP 256-frame zero tail + // completes within three 256-frame exchanges. Each handoff is awaited; + // setRunning(true) then waits for that completed fade's flush worker. + for (std::size_t block = 0; block < 3; ++block) { + if (!require(signal.process(*channel, block, left, right) == WdspChannel::ProcessResult::Ok + && awaitWorker([&] { return channel->outputSamplesReadyForTest() == 7 * 256; }), + "stop clocks real fade and flush without starving worker")) { return false; } + } + if (!require(channel->setRunning(true), "flushed WFM restarts without rebuilding")) { return false; } + ok = require(channel->outputSamplesReadyForTest() == 7 * 256, + "flush retains prepared depth8 credits") && ok; + ok = unpublishedOutput(*channel, signal) && ok; + } + // The same slot must not retain its former depth when a legacy owner opens + // it. Blocking WFM, legacy WBFM, ordinary receive and transmit stay at2. + std::array legacy{config(), config(), WdspChannel::Config{}, WdspChannel::Config{}}; + legacy[1].wbfmReceive.reset(); legacy[1].blockForOutput = false; + legacy[3].direction = WdspChannel::Direction::Transmit; + for (const WdspChannel::Config& candidate : legacy) { + if (!require(channel->reconfigure(candidate, &error), error.c_str())) { return false; } + ok = require(channel->outputSamplesReadyForTest() == static_cast(channel->outputBlockSize()), + "legacy and blocking owners reset reused slot to depth2") && ok; + } + return ok; +} + bool invalidConfiguration() { using Deemphasis = WdspChannel::WbfmReceive::Deemphasis; @@ -397,6 +693,8 @@ bool invalidConfiguration() int main() { const std::uint64_t allocations = WdspChannel::outstandingAllocationsForTest(); + const bool order = exchangeOrder(); + const bool preparation = exchangePreparation(); const bool filter = filterAndHeadroom(); const bool response = deemphasis(); const bool envelope = rfEnvelope(); @@ -405,5 +703,7 @@ int main() const bool validation = invalidConfiguration(); const bool leaks = require(WdspChannel::outstandingAllocationsForTest() == allocations, "WFM channel teardown frees tracked allocations"); - return filter && response && envelope && stereo && conditions && validation && leaks ? 0 : 1; + const bool ok = order && preparation && filter && response && envelope && stereo && conditions && validation && leaks; + std::cout << "WDSP_WBFM_TEST completed success=" << ok << '\n'; + return ok ? 0 : 1; } diff --git a/tests/wfm_controls_test.cpp b/tests/wfm_controls_test.cpp new file mode 100644 index 0000000000..24ed2a607f --- /dev/null +++ b/tests/wfm_controls_test.cpp @@ -0,0 +1,832 @@ +// Socket-free model and widget validation. The injected backend captures +// typed intents; no receiver, firmware peer, sound device or RF is opened. +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" +#include "core/backends/SliceDelta.h" +#include "gui/ControlAvailabilityRegistry.h" +#include "gui/FilterPassbandWidget.h" +#include "gui/RxApplet.h" +#include "gui/VfoWidget.h" +#include "gui/WfmApplet.h" +#include "gui/WfmLockScope.h" +#include "core/ThemeManager.h" +#include "models/RadioModel.h" +#include "models/SliceModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace AetherSDR; + +namespace { +class WfmBackend final : public IRadioBackend { +public: + RadioCapabilities caps; + bool connected = true; + bool refuseWfm = false; + QVector> requests; + QVector> monoRequests; + RadioCapabilities capabilities() const override { return caps; } + bool isConnected() const override { return connected; } + ReceiveControlPolicy receiveControlPolicy() const override { return ReceiveControlPolicy::Confirmed; } + void connectRadio(const RadioConnectRequest&) override {} + void disconnectRadio() override { connected = false; } + void setSliceFrequency(int, double) override {} + void setSliceMode(int, const QString&) override {} + void setSliceFilter(int, int, int) override {} + void setSliceAgc(int, const QString&, int) override {} + ReceiveDispatch requestSliceWfm(int id, const SliceWfmRequest& request) override + { + if (refuseWfm || !request.valid()) { return ReceiveDispatch::Unsupported; } + if (request.field == SliceWfmRequest::Field::ForceMono) { + setSliceWfmForceMono(id, request.value != 0); + } else { + setSliceWfmDeemphasis(id, request.value); + } + return ReceiveDispatch::Dispatched; + } + void setSliceWfmDeemphasis(int id, int microseconds) override + { requests.append({id, microseconds}); } + void setSliceWfmForceMono(int id, bool mono) override + { monoRequests.append({id, mono}); } + void setPanCenter(const QString&, double, PanCenterIntent) override {} + void setKeying(bool, const TxCoordinator::Operation&, + const TxCoordinator::Completion&) override {} + void invokeExtension(const QString&, const QString&, quint64, const QVariant&) override {} +}; + +WfmReceptionDiagnostics measuredPilot(bool locked = false) +{ + WfmReceptionDiagnostics value; + value.valid = true; + value.pilotMagnitude = locked ? 0.04 : 0.001; + value.pilotEngageThreshold = 0.01; + value.pilotReleaseThreshold = 0.005; + value.engageBlocks = 3; + value.releaseBlocks = 5; + value.pilotLocked = locked; + value.observationDurationMs = 6000; + value.stableDurationMs = 5000; + value.lockDurationMs = locked ? 6000 : 0; + return value; +} + +SliceDelta initialWfm() +{ + SliceDelta delta; + delta.mode = QStringLiteral("WFM"); + delta.frequency = 100.3; + delta.filterLow = -90000; + delta.filterHigh = 90000; + delta.wfmDeemphasisUs = 75; + delta.wfmStereoStatus = WfmStereoStatus::Acquiring; + delta.wfmForceMono = false; + delta.wfmReceptionDiagnostics = measuredPilot(); + return delta; +} + +WfmBackend* attachBackend(RadioModel& model) +{ + auto backend = std::make_unique(); + backend->caps.broadcastFmReceive = BroadcastFmReceive{{50, 75}, true, true}; + backend->caps.receiveFilterControl = ReceiveFilterControl{ + SliceFrequencyControl::Authority::Engine, + {{QStringLiteral("WFM"), -100000, -15000, 15000, 100000, 30000, 200000}}}; + WfmBackend* source = backend.get(); + model.setBackendForTest(std::move(backend), QStringLiteral("test")); + emit source->sliceChanged(3, initialWfm()); + return source; +} + +QPushButton* button(QWidget& widget, const QString& text) +{ + for (QPushButton* candidate : widget.findChildren()) { + if (candidate->text().compare(text, Qt::CaseInsensitive) == 0) { + return candidate; + } + } + return nullptr; +} + +// Mirrors the panel's ownership of complete tile visibility. The child +// must remain showable when the parent tile reconnects or floats. +class WfmHarness : public QWidget { +public: + QWidget* tile; + WfmApplet* applet; + WfmHarness() + { + auto* outer = new QVBoxLayout(this); + tile = new QWidget(this); + auto* inner = new QVBoxLayout(tile); + applet = new WfmApplet(tile); + inner->addWidget(applet); + outer->addWidget(tile); + tile->hide(); + connect(applet, &WfmApplet::availabilityChanged, tile, &QWidget::setVisible); + } +}; + +void checkBroadcastFmVisibility(WfmApplet& applet, bool visible) +{ + QComboBox* combo = applet.findChild(QStringLiteral("wfmDeemphasis")); + QLabel* status = applet.findChild(QStringLiteral("wfmStereoStatus")); + QVERIFY(combo && status); + QWidget* tile = applet.parentWidget(); + QVERIFY(tile && tile->parentWidget()); + QCOMPARE(applet.isAvailable(), visible); + QCOMPARE(tile->isHidden(), !visible); + QVERIFY(!applet.isHidden()); + QCOMPARE(combo->isVisibleTo(tile->parentWidget()), visible); + QCOMPARE(status->isVisibleTo(tile->parentWidget()), visible); + QLayout* layout = tile->parentWidget()->layout(); + QVERIFY(layout); + const int index = layout->indexOf(tile); + QVERIFY(index >= 0); + QCOMPARE(layout->itemAt(index)->isEmpty(), !visible); +} +} // namespace + +class WfmControlsTest : public QObject { + Q_OBJECT +private slots: + void observationsNeverFollowIntentOptimistically() + { + SliceModel slice(3); + QCOMPARE(slice.wfmDeemphasisUs(), 0); + QCOMPARE(slice.wfmStereoStatus(), WfmStereoStatus::Unavailable); + QSignalSpy requested(&slice, &SliceModel::wfmDeemphasisRequested); + QSignalSpy adopted(&slice, &SliceModel::wfmDeemphasisChanged); + QSignalSpy status(&slice, &SliceModel::wfmStereoStatusChanged); + QSignalSpy wire(&slice, &SliceModel::commandReady); + slice.setWfmDeemphasis(50); + QCOMPARE(requested.count(), 1); + QCOMPARE(slice.wfmDeemphasisUs(), 0); + QVERIFY(adopted.isEmpty()); + QVERIFY(wire.isEmpty()); + for (int invalid : {-1, 0, 51, 100}) { slice.setWfmDeemphasis(invalid); } + QCOMPARE(requested.count(), 1); + slice.applyChanges(initialWfm()); + QCOMPARE(slice.wfmDeemphasisUs(), 75); + QCOMPARE(slice.wfmStereoStatus(), WfmStereoStatus::Acquiring); + QCOMPARE(adopted.count(), 1); + QCOMPARE(status.count(), 1); + slice.applyChanges(initialWfm()); + QCOMPARE(adopted.count(), 1); + QCOMPARE(status.count(), 1); + SliceDelta invalid; + invalid.wfmDeemphasisUs = 99; + invalid.wfmStereoStatus = static_cast(99); + slice.applyChanges(invalid); + QCOMPARE(slice.wfmDeemphasisUs(), 75); + QCOMPARE(slice.wfmStereoStatus(), WfmStereoStatus::Acquiring); + for (WfmStereoStatus value : {WfmStereoStatus::Stereo, WfmStereoStatus::Mono, + WfmStereoStatus::Unavailable}) { + SliceDelta delta; + delta.wfmStereoStatus = value; + slice.applyChanges(delta); + QCOMPARE(slice.wfmStereoStatus(), value); + } + QCOMPARE(status.count(), 4); + } + + void intentsRequireLiveOwnedWfmAndDeclaredValue() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + SliceModel* slice = model.slice(3); + QVERIFY(slice); + slice->setWfmDeemphasis(50); + QCOMPARE(source->requests.size(), 1); + QCOMPARE(source->requests.last().first, 3); + QCOMPARE(source->requests.last().second, 50); + QCOMPARE(slice->wfmDeemphasisUs(), 75); + source->caps.broadcastFmReceive.reset(); + slice->setWfmDeemphasis(50); + source->caps.broadcastFmReceive = BroadcastFmReceive{{75}}; + slice->setWfmDeemphasis(50); + source->caps.broadcastFmReceive = BroadcastFmReceive{{50, 75}}; + source->connected = false; + slice->setWfmDeemphasis(50); + source->connected = true; + SliceDelta delta; + delta.mode = QStringLiteral("FM"); + slice->applyChanges(delta); + slice->setWfmDeemphasis(50); + delta.mode = QStringLiteral("WFM"); + slice->applyChanges(delta); + slice->setExternalReceiveAudioReplacementMute(true); + slice->setWfmDeemphasis(50); + slice->setExternalReceiveAudioReplacementMute(false); + std::thread foreignThread([slice] { slice->setWfmDeemphasis(50); }); + foreignThread.join(); + QCOMPARE(source->requests.size(), 1); + emit source->sliceRemoved(3); + QVERIFY(!model.slice(3)); + // The retired object remains alive until deleteLater is delivered. + slice->setWfmDeemphasis(50); + QCOMPARE(source->requests.size(), 1); + } + + void backendRefusalReportsOperatorIntentWithoutChangingObservation() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + SliceModel* slice = model.slice(3); + QVERIFY(slice); + QSignalSpy refused(&model, &RadioModel::commandDropped); + source->refuseWfm = true; + slice->setWfmDeemphasis(50); + slice->setWfmForceMono(true); + QCOMPARE(refused.count(), 2); + QVERIFY(source->requests.isEmpty()); + QVERIFY(source->monoRequests.isEmpty()); + QCOMPARE(slice->wfmDeemphasisUs(), 75); + QVERIFY(!slice->wfmForceMono()); + source->refuseWfm = false; + slice->setWfmDeemphasis(50); + QCOMPARE(source->requests.size(), 1); + QCOMPARE(refused.count(), 2); + } + + void registryKeepsLegacyOfflinePolicyWhileLiveControlsDim() + { + RadioModel model; + QWidget parent; + QWidget legacy(&parent); + QWidget live(&parent); + ControlAvailabilityRegistry registry(model); + const auto never = [](bool, const RadioCapabilities&) { return false; }; + registry.registerWidget(&legacy, QStringLiteral("Unsupported"), never); + registry.registerWidget(&live, QStringLiteral("Connect a receiver"), never, {}, false); + QVERIFY(legacy.isEnabled()); + QVERIFY(!live.isEnabled()); + QVERIFY(!live.isHidden()); + QCOMPARE(live.accessibleDescription(), QStringLiteral("Connect a receiver")); + } + + void keyboardSelectionWaitsForAdoptedStateAndStatusIsObserved() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + SliceModel* slice = model.slice(3); + QVERIFY(slice); + WfmHarness host; + WfmApplet& rx = *host.applet; + QComboBox* combo = rx.findChild(QStringLiteral("wfmDeemphasis")); + QLabel* status = rx.findChild(QStringLiteral("wfmStereoStatus")); + QVERIFY(combo && status); + checkBroadcastFmVisibility(rx, false); + QVERIFY(!combo->isEnabled()); + QVERIFY(!combo->accessibleDescription().isEmpty()); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + rx.setSlice(slice); + checkBroadcastFmVisibility(rx, false); + rx.setRadioModel(&model); + checkBroadcastFmVisibility(rx, true); + QVERIFY(combo->isEnabled()); + QCOMPARE(combo->focusPolicy(), Qt::StrongFocus); + QCOMPARE(combo->currentData().toInt(), 75); + QCOMPARE(status->text(), QStringLiteral("Acquiring")); + QVERIFY(source->requests.isEmpty()); + QTest::keyClick(combo, Qt::Key_Up); + QCOMPARE(source->requests.size(), 1); + QCOMPARE(source->requests.last().second, 50); + QCOMPARE(combo->currentData().toInt(), 75); + SliceDelta delta; + delta.wfmDeemphasisUs = 50; + delta.wfmStereoStatus = WfmStereoStatus::Stereo; + delta.wfmReceptionDiagnostics = measuredPilot(true); + emit source->sliceChanged(3, delta); + QCOMPARE(combo->currentData().toInt(), 50); + QCOMPARE(status->text(), QStringLiteral("Stereo")); + QVERIFY(status->accessibleName().contains(QStringLiteral("Stereo"))); + delta.wfmStereoStatus = WfmStereoStatus::Mono; + delta.wfmReceptionDiagnostics = measuredPilot(false); + emit source->sliceChanged(3, delta); + QCOMPARE(status->text(), QStringLiteral("Mono fallback")); + delta.mode = QStringLiteral("FM"); + emit source->sliceChanged(3, delta); + checkBroadcastFmVisibility(rx, false); + QVERIFY(!combo->isEnabled()); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + delta.mode = QStringLiteral("WFM"); + emit source->sliceChanged(3, delta); + checkBroadcastFmVisibility(rx, true); + QVERIFY(combo->isEnabled()); + // Replacement receive changes the individual controls' availability, + // not whether this supported WFM cluster belongs in the layout. + slice->setExternalReceiveAudioReplacementMute(true); + checkBroadcastFmVisibility(rx, true); + QVERIFY(!combo->isEnabled()); + slice->setExternalReceiveAudioReplacementMute(false); + checkBroadcastFmVisibility(rx, true); + QVERIFY(combo->isEnabled()); + delta.inCapture = false; + delta.wfmStereoStatus = WfmStereoStatus::Unavailable; + emit source->sliceChanged(3, delta); + checkBroadcastFmVisibility(rx, true); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + delta.inCapture = true; + delta.wfmStereoStatus = WfmStereoStatus::Acquiring; + emit source->sliceChanged(3, delta); + checkBroadcastFmVisibility(rx, true); + + QLayout* layout = host.layout(); + QVERIFY(layout); + layout->activate(); + const int visibleHeight = layout->minimumSize().height(); + source->caps.broadcastFmReceive.reset(); + emit model.capabilitiesChanged(true, source->caps); + checkBroadcastFmVisibility(rx, false); + QVERIFY(!combo->isEnabled()); + layout->activate(); + QVERIFY(layout->minimumSize().height() < visibleHeight); + source->caps.broadcastFmReceive = BroadcastFmReceive{{50, 75}, true, true}; + emit model.capabilitiesChanged(true, source->caps); + checkBroadcastFmVisibility(rx, true); + QVERIFY(combo->isEnabled()); + layout->activate(); + QCOMPARE(layout->minimumSize().height(), visibleHeight); + source->connected = false; + emit model.connectionStateChanged(false); + checkBroadcastFmVisibility(rx, false); + QVERIFY(!combo->isEnabled()); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + QVERIFY(!combo->accessibleDescription().isEmpty()); + source->connected = true; + emit model.connectionStateChanged(true); + checkBroadcastFmVisibility(rx, true); + QVERIFY(combo->isEnabled()); + rx.setSlice(nullptr); + checkBroadcastFmVisibility(rx, false); + QVERIFY(!combo->isEnabled()); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + QCOMPARE(source->requests.size(), 1); + } + + void clusterVisibilityWaitsForAcceptedMode() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + SliceModel* slice = model.slice(3); + QVERIFY(slice); + WfmHarness host; + WfmApplet& rx = *host.applet; + rx.setRadioModel(&model); + rx.setSlice(slice); + QSignalSpy requested(slice, &SliceModel::modeChangeRequested); + checkBroadcastFmVisibility(rx, true); + + slice->setMode(QStringLiteral("AM")); + QCOMPARE(requested.count(), 1); + QCOMPARE(slice->mode(), QStringLiteral("WFM")); + checkBroadcastFmVisibility(rx, true); + SliceDelta accepted; + accepted.mode = QStringLiteral("AM"); + emit source->sliceChanged(3, accepted); + checkBroadcastFmVisibility(rx, false); + + slice->setMode(QStringLiteral("WFM")); + QCOMPARE(requested.count(), 2); + QCOMPARE(slice->mode(), QStringLiteral("AM")); + checkBroadcastFmVisibility(rx, false); + accepted.mode = QStringLiteral("WFM"); + emit source->sliceChanged(3, accepted); + checkBroadcastFmVisibility(rx, true); + QVERIFY(source->requests.isEmpty()); + } + + void clusterFollowsSelectedOwnedSliceAndModelBinding() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + SliceDelta otherState = initialWfm(); + otherState.mode = QStringLiteral("FM"); + otherState.filterLow = -8000; + otherState.filterHigh = 8000; + otherState.wfmStereoStatus = WfmStereoStatus::Unavailable; + emit source->sliceChanged(4, otherState); + SliceModel* first = model.slice(3); + SliceModel* second = model.slice(4); + QVERIFY(first && second); + SliceModel foreign(3); + foreign.applyChanges(initialWfm()); + WfmHarness host; + WfmApplet& rx = *host.applet; + rx.setRadioModel(&model); + checkBroadcastFmVisibility(rx, false); + rx.setSlice(first); + checkBroadcastFmVisibility(rx, true); + rx.setSlice(second); + checkBroadcastFmVisibility(rx, false); + + SliceDelta oldSliceUpdate; + oldSliceUpdate.wfmStereoStatus = WfmStereoStatus::Stereo; + emit source->sliceChanged(3, oldSliceUpdate); + checkBroadcastFmVisibility(rx, false); + otherState = initialWfm(); + emit source->sliceChanged(4, otherState); + checkBroadcastFmVisibility(rx, true); + oldSliceUpdate.mode = QStringLiteral("AM"); + emit source->sliceChanged(3, oldSliceUpdate); + checkBroadcastFmVisibility(rx, true); + rx.setSlice(first); + checkBroadcastFmVisibility(rx, false); + emit source->sliceChanged(3, initialWfm()); + checkBroadcastFmVisibility(rx, true); + + // A matching numeric ID does not make a slice owned by this model. + rx.setSlice(&foreign); + checkBroadcastFmVisibility(rx, false); + rx.setSlice(first); + checkBroadcastFmVisibility(rx, true); + rx.setRadioModel(nullptr); + checkBroadcastFmVisibility(rx, false); + rx.setRadioModel(&model); + checkBroadcastFmVisibility(rx, true); + rx.setSlice(nullptr); + checkBroadcastFmVisibility(rx, false); + emit source->sliceChanged(3, oldSliceUpdate); + checkBroadcastFmVisibility(rx, false); + rx.setSlice(second); + checkBroadcastFmVisibility(rx, true); + QVERIFY(source->requests.isEmpty()); + } + + void rxNoLongerContainsBroadcastControls() + { + RxApplet rx; + QVERIFY(!rx.findChild(QStringLiteral("wfmDeemphasis"))); + QVERIFY(!rx.findChild(QStringLiteral("wfmStereoStatus"))); + QVERIFY(!rx.findChild(QStringLiteral("broadcastFmControls"))); + } + + void audioCycleWaitsForAcceptanceAndPreservesObservedPilot() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + WfmHarness host; + WfmApplet& applet = *host.applet; + applet.setRadioModel(&model); + applet.setSlice(model.slice(3)); + QPushButton* cycle = applet.findChild(QStringLiteral("wfmAudioMode")); + QLabel* status = applet.findChild(QStringLiteral("wfmStereoStatus")); + QVERIFY(cycle && status && cycle->isEnabled()); + QCOMPARE(cycle->text(), QStringLiteral("Auto Stereo")); + SliceDelta accepted; + accepted.wfmStereoStatus = WfmStereoStatus::Stereo; + accepted.wfmReceptionDiagnostics = measuredPilot(true); + emit source->sliceChanged(3, accepted); + QCOMPARE(status->text(), QStringLiteral("Stereo")); + const QString stereoStyle = status->styleSheet(); + QTest::keyClick(cycle, Qt::Key_Space); + QCOMPARE(source->monoRequests.size(), 1); + QCOMPARE(source->monoRequests.last().first, 3); + QVERIFY(source->monoRequests.last().second); + QVERIFY(!model.slice(3)->wfmForceMono()); + QCOMPARE(cycle->text(), QStringLiteral("Auto Stereo")); + QCOMPARE(status->styleSheet(), stereoStyle); + accepted.wfmForceMono = true; + accepted.wfmStereoStatus = WfmStereoStatus::Mono; + emit source->sliceChanged(3, accepted); + QCOMPARE(cycle->text(), QStringLiteral("Mono")); + QCOMPARE(status->text(), QStringLiteral("Pilot detected")); + QVERIFY(status->styleSheet() != stereoStyle); + QVERIFY(cycle->accessibleName().contains(QStringLiteral("Mono"))); + cycle->click(); + QCOMPARE(source->monoRequests.size(), 2); + QVERIFY(!source->monoRequests.last().second); + QCOMPARE(cycle->text(), QStringLiteral("Mono")); + accepted.wfmForceMono = false; + accepted.wfmStereoStatus = WfmStereoStatus::Stereo; + emit source->sliceChanged(3, accepted); + QCOMPARE(cycle->text(), QStringLiteral("Auto Stereo")); + QCOMPARE(status->styleSheet(), stereoStyle); + source->caps.broadcastFmReceive->forceMonoControl = false; + emit model.capabilitiesChanged(true, source->caps); + QVERIFY(!cycle->isEnabled()); + QVERIFY(!cycle->accessibleDescription().isEmpty()); + checkBroadcastFmVisibility(applet, true); + cycle->click(); + QCOMPARE(source->monoRequests.size(), 2); + } + + void bandwidthUsesAcceptedEdgesAndCapabilityBounds() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + WfmHarness host; + WfmApplet& applet = *host.applet; + applet.setRadioModel(&model); + applet.setSlice(model.slice(3)); + QComboBox* bandwidth = applet.findChild(QStringLiteral("wfmBandwidth")); + QVERIFY(bandwidth && bandwidth->isEnabled()); + QCOMPARE(bandwidth->currentData().toInt(), 180000); + QSignalSpy requests(model.slice(3), &SliceModel::filterCommandIssued); + QTest::keyClick(bandwidth, Qt::Key_Down); + QCOMPARE(requests.count(), 1); + QCOMPARE(requests.last().at(0).toInt(), -100000); + QCOMPARE(requests.last().at(1).toInt(), 100000); + QCOMPARE(bandwidth->currentData().toInt(), 180000); + SliceDelta accepted; + accepted.filterLow = -70000; + accepted.filterHigh = 80000; + emit source->sliceChanged(3, accepted); + QCOMPARE(bandwidth->currentText(), QStringLiteral("150 kHz (custom)")); + QCOMPARE(requests.count(), 1); + source->caps.receiveFilterControl.reset(); + emit model.capabilitiesChanged(true, source->caps); + QVERIFY(!bandwidth->isEnabled()); + QCOMPARE(model.slice(3)->filterLow(), -70000); + QCOMPARE(model.slice(3)->filterHigh(), 80000); + checkBroadcastFmVisibility(applet, true); + QCOMPARE(requests.count(), 1); + } + + void diagnosticsExpireAndMeasuredTransitionsWarn() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + WfmApplet applet; + applet.setRadioModel(&model); + applet.setSlice(model.slice(3)); + QLabel* status = applet.findChild(QStringLiteral("wfmStereoStatus")); + QLabel* details = applet.findChild(QStringLiteral("wfmDiagnostics")); + QVERIFY(status && details); + SliceDelta observation; + observation.wfmStereoStatus = WfmStereoStatus::Stereo; + observation.wfmReceptionDiagnostics = measuredPilot(true); + emit source->sliceChanged(3, observation); + const QString healthyStyle = status->styleSheet(); + QCOMPARE(status->text(), QStringLiteral("Stereo")); + observation.wfmReceptionDiagnostics->lockLossCount = 1; + observation.wfmReceptionDiagnostics->reacquisitionCount = 1; + observation.wfmReceptionDiagnostics->stableDurationMs = 100; + emit source->sliceChanged(3, observation); + QVERIFY(status->text().contains(QStringLiteral("Unstable"))); + QVERIFY(status->styleSheet() != healthyStyle); + QVERIFY(details->text().contains(QStringLiteral("Lock losses: 1"))); + QVERIFY(details->text().contains(QStringLiteral("Acquire / release: 0.01 / 0.005 relative"))); + QVERIFY(details->text().contains(QStringLiteral("High blocks: 0 / 3"))); + QVERIFY(details->text().contains(QStringLiteral("Pilot state unchanged for 0.1 s (5 s window)"))); + observation.wfmReceptionDiagnostics->stableDurationMs = 5000; + emit source->sliceChanged(3, observation); + QCOMPARE(status->text(), QStringLiteral("Stereo")); + QCOMPARE(status->styleSheet(), healthyStyle); + // Explicit evidence export only: no files or extra waits in the + // default test run. The image names and visible banner identify the + // source as a socket-free fixture, never a live-radio qualification. + const QString screenshotDirectory = qEnvironmentVariable("AETHER_WFM_APPLET_SCREENSHOT_DIR"); + if (!screenshotDirectory.isEmpty()) { + QDir output(screenshotDirectory); + QVERIFY(output.mkpath(QStringLiteral("."))); + QCheckBox* showScope = applet.findChild(QStringLiteral("wfmShowLockScope")); + QCheckBox* showDiagnostics = applet.findChild(QStringLiteral("wfmShowDiagnostics")); + QPushButton* settings = applet.findChild(QStringLiteral("wfmSettingsToggle")); + QVERIFY(showScope && showDiagnostics && settings); + const bool previousScope = showScope->isChecked(); + const bool previousDiagnostics = showDiagnostics->isChecked(); + QLabel banner(QStringLiteral("TEST FIXTURE · synthetic pilot data"), &applet); + ThemeManager::instance().applyStyleSheet(&banner, + "QLabel { color: {{color.accent.warning}}; font-size: 10px; }"); + auto* layout = qobject_cast(applet.layout()); + QVERIFY(layout); + layout->insertWidget(0, &banner); + showScope->setChecked(true); + showDiagnostics->setChecked(true); + settings->setChecked(true); + applet.resize(300, applet.minimumSizeHint().height()); + applet.show(); + // Populate a short, real-time display history at the same 4 Hz + // presentation cadence used by the backend. No receiver is open. + for (int i = 0; i < 16; ++i) { + SliceDelta fixture = observation; + fixture.wfmReceptionDiagnostics->pilotMagnitude = 0.025 + 0.003 * (i % 5); + fixture.wfmReceptionDiagnostics->observationDurationMs += 250 * (i + 1); + fixture.wfmReceptionDiagnostics->observationSequence = i + 1; + emit source->sliceChanged(3, fixture); + QTest::qWait(250); + } + QVERIFY(applet.grab().save(output.filePath(QStringLiteral("wfm-test-fixture-expanded.png")))); + showScope->setChecked(false); + showDiagnostics->setChecked(false); + settings->setChecked(false); + applet.resize(260, applet.minimumSizeHint().height()); + QApplication::processEvents(); + QVERIFY(applet.grab().save(output.filePath(QStringLiteral("wfm-test-fixture-controls.png")))); + showScope->setChecked(previousScope); + showDiagnostics->setChecked(previousDiagnostics); + applet.hide(); + } + // Invalidation clears a previously green status even if a producer + // has not delivered a separate stereo-status change yet. + observation.wfmReceptionDiagnostics = WfmReceptionDiagnostics{}; + emit source->sliceChanged(3, observation); + QCOMPARE(status->text(), QStringLiteral("Unavailable")); + QVERIFY(status->styleSheet() != healthyStyle); + QVERIFY(details->text().startsWith(QStringLiteral("No current receive measurements"))); + observation.wfmStereoStatus = WfmStereoStatus::Mono; + observation.wfmReceptionDiagnostics = measuredPilot(false); + emit source->sliceChanged(3, observation); + QCOMPARE(status->text(), QStringLiteral("Mono fallback")); + QVERIFY(!status->text().contains(QStringLiteral("Unstable"))); + } + + void settingsPersistIndependentDisplaysAndPreserveSiblingValues() + { + AppSettings::instance().setValue(QStringLiteral("WfmApplet"), + QStringLiteral(R"({"other":17,"ui":{"retained":"yes","showLockScope":true,"showDiagnostics":false}})")); + RadioModel model; + attachBackend(model); + { + WfmApplet applet; + applet.setRadioModel(&model); + applet.setSlice(model.slice(3)); + QCheckBox* scope = applet.findChild(QStringLiteral("wfmShowLockScope")); + QCheckBox* diagnostics = applet.findChild(QStringLiteral("wfmShowDiagnostics")); + QLabel* status = applet.findChild(QStringLiteral("wfmStereoStatus")); + QVERIFY(scope && diagnostics && status); + scope->setChecked(false); + QVERIFY(!status->isHidden()); + diagnostics->setChecked(true); + QVERIFY(applet.findChild()->isHidden()); + QVERIFY(!applet.findChild(QStringLiteral("wfmDiagnostics"))->isHidden()); + } + WfmApplet restored; + QVERIFY(!restored.findChild(QStringLiteral("wfmShowLockScope"))->isChecked()); + QVERIFY(restored.findChild(QStringLiteral("wfmShowDiagnostics"))->isChecked()); + const QJsonObject document = QJsonDocument::fromJson(AppSettings::instance() + .value(QStringLiteral("WfmApplet")).toString().toUtf8()).object(); + QCOMPARE(document.value(QStringLiteral("other")).toInt(), 17); + QCOMPARE(document.value(QStringLiteral("ui")).toObject().value(QStringLiteral("retained")).toString(), QStringLiteral("yes")); + AppSettings::instance().remove(QStringLiteral("WfmApplet")); + } + + void scopeIsBoundedAndStopsWhileHidden() + { + QWidget parent; + WfmLockScope scope(&parent); + scope.appendSample(0.04, 0.01, 0.005, WfmStereoStatus::Stereo, false); + QCOMPARE(scope.sampleCount(), 0); + parent.show(); + scope.show(); + for (int i = 0; i < 200; ++i) { + scope.appendSample(0.04, 0.01, 0.005, WfmStereoStatus::Stereo, false); + } + QCOMPARE(scope.sampleCount(), 160); + QAccessibleInterface* accessible = QAccessible::queryAccessibleInterface(&scope); + QVERIFY(accessible); + QVERIFY(accessible->text(QAccessible::Value).contains(QStringLiteral("0.04 relative"))); + QVERIFY(accessible->text(QAccessible::Value).contains(QStringLiteral("Stereo"))); + scope.hide(); + QCOMPARE(scope.sampleCount(), 0); + scope.appendSample(0.04, 0.01, 0.005, WfmStereoStatus::Stereo, false); + QCOMPARE(scope.sampleCount(), 0); + scope.show(); + QCOMPARE(scope.sampleCount(), 0); + scope.appendSample(0.04, 0.01, 0.005, WfmStereoStatus::Stereo, false); + QCOMPARE(scope.sampleCount(), 1); + scope.appendSample(std::numeric_limits::quiet_NaN(), 0.01, 0.005, + WfmStereoStatus::Stereo, false); + QCOMPARE(scope.sampleCount(), 0); + scope.appendSample(0.04, 0.01, 0.005, WfmStereoStatus::Stereo, false); + scope.clear(); + QCOMPARE(scope.sampleCount(), 0); + QVERIFY(accessible->text(QAccessible::Value).contains(QStringLiteral("Awaiting"))); + scope.appendSample(0.04, 0.005, 0.01, WfmStereoStatus::Stereo, false); + QCOMPARE(scope.sampleCount(), 0); + scope.appendSample(0.04, 0.01, 0.005, WfmStereoStatus::Unavailable, false); + QCOMPARE(scope.sampleCount(), 0); + } + + void scopeHistoryClearsAcrossTuneSelectionAndDisconnect() + { + RadioModel model; + WfmBackend* source = attachBackend(model); + WfmHarness host; + WfmApplet& applet = *host.applet; + applet.setRadioModel(&model); + applet.setSlice(model.slice(3)); + host.show(); + WfmLockScope* scope = applet.findChild(); + QVERIFY(scope && scope->isVisible()); + SliceDelta observation; + observation.wfmStereoStatus = WfmStereoStatus::Stereo; + observation.wfmReceptionDiagnostics = measuredPilot(true); + emit source->sliceChanged(3, observation); + QCOMPARE(scope->sampleCount(), 1); + SliceDelta tune; + tune.frequency = 100.5; + emit source->sliceChanged(3, tune); + QCOMPARE(scope->sampleCount(), 0); + observation.wfmReceptionDiagnostics->observationDurationMs += 250; + emit source->sliceChanged(3, observation); + QCOMPARE(scope->sampleCount(), 1); + applet.setSlice(nullptr); + QCOMPARE(scope->sampleCount(), 0); + applet.setSlice(model.slice(3)); + QCOMPARE(scope->sampleCount(), 0); + observation.wfmReceptionDiagnostics->observationDurationMs += 250; + emit source->sliceChanged(3, observation); + QCOMPARE(scope->sampleCount(), 1); + source->connected = false; + emit model.connectionStateChanged(false); + QCOMPARE(scope->sampleCount(), 0); + source->connected = true; + emit model.connectionStateChanged(true); + QCOMPARE(scope->sampleCount(), 0); + observation.wfmReceptionDiagnostics = WfmReceptionDiagnostics{}; + emit source->sliceChanged(3, observation); + QCOMPARE(scope->sampleCount(), 0); + } + + void retiredSliceAndDestroyedModelReleaseBinding() + { + WfmHarness host; + auto model = std::make_unique(); + WfmBackend* source = attachBackend(*model); + host.applet->setRadioModel(model.get()); + host.applet->setSlice(model->slice(3)); + checkBroadcastFmVisibility(*host.applet, true); + emit source->sliceRemoved(3); + checkBroadcastFmVisibility(*host.applet, false); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + emit source->sliceChanged(3, initialWfm()); + // Reusing an ID must not bind the new receiver implicitly. + checkBroadcastFmVisibility(*host.applet, false); + host.applet->setSlice(model->slice(3)); + checkBroadcastFmVisibility(*host.applet, true); + model.reset(); + checkBroadcastFmVisibility(*host.applet, false); + } + + void broadcastFilterPresetsPreserveSavedEdgesInBothWidgets() + { + RadioModel model; + attachBackend(model); + SliceModel* slice = model.slice(3); + QVERIFY(slice); + RxApplet rx; + VfoWidget vfo; + rx.setRadioModel(&model); + vfo.setRadioModel(&model); + rx.setSlice(slice); + vfo.setSlice(slice); + FilterPassbandWidget* passband = rx.findChild(); + QVERIFY(passband && passband->isEnabled()); + QSignalSpy intents(slice, &SliceModel::filterCommandIssued); + for (QWidget* surface : {static_cast(&rx), static_cast(&vfo)}) { + QPushButton* preset = button(*surface, QStringLiteral("180K")); + QVERIFY(preset && preset->isEnabled()); + const int before = intents.count(); + preset->click(); + QCOMPARE(intents.count(), before + 1); + QCOMPARE(intents.last().at(0).toInt(), -90000); + QCOMPARE(intents.last().at(1).toInt(), 90000); + } + AppSettings::instance().setValue(QStringLiteral("FilterPresets_WFM"), + QStringLiteral("-70000:80000")); + rx.setSlice(slice); + vfo.setSlice(slice); + for (QWidget* surface : {static_cast(&rx), static_cast(&vfo)}) { + QPushButton* saved = button(*surface, QStringLiteral("150K")); + QVERIFY(saved && saved->isEnabled()); + const int before = intents.count(); + saved->click(); + QCOMPARE(intents.count(), before + 1); + QCOMPARE(intents.last().at(0).toInt(), -70000); + QCOMPARE(intents.last().at(1).toInt(), 80000); + } + QCOMPARE(AppSettings::instance().value(QStringLiteral("FilterPresets_WFM")).toString(), + QStringLiteral("-70000:80000")); + AppSettings::instance().remove(QStringLiteral("FilterPresets_WFM")); + } +}; + +int main(int argc, char** argv) +{ + TestSettingsProfile profile(QStringLiteral("wfm-controls")); + if (!profile.isValid()) { return 1; } + QApplication app(argc, argv); + AppSettings::instance().load(); + qRegisterMetaType(); + WfmControlsTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "wfm_controls_test.moc" diff --git a/tests/workspace_controller_test.cpp b/tests/workspace_controller_test.cpp index ecd3347c25..450cc10975 100644 --- a/tests/workspace_controller_test.cpp +++ b/tests/workspace_controller_test.cpp @@ -803,6 +803,54 @@ int main(int argc, char** argv) report("...both ways", inner->isContainerVisible()); } + // A capability/mode-specific applet can suppress its presentation + // without closing the workspace item. Recall still owns logical open + // state, including a workspace that deliberately closed the applet. + { + ctl.switchWorkspace(wsA); + rx->setContainerVisible(true); + if (!rx->isOnCanvas()) { ctl.sendAppletToCanvas("RX"); } + const QString openWorkspace = ctl.createWorkspace( + WorkspaceController::NewWorkspaceSource::Current, + QStringLiteral("PresentationOpen")); + const QString closedWorkspace = ctl.createWorkspace( + WorkspaceController::NewWorkspaceSource::Current, + QStringLiteral("PresentationClosed")); + rx->setContainerVisible(false); + ctl.switchWorkspace(openWorkspace); + const QString beforeGate = storedDocument(); + const QVariant beforePreference = AppSettings::instance().value("Applet_RX"); + const NormRect beforeRect = canvas.itemRect("applet:RX"); + rx->setPresentationAvailable(false); + report("presentation suppression preserves workspace bytes and preference", + storedDocument() == beforeGate + && AppSettings::instance().value("Applet_RX") == beforePreference + && rx->isContainerVisible() && rx->isOnCanvas() + && canvas.itemRect("applet:RX") == beforeRect && rx->isHidden()); + ctl.switchWorkspace(closedWorkspace); + ctl.switchWorkspace(openWorkspace); + report("recall retains logical membership without exposing suppressed content", + rx->isContainerVisible() && rx->isOnCanvas() + && canvas.contains("applet:RX") && rx->isHidden() + && canvas.itemRect("applet:RX") == beforeRect); + const QString beforeResume = storedDocument(); + rx->setPresentationAvailable(true); + report("resuming presentation preserves recalled workspace intent", + rx->isVisible() && storedDocument() == beforeResume + && AppSettings::instance().value("Applet_RX") == beforePreference); + rx->setPresentationAvailable(false); + ctl.switchWorkspace(closedWorkspace); + const QString beforeClosedResume = storedDocument(); + rx->setPresentationAvailable(true); + report("availability cannot reopen a workspace's deliberately closed applet", + !rx->isContainerVisible() && rx->isHidden() + && !canvas.contains("applet:RX") + && storedDocument() == beforeClosedResume); + ctl.switchWorkspace(wsA); + ctl.deleteWorkspace(openWorkspace); + ctl.deleteWorkspace(closedWorkspace); + } + // Red-team B2: recall-driven bulk visibility runs inside the // recall guard, so the panel can suppress preference writes. { diff --git a/third_party/wdsp/AETHERSDR-PATCHES.md b/third_party/wdsp/AETHERSDR-PATCHES.md index ca67db487c..7d27a52f92 100644 --- a/third_party/wdsp/AETHERSDR-PATCHES.md +++ b/third_party/wdsp/AETHERSDR-PATCHES.md @@ -1106,3 +1106,135 @@ mutex to the acquisition-side getter. Keep the discriminator/DC changes explicitly opt-in unless a separately reviewed upstream equivalent preserves both continuous-waveform response and low-frequency stereo phase. Regenerate the FIR from its polynomial and rerun analytic fixtures if its geometry changes. + + +## Patch 20 — prepared WFM exchange headroom and output publication (RFC #5468) + +`upstream/channel.{h,c}` and `upstream/iobuffs.c` add an immutable prepared +`exchangeDepth` per channel. `OpenChannelWithExchangeDepth()` rejects channel +indices outside the table and depths outside 2..8 before modifying any state, +then shares the existing construction body. Its other arguments retain the +ordinary `OpenChannel()` contract. Every legacy `OpenChannel()` explicitly +selects `DSP_MULT` (still 2), including a reused former WFM slot. Existing +size/rate rebuilds retain the prepared field. Both creation and flush seed the +same output prefix from `r2_active_buffsize - r2_size`. + +The facade selects 8 only for its opt-in nonblocking `WbfmReceive` recipe: +384 kHz / 2048 IQ input, 192 kHz / 1024 DSP, 48 kHz / 256 paired output. +Blocking WFM, legacy WBFM, other RX, and TX retain depth 2. Measured continuous +8192-sample extraction callbacks realize a maximum of seven complete IQ +exchanges at the lowest approved capture rates (225001 and 250000 Hz). The +composed converter bound is seven; no wall-clock scheduler assumption is used +to derive it. The tests-only RED against `2e44eb04` accepted two exchanges with +the worker held after its first handoff, then reported the actual `Underrun`. +Live first-fault telemetry had independently observed `Underrun` with continuous +capture, so a missing capture packet is not needed to produce this failure. + +At this geometry the two rings occupy 288 KiB per depth-8 channel, versus +72 KiB at depth 2: 216 KiB additional prepared memory. Seven seeded output +blocks plus the existing previous-chain-output worker stage delay audio by +2048 frames / 42.667 ms, exactly six blocks / 1536 frames / 32 ms more than +blocking depth 2. These are exchange-stage costs only; RF filtering, rate +conversion, decoder, mixer waiting and device buffering are additional. One +callback's bound does not promise survival of arbitrary host starvation or +multiple queued callbacks without worker progress. Depth 8 is not a relaxed +mixer deadline and does not extend the existing 2048-frame hole-wait policy. + +For equal exchange/DSP transfers, S successful host calls and P worker +handoffs satisfy S-P <= D-1. The worker has copied each input before publishing +its corresponding credit. The next host write therefore cannot overwrite an +unread input before the first failure. The failing call can occupy the final +slot, so no further call is safe: every non-OK still withdraws that receiver, +and repair prepares a new epoch off acquisition. No retries or substitute silence on underrun, +callback allocations, new waits or new threads are introduced. + +A separate existing publication race incremented `r2_havesamps` before copying +the output bytes. `dexchange()` now copies the output and advances its private +write index first, then publishes credit under the existing count lock. The +copy remains outside that lock. This shared correctness repair is not claimed +as the established cause of the observed underrun. + +The default-inactive port rendezvous immediately before `copy_output_to_ring` +copies bytes and the test/control-only locked output-count readback provide +scheduler-independent regression barriers. A test-only `csDSP` rendezvous, +after count readiness and before arming a hold, also lets the previous block's +post-publication hook finish, so it cannot claim the next block's hold. The +count query takes `csEXCH` then the count lock to cover asynchronous flush. +Production calls none of these readiness helpers. Fixtures serialize the global +holds, keep other workers idle, and release before teardown/reprepare. +`wdsp_wbfm_test` compares nonzero paired output against the unchanged blocking +reference through a warm eight-exchange held burst, wraparound, and release, +with the exact six-block displacement removed. It checks repeated preparation, +clocked flush, legacy slot reuse, bounded API refusal, and genuinely unpublished +output: seven seeded calls succeed, the eighth must underrun while the copy is +held. Moving publication before the helper must fail that assertion. +`rtl_wfm_pipeline_test` acknowledges the first completed handoff, accepts eight +calls total, then requires the ninth's actual underrun, one typed first fault, +receiver withdrawal, no stale PCM, and a fresh nonzero stereo PCM repair epoch. +These are socket-free deterministic DSP tests, not hardware latency proof. + +On upstream refresh, retain the explicit legacy depth reset, prepared field on +all rebuilds, matching create/flush prefix, and bytes-before-credit edge unless +upstream supplies equivalent semantics. Do not increase global `DSP_MULT`, +weaken non-OK withdrawal, or move the test readiness query onto acquisition. + + +## Patch 21 — selected WFM mono and bounded pilot reception snapshot + +Local changes to `upstream/wbfm.c`, `upstream/wbfm.h`, the public WDSP header, +and the narrow `include/aether_wdsp.h` / `aether_wbfm_observation.h` facade, +against the same pinned upstream 2.10 revision. This is an AetherSDR extension, +not an upstream PLL or a calibrated signal-quality measurement. + +`SetRXAWBFMForceMono` selects the existing L+R-only matrix branch while leaving +INDY and automatic squelch running. The default remains Auto Stereo. RTL sets +this only while preparing an immutable receiver recipe; accepted state and +persistence follow the matching receiver revision, including failure/rollback. +A changed recipe retires both slice and speaker PCM epochs. Other WDSP owners +retain the false default, and rebuilding a configured receiver reapplies its +selection. Mono is real identical-channel audio, not merely a changed badge. Packet/model +stereo status describes the actual selected output: forced mono remains Mono +even when the independent reception diagnostics report an acquired pilot. + +`GetRXAWBFMReception` publishes one coherent latest-completed-block snapshot. +The worker publishes a fixed sequence plus 17 interlocked 32-bit payload words; +each exact double is copied into two words, never read nonatomically across +threads. A reader makes at most four attempts and returns unavailable on a +collision. No DSP mutex, wait, allocation, wall clock or logging enters the +acquisition path. Both publication and reading use the port's existing +sequentially consistent interlocked operations. The sequence advances only on +completed blocks or an explicitly invalidating reset, allowing backend freshness +to reject repeated cached observations while exchange credits are consumed. + +The amplitude is INDY's actual `sqrt(filtIstable^2 + filtQstable^2)` after the +19 kHz filter and before pilot AGC, in FM-discriminator units normalized to +75 kHz peak deviation. It is not RF power, dBm, dBFS, SNR, a quality percentage, +or PLL phase/frequency/lock. Exact low/high thresholds and consecutive-low/high +block counters plus their configured off/on block counts are included. At +192 kHz / 1,024 frames those thresholds are 0.03/0.06 and the integer hysteresis +counts are 187/93; readback uses the actual fields rather than duplicated UI +constants. Absent pilot is ordinary mono fallback. + +Lock duration, observation duration and time since either indicator transition +are computed from processed DSP samples. The recent stable duration is capped +at 5,000 ms. Loss and reacquisition counts cover every completed block between +UI updates; the first acquisition is excluded from reacquisitions. Durations +and counts saturate at `INT32_MAX`. Construction, flush, rate/size rebuild and +new receiver lifetimes invalidate/reset the measurements. These durations do +not include audio-ring/device latency or assert wall-clock continuity. + +The RTL owner publishes diagnostics at most four times per second plus actual +indicator transitions/invalidation, under the accepted PCM session/revision. +Only a new valid decoder sequence refreshes its 500 ms freshness clock. Retune, +park, mode/session changes and expiry clear the model's reception claim; raw +measurements are never persisted. Processing health remains a separate surface. + +Generated `wdsp_wbfm_test` vectors cover exact nonzero paired Mono output with +an honestly retained pilot, Auto Stereo restoration, measured hysteresis, +loss/reacquisition and flush reset. `rtl_wfm_pipeline_test` checks both the +independent tap and speaker with new epochs. Settings/model fixtures cover +request-versus-adoption, strict boolean decoding, old-document defaults, replay +freshness and retired observations. These tests are added validation obligations; +their results must come from a subsequent exact-source Nobara run. On upstream +refresh retain this extension unless equivalent typed, bounded, race-free +semantics replace it, and do not label INDY as a PLL. diff --git a/third_party/wdsp/README.md b/third_party/wdsp/README.md index b7dea38e86..3865c00377 100644 --- a/third_party/wdsp/README.md +++ b/third_party/wdsp/README.md @@ -24,6 +24,9 @@ therefore retains its upstream fallback behavior. recorded in `AETHERSDR-PATCHES.md`. The WFM integration adds patch 14: live paired deemphasis, lock-free stereo observation, complete decoder reset, and opt-in phase-difference/DC corrections that preserve stereo separation. +Patch 15 prepares depth-8 exchange rings only for nonblocking opt-in WFM, +retains legacy depth 2, and publishes copied output before its ring credit. +Its fixed memory/latency costs and deterministic tests are in the patch registry. Existing WDSP overlap-save RF filtering is exposed through the narrow C facade without changing the vendor filter. All portability changes live outside it: diff --git a/third_party/wdsp/include/aether_wbfm_observation.h b/third_party/wdsp/include/aether_wbfm_observation.h new file mode 100644 index 0000000000..9c2854d44e --- /dev/null +++ b/third_party/wdsp/include/aether_wbfm_observation.h @@ -0,0 +1,17 @@ +#pragma once +#include + +// C-only data at the vendor boundary; latest completed decoder block, not the +// delayed output ring's exact audio timestamp. Caller owns channel lifetime. +// Pilot magnitude/thresholds are exact normalized-discriminator values before +// pilot AGC. Durations count DSP samples, milliseconds saturate at INT32_MAX. +// Stereo means the hysteretic INDY result, independently of forced-mono output. +typedef struct AetherWdspWbfmObservation { + uint32_t observationSequence; + int valid; + int pilotLocked; + uint32_t lockDurationMs, lockLossCount, reacquisitionCount; + uint32_t observationDurationMs, stableDurationMs; + uint32_t consecutiveHighBlocks, consecutiveLowBlocks, engageBlocks, releaseBlocks; + double pilotMagnitude, pilotReleaseThreshold, pilotEngageThreshold; +} AetherWdspWbfmObservation; diff --git a/third_party/wdsp/include/aether_wdsp.h b/third_party/wdsp/include/aether_wdsp.h index 5797c941cc..3e3c52c400 100644 --- a/third_party/wdsp/include/aether_wdsp.h +++ b/third_party/wdsp/include/aether_wdsp.h @@ -1,6 +1,7 @@ #pragma once #include +#include "aether_wbfm_observation.h" #ifdef __cplusplus extern "C" { @@ -27,6 +28,13 @@ void OpenChannel(int channel, int inputSize, int dspSize, int inputSampleRate, int dspSampleRate, int outputSampleRate, int type, int state, double delayUp, double slewUp, double delayDown, double slewDown, int blockForOutput); +// Prepared ring depth, fixed for this channel lifetime and its rebuilds. +// Returns 0 before touching the channel for an invalid channel/depth; otherwise +// the ordinary OpenChannel parameter/lifetime contract applies. Depth is 2..8. +int OpenChannelWithExchangeDepth(int channel, int inputSize, int dspSize, + int inputSampleRate, int dspSampleRate, int outputSampleRate, int type, + int state, double delayUp, double slewUp, double delayDown, double slewDown, + int blockForOutput, int exchangeDepth); void CloseChannel(int channel); // Channel run state. state 1 = running, 0 = stopped. dmode 1 makes a stop // BLOCK until the channel has flushed (bounded by WDSP's own 100 ms timeout), @@ -52,6 +60,10 @@ void SetRXAMode(int channel, int mode); void SetRXAWBFMDiscriminatorCompensation(int channel, int enabled); void SetRXAWBFMdmph(int channel, int run, int continent); int GetRXAWBFMStereoIndicator(int channel); +// Construction/control only; actual matrix output becomes paired L+R in mono. +void SetRXAWBFMForceMono(int channel, int forceMono); +// Fixed four-attempt atomic snapshot; 0 means retry later, never wait on DSP. +int GetRXAWBFMReception(int channel, AetherWdspWbfmObservation* observation); // Host-owned WFM RF prefilter. WBFM intentionally disables RXA's internal // RF filters, so this existing overlap-save stage runs at the input rate @@ -451,6 +463,20 @@ uint64_t wdspPortOutstandingAllocations(void); // which is the window in which #5734's input overwrite happened. 0 (the // default) is a single relaxed load and no sleep. Process-global. void wdspPortSetHandoffPauseForTest(unsigned microseconds); +// TEST ONLY: arm the next worker at that same handoff; only the claiming worker +// waits. Poll the acknowledgement outside acquisition. Serialize fixtures and +// release (enabled=0) before retiring/closing that worker or arming another. +void wdspPortSetHandoffHoldForTest(int enabled); +int wdspPortHandoffHeldForTest(void); +// Same lifetime/serialization contract, but immediately BEFORE output memcpy. +void wdspPortSetOutputCopyHoldForTest(int enabled); +int wdspPortOutputCopyHeldForTest(void); +// Locks existing exchange then ring-count sections. Test/control only; caller owns +// an open channel and excludes destruction. Never an acquisition readiness API. +int GetChannelOutputSamplesForTest(int channel); +// After count readiness, wait for the previous handoff hook/DSP to finish. +// Test/control only; never call with either worker hold armed or entered. +void SynchronizeChannelWorkerForTest(int channel); #ifdef __cplusplus } diff --git a/third_party/wdsp/port/wdsp_port.c b/third_party/wdsp/port/wdsp_port.c index 82e3a35a50..8f77f0748d 100644 --- a/third_party/wdsp/port/wdsp_port.c +++ b/third_party/wdsp/port/wdsp_port.c @@ -6,6 +6,10 @@ * shutdown through its run flags and semaphores. */ +// Default-inactive, one-worker test rendezvous at the existing handoff hook. +// Inactive hooks only read their state; no shipping scheduling change. +enum WdspHandoffHoldState { WDSP_HOLD_OFF, WDSP_HOLD_ARMED, WDSP_HOLD_ENTERED }; + #ifndef _WIN32 #include "wdsp_port.h" @@ -42,6 +46,8 @@ static _Atomic uint64_t g_allocationSequence = 0; static _Atomic uint64_t g_outstandingAllocations = 0; // AetherSDR patch 13: see wdspPortHandoffPauseForTest() below. static _Atomic unsigned g_handoffPauseMicroseconds = 0; +static _Atomic int g_handoffHoldState = WDSP_HOLD_OFF; +static _Atomic int g_outputCopyHoldState = WDSP_HOLD_OFF; static uint64_t monotonicMilliseconds(void) { @@ -422,8 +428,58 @@ void wdspPortSetHandoffPauseForTest(unsigned microseconds) atomic_store_explicit(&g_handoffPauseMicroseconds, microseconds, memory_order_relaxed); } +void wdspPortSetOutputCopyHoldForTest(int enabled) +{ + atomic_store_explicit(&g_outputCopyHoldState, + enabled ? WDSP_HOLD_ARMED : WDSP_HOLD_OFF, memory_order_release); +} + +int wdspPortOutputCopyHeldForTest(void) +{ + return atomic_load_explicit(&g_outputCopyHoldState, memory_order_acquire) == WDSP_HOLD_ENTERED; +} + +void wdspPortOutputCopyHoldForTest(void) +{ + if (atomic_load_explicit(&g_outputCopyHoldState, memory_order_relaxed) == WDSP_HOLD_ARMED) + { + int expected = WDSP_HOLD_ARMED; + if (atomic_compare_exchange_strong_explicit(&g_outputCopyHoldState, &expected, + WDSP_HOLD_ENTERED, memory_order_acq_rel, memory_order_relaxed)) + { + while (atomic_load_explicit(&g_outputCopyHoldState, memory_order_acquire) == WDSP_HOLD_ENTERED) + { + Sleep(1); + } + } + } +} + +void wdspPortSetHandoffHoldForTest(int enabled) +{ + atomic_store_explicit(&g_handoffHoldState, + enabled ? WDSP_HOLD_ARMED : WDSP_HOLD_OFF, memory_order_release); +} + +int wdspPortHandoffHeldForTest(void) +{ + return atomic_load_explicit(&g_handoffHoldState, memory_order_acquire) == WDSP_HOLD_ENTERED; +} + void wdspPortHandoffPauseForTest(void) { + if (atomic_load_explicit(&g_handoffHoldState, memory_order_relaxed) == WDSP_HOLD_ARMED) + { + int expected = WDSP_HOLD_ARMED; + if (atomic_compare_exchange_strong_explicit(&g_handoffHoldState, &expected, + WDSP_HOLD_ENTERED, memory_order_acq_rel, memory_order_relaxed)) + { + while (atomic_load_explicit(&g_handoffHoldState, memory_order_acquire) == WDSP_HOLD_ENTERED) + { + Sleep(1); + } + } + } const unsigned microseconds = atomic_load_explicit(&g_handoffPauseMicroseconds, memory_order_relaxed); if (microseconds != 0) @@ -534,14 +590,62 @@ uint64_t wdspPortOutstandingAllocations(void) // millisecond resolution, so a non-zero request rounds UP to whole milliseconds // -- a longer window, never a shorter one. static volatile LONG g_handoffPauseMicroseconds = 0; +static volatile LONG g_handoffHoldState = WDSP_HOLD_OFF; +static volatile LONG g_outputCopyHoldState = WDSP_HOLD_OFF; void wdspPortSetHandoffPauseForTest(unsigned microseconds) { InterlockedExchange(&g_handoffPauseMicroseconds, (LONG)microseconds); } +void wdspPortSetOutputCopyHoldForTest(int enabled) +{ + InterlockedExchange(&g_outputCopyHoldState, enabled ? WDSP_HOLD_ARMED : WDSP_HOLD_OFF); +} + +int wdspPortOutputCopyHeldForTest(void) +{ + return InterlockedCompareExchange(&g_outputCopyHoldState, 0, 0) == WDSP_HOLD_ENTERED; +} + +void wdspPortOutputCopyHoldForTest(void) +{ + // Aligned LONG reads are atomic on Windows. Only an armed test performs a + // modifying interlocked operation, so inactive workers do not contend. + if (g_outputCopyHoldState == WDSP_HOLD_ARMED && + InterlockedCompareExchange(&g_outputCopyHoldState, WDSP_HOLD_ENTERED, + WDSP_HOLD_ARMED) == WDSP_HOLD_ARMED) + { + while (InterlockedCompareExchange(&g_outputCopyHoldState, 0, 0) == WDSP_HOLD_ENTERED) + { + Sleep(1); + } + } +} + +void wdspPortSetHandoffHoldForTest(int enabled) +{ + InterlockedExchange(&g_handoffHoldState, enabled ? WDSP_HOLD_ARMED : WDSP_HOLD_OFF); +} + +int wdspPortHandoffHeldForTest(void) +{ + return InterlockedCompareExchange(&g_handoffHoldState, 0, 0) == WDSP_HOLD_ENTERED; +} + void wdspPortHandoffPauseForTest(void) { + // Aligned LONG reads are atomic on Windows. Only an armed test performs a + // modifying interlocked operation, so inactive workers do not contend. + if (g_handoffHoldState == WDSP_HOLD_ARMED && + InterlockedCompareExchange(&g_handoffHoldState, WDSP_HOLD_ENTERED, + WDSP_HOLD_ARMED) == WDSP_HOLD_ARMED) + { + while (InterlockedCompareExchange(&g_handoffHoldState, 0, 0) == WDSP_HOLD_ENTERED) + { + Sleep(1); + } + } const LONG microseconds = InterlockedCompareExchange(&g_handoffPauseMicroseconds, 0, 0); if (microseconds != 0) { diff --git a/third_party/wdsp/upstream/channel.c b/third_party/wdsp/upstream/channel.c index bf9f9bc83d..c4b973cf7f 100644 --- a/third_party/wdsp/upstream/channel.c +++ b/third_party/wdsp/upstream/channel.c @@ -81,10 +81,12 @@ void build_channel (int channel) post_main_build (channel); } -PORT -void OpenChannel (int channel, int in_size, int dsp_size, int input_samplerate, int dsp_rate, int output_samplerate, - int type, int state, double tdelayup, double tslewup, double tdelaydown, double tslewdown, int bfo) +// AetherSDR patch 15: one construction path. The depth is prepared before +// allocating either ring and survives the existing size/rate rebuilds. +static void open_channel (int channel, int in_size, int dsp_size, int input_samplerate, int dsp_rate, int output_samplerate, + int type, int state, double tdelayup, double tslewup, double tdelaydown, double tslewdown, int bfo, int exchangeDepth) { + ch[channel].exchangeDepth = exchangeDepth; ch[channel].in_size = in_size; ch[channel].dsp_size = dsp_size; ch[channel].in_rate = input_samplerate; @@ -109,6 +111,28 @@ void OpenChannel (int channel, int in_size, int dsp_size, int input_samplerate, _MM_SET_FLUSH_ZERO_MODE (_MM_FLUSH_ZERO_ON); } +PORT +void OpenChannel (int channel, int in_size, int dsp_size, int input_samplerate, int dsp_rate, int output_samplerate, + int type, int state, double tdelayup, double tslewup, double tdelaydown, double tslewdown, int bfo) +{ + // Explicit on EVERY legacy open, including a slot previously used at depth 8. + open_channel (channel, in_size, dsp_size, input_samplerate, dsp_rate, output_samplerate, + type, state, tdelayup, tslewup, tdelaydown, tslewdown, bfo, DSP_MULT); +} + +PORT +int OpenChannelWithExchangeDepth (int channel, int in_size, int dsp_size, int input_samplerate, int dsp_rate, int output_samplerate, + int type, int state, double tdelayup, double tslewup, double tdelaydown, double tslewdown, int bfo, int exchangeDepth) +{ + if (channel < 0 || channel >= MAX_CHANNELS || exchangeDepth < 2 || exchangeDepth > 8) + { + return 0; + } + open_channel (channel, in_size, dsp_size, input_samplerate, dsp_rate, output_samplerate, + type, state, tdelayup, tslewup, tdelaydown, tslewdown, bfo, exchangeDepth); + return 1; +} + void pre_main_destroy (int channel) { IOB a = ch[channel].iob.pc; diff --git a/third_party/wdsp/upstream/channel.h b/third_party/wdsp/upstream/channel.h index 88da7e6aae..0d94a4a142 100644 --- a/third_party/wdsp/upstream/channel.h +++ b/third_party/wdsp/upstream/channel.h @@ -50,6 +50,7 @@ struct _ch double tslewup; double tdelaydown; double tslewdown; + int exchangeDepth; // AetherSDR patch 15: prepared ring slots; legacy Open resets to DSP_MULT int bfo; // 'block_for_output', block fexchange until output is available volatile long flushflag; struct //io buffers @@ -63,6 +64,8 @@ extern struct _ch ch[]; PORT void OpenChannel (int channel, int in_size, int dsp_size, int input_samplerate, int dsp_rate, int output_samplerate, int type, int state, double tdelayup, double tslewup, double tdelaydown, double tslewdown, int bfo); +PORT int OpenChannelWithExchangeDepth (int channel, int in_size, int dsp_size, int input_samplerate, int dsp_rate, int output_samplerate, int type, int state, double tdelayup, double tslewup, double tdelaydown, double tslewdown, int bfo, int exchangeDepth); + PORT void CloseChannel (int channel); extern void flushChannel (void* p); diff --git a/third_party/wdsp/upstream/iobuffs.c b/third_party/wdsp/upstream/iobuffs.c index 1cde690ae6..6722451f97 100644 --- a/third_party/wdsp/upstream/iobuffs.c +++ b/third_party/wdsp/upstream/iobuffs.c @@ -30,6 +30,7 @@ warren@wpratt.com // Declared here rather than in a port header so the same line serves the POSIX // port and the native Windows build alike. void wdspPortHandoffPauseForTest (void); +void wdspPortOutputCopyHoldForTest (void); /******************************************************************************************************** * * @@ -404,16 +405,16 @@ void create_iobuffs (int channel) a->r2_size = a->out_size; else a->r2_size = a->r2_insize; - a->r1_active_buffsize = DSP_MULT * a->r1_size; - a->r2_active_buffsize = DSP_MULT * a->r2_size; + a->r1_active_buffsize = ch[channel].exchangeDepth * a->r1_size; + a->r2_active_buffsize = ch[channel].exchangeDepth * a->r2_size; a->r1_baseptr = (double*) malloc0 (a->r1_active_buffsize * sizeof (complex)); a->r2_baseptr = (double*) malloc0 (a->r2_active_buffsize * sizeof (complex)); a->r1_inidx = 0; a->r1_outidx = 0; a->r1_unqueuedsamps = 0; - a->r2_inidx = (DSP_MULT - 1) * a->r2_size; + a->r2_inidx = (a->r2_active_buffsize - a->r2_size); a->r2_outidx = 0; - a->r2_havesamps = (DSP_MULT - 1) * a->r2_size; + a->r2_havesamps = (a->r2_active_buffsize - a->r2_size); n = a->r2_havesamps / a->out_size; a->r2_unqueuedsamps = a->r2_havesamps - n * a->out_size; InitializeCriticalSectionAndSpinCount(&a->r2_ControlSection, 2500); @@ -504,9 +505,9 @@ void flush_iobuffs (int channel) a->r1_inidx = 0; a->r1_outidx = 0; a->r1_unqueuedsamps = 0; - a->r2_inidx = (DSP_MULT - 1) * a->r2_size; + a->r2_inidx = (a->r2_active_buffsize - a->r2_size); a->r2_outidx = 0; - a->r2_havesamps = (DSP_MULT - 1) * a->r2_size; + a->r2_havesamps = (a->r2_active_buffsize - a->r2_size); while (!WaitForSingleObject (a->Sem_BuffReady, 1)); n = a->r2_havesamps / a->out_size; a->r2_unqueuedsamps = a->r2_havesamps - n * a->out_size; @@ -650,6 +651,42 @@ void fexchange2 (int channel, INREAL *Iin, INREAL *Qin, OUTREAL *Iout, OUTREAL * } } +// Test/control-thread observation only. Caller excludes channel teardown. +// Production exchange does not call this or poll for worker readiness. +PORT +int GetChannelOutputSamplesForTest (int channel) +{ + IOB a = ch[channel].iob.pc; + int samples; + // flush_iobuffs resets the count under csEXCH, outside the count lock. + // Test-only observation takes the same order as fexchange, covering both. + EnterCriticalSection (&ch[channel].csEXCH); + EnterCriticalSection (&a->r2_ControlSection); + samples = a->r2_havesamps; + LeaveCriticalSection (&a->r2_ControlSection); + LeaveCriticalSection (&ch[channel].csEXCH); + return samples; +} + +// After the output-count barrier proves all submitted handoffs were published, +// this control/test-only rendezvous also waits for the last hook and DSP body. +// Never call while a test hold is armed/entered; production does not call it. +PORT +void SynchronizeChannelWorkerForTest (int channel) +{ + EnterCriticalSection (&ch[channel].csDSP); + LeaveCriticalSection (&ch[channel].csDSP); +} + +static void copy_output_to_ring (IOB a, const double* in) +{ + // Default-inactive fixture rendezvous BEFORE the actual bytes are copied. + wdspPortOutputCopyHoldForTest (); + memcpy (a->r2_baseptr + 2 * a->r2_inidx, in, a->r2_insize * sizeof (complex)); + if ((a->r2_inidx += a->r2_insize) == a->r2_active_buffsize) + a->r2_inidx = 0; +} + int dexchange (int channel, double* in, double* out) { int n; @@ -696,13 +733,9 @@ int dexchange (int channel, double* in, double* out) if ((a->r1_outidx += a->r1_outsize) == a->r1_active_buffsize) a->r1_outidx = 0; - // AetherSDR patch 15: PUBLISH AFTER THE COPY. Upstream counts the block - // into r2_havesamps before it is in r2, so a host that exchanges the moment - // GetChannelOutputReady() says 1 could read a slot still being written. - // See AETHERSDR-PATCHES.md patch 15. - memcpy (a->r2_baseptr + 2 * a->r2_inidx, in, a->r2_insize * sizeof (complex)); - if ((a->r2_inidx += a->r2_insize) == a->r2_active_buffsize) - a->r2_inidx = 0; + // AetherSDR patches 15/17: publish only bytes already copied. The memcpy stays + // OUTSIDE the existing short credit lock; no longer acquisition lock span. + copy_output_to_ring (a, in); EnterCriticalSection (&a->r2_ControlSection); a->r2_havesamps += a->r2_insize; LeaveCriticalSection (&a->r2_ControlSection); diff --git a/third_party/wdsp/upstream/wbfm.c b/third_party/wdsp/upstream/wbfm.c index 3a8660edf6..74d7cb5fb0 100644 --- a/third_party/wdsp/upstream/wbfm.c +++ b/third_party/wdsp/upstream/wbfm.c @@ -25,6 +25,7 @@ warren@pratt.one #define _CRT_SECURE_NO_WARNINGS #include "comm.h" +#include "../include/aether_wbfm_observation.h" /******************************************************************************************************** * * @@ -744,6 +745,72 @@ static void decalc_wbfm (WBFM a) _aligned_free(a->disc_out); } +// Counters measure decoder work, independent of GUI sampling and audio-ring +// latency. Publishing all words atomically avoids a C data race during retry. +static uint32_t reception_ms(uint64_t samples, double rate) +{ + double value = 1000.0 * (double)samples / rate; + return value >= INT_MAX ? INT_MAX : (uint32_t)value; +} + +static void publish_reception(WBFM a, int valid) +{ + uint32_t words[17] = {0}; + double values[3] = {a->mag19, a->pIndy->low_thresh, a->pIndy->high_thresh}; + words[0] = valid; + words[1] = a->stereo; + words[2] = reception_ms(a->lockedSamples, a->rate); + words[3] = a->lockLossCount; + words[4] = a->reacquisitionCount; + words[5] = reception_ms(a->receptionSamples, a->rate); + words[6] = min(5000U, reception_ms(a->stableSamples, a->rate)); + words[7] = a->pIndy->consecutive_high_frames; + words[8] = a->pIndy->consecutive_low_frames; + words[9] = a->pIndy->on_delay_frames; + words[10] = a->pIndy->off_delay_frames; + memcpy(&words[11], values, sizeof(values)); + // Stay within signed 32-bit long on Windows; wrap only the sequence. + long sequence = InterlockedAnd(&a->receptionPublished[0], ~0L); + long next = (long)(((unsigned long)sequence + 2UL) & 0x7ffffffeUL); + InterlockedExchange(&a->receptionPublished[0], sequence + 1); + for (int i = 0; i < 17; ++i) { + InterlockedExchange(&a->receptionPublished[i + 1], (long)words[i]); + } + InterlockedExchange(&a->receptionPublished[0], next); +} + +static void reset_reception(WBFM a) +{ + a->stereo = 0; a->mag19 = 0.0; + InterlockedExchange(&a->stereoPublished, 0); + a->receptionSamples = a->lockedSamples = a->stableSamples = 0; + a->lockLossCount = a->reacquisitionCount = 0; + a->receptionPreviousStereo = a->receptionHadPilot = 0; + publish_reception(a, 0); +} + +static void observe_reception(WBFM a) +{ + // Saturation in samples prevents all long-session arithmetic overflow. + const uint64_t maximum = (uint64_t)INT_MAX * (uint64_t)a->rate / 1000; + a->receptionSamples = min(maximum, a->receptionSamples + (uint64_t)a->size); + if (a->stereo != a->receptionPreviousStereo) { + a->stableSamples = 0; + a->lockedSamples = 0; + if (a->stereo) { + if (a->receptionHadPilot && a->reacquisitionCount < INT_MAX) { ++a->reacquisitionCount; } + a->receptionHadPilot = 1; + } else if (a->lockLossCount < INT_MAX) { ++a->lockLossCount; } + } else { + a->stableSamples = min(maximum, a->stableSamples + (uint64_t)a->size); + } + if (a->stereo && a->receptionPreviousStereo) { + a->lockedSamples = min(maximum, a->lockedSamples + (uint64_t)a->size); + } else { a->lockedSamples = 0; } + a->receptionPreviousStereo = a->stereo; + publish_reception(a, 1); +} + WBFM create_wbfm (int run, int size, double* in, double* out, double rate) { WBFM a = (WBFM)malloc0(sizeof(wbfm)); @@ -759,6 +826,7 @@ WBFM create_wbfm (int run, int size, double* in, double* out, double rate) a->dmph = 1; a->dmph_type = 0; calc_wbfm(a); + reset_reception(a); return a; } @@ -808,6 +876,7 @@ void flush_wbfm(WBFM a) a->psql->npwr.bqsec[section].y_z2 = 0.0; } InterlockedExchange(&a->stereoPublished, 0); + reset_reception(a); } void xwbfm(WBFM a) @@ -825,12 +894,13 @@ void xwbfm(WBFM a) xsdelay(a->del15); xsdelay(a->del53); dsb_demod(a->size, a->fil19_out, a->fil53_out, a->LmR); - mx(a->size, a->stereo, a->LpR, a->LmR, a->L, a->R, a->sqgain); + mx(a->size, a->stereo && !a->force_mono, a->LpR, a->LmR, a->L, a->R, a->sqgain); if (a->disc_compensate) audio_dc_block(a); xdmph(a->dmphL); xdmph(a->dmphR); combine(a->size, a->L, a->R, a->out); InterlockedExchange(&a->stereoPublished, a->stereo); + observe_reception(a); } else if (a->in != a->out) memcpy(a->out, a->in, a->size * sizeof(complex)); @@ -847,6 +917,7 @@ void setSamplerate_wbfm(WBFM a, int rate) decalc_wbfm(a); a->rate = rate; calc_wbfm(a); + reset_reception(a); } void setSize_wbfm(WBFM a, int size) @@ -854,6 +925,7 @@ void setSize_wbfm(WBFM a, int size) decalc_wbfm(a); a->size = size; calc_wbfm(a); + reset_reception(a); } @@ -896,6 +968,44 @@ void SetRXAWBFMdmph(int channel, int dmph_run, int dmph_continent) LeaveCriticalSection(&ch[channel].csDSP); } +PORT +void SetRXAWBFMForceMono(int channel, int forceMono) +{ + EnterCriticalSection(&ch[channel].csDSP); + rxa[channel].wbfm.p->force_mono = forceMono != 0; + LeaveCriticalSection(&ch[channel].csDSP); +} + +PORT +int GetRXAWBFMReception(int channel, AetherWdspWbfmObservation* observation) +{ + if (!observation) { return 0; } + WBFM a = rxa[channel].wbfm.p; + for (int attempt = 0; attempt < 4; ++attempt) { + long sequence = InterlockedAnd(&a->receptionPublished[0], ~0L); + if (sequence & 1) { continue; } + uint32_t words[17]; + for (int i = 0; i < 17; ++i) { + words[i] = (uint32_t)InterlockedAnd(&a->receptionPublished[i + 1], ~0L); + } + if (sequence != InterlockedAnd(&a->receptionPublished[0], ~0L)) { continue; } + AetherWdspWbfmObservation result = {0}; + result.observationSequence = (uint32_t)sequence; + result.valid = words[0]; result.pilotLocked = words[1]; + result.lockDurationMs = words[2]; result.lockLossCount = words[3]; + result.reacquisitionCount = words[4]; result.observationDurationMs = words[5]; + result.stableDurationMs = words[6]; result.consecutiveHighBlocks = words[7]; + result.consecutiveLowBlocks = words[8]; result.engageBlocks = words[9]; + result.releaseBlocks = words[10]; + double values[3]; memcpy(values, &words[11], sizeof(values)); + result.pilotMagnitude = values[0]; result.pilotReleaseThreshold = values[1]; + result.pilotEngageThreshold = values[2]; + *observation = result; + return 1; + } + return 0; +} + PORT int GetRXAWBFMStereoIndicator(int channel) { diff --git a/third_party/wdsp/upstream/wbfm.h b/third_party/wdsp/upstream/wbfm.h index 168935c272..15d49f60b6 100644 --- a/third_party/wdsp/upstream/wbfm.h +++ b/third_party/wdsp/upstream/wbfm.h @@ -27,6 +27,7 @@ warren@pratt.one #define _wbfm_h #include "firmin.h" +#include #include "wcpAGC.h" //Forward declarations: @@ -110,6 +111,13 @@ typedef struct _wbfm int stereo; // AetherSDR: lock-free publication of the last completed decoder block. long stereoPublished; + // AetherSDR: all publication words use interlocked accesses. Word zero is + // a sequence, the remaining fixed words form one coherent bounded snapshot. + long receptionPublished[18]; + uint64_t receptionSamples, lockedSamples, stableSamples; + uint32_t lockLossCount, reacquisitionCount; + int receptionPreviousStereo, receptionHadPilot; + int force_mono; double sqgain; double mag19; int dmph; diff --git a/third_party/wdsp/upstream/wdsp.h b/third_party/wdsp/upstream/wdsp.h index e1afaccefb..a15086c64b 100644 --- a/third_party/wdsp/upstream/wdsp.h +++ b/third_party/wdsp/upstream/wdsp.h @@ -786,7 +786,10 @@ WDSP_API int GetWDSPVersion (void); /* ---- wbfm.c ------------------------------------------------------------ */ +#include "../include/aether_wbfm_observation.h" WDSP_API int GetRXAWBFMStereoIndicator (int channel); +WDSP_API void SetRXAWBFMForceMono (int channel, int forceMono); +WDSP_API int GetRXAWBFMReception (int channel, AetherWdspWbfmObservation* observation); WDSP_API void SetRXAWBFMdmph (int channel, int dmph_run, int dmph_continent); /* ---- wcpAGC.c ---------------------------------------------------------- */