Skip to content

Feature/wfm demod: add WideFM software demodulator via DAX IQ#3407

Merged
ten9876 merged 18 commits into
aethersdr:mainfrom
ea5wa:feature/wfm-demod
Jun 13, 2026
Merged

Feature/wfm demod: add WideFM software demodulator via DAX IQ#3407
ten9876 merged 18 commits into
aethersdr:mainfrom
ea5wa:feature/wfm-demod

Conversation

@ea5wa

@ea5wa ea5wa commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

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.

@ea5wa ea5wa requested review from a team as code owners June 5, 2026 15:44
@aethersdr-agent

Copy link
Copy Markdown
Contributor

Hi @ea5wa — thanks for putting this together! CI is red on all three platforms (build, check-macos, check-windows). I dug through the diff and the most likely root cause is the new sources are unconditionally added to the cross-platform source lists, but they are Windows-only code.

The big one — Windows-only sources added to cross-platform targets

In CMakeLists.txt you added these to CORE_SOURCES and GUI_SOURCES:

  • src/core/WfmDemodulator.cpp
  • src/core/WaveOutWriter.cpp
  • src/core/WaveInReader.cpp
  • src/gui/WfmDeviceDialog.cpp

All of them #include <windows.h> and <mmsystem.h> and call waveInOpen / waveOutOpen / etc. Those headers don't exist on macOS or Linux, so check-macos and the Linux build job will fail at the first #include.

Fix: wrap those entries in if(WIN32) ... endif() like the project already does elsewhere — see the existing pattern at CMakeLists.txt:1190-1199 where winmm/CoreAudio/ALSA are selected per platform. Something like:

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 — winmm is already linked on Windows at CMakeLists.txt:1192, so the waveIn* / waveOut* symbols should resolve once the build reaches link time.

A few smaller cleanups while you're in there

  1. Hardcoded debug log pathWaveOutWriter.cpp, WaveInReader.cpp, and WfmDemodulator.cpp all write to C:/Users/reigc/wfm_debug.txt. That's your local machine; on any other Windows box QFile::open will silently fail (or, worse, succeed). Worth removing the wfmLog helper or routing through qDebug() / qCDebug() before merge.

  2. Stray file in .gitignoreResumen para empezar de nuevo.txt looks like a personal scratch file. Safe to drop that .gitignore entry from the PR.

  3. check-windows is also failing — I couldn't pull the raw compiler output from here, but with the platform guards in place the Windows job will run further and any remaining error will be much easier to read. If it still fails after that, ping me with the log and I'll take another look.

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

@ten9876 ten9876 self-assigned this Jun 6, 2026
@ten9876

ten9876 commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

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 rule

Every 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 qualify

Looking 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:

  • Windows — Virtual Audio Cable, VB-Audio Cable
  • macOS — BlackHole, Loopback, Soundflower
  • Linux — PipeWire null-sinks, JACK, PulseAudio loopback modules, ALSA `snd-aloop`

All three loopback drivers register as ordinary audio output devices, so they'd show up in `QMediaDevices::audioOutputs()` with no special handling.

The architectural change

Replace `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:

  • The four new `.cpp` files can go back into the unconditional `CORE_SOURCES` / `GUI_SOURCES` lists (no platform guard needed because nothing is Win32-only anymore).
  • The WFM toggle button can stay visible on every platform.
  • macOS and Linux operators get the same feature you've already validated on Windows.
  • The hardcoded debug path goes away because you can switch to `qCDebug(lcAudio)` (project's logging category in `src/core/LogManager.h`).

Bonus: there's a foundation coming that fits this exactly

We 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

  • Refactor the output stage to `QAudioSink`.
  • Drop the `.gitignore` line and the hardcoded debug path.
  • Move the device-pick + "remember choice" settings into nested JSON (Principle V).
  • Verify on at least one of {macOS, Linux} in addition to your existing Windows pass. If you don't have a non-Windows test box, I can run the Linux side once the refactor is up.

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 M7HNF-Ian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

  1. macOS / Linux buildsWaveOutWriter.h and WaveInReader.h include <windows.h> / <mmsystem.h> and use the Win32 waveOut/waveIn APIs, but the three new files are added to CORE_SOURCES in CMakeLists.txt unconditionally. 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).

  2. Hardcoded debug log path — both WfmDemodulator.cpp and WaveOutWriter.cpp have a wfmLog() that writes to C:/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's qCDebug() logging category if you want to keep the diagnostics.

  3. .gitignore — the Resumen para empezar de nuevo.txt entry looks like a personal scratch file that snuck in; probably wants dropping from this PR.

  4. Leftover qDebug() counters in DaxIqModel.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_agcGain is 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!

@M7HNF-Ian

Copy link
Copy Markdown
Contributor

Agree with @ten9876's direction here — the QAudioSink refactor is the right call over my earlier Win32-gating note (disregard that bit). Making the output stage cross-platform is the whole point: macOS and Linux operators get the feature too, and it slots straight into the existing audio stack. The demod chain itself is genuinely good work — once the output moves to QAudioSink + nested-JSON settings per Principle V, this'll be in solid shape. 73

@ea5wa ea5wa force-pushed the feature/wfm-demod branch from ea90c3e to 6cc2a94 Compare June 6, 2026 21:02
ea5wa added 4 commits June 6, 2026 23:22
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
@ea5wa ea5wa force-pushed the feature/wfm-demod branch from 6cc2a94 to ef5b0e9 Compare June 6, 2026 21:23
@ea5wa

ea5wa commented Jun 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed architectural guidance

Refactor complete in ef5b0e9:

WaveOutWriter — rewritten using QAudioSink (Qt6 Multimedia). Device is now selected by QAudioDevice::id() (the persistent opaque byte string from QMediaDevices::audioOutputs()) rather than a human-readable name fragment, so it survives device renames and reordering.

WaveInReader — removed entirely. The VITA-49 DaxIqModel::iqSamplesReady path is cross-platform and already delivers IQ samples on all platforms. The Win32 DAX waveIn path was redundant.

WfmDeviceDialog — rewritten using QMediaDevices::audioOutputs() and QAudioDevice::id(). Works on Windows, macOS, and Linux. Virtual loopback devices (Hi-Fi Cable, BlackHole, PipeWire null-sink) show up automatically as ordinary output devices.

WfmSettings — new header-only settings class following Principle V: all WFM settings live under a single "WFM" nested-JSON key in AppSettings. Includes migrateLegacy() to transparently migrate the old flat "WfmAudioDevice" key for existing testers.

WfmDemodulator — simplified to the single cross-platform VITA-49 path; all debug output now goes through qCDebug(lcAudio).

CMakeLists — WFM sources are back in the unconditional CORE_SOURCES / GUI_SOURCES lists. No if(WIN32) guard needed.

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

@ea5wa

ea5wa commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

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>
@ea5wa

ea5wa commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Now is working....
implementation with a fully cross-platform Qt6 Multimedia stack.

Signal path

FlexRadio hardware
  → SmartSDR DAX IQ (exposes IQ as a Windows audio capture device)
  → QAudioSource  (reads "DAX IQ RX 1" as if it were a microphone)
  → FM phase-difference discriminator
  → WfmRingBuffer (thread-safe circular buffer, 1 s capacity)
  → QAudioSink    (timer-driven push mode, 10 ms ticks)
  → HiFi Cable / VAC → hs-soundmodem

Key design decisions

  • QAudioSource instead of VITA-49: SmartSDR DAX takes exclusive ownership of the IQ stream when SmartSDR is running. Issuing a second stream create type=dax_iq command put the stream into a Busy state and no data arrived. Reading the DAX capture audio device directly (same approach SmartSDR's own DAX client uses) avoids the conflict entirely.
  • Timer-driven push mode: WASAPI pull mode (where the sink calls readData()) proved unreliable on Windows. A 10 ms QTimer calls bytesFree() and pushes exactly that many bytes per tick, letting the driver control the effective data rate without blocking or flooding the sink.
  • Float32 PCM output: Int16 is not universally supported by virtual audio cables (HiFi Cable, VB-Audio). Float32 is accepted by all Qt backends (WASAPI, CoreAudio, PipeWire/PulseAudio) and by the VAC drivers tested.
  • Linux/macOS fallback: If no DAX audio capture device is found, the demodulator falls back to the VITA-49 DaxIqModel::iqSamplesReady signal path, keeping the feature usable without SmartSDR DAX.
  • Spectral inversion correction: SmartSDR DAX delivers IQ with upper-sideband mixing, which inverts the baseband spectrum. The discriminator output is negated (-atan2(...)) to compensate. This needs verification with a live satellite pass — if the polarity turns out to be already correct, the negation should be reverted.

Files changed

File Change
src/core/WfmDemodulator.h/.cpp Full rewrite — QAudioSource capture path, FM discriminator, fallback to VITA-49
src/core/WaveOutWriter.h/.cpp Full rewrite — QAudioSink push mode + WfmRingBuffer
src/gui/MainWindow.h Added missing class WfmDemodulator forward declaration
src/gui/MainWindow.cpp Guarded pre-existing TMate2/HIDAPI call sites with #ifdef HAVE_HIDAPI to unblock compilation

Known pending item

Waterfall 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

ea5wa and others added 3 commits June 9, 2026 16:24
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>
@ea5wa

ea5wa commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

From my side is OK.

image

Could anyone check it?

73 de EA5WA Juan Carlos

ea5wa and others added 2 commits June 10, 2026 21:19
…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
@ea5wa

ea5wa commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Summary of changes

src/core/WfmDsp.h + src/core/WfmDsp.cpp (new, 288 lines)
The heart of the change: the equivalent of SkyRoof's Slicer as a pure C++ class — no Qt audio, no I/O, cold-testable. Chain: phase-continuous NCO → twin r8brain resamplers (I/Q phase-matched) down to exactly 48000 Hz, flat to 0.95·Nyquist (the same useful-bandwidth ratio SkyRoof uses) → atan2 discriminator → 95-tap linear-phase FIR, fc = 20 kHz. Deliberately no de-emphasis, squelch or EQ in the data path. Why: everything HS-SoundModem needs was scattered or missing; isolating it lets the DSP be proven correct with unit tests instead of being tuned by eye against the waterfall.

src/core/WfmDemodulator.h + .cpp (rewritten, −328/+~250)
Now only the device plumbing, delegating DSP to WfmDsp. Two functional changes:

  1. Capture the DAX IQ endpoint at its native rate. It previously forced 48 kHz, so Windows silently resampled whatever DAX actually delivered (24/48/96/192 k) — a 24 k stream upsampled by the OS mixer has no energy above ±12 kHz: that was the real origin of the "10–13.5 kHz notch" that earlier commits tried to patch with EQ compensators (now removed).
  2. Idle-input gate (−80 dBFS with hysteresis): the atan2 discriminator is amplitude-invariant and turned the dither of a stopped stream into full-scale noise; it now writes silence while keeping the DSP state and output cadence alive.

src/core/WaveOutWriter.cpp (12 lines)
pushData now zero-fills whenever the ring buffer falls short — the exact policy of SkyRoof's RingBuffer.ReadBytes. Why: when the IQ stream stopped, no writes arrived and HiFi Cable looped its internal buffer — the periodic pattern seen on the soundmodem waterfall. A virtual audio cable must never starve; it now receives continuous silence (verified: waterfall goes black on IQ stop).

src/gui/MainWindow.cpp (39 lines, in activateWFM/deactivateWFM)
SkyRoof's tracking policy (FrequencyWidget): centre the pan on the slice once at WFM activation; from then on Doppler rides the demodulator's NCO (setFreqOffsetHz, phase-continuous retune, no clicks). The pan is recentred only if the slice would leave the usable IQ window (±14.8 kHz), which is rare at 70 cm. Why: without the correction, a Doppler offset beyond ~8 kHz produced a DC term that hard-clipped the demodulated audio entirely — half the pass lost.

src/core/RigctlProtocol.cpp + src/core/TciProtocol.cpp (35 lines)
In-span retunes use autopan=0; only band changes recentre. Why: Doppler steps from SatPC32 every few seconds kept yanking the pan. This dovetails with the above: fixed pan + NCO.

tests/wfm_dsp_test.cpp (new) + CMakeLists.txt (+17)
13 checks pinning the contract: the NCO removes the DC term exactly (1.25 → 0.000) with the FSK tone intact (measured 0.375 = theoretical), 96 k→48 k preserves amplitude/phase, chunked processing is bit-identical to single-shot (no clicks at block edges), and the offset-window policy values. CMake adds WfmDsp.cpp to the main target and registers wfm_dsp_test with CTest, following the tx_mic_channel_normalizer_test pattern.

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

@ea5wa ea5wa requested a review from M7HNF-Ian June 11, 2026 07:43

@M7HNF-Ian M7HNF-Ian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.h include and reverseMouseWheel() check in wheelEvent are removed
  • The ESC +180 phase button and its handler are deleted entirely
  • Four adjustSize() calls (the #3383 layout-flush fix) revert to resize(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.cpps_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 packetDaxIqWorker::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!

ea5wa and others added 2 commits June 11, 2026 13:55
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>
@ea5wa

ea5wa commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Working perfectly:

image

ea5wa and others added 3 commits June 11, 2026 15:43
…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>
@ea5wa ea5wa requested a review from M7HNF-Ian June 11, 2026 16:40
@M7HNF-Ian

Copy link
Copy Markdown
Contributor

Update on the red check-windows: the CI fix is already on main — Jeremy landed #3509 before I got mine up (closed #3525 as duplicate). So no waiting needed: just merge/rebase onto current main and check-windows should go green, along with picking up the VfoWidget conflict resolution from my review above.

…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>

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

ea5wa and others added 2 commits June 13, 2026 17:33
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>
@ten9876

ten9876 commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

@ea5wa — I pushed a small maintainer fix-up to your branch (c811b72) addressing the latest agent review. Triage of the five items:

Fixed in the push:

  • build(deps): Bump actions/checkout from 4 to 6 #3 (dead "WFM" branch) — removed. "WFM" is never an entry in m_modeCombo, so the branch was unreachable; selecting a real mode already tears down the overlay via the unconditional wfmActivated(false) emit, which is now the only path. No behaviour change.
  • APD status indicators need refinement #4 (toggle desync) — fixed. The RxApplet and VfoWidget WFM buttons were independent, so activating from one left the other stale and a mode-change teardown left a button green while the demod was off. MainWindow now pushes the authoritative demod state onto both surfaces (reflectWfmButtons()) on every activate/deactivate — including the dialog-cancel and start-failure paths, so the button that triggered an aborted activation un-sticks. The setWfmActive() setters self-gate on their slice and block the button signal, so there's no feedback loop.
  • Implement XVTR band sub-menu #5 (while (true)) — collapsed to a plain block; there was no retry path so it always returned on the first pass.

Left as-is (with rationale):

  • PHONE applet: DEXP commands rejected on firmware v1.4.0.0 #1 (CAT autopan=0 reaches beyond WFM) — kept in this PR. It's coupled to WFM's in-span Doppler retune, the logic is correct (null pan → falls back to the old recenter path, preserving rigctld set_freq shifts panadapter center instead of tuning the slice #536), and it's already explicitly documented under "Behaviour change (CAT)" in CHANGELOG.md — which is the bot's stated minimum-acceptable resolution. Flagging it here for maintainer visibility: this is a conscious cross-cutting change to set_freq/VFO behaviour for all rigctl/TCI users, not a rider.
  • build(deps): Bump github/codeql-action from 3 to 4 #2 (HIDAPI guard) — kept. The #ifdef HAVE_HIDAPI around m_hidEncoder->isTMate2() is correct and needed for non-HIDAPI builds (m_hidEncoder is HIDAPI-gated); the branch is already rebased onto current main. Dropping it would only tidy the diff at the cost of a non-HIDAPI build break, so it stays.

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 wfmActivated(false). That's defensible (a real demod mode supersedes the software overlay) and matches VfoWidget's existing leave-FM behaviour — just confirm it's what you intend.

Built clean locally and the full suite (34 tests, incl. wfm_dsp_test) passes. The earlier findings (Win32-gated sources, hardcoded debug path, stray .gitignore) were already resolved by your QAudioSink rewrite. Nice work on this — the headless WfmDsp + real unit tests are the strong part. 73

@NF0T NF0T self-assigned this Jun 13, 2026
@NF0T NF0T enabled auto-merge (squash) June 13, 2026 20:12

@NF0T NF0T left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 clobberVfoWidget.cpp diff is +32/-0; no deletions from main. Reverse mouse-wheel tuning, ESC +180 phase button, and adjustSize() calls are all intact in the current head.
  • DaxIqModel debug counterss_feedCount/s_emitCount are entirely absent from the diff. The processIqPacket hunk adds only the emit samplesReady(...) line and removes Q_UNUSED(sampleRate).
  • samplesReady unconditional heap allocation — correctly restructured. DaxIqWorker::samplesReady carries a QByteArray (implicitly shared ref-count, no copy). The QVector<float> conversion only happens in DaxIqModel::relayIqSamples after isSignalConnected(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 leakdeactivateWFM() always cleans up; the comment is explicit about why. The !m_wfmCooldown debounce lives only in the wfmActivated signal handlers in MainWindow.cpp and MainWindow_Wiring.cpp. A switch-slice activation path calls deactivateWFM() directly before constructing the new demod — no leak path.
  • Cross-slice mode deactivationRxApplet mode-combo emits wfmActivated(false, m_slice->sliceId()); the MainWindow handler gates on sliceId == m_wfmSliceId. Changing mode on slice B when WFM is on slice A is a no-op. Correctly scoped.
  • Stray .gitignore entry — 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 NF0T left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::wfmActivatedactivateWFM/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.

@ten9876 ten9876 merged commit d43a77a into aethersdr:main Jun 13, 2026
6 checks passed
ten9876 added a commit that referenced this pull request Jun 14, 2026
…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.
jensenpat added a commit to jensenpat/AetherSDR that referenced this pull request Jun 16, 2026
…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>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
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>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants