Skip to content

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

Open
ea5wa wants to merge 4 commits into
aethersdr:mainfrom
ea5wa:feature/wfm-demod
Open

Feature/wfm demod: add WideFM software demodulator via DAX IQ#3407
ea5wa wants to merge 4 commits into
aethersdr:mainfrom
ea5wa:feature/wfm-demod

Conversation

@ea5wa
Copy link
Copy Markdown
Contributor

@ea5wa ea5wa commented Jun 5, 2026

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
Copy link
Copy Markdown
Collaborator

ten9876 commented Jun 6, 2026

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

Copy link
Copy Markdown
Contributor

@M7HNF-Ian M7HNF-Ian left a comment

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
Copy link
Copy Markdown
Contributor Author

ea5wa commented Jun 6, 2026

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

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.

3 participants