Skip to content

fix(dax-iq): deliver IQ samples end-to-end (#2529)#3521

Open
Ozy311 wants to merge 3 commits into
aethersdr:mainfrom
Ozy311:fix/dax-iq-deliver-samples
Open

fix(dax-iq): deliver IQ samples end-to-end (#2529)#3521
Ozy311 wants to merge 3 commits into
aethersdr:mainfrom
Ozy311:fix/dax-iq-deliver-samples

Conversation

@Ozy311

@Ozy311 Ozy311 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

DAX-IQ has never delivered samples in-app on Linux/macOS. The CHANGELOG advertises "DAX IQ pipe output is Linux/macOS only" as a working feature, and #2529 reports it producing zero/empty IQ payloads. This is the root-cause fix: three independent defects in the data path that, together, prevented any IQ from reaching the pipe.

Root cause (3 defects, all in DaxIqModel)

1. The pipe was never created on a normal enable. createPipe() was only called inside a rate-change guard (if (newRate != s.sampleRate)), but a fresh stream is born at daxiq_rate=48000, which equals the model's default sample rate — so enabling a channel produced no rate delta and the FIFO + its PipeWire source node were never built. → fire createPipe on first appearance (isNew) as well.

2. Even when triggered, the FIFO open raced and failed. The old path fire-and-forgot pactl load-module, slept 200 ms, then ::open(O_WRONLY|O_NONBLOCK) — which returns ENXIO if the module hasn't attached its read side yet (the "cannot open IQ pipe" warning). → create the FIFO ourselves first (mkfifo), load the pipe-source module synchronously (so we know it succeeded and can unload it by index), and open O_RDWR|O_NONBLOCK (which never ENXIOs even with no reader yet). The FIFO also moves off world-writable /tmp to $XDG_RUNTIME_DIR/aethersdr/iq-N.pipe mode 0600, mirroring the existing DAX-audio hardening.

3. The payload was decoded with the wrong endianness. dax_iq is the one stream type the radio sends as payload_endian=little; the client force-swapped it as big-endian (qFromBigEndian), byte-reversing every float32 into a denormal ≈ 0 — a flat meter and a pipe full of near-zero. This is the likely mechanism behind #2529's "zero-byte payload." → honor little-endian (qFromLittleEndian); a correct no-op on an LE host.

Verified

Live on a FLEX-6700 (RX-only) from a Linux/PipeWire host:

  • The aethersdr-iq-1 PipeWire source appears on enable (was absent before); FIFO /run/user/<uid>/aethersdr/iq-1.pipe present, mode 0600.
  • parec off that source captured 768 KB @ ~384 KB/s (= float32 × 2ch × 48 kHz), 98% non-zero, clean small-integer IQ that only resolves correctly decoded little-endian (big-endian reads as denormals).
  • Note: the pipe exposes the radio's native int16-scale float32 (raw baseband amplitude, magnitudes up to ~32768), matching DAX-audio convention — not normalized [-1, 1]. This is a data convention, not a wire-type guarantee.

Scope

  • 3 commits, src/models/DaxIqModel.{cpp,h} only.
  • New FIFO/pactl plumbing is entirely #ifndef Q_OS_WIN (Windows uses its own DAX path); the endian decode is platform-independent.

Test plan

  • Each commit builds standalone; full build clean (Linux, gcc 15.2.1, Qt 6.10.3).
  • Live A/B on a FLEX-6700 (RX-only): before → no aethersdr-iq-* node, flat meter; after → named source + real IQ. parec off the source captured 768 KB @ ~384 KB/s (float32 ×2ch ×48 kHz), 98% non-zero, resolving correctly only as little-endian (big-endian reads as denormals ≈ 0 — the DAX IQ streams report endpoint_type=Display with zero-byte payload — should AE be requesting a DAX endpoint? #2529 "zero payload").
  • CI green on all three platforms: Linux build · Windows (MSVC) · macOS (clang) · CodeQL · accessibility.
  • Runtime-confirmed on Windows and macOS native builds against the same FLEX-6700: DAX-IQ works — the level meter tracks live signal, confirming the (platform-independent) little-endian decode. The full end-to-end pipe is proven on Linux via parec above; the FIFO/pactl plumbing is non-Windows (#ifndef Q_OS_WIN) and, since pactl is a PipeWire/PulseAudio tool, the OS pipe node materializes on Linux specifically — so the macOS confirmation here is of the decode (via the meter), not a macOS pipe node.

Closes #2529


73, Ozy K6OZY
AI compute partnership: cloaked.agency — drafted with Don, our VP of Engineering agent, on Linux dev stack (nobara-dell), live-verified against a FLEX-6700.

Ozy311 added 3 commits June 10, 2026 18:50
The IQ pipe was only (re)built when a daxiq_rate CHANGE arrived. A fresh
enable at the default rate (daxiq_rate=48000 == IqStream::sampleRate{48000})
produced no rate delta, so the pipe was never created. Track isNew on the
new-stream branch and merge the guard to fire createPipe on isNew OR a rate
change, hoisted outside the daxiq_rate-present check.

Co-authored-by: Don @ cloaked.agency <don@cloaked.agency> & K6OZY
Rewrite createPipe/destroyPipe: mkfifo the owner-only FIFO ourselves first
(0600 under $XDG_RUNTIME_DIR/aethersdr, not world-writable /tmp), then load
module-pipe-source SYNCHRONOUSLY and capture the module index so we know it
succeeded and can unload it cleanly by index. Open the write end O_RDWR|
O_NONBLOCK (never ENXIOs before the read side attaches). Single-quote
source_properties so the spaced device.description "AetherSDR DAX IQ N"
survives the module-arg parser and drives the label shown in qpwgraph/JACK.
Adds anon-namespace helpers (iqFifoDir/iqPipePath/makeOwnedFifo/runPactlSync)
and an m_pipeModuleIdx[] member to track the loaded module per channel.

Co-authored-by: Don @ cloaked.agency <don@cloaked.agency> & K6OZY
dax_iq float32 samples arrive little-endian (payload_endian=little), unlike
the big-endian pan/wf/meter/audio streams. Reading them big-endian reversed
each float into a denormal ≈ 0, flat-lining the RMS meter and feeding garbage
to the format=float32le pipe. Decode little-endian (a no-op on LE hosts).

Co-authored-by: Don @ cloaked.agency <don@cloaked.agency> & K6OZY

@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 @Ozy311 — this is an excellent root-cause PR. I verified all three claimed defects against the base code and they're real:

  1. Rate-change-only guard — confirmed: createPipe was only reachable via newRate != s.sampleRate, and IqStream::sampleRate{48000} equals the radio's default daxiq_rate, so a fresh enable never built the pipe. The merged isNew || rate-change guard (kept outside the daxiq_rate-present check) is the right fix, and destroyPipe is idempotent so destroy-then-create on a fresh stream is safe.
  2. Open race — confirmed: the old path fire-and-forgot pactl via bash -c, slept 200 ms, and opened O_WRONLY. mkfifo-first + synchronous load + O_RDWR|O_NONBLOCK is strictly better, and error paths roll back cleanly (unload module, unlink FIFO).
  3. Endianness — can't verify against hardware, but the live A/B evidence (data only resolving as LE, BE reads as denormals ≈ 0) is convincing and consistent with the #2529 symptom.

I also confirmed the security hardening genuinely mirrors PipeWireAudioBridge (daxFifoDir/makeOwnedFifo/runPactl, the GHSA-x8xf-4g5v-ppf9 pattern, single-quoted source_properties, unload-by-index), and that cleanupStaleModules() in PipeWireAudioBridge matches the aethersdr- prefix, so stale aethersdr-iq-N modules from a crashed session still get reaped on next launch. Scope is exactly the two stated files. No conventions issues.

Two minor, non-blocking suggestions:

  • Misleading warning when pactl is absent (DaxIqModel.cpp, runPactlSync): if the binary doesn't exist, QProcess::start fails and waitForFinished returns false immediately, so the log says "pactl timed out" — and macOS hits this path on every IQ enable. Consider checking proc.error() == QProcess::FailedToStart and logging "pactl not found" instead. (The existing PipeWireAudioBridge::runPactl has the same shape, so fine to defer.)
  • Helper duplication: iqFifoDir/makeOwnedFifo/runPactlSync are near-verbatim copies of the anonymous-namespace helpers in PipeWireAudioBridge.cpp. Worth a follow-up to hoist them into a shared utility so a future hardening fix doesn't have to land twice — not something to block this fix on.

One pre-existing behavior worth noting (not a regression): on normal app exit with an active IQ stream, the worker destructor closes the fds but the module + FIFO survive until the next session's stale-module cleanup. Now that the module index is tracked, unloading in ~DaxIqWorker would be a cheap follow-up.

Also noting the visible node label changes from "AetherSDR IQ N" to "AetherSDR DAX IQ N" — looks intentional and consistent with the DAX naming work, just flagging it since patchbay users will see the rename.

Great write-up and verification rigor. 73!


🤖 aethersdr-agent · cost: $8.6511 · model: claude-fable-5

@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 @Ozy311 — this is a high-quality root-cause fix with an unusually clear writeup. I verified each of the three defects against the current code and they all check out:

  1. isNew pipe creation — correct. IqStream::sampleRate{48000} matches the radio's default daxiq_rate, so the old rate-delta guard genuinely never fired on a fresh enable. Keeping the trigger outside the kvs.contains("daxiq_rate") check is the right call, and since handleStreamRemoved resets the slot to IqStream{}, re-enable also goes through the isNew path again. 👍
  2. mkfifo-first + synchronous pactl + O_RDWR|O_NONBLOCK — this faithfully mirrors the hardened PipeWireAudioBridge pattern (daxFifoDir/makeOwnedFifo/runPactl, the single-quoted source_properties for pipewire-pulse's arg parser, and the unload+unlink cleanup on both failure paths). Good consistency with the GHSA-x8xf-4g5v-ppf9 hardening.
  3. Little-endian decode — the dax_iq LE convention matches FlexLib behavior, and the denormal-≈-0 explanation is consistent with the #2529 symptom. Live A/B on real hardware is convincing.

Two non-blocking notes:

Minor (inherited): the /tmp fallback in iqFifoDir() can't actually work. When XDG_RUNTIME_DIR is unset and /run/user/<uid> doesn't exist, base becomes /tmp/aethersdr-<uid>, so the ::mkdir of base + "/aethersdr" fails with ENOENT — the parent is never created — and the function returns empty. This exact quirk exists in the PipeWireAudioBridge::daxFifoDir you mirrored, so it's pre-existing and essentially unreachable on a normal desktop session (XDG_RUNTIME_DIR is always set under systemd). Fine to leave as-is here for fidelity; a one-line QDir().mkpath(dir) (or creating base first) in both copies would make a good tiny follow-up.

Suggestion: clean up on app exit. ~DaxIqWorker closes the fds but doesn't unload the pipe-source modules or unlink the FIFOs, so an enabled IQ channel at quit leaves a stale aethersdr-iq-N node behind, and the next session's load-module can collide with the leftover source_name. Now that you track m_pipeModuleIdx, the destructor could simply call destroyPipe() for each channel — the old grep-based code couldn't do this cleanly, but your index tracking makes it trivial. Happy to take this as a follow-up if you'd rather keep this PR's scope tight.

Convention/scope checks: file scope matches the PR description (the two DaxIqModel files only), the new plumbing is correctly #ifndef Q_OS_WIN, error handling at every system boundary (mkdir, mkfifo, pactl, open) logs and bails cleanly, and the raw-fd handling matches the existing house style. No Copilot findings to address.

Nice work — and thanks for the cross-platform runtime verification matrix; it made this easy to review.


🤖 aethersdr-agent · cost: $4.5088 · model: claude-fable-5

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.

DAX IQ streams report endpoint_type=Display with zero-byte payload — should AE be requesting a DAX endpoint?

1 participant