fix(dax-iq): deliver IQ samples end-to-end (#2529)#3521
Conversation
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
There was a problem hiding this comment.
Thanks @Ozy311 — this is an excellent root-cause PR. I verified all three claimed defects against the base code and they're real:
- Rate-change-only guard — confirmed:
createPipewas only reachable vianewRate != s.sampleRate, andIqStream::sampleRate{48000}equals the radio's defaultdaxiq_rate, so a fresh enable never built the pipe. The mergedisNew || rate-changeguard (kept outside thedaxiq_rate-present check) is the right fix, anddestroyPipeis idempotent so destroy-then-create on a fresh stream is safe. - Open race — confirmed: the old path fire-and-forgot
pactlviabash -c, slept 200 ms, and openedO_WRONLY. mkfifo-first + synchronous load +O_RDWR|O_NONBLOCKis strictly better, and error paths roll back cleanly (unload module, unlink FIFO). - 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
pactlis absent (DaxIqModel.cpp,runPactlSync): if the binary doesn't exist,QProcess::startfails andwaitForFinishedreturns false immediately, so the log says "pactl timed out" — and macOS hits this path on every IQ enable. Consider checkingproc.error() == QProcess::FailedToStartand logging "pactl not found" instead. (The existingPipeWireAudioBridge::runPactlhas the same shape, so fine to defer.) - Helper duplication:
iqFifoDir/makeOwnedFifo/runPactlSyncare near-verbatim copies of the anonymous-namespace helpers inPipeWireAudioBridge.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
There was a problem hiding this comment.
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:
isNewpipe creation — correct.IqStream::sampleRate{48000}matches the radio's defaultdaxiq_rate, so the old rate-delta guard genuinely never fired on a fresh enable. Keeping the trigger outside thekvs.contains("daxiq_rate")check is the right call, and sincehandleStreamRemovedresets the slot toIqStream{}, re-enable also goes through theisNewpath again. 👍- mkfifo-first + synchronous
pactl+O_RDWR|O_NONBLOCK— this faithfully mirrors the hardenedPipeWireAudioBridgepattern (daxFifoDir/makeOwnedFifo/runPactl, the single-quotedsource_propertiesfor pipewire-pulse's arg parser, and the unload+unlink cleanup on both failure paths). Good consistency with the GHSA-x8xf-4g5v-ppf9 hardening. - 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
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 atdaxiq_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. → firecreatePipeon 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 returnsENXIOif 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 openO_RDWR|O_NONBLOCK(which neverENXIOs even with no reader yet). The FIFO also moves off world-writable/tmpto$XDG_RUNTIME_DIR/aethersdr/iq-N.pipemode0600, mirroring the existing DAX-audio hardening.3. The payload was decoded with the wrong endianness.
dax_iqis the one stream type the radio sends aspayload_endian=little; the client force-swapped it as big-endian (qFromBigEndian), byte-reversing everyfloat32into 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:
aethersdr-iq-1PipeWire source appears on enable (was absent before); FIFO/run/user/<uid>/aethersdr/iq-1.pipepresent, mode0600.parecoff 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).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
src/models/DaxIqModel.{cpp,h}only.pactlplumbing is entirely#ifndef Q_OS_WIN(Windows uses its own DAX path); the endian decode is platform-independent.Test plan
aethersdr-iq-*node, flat meter; after → named source + real IQ.parecoff 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").parecabove; the FIFO/pactlplumbing is non-Windows (#ifndef Q_OS_WIN) and, sincepactlis 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.