Feature/wfm demod: add WideFM software demodulator via DAX IQ#3407
Conversation
|
Hi @ea5wa — thanks for putting this together! CI is red on all three platforms ( The big one — Windows-only sources added to cross-platform targetsIn
All of them Fix: wrap those entries in if(WIN32)
list(APPEND CORE_SOURCES
src/core/WfmDemodulator.cpp
src/core/WaveOutWriter.cpp
src/core/WaveInReader.cpp
)
list(APPEND GUI_SOURCES
src/gui/WfmDeviceDialog.cpp
)
endif()Good news — A few smaller cleanups while you're in there
Really appreciate the contribution — WFM demod via DAX IQ is a great addition. Once the sources are Win32-gated this should turn green on all three platforms. 🤖 aethersdr-agent · cost: $5.2795 · model: claude-opus-4-7 |
|
Hi @ea5wa — thank you for the work, the on-air verification against 9k6 satellite telemetry through hs-soundmodem is exactly the kind of end-to-end test that earns review time. I want to redirect on architecture before you invest more cycles. @aethersdr-agent already nailed the immediate CI diagnosis (Win32-only sources added to cross-platform CMake lists, three hardcoded `C:/Users/reigc/wfm_debug.txt` paths, stray `.gitignore` entry), but adding `if(WIN32)` gates isn't the right fix here. The project ruleEvery feature in AetherSDR should be cross-platform unless it specifically addresses a platform-only problem. AetherSDR exists to give Linux/macOS operators feature-parity with the Windows-only SmartSDR client — see AGENTS.md "Project Goal". A Windows-only feature inside AetherSDR re-creates the gap the project is trying to close. Examples already in tree that ARE genuinely platform-specific because they address a platform-only problem: `MacNRFilter` (macOS-only neural NR using Apple's framework), the Windows WASAPI 24k-artifact policy (#2120), the macOS Bluetooth-HFP guard (#2615). They're each solving a platform-shaped bug, not just convenient to ship that way. Why WFM doesn't qualifyLooking at the diff, the Win32 dependency is only in the audio output stage — `WaveOutWriter` wraps `waveOut` to deliver Int16 PCM to a Virtual Audio Cable. The actual demodulation chain in `WfmDemodulator` is pure math (atan2 discriminator + 75 µs de-emphasis + volume scaling) and would run identically on every platform. The use case ("route demodulated audio into another application") also exists on every platform:
All three loopback drivers register as ordinary audio output devices, so they'd show up in `QMediaDevices::audioOutputs()` with no special handling. The architectural changeReplace `WaveOutWriter` with `QAudioSink`. It's part of Qt 6 Multimedia (already a dependency), works on Win32/CoreAudio/PipeWire/PulseAudio/ALSA, and the existing AetherSDR audio stack already routes through it — see `AudioEngine::startRxStream()` for the canonical pattern (opens a `QAudioSink` on a selected `QAudioDevice` at a negotiated format, writes float PCM to its `QIODevice*` from a timer). You'll also want to replace `WfmDeviceDialog` with a standard Qt device picker — enumerate `QMediaDevices::audioOutputs()`, store the selected ID in `AppSettings` under a `"WFM"` nested-JSON root (Constitution Principle V — flat `"WfmAudioDevice"` keys are non-compliant; see `docs/audio-pipeline.md` or `CwDecodeSettings.h` for the pattern). Once that's done:
Bonus: there's a foundation coming that fits this exactlyWe just landed #3397 (and #3398 is in flight) — the consolidated audio sink/rate negotiation factory (`AudioFormatNegotiator` + `AudioDeviceNegotiator`, tracked in #3306). Once #3398 lands, WFM's output stage can go through that helper for free: it'll handle the 48 kHz preferred rate, the Int16-only-device fallback for VAC clones, and the per-OS preferred-format ladder without you having to encode any of it in WFM-specific code. You don't have to wait for #3398 to refactor — `QAudioSink` direct works today. But if the wait is easier, that's also fine. What I'd like next
I'm happy to point at more of the existing patterns or sketch the `QAudioSink` wrapper if it'd help. The demod chain itself is great work — I just want it usable for everyone. 73, KK7GWY |
M7HNF-Ian
left a comment
There was a problem hiding this comment.
Nice work on this, Juan Carlos — and welcome! 👋 The DSP core here is genuinely solid: the atan2 phase-difference discriminator, the incremental-rotation frequency shifter with per-block phasor renormalization, and the limiter are all done properly. That's the hard part and you nailed it.
I had a read through and there are a few things that'll need sorting before this can go green. Grouping them so it's easy to work through:
Build-blocking (CI will fail on these):
-
macOS / Linux builds —
WaveOutWriter.handWaveInReader.hinclude<windows.h>/<mmsystem.h>and use the Win32waveOut/waveInAPIs, but the three new files are added toCORE_SOURCESinCMakeLists.txtunconditionally. The MainWindow call sites are nicely guarded with#ifdef Q_OS_WIN, but the core files themselves aren't, so the macOS-DMG and AppImage jobs won't compile. These need to be gated to Windows-only (conditional source inclusion in CMake + the file bodies guarded). -
Hardcoded debug log path — both
WfmDemodulator.cppandWaveOutWriter.cpphave awfmLog()that writes toC:/Users/reigc/wfm_debug.txt. Looks like leftover debugging — it opens/closes the file on every call (including in the audio path). Worth removing, or switching to the project'sqCDebug()logging category if you want to keep the diagnostics. -
.gitignore— theResumen para empezar de nuevo.txtentry looks like a personal scratch file that snuck in; probably wants dropping from this PR. -
Leftover
qDebug()counters inDaxIqModel.cpp(s_feedCount/s_emitCount) — same deal, debug remnants.
Smaller stuff:
- The summary mentions a 75 µs de-emphasis filter, but I don't see one in
processSamples(). For 9k6 G3RUH FSK you actually want flat discriminator audio, so I think the code is right and the description just needs a tweak. m_agcGainis declared but never used.
One bigger thing I'll leave for @ten9876 since he's reviewing: the primary IQ path opens the SmartSDR DAX waveIn device directly, with the native VITA-49 DaxIqModel as fallback. Given AetherSDR is a native client, it might be worth discussing whether the VITA-49 path should be the primary one — but that's a design call above my pay grade. 😄
Really promising feature though — fed-FM-into-soundmodem is a great use case. Happy to help if any of the above is unclear. 73!
|
Agree with @ten9876's direction here — the |
Adds a software FM demodulator that receives IQ samples from DAX IQ RX, demodulates them and routes audio to a selected output device (e.g. Hi-Fi Cable) at 48 kHz. New files: - WfmDemodulator: FM discriminator, de-emphasis, volume control - WaveInReader / WaveOutWriter: Win32 audio I/O wrappers - WfmDeviceDialog: output device picker with remember-choice option Wiring: - DaxIqModel: expose iqSamplesReady signal to feed the demodulator - RxApplet / VfoWidget: WFM toggle button (visible in FM/NFM mode only) - MainWindow: activateWFM/deactivateWFM, widens IF filter to ±20 kHz, centers panadapter on slice frequency, restores filter on deactivation - CMakeLists: register new source files
- CMakeLists: move WfmDemodulator, WaveOutWriter, WaveInReader, and WfmDeviceDialog into if(WIN32) block — these files use waveIn/waveOut Win32 APIs unavailable on macOS and Linux - WaveOutWriter, WaveInReader, WfmDemodulator: remove wfmLog() helper that wrote to a hardcoded local path (C:/Users/reigc/wfm_debug.txt); replace all calls with qDebug().noquote() and remove unused QFile include
…platform) Replace the Windows-only WaveOutWriter (waveOut API) and WaveInReader (waveIn API) with cross-platform Qt6 equivalents so WFM demod works on Windows, macOS, and Linux without platform guards in CMake. Changes: - WaveOutWriter: rewritten using QAudioSink; device selected by QAudioDevice::id() (persistent opaque string) instead of a human-readable name fragment - WaveInReader: removed — the VITA-49 DaxIqModel::iqSamplesReady path is cross-platform and already works; the Win32 DAX waveIn path is no longer needed - WfmDemodulator: simplified to use only the VITA-49 IQ path; updated all debug output to qCDebug(lcAudio) - WfmDeviceDialog: rewritten using QMediaDevices::audioOutputs() and QAudioDevice::id() — works on all platforms - WfmSettings: new header-only settings class following constitution Principle V (nested JSON under 'WFM' key); migrates the legacy flat 'WfmAudioDevice' AppSettings key on first access - CMakeLists: WFM sources moved back to unconditional CORE_SOURCES / GUI_SOURCES — no WIN32 guard needed - MainWindow: resolveAudioDevice() updated to use WfmSettings API
|
Thanks for the detailed architectural guidance Refactor complete in ef5b0e9: WaveOutWriter — rewritten using WaveInReader — removed entirely. The VITA-49 WfmDeviceDialog — rewritten using WfmSettings — new header-only settings class following Principle V: all WFM settings live under a single WfmDemodulator — simplified to the single cross-platform VITA-49 path; all debug output now goes through CMakeLists — WFM sources are back in the unconditional I don't have a macOS or Linux test box available right now — happy to re-verify on Windows once CI confirms the other platforms are green. If you're able to run the Linux side I'd appreciate it. Compiled without errors. Tomorrow I'll test it on windows 11 73 de EA5WA Juan Carlos |
|
I'm having problems to have a good 48kHz output with QAudioSink. At the moment, no valid output for UZ7HO soundmodem...... 73 de EA5WA Juan Carlos |
Replace the Windows-only waveIn/waveOut path with a portable Qt6 stack:
IQ input (primary, Windows):
QAudioSource reads from the SmartSDR DAX IQ capture device
("DAX IQ RX 1"). DAX delivers IQ as stereo Int16 PCM (L=I, R=Q).
This avoids conflicting with DAX's own VITA-49 stream ownership.
IQ input (fallback, Linux/macOS):
VITA-49 DaxIqModel::iqSamplesReady signal, unchanged.
Audio output:
WaveOutWriter uses QAudioSink in push mode driven by a 10 ms QTimer.
The timer calls bytesFree() and pushes exactly that many bytes from
a thread-safe ring buffer (WfmRingBuffer, 1 s capacity).
Output format is Float32 stereo, universally supported by WASAPI,
CoreAudio, and PipeWire/PulseAudio.
Other fixes:
- Add class WfmDemodulator forward declaration to MainWindow.h
- Guard three TMate2/m_hidEncoder call sites with #ifdef HAVE_HIDAPI
to unblock compilation when hidapi is not installed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Now is working.... Signal pathKey design decisions
Files changed
Known pending itemWaterfall polarity seems inverted, I have to confirm it with a real satellite pass tomorrow. A UI toggle (flip polarity without recompiling) may be added as a follow-up if needed. 73 de EA5WA Juan Carlos |
The hs-soundmodem waterfall was showing the FM audio spectrum inverted (energy at high frequencies, blue at low), preventing satellite packet decoding (G3RUH 9600 baud, LilacSat-2 GMSK 4800 baud). Root causes and fixes: 1. Spectral polarity: confirmed -atan2(cross, dot) as the correct discriminator sign for the DAX IQ RX 1 channel order (L=I, R=Q), producing the expected green/yellow at low frequencies in soundmodem. 2. FM noise parabola: added unit-circle normalization before the discriminator. Without it, FM discriminator noise power rises with f², making the high-frequency end of the waterfall appear dominant even with no signal. Normalization makes the noise spectrally flat. 3. Gain calibration: lowered GAIN from 1.0 to 0.5 to avoid clipping after normalization; discriminator output now stays comfortably within [-1, 1] for typical satellite signal levels. 4. Audio output rate: open QAudioSink at the actual IQ capture rate (queried from the DAX device) rather than the hardcoded constant, keeping input and output sample rates in sync. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Chain: IQ → atan2 FM discriminator → FIR LP (95-tap Hamming, fc=20kHz)
→ 2-pole IIR slope compensator (α=0.80, fc≈1.5kHz) → QAudioSink
Problem: the FM discriminator produces noise with f² spectral shape
(FM noise parabola), causing the soundmodem waterfall to appear inverted
(more energy at high frequencies than low).
Fix:
- FIR low-pass: 95 taps, order 94, Hamming window, fc=20kHz @ 48kHz,
h[n]=h[N-1-n] (symmetric/linear-phase), Σh=1 (no DC notch).
Passband flat 0–18kHz, stopband ≥53dB from 22kHz.
- 2-pole IIR compensator after FIR: two cascaded first-order LPF with
α=0.80 (fc≈1.5kHz). Combined response ∝1/f² above fc, which cancels
the f² FM noise parabola giving a gently falling noise floor.
Net spectrum: −5 dB from 1→5kHz, −10 dB from 1→10kHz.
- GAIN=3.0 for adequate output level (G3RUH peak ≈±0.6, no clipping).
- MainWindow: real-time Doppler correction via setFreqOffsetHz() on
slice frequency changes.
Result: soundmodem waterfall shows warm colours (red/yellow) at low
frequencies and cool colours (blue) at high frequencies, both with and
without satellite signal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a Direct Form I biquad peaking EQ after the FM noise-parabola compensator to fill the external spectral notch the FlexRadio DAX IQ path leaves around 10–13.5 kHz (≈ fs/4). centre 11.75 kHz, Q = 2.0, +9 dB (Audio EQ Cookbook) b0=1.23561 b1=-0.05778 b2=0.50529 a1=-0.05778 a2=0.74092 DC gain = 1.0, Nyquist gain = 1.0 (peak localised, rest untouched) poles at |z|=0.86 → stable Gated by m_eqEnabled (default true); set false to bypass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…VAC underflow fix. Principle XI. Replicates the receive chain of SkyRoof (VE3NEA), whose output HS-SoundModem decodes reliably, replacing the EQ/notch compensators: - WfmDsp (new, unit-tested): pure DSP core — phase-continuous NCO mix-down, twin r8brain resamplers to exactly 48 kHz (flat to 0.95*Nyquist), atan2 discriminator, 95-tap linear-phase FIR. No de-emphasis, squelch or EQ in the data path. - WfmDemodulator: capture the DAX IQ endpoint at its NATIVE rate; forcing 48 kHz made the OS mixer resample silently (a 24 k DAX stream upsampled by Windows has no energy above +-12 kHz — the 10-13.5 kHz waterfall notch). Idle-input gate (-80 dBFS, with hysteresis) writes silence when the IQ stream stops: the atan2 discriminator is amplitude-invariant and demodulates idle-channel dither into full-scale noise. - WaveOutWriter: zero-fill the sink on ring underflow (SkyRoof's RingBuffer.ReadBytes policy) — HiFi Cable/VB-Cable loop their internal buffer when the writer stalls, which the soundmodem saw as repeating garbage after stopping the IQ stream. - MainWindow: centre the pan on the slice once at WFM activation, then ride Doppler on the demodulator NCO (pan and DAX IQ centre stay fixed); recentre only if the slice leaves the usable IQ window (maxFreqOffsetHz). Without the NCO, a slice offset beyond ~8 kHz produced a DC term that hard-clipped the demodulated audio. - Rigctl/TCI: in-span retunes use autopan=0 so Doppler steps from satellite trackers (SatPC32) stop yanking the pan. - tests/wfm_dsp_test.cpp: 13 checks pin DC removal, 96k->48k tone integrity, chunked==single-shot continuity, offset window policy. Verified on air: flat soundmodem waterfall, silence on IQ stop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # src/gui/MainWindow.cpp # src/gui/MainWindow.h
Summary of changes
Verified on air: flat soundmodem waterfall (no EQ compensators), clean silence on IQ stop, G3RUH 9600 bd decoding through a full Doppler swing. 73 de EA5WA Juan Carlos |
M7HNF-Ian
left a comment
There was a problem hiding this comment.
Pulled the branch and tested locally on macOS (Apple Silicon): wfm_dsp_test builds and all 13 checks pass. The DSP core is genuinely excellent — the NCO/DC-term test pinning dc=1.25 uncorrected → 0.000 corrected, and bit-identical chunked-vs-single-shot output, are exactly the right invariants for the soundmodem use case. Nice work.
Before this can merge, though, there's one blocking issue and a few smaller ones:
1. The merge from main clobbered recent upstream VfoWidget work (blocking). Comparing the PR against current main, three landed features get reverted in VfoWidget.cpp:
- Reverse mouse-wheel tuning (#3302 / ece02d1) — the
InteractionSettings.hinclude andreverseMouseWheel()check inwheelEventare removed - The ESC
+180phase button and its handler are deleted entirely - Four
adjustSize()calls (the #3383 layout-flush fix) revert toresize(sizeHint())
Looks like the merge in 415be2b resolved conflicts toward the older branch copy. Easiest fix: git checkout upstream/main -- src/gui/VfoWidget.cpp and re-apply just your WFM additions (the m_wfmBtn block, the signal, and the two syncFromSlice/setSlice hunks).
2. Leftover debug counters in DaxIqModel.cpp — s_feedCount/s_emitCount qDebug() blocks are still in feedRawIqPacket/processIqPacket (flagged in the earlier round). Should be dropped or moved to qCDebug(lcAudio).
3. samplesReady is emitted unconditionally for every IQ packet — DaxIqWorker::processIqPacket now heap-allocates a QVector<float> copy and queues a cross-thread signal per packet even when WFM is inactive, which taxes the existing DAX IQ level-meter path for all users. Cheap fix: guard with isSignalConnected(QMetaMethod::fromSignal(&DaxIqWorker::samplesReady)).
4. Cooldown re-entrancy can leak a running demodulator. activateWFM() calls deactivateWFM(), but if a second activation arrives inside the 1 s cooldown window (e.g. user flips WFM on slice A then slice B), deactivateWFM() early-returns and m_wfmDemod is overwritten — the old demodulator keeps capturing and writing to the VAC until app exit, and both write to the same device. Suggest making deactivateWFM() always clean up the demod and have the cooldown only debounce the UI-initiated toggle-off path.
5. Every non-WFM mode change deactivates WFM globally — the RxApplet mode-combo handler emits wfmActivated(false, …) for any mode selection, so changing mode on slice B kills WFM running on slice A. Probably wants a slice-ID check in the handler.
6. .gitignore — the Resumen para empezar de nuevo.txt entry is still in the diff (third time's the charm 😄).
For the maintainers (@ten9876): the RigctlProtocol/TciProtocol change is a behaviour change for all CAT users, not just WFM — in-span retunes no longer recenter the pan. I think it's the right default (and the cross-band case still recenters), but it deserves an explicit sign-off and a CHANGELOG line since #536 originally mandated recenter-always.
On the red check-windows: that failure is not this PR — it's the windows-latest runner image mid-rollout to CMake 4, which hard-errors on hidapi 0.14.0's old cmake_minimum_required in setup-hidapi.ps1. Same script passes on runners still on CMake 3.x. I'll put up a separate one-line fix (-DCMAKE_POLICY_VERSION_MINIMUM=3.5) — once that merges, a rebase/re-run here should go green.
Verified locally: ✅ wfm_dsp_test 13/13 on macOS arm64. 73!
Signed-off-by: ea5wa Juan Carlos <54686689+ea5wa@users.noreply.github.com>
…, fix demod leak. Principle XI. Review round on the WFM PR: - VfoWidget: the branch carried an old copy that silently reverted three landed features (reverse mouse-wheel aethersdr#3302, ESC +180 button, adjustSize layout flushes aethersdr#3383). Reset both files to upstream/main and re-applied only the WFM additions (m_wfmBtn block, wfmActivated signal, setSlice/syncFromSlice sync hunks) — diff vs main is now 27 additions, 0 removals. - DaxIqModel: dropped leftover s_feedCount/s_emitCount qDebug blocks. Worker now emits the implicitly-shared QByteArray (no per-packet QVector copy); the model relay converts to QVector<float> only when iqSamplesReady has a consumer, so the level-meter path no longer pays for WFM when it is inactive. - MainWindow: deactivateWFM() always cleans up — the cooldown debounce moved into the wfmActivated handlers, so a second activation inside the 1 s window can no longer leak a running demodulator writing to the same VAC. Handlers are also slice-gated: a mode change on slice B no longer kills WFM running on slice A. - .gitignore: dropped the local scratch-file entry. - CHANGELOG: Unreleased section documenting the WFM feature and the rigctl/TCI in-span no-recenter behaviour change for maintainer sign-off (aethersdr#536 cross-band recenter preserved). Verified: full Windows build clean, wfm_dsp_test 13/13 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ple XI. Both features fight over the pan centre: Pan Follow recentres on every slice-0 frequency change, while the WFM demodulator deliberately holds the pan fixed and rides Doppler on its NCO. Recentring underneath an active demodulator desyncs the NCO offset until the next slice retune. WFM wins while active on the pan; precedence is flagged for maintainer review in the PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
check-windows builds with cl.exe, where M_PI is not declared unless _USE_MATH_DEFINES is defined before <cmath>. The local MinGW build defines it as a GNU extension, which masked the error. C++20 std::numbers::pi is portable across all three CI toolchains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Update on the red |
…CI fix) Conflict resolution in src/gui/MainWindow.cpp followed the aethersdr#3351 extraction: took upstream's refactored constructor and ported the two WFM-branch additions to their new homes — the VfoWidget::wfmActivated connect into wireVfoWidget() (MainWindow_Wiring.cpp) and the HAVE_HIDAPI guard around the FlexControl isTMate2() call (MainWindow_Controllers.cpp). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for the persistence on this one, @ea5wa — it's come a long way. The earlier review feedback is all addressed: the output stage is now cross-platform QAudioSink, settings live under a single nested "WFM" JSON key with migrateLegacy() (Principle V ✓, AppSettings not QSettings ✓), the hardcoded C:/Users/reigc/... debug paths and the stray .gitignore line are gone, and the DSP is extracted into a pure, headless WfmDsp with real unit tests (tests/wfm_dsp_test.cpp). CI is green on build, check-macos, and check-windows. The NCO-rides-Doppler / fixed-pan design is well-reasoned and the rationale comments are genuinely good.
A few things worth addressing before merge — none are crashes, mostly scope and tidy-up:
1. CAT autopan change reaches beyond WFM (RigctlProtocol.cpp, TciProtocol.cpp).
The in-span setFrequency / out-of-span tuneAndRecenter split changes set_freq/VFO behaviour for every rigctl and TCI user, not just WFM — it revises the #536 behaviour. The logic itself looks correct (null pan → inSpan=false → falls back to the old recenter path, preserving #536 for the case it was written for), and it's documented in the CHANGELOG. But it's logically separable from the demodulator and affects people who never touch WFM. Flagging it for maintainer visibility — ideally its own PR, or at minimum an explicit call-out so it's a conscious behaviour change rather than a rider.
2. MainWindow_Controllers.cpp HIDAPI guard is unrelated to WFM.
The #ifdef HAVE_HIDAPI around m_hidEncoder->isTMate2() is a build workaround (correct in itself — m_hidEncoder is HIDAPI-gated). Per @M7HNF-Ian, the Windows CI breakage was already fixed on main by #3509. Recommend rebasing onto current main and dropping this hunk if it's no longer needed, so the PR diff stays scoped to the feature.
3. Dead "WFM" mode-combo branch in RxApplet.cpp.
"WFM" is never added to m_modeCombo (the static list and the dynamic modeListChanged path only carry real radio modes — WFM is a separate button), so if (mode == "WFM") is unreachable. More notably, the handler now emits wfmActivated(false, …) on every non-WFM mode change. That's a harmless no-op in MainWindow when WFM is off, but it means an FM↔NFM combo change while WFM is active will tear down the demod. If that's intended, fine — but the dead "WFM" branch should go, and it's worth confirming the FM↔NFM case is the behaviour you want.
4. The two WFM toggles can desync.
RxApplet::m_wfmButton and VfoWidget::m_wfmBtn are independent checkable buttons with no shared state. Activate via one and the other stays unchecked; deactivate via a VfoWidget mode change and the RxApplet button stays green while the demod is actually off. Not a crash, but the UI will lie. Consider reflecting the real demod state back onto both controls (e.g. a MainWindow signal both subscribe to).
5. Minor: the while (true) in resolveAudioDevice (activateWFM) always returns on the first iteration — there's no retry path, so it can be a plain block.
The demod chain and test coverage are the strong part here — nice work isolating it so it's provable rather than tuned by eye. Items 1 and 2 (scope) are the main ones I'd like resolved; the rest are quick cleanups. 73!
🤖 aethersdr-agent · cost: $3.8033 · model: claude-opus-4-8
Signed-off-by: ea5wa Juan Carlos <54686689+ea5wa@users.noreply.github.com>
…ersdr#3407) Addresses the aethersdr-agent review (pullrequestreview-4491533302) on the WFM software demodulator PR. - RxApplet: remove the dead `if (mode == "WFM")` branch in the mode-combo handler. "WFM" is never an entry in m_modeCombo (it has its own toggle button), so the branch was unreachable; selecting any real radio mode already tears down the WFM overlay via the unconditional wfmActivated(false) emit, which is now the only path. No behaviour change. - MainWindow::activateWFM: collapse the single-iteration `while (true)` in resolveAudioDevice into a plain block — there was no retry path, so it always returned on the first pass. - Keep RxApplet::m_wfmButton and VfoWidget::m_wfmBtn in sync with the real demod state. Previously the two per-slice WFM toggles were independent: activating from one left the other unchecked, and a mode-change teardown left the button green while the demod was off, so the UI could lie. MainWindow now pushes the authoritative state onto both surfaces (new reflectWfmButtons()) on every activate/deactivate, including the cancel and start-failure paths so the triggering button un-sticks. The new setWfmActive() setters self-gate on their slice and block the button signal, so there is no feedback loop. The bot's "big one" (Win32-gated sources) and the hardcoded debug-path / stray .gitignore findings were already resolved by the QAudioSink rewrite. The cross-cutting CAT autopan=0 change is intentional and is called out under "Behaviour change (CAT)" in CHANGELOG.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@ea5wa — I pushed a small maintainer fix-up to your branch ( Fixed in the push:
Left as-is (with rationale):
One thing to confirm (not changed): with the dead branch gone, a mode change on the WFM slice (e.g. FM↔NFM) still tears down the demod via Built clean locally and the full suite (34 tests, incl. |
NF0T
left a comment
There was a problem hiding this comment.
Long road to get here, but the current head is solid. Verified the full diff at c811b72 against every outstanding item.
All six of @M7HNF-Ian's second-review items resolved:
- VfoWidget clobber —
VfoWidget.cppdiff is +32/-0; no deletions from main. Reverse mouse-wheel tuning, ESC +180 phase button, andadjustSize()calls are all intact in the current head. DaxIqModeldebug counters —s_feedCount/s_emitCountare entirely absent from the diff. TheprocessIqPackethunk adds only theemit samplesReady(...)line and removesQ_UNUSED(sampleRate).samplesReadyunconditional heap allocation — correctly restructured.DaxIqWorker::samplesReadycarries aQByteArray(implicitly shared ref-count, no copy). TheQVector<float>conversion only happens inDaxIqModel::relayIqSamplesafterisSignalConnected(QMetaMethod::fromSignal(&DaxIqModel::iqSamplesReady))returns true. When WFM is inactive: one cheap cross-thread ref-count per packet, no heap allocation.- Cooldown re-entrancy / demodulator leak —
deactivateWFM()always cleans up; the comment is explicit about why. The!m_wfmCooldowndebounce lives only in thewfmActivatedsignal handlers inMainWindow.cppandMainWindow_Wiring.cpp. A switch-slice activation path callsdeactivateWFM()directly before constructing the new demod — no leak path. - Cross-slice mode deactivation —
RxAppletmode-combo emitswfmActivated(false, m_slice->sliceId()); theMainWindowhandler gates onsliceId == m_wfmSliceId. Changing mode on slice B when WFM is on slice A is a no-op. Correctly scoped. - Stray
.gitignoreentry — not present in the diff.
DSP and architecture:
WfmDsp as a headless pure-C++ class (NCO → r8brain resamplers → atan2 discriminator → 95-tap FIR) is the right extraction — proven correct by the unit tests rather than tuned by eye. The native-rate QAudioSource capture path (avoiding the Windows mixer's silent upsampling that caused the historical 10–13.5 kHz notch) is well-reasoned and the rationale is documented inline. The idle-input gate (−80 dBFS / −74 dBFS hysteresis) correctly prevents the amplitude-invariant discriminator from turning DAX dither into full-scale noise while keeping DSP state and output cadence live. WaveOutWriter zero-fills on ring underrun — correct VAC policy. VITA-49 fallback for Linux/macOS verified building and passes all 13 wfm_dsp_test checks on macOS ARM64 per @M7HNF-Ian.
WfmSettings: nested "WFM" JSON root with migrateLegacy() for the old flat key — Principle V compliant. QMediaDevices::audioOutputs() + QAudioDevice::id() in WfmDeviceDialog survives device renames and reordering.
MainWindow refactor check (#3351): activateWFM(), deactivateWFM(), reflectWfmButtons() land in MainWindow.cpp — there is no prior MainWindow_WFM.cpp TU, so this is new mass rather than a decomposition violation. Signal wiring goes in MainWindow_Wiring.cpp (correct TU). The HIDAPI guard in MainWindow_Controllers.cpp is correct for non-HIDAPI builds and kept intentionally.
CAT autopan=0 behaviour change (RigctlProtocol.cpp, TciProtocol.cpp): the in-span / out-of-span split is a conscious cross-cutting change to set_freq/VFO behaviour for all rigctl and TCI users, not just WFM. It's documented under "Behaviour change (CAT)" in CHANGELOG.md and the null-pan fallback correctly restores the original #536 recenter path. Flagging it here for the record — @ten9876 has already signed off on keeping it in this PR.
One minor design note (non-blocking): RxApplet::m_wfmButton is always visible regardless of slice mode; VfoWidget::m_wfmBtn shows only in FM/NFM. Intentional per the PR body (the demod doesn't require FM mode), but a user can activate WFM from USB mode via RxApplet while the VfoWidget hides the button. No functional issue — worth a follow-up note in docs or a tooltip update if it proves confusing in practice.
CI 6/6 green. On-air validated: G3RUH 9600 bd satellite telemetry decoded through a full Doppler swing, clean silence on IQ stop, flat soundmodem waterfall. Welcome to the project, EA5WA — nice work seeing this through.
NF0T
left a comment
There was a problem hiding this comment.
Flagging a structural placement issue for @ten9876's call before formal approval — the approval on the logic stands, this is about fit with the #3351 decomposition.
activateWFM() / deactivateWFM() / reflectWfmButtons() → MainWindow_DigitalModes.cpp
These three functions (~120 lines) are new MainWindow methods that follow exactly the same pattern as startDax() / stopDax() already in _DigitalModes.cpp: save filter state, change mode, create/destroy an engine object, restore on teardown, reflect UI. Landing them in MainWindow.cpp instead is precisely the re-accumulation that #3351 was designed to prevent.
WFM activation qualifies as a digital mode lifecycle operation — not a DSP applet, not wiring — because it replaces the receive demodulation path entirely: it saves and overrides the slice filter, creates a WfmDemodulator engine object, and tears it down with full state restore on exit. That is the same shape as startDax()/stopDax() (bridge + stream lifecycle) and startRade()/stopRade() (engine lifecycle), both of which already live in _DigitalModes.cpp. By contrast, _DspApplets.cpp holds parameter overlays (NR, NB, DFNR) that augment an existing receive path without touching mode or filter state, and _Wiring.cpp holds signal connections only.
RxApplet::wfmActivated connection → MainWindow_Wiring.cpp
This PR already puts the VfoWidget::wfmActivated → activateWFM/deactivateWFM connection inside wireVfoWidget() in _Wiring.cpp — the right call. The parallel RxApplet::wfmActivated connection added at MainWindow.cpp:1361 was inlined into the constructor instead. Both wires should live together: either in a wireRxApplet() if one exists, or a new wireWfm() in _Wiring.cpp that holds both.
Summary of what moves:
| What | From | To |
|---|---|---|
activateWFM(), deactivateWFM(), reflectWfmButtons() |
MainWindow.cpp |
MainWindow_DigitalModes.cpp |
WFM-specific #includes |
MainWindow.cpp |
MainWindow_DigitalModes.cpp |
RxApplet::wfmActivated connection |
inline in MainWindow() ctor |
MainWindow_Wiring.cpp (wireWfm() or alongside wireVfoWidget()) |
| Pan Follow WFM guard | MainWindow.cpp::setPanFollow() |
stays — parent function didn't move in #3351 |
m_wfmDemod et al. members + declarations |
MainWindow.h |
stays in MainWindow.h |
Both active moves are purely mechanical — no logic change, no re-test. Flagging for @ten9876 to decide whether to gate on before merge.
…des.cpp (#3351/#3407 follow-up) (#3562) Pure code motion: move activateWFM/deactivateWFM/reflectWfmButtons from MainWindow.cpp into MainWindow_DigitalModes.cpp beside the RADE/FreeDV/DAX lifecycles, with the WFM includes carried explicitly into the sibling TU. No behaviour change; MainWindow.cpp net-zero. Continues the #3351 decomposition.
…ethersdr#3306) Replace the hand-wired "connect each external playback sink to outputDeviceChanged" lambda in MainWindow with a single registry. External sinks that own their own QAudioSink (the post-DSP Pudu monitor, QSO playback) already followed the user-selected output device correctly — every device-change path (user selection, device removal, OS-default change) flows through AudioEngine::setOutputDevice() -> outputDeviceChanged. The risk was purely structural: each NEW external sink had to remember to join that lambda or it would "uncouple" and keep playing on the old/default endpoint (the recurring class behind aethersdr#2899 / aethersdr#3361 / aethersdr#3378). AudioOutputRouter (src/core/AudioOutputRouter.{h,cpp}) makes following a registration instead of hand-wiring: - addFollower(sink) takes any object with setOutputDevice(QAudioDevice) (or a std::function); the follower is seeded immediately and re-seeded on every change, QPointer-guarded against early destruction. - One QueuedConnection forwards outputDeviceChanged to setCurrentDevice(), which fans out to all followers on the GUI thread (matching prior behaviour) over a snapshot so a follower mutating the list mid-fan-out can't invalidate it. - Depends only on Qt Core/Multimedia, so the registry/fan-out is unit-tested headless (tests/audio_output_router_test, 11/11) without a live AudioEngine, including the QPointer-guard and reentrancy paths. MainWindow registers m_finalMonitor + m_qsoRecorder; a future external sink follows correctly just by registering — no new connect to forget. Behaviour- identical hardening. Intentionally NOT routed (documented): the AudioEngine- internal sinks (RX speaker, CW sidetone, Quindar — follow via the RX restart), and the WFM demodulator's WaveOutWriter (plays to its own WfmDeviceDialog device by design, aethersdr#3407). Builds & links clean into the full app; all three audio tests pass under CTest. Refs aethersdr#3306 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Software wideband-FM demodulator for satellite data work (G3RUH 9600 bd): DAX IQ -> phase-continuous NCO Doppler correction -> exact 48 kHz resampling -> atan2 discriminator -> virtual audio cable. Cross-platform QAudioSource/QAudioSink with a headless, unit-tested WfmDsp core. Per-slice WFM toggle on FM modes. Includes a deliberate CAT autopan=0 behaviour change (see CHANGELOG). Authored by @ea5wa with a maintainer fix-up (button-sync + cleanups). Co-authored-by: ea5wa Juan Carlos <54686689+ea5wa@users.noreply.github.com>
…des.cpp (aethersdr#3351/aethersdr#3407 follow-up) (aethersdr#3562) Pure code motion: move activateWFM/deactivateWFM/reflectWfmButtons from MainWindow.cpp into MainWindow_DigitalModes.cpp beside the RADE/FreeDV/DAX lifecycles, with the WFM includes carried explicitly into the sibling TU. No behaviour change; MainWindow.cpp net-zero. Continues the aethersdr#3351 decomposition.


Summary
Adds a software FM demodulator that receives raw IQ samples from the
radio's DAX IQ RX stream, demodulates them in software, and routes the
resulting audio to a user-selected output device (e.g. a Virtual Audio
Cable such as Hi-Fi Cable Input) at 48 kHz. This makes it possible to
use the Flex radio as a wideband FM / satellite receiver feeding any
Windows audio application — in particular 9k6 packet/telemetry modems
such as hs-soundmodem.
Constitution principle honored: Principle I — the demodulator is
fully self-contained; it activates only when the operator presses the
WFM button and deactivates cleanly on toggle-off or mode change,
restoring the original IF filter without side-effects on any other
subsystem.
Audio path:
Flex DAX IQ RX (channel 1, 24 kHz IQ @ 48 kHz sample rate)
└─► DaxIqWorker::processIqPacket()
└─► samplesReady(channel, QVector iqInterleaved, sampleRate)
└─► WfmDemodulator::onIqSamples()
├─► FM discriminator (atan2 phase difference)
├─► 75 µs de-emphasis filter
├─► volume scaling
└─► WaveOutWriter → selected output device @ 48 kHz Int16
On activation the IF filter is widened to ±20 kHz (G3RUH standard) and
the panadapter is centered on the slice frequency so DAX IQ RX 1 is
aligned with the signal. Both are restored on deactivation.
The audio output device is resolved via WfmDeviceDialog on first use;
the choice can be saved to AppSettings ("WfmAudioDevice") to skip the
dialog on subsequent activations.
Changes
New files:
src/core/WfmDemodulator.{h,cpp} — FM discriminator, de-emphasis,
volume control, start/stop lifecycle.
src/core/WaveInReader.{h,cpp} — Win32 waveIn capture wrapper
(reserved for loopback / future use).
src/core/WaveOutWriter.{h,cpp} — Win32 waveOut playback wrapper
used by WfmDemodulator to write PCM to the selected VAC.
src/gui/WfmDeviceDialog.{h,cpp} — output device picker dialog with
"remember my choice" checkbox.
Modified files:
src/models/DaxIqModel.{h,cpp}: expose iqSamplesReady / samplesReady
signals so the demodulator can subscribe to raw IQ without polling.
src/gui/RxApplet.{h,cpp}: WFM toggle button (36×20 px, visible
alongside the mode combo); intercepts "WFM" mode-combo selection.
src/gui/VfoWidget.{h,cpp}: WFM toggle button on the DSP/OPT tab,
visible only when the slice is in FM or NFM mode; auto-unchecks on
mode change.
src/gui/MainWindow.{h,cpp}: activateWFM / deactivateWFM methods;
wires both RxApplet and VfoWidget signals; manages filter save/restore
and panadapter centering.
CMakeLists.txt: registers the four new source files.
Test plan
Platform: Windows 11, Flex-6600, Hi-Fi Cable (VB-Audio).
WideFM demodulation — satellite 9k6 telemetry:
Tuned Slice A to a satellite downlink frequency in FM mode.
Pressed the WFM button in the RxApplet — WfmDeviceDialog appeared;
selected Hi-Fi Cable Input and checked "remember choice".
Confirmed that:
IF filter widened to ±20 kHz automatically.
Panadapter recentered on the slice frequency.
Audio appeared on Hi-Fi Cable Input at 48 kHz.
Launched hs-soundmodem pointed at Hi-Fi Cable Output; decoded 9k6
FSK satellite telemetry frames correctly with no audio glitches.
Pressed WFM again to deactivate:
Audio stopped.
IF filter restored to its previous width.
No crashes; repeated activate/deactivate cycle 5+ times without issues.
Changed slice mode away from FM — WFM button hid automatically and
demodulator stopped cleanly.
Cleared saved device preference, reactivated — dialog appeared again
as expected.
Device error handling:
Unplugged Hi-Fi Cable, attempted activation — demodulator detected
failed open, cleared the saved preference, and returned gracefully
without crashing.