Skip to content

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

Merged
NF0T merged 3 commits into
aethersdr:mainfrom
Ozy311:fix/dax-iq-deliver-samples
Jun 13, 2026
Merged

fix(dax-iq): deliver IQ samples end-to-end (#2529)#3521
NF0T merged 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

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

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

Claimed; read full diff, all three commits, PR body, and bot review.

Evidence basis — Principle VIII satisfied. parec capture off the aethersdr-iq-1 PipeWire source: 768 KB @ ~384 KB/s (float32 × 2ch × 48 kHz), 98% non-zero. The big-endian-decoded equivalent reads as denormals — the #2529 "zero-byte payload" failure mode is demonstrated, not asserted.

All three root causes independently verified as real:

Bug 1 — pipe never created on fresh enable. createPipe was gated on newRate != s.sampleRate; a fresh enable at daxiq_rate=48000 matches IqStream::sampleRate{48000} — zero delta, gate never fired. The isNew || rate-change guard hoisted outside the kvs.contains("daxiq_rate") check is the correct fix. destroyPipe is idempotent so destroy-then-create composes safely.

Bug 2 — race on FIFO open. Fire-and-forget startDetached + 200ms sleep + O_WRONLY|O_NONBLOCKENXIO if the module's read side hadn't attached. Fix is correct on all three axes: create the FIFO before loading the module, load synchronously to capture the module index for a clean unload, open O_RDWR|O_NONBLOCK which never ENXIOs regardless of read-side state. The makeOwnedFifo pattern (TOCTOU-safe: mkfifo(0600) → on EEXIST, lstat verifies uid + S_ISFIFO before unlinking and retrying) directly mirrors PipeWireAudioBridge::makeOwnedFifo and closes the same world-writable /tmp FIFO injection vector from GHSA-x8xf-4g5v-ppf9. iqFifoDir() correctly mirrors daxFifoDir()$XDG_RUNTIME_DIR/run/user/<uid>/tmp/aethersdr-<uid> fallback chain with 0700 directory permissions.

Bug 3 — wrong endian. dax_iq is payload_endian=little; qFromBigEndian byte-reversed every float32 into a denormal ≈ 0. qFromLittleEndian is a correct no-op on LE hosts.

Scope discipline. DaxIqModel.{cpp,h} only. No MainWindow touch — zero #3351 refactor exposure.

Governance note (non-blocking). All three commits are signed and verified. AI co-authorship is disclosed as Co-authored-by: Don @ cloaked.agency <don@cloaked.agency>. The spirit of disclosure is met; the project convention is Co-Authored-By: Claude [version] <noreply@anthropic.com> / Codex <noreply@openai.com>. No explicit format requirement exists in CODEOWNERS or AGENTS.md, so this is informational — @Ozy311, please be aware of the convention for future PRs.

Approved. Three independently real, hardware-validated bugs fixed in the correct layer with security-hardened plumbing that mirrors existing repo patterns. Closes #2529.

@NF0T NF0T merged commit d0a9bec into aethersdr:main Jun 13, 2026
6 checks passed
@Ozy311 Ozy311 deleted the fix/dax-iq-deliver-samples branch June 13, 2026 23:27
NF0T pushed a commit that referenced this pull request Jun 14, 2026
…3522)

## Summary

**Stacked on #3521** (the core fix that makes DAX-IQ actually deliver
samples). Because both PRs target `main`, this one currently shows **all
8 commits** — the first 3 belong to #3521; the **5 commits unique to
this PR** are the UX/state fixes listed below. Once #3521 merges, this
diff auto-reduces to just those 5. **Please review/merge #3521 first.**

With IQ finally flowing, the DAX-IQ applet's UX/state handling — never
exercised because the feature never worked — needed fixing: the level
meter, sample-rate switching, and enable/disable/startup state. Four
focused commits.

> Review note for the meter: DAX-IQ is the radio's **native int16-scale
`float32`** (raw amplitude, ~32768 full-scale), not normalized `[-1, 1]`
— so the meter is scaled accordingly (see commit 1). This is a radio
data convention, not a wire-type guarantee.

## Fixes

**1. `fix(dax-iq): scale the level meter in dBFS for raw int16 IQ`** —
the meter did `rms * 200` assuming normalized `[0, 0.5]` IQ, so it
**pegged at 100% on the noise floor**. It now maps RMS to **dBFS over a
`[-70, -10]` window** (a level/overload meter: noise sits low, the bar
fills toward digital overload) with attack/decay ballistics matching the
DAX-audio meter.

*Before (pegged `×200`) vs after (responsive dBFS):*

| Before — `rms × 200` pegs on the noise floor | After — dBFS, tracking
signal across rates |
|---|---|
|
![before](https://raw.githubusercontent.com/Ozy311/AetherSDR/pr-assets/broken.png)
|
![after](https://raw.githubusercontent.com/Ozy311/AetherSDR/pr-assets/fixed.png)
|

**2. `fix(dax-iq): apply and persist the selected sample rate across
enable`** — picking 24/96/192 k reverted to 48 k: `createStream` sent no
rate (radio defaults 48 k) and `setSampleRate` dropped the selection
when no stream existed yet. The chosen rate is now remembered and
re-applied (`stream set … daxiq_rate=`) once the stream exists; the
combo retains the user's choice across disable; and the transient 48k UI
flip on enable is suppressed.

**3. `fix(dax-iq): zero the level meter when a channel is disabled or
unbound`** — a level update queued just before a channel was switched
off (or unbound from its panadapter, `pan=0x0`) could re-freeze the bar
at its last value. The meter now drops to 0 whenever a channel isn't
actively bound and receiving.

**4. `fix(dax-iq): restore persisted-enabled IQ channels and rates on
startup`** — enabled channels showed "On" at launch but had no stream
(dead). On Linux/macOS the IQ↔model wiring is established lazily inside
`MainWindow::startDax()`, so the applet's connect-time restore fired
before the wiring existed and was dropped. The restore is now triggered
from `startDax()` (idempotent), and saved **rates** restore too.

**5. `fix(dax-iq): auto-start DAX so a DAX-IQ channel works without
first enabling DAX audio`** — on PipeWire/macOS the DAX-IQ `enable →
stream create` wiring is established lazily inside `startDax()` (Windows
wires it at construction). So enabling a DAX-IQ channel *before* DAX
audio was started silently dropped the request — nothing reached the
radio and the meter never moved. Enabling a DAX-IQ channel now
auto-starts DAX and creates the triggering channel directly; once the
bridge is up the guard is inert and `startDax()`'s own connection
handles later enables (no double-create). *(Open design note for the
maintainer: this auto-starts DAX **audio** as a side effect — the small,
additive fix. The alternative is to un-lazy the DAX-IQ connections so
they need no DAX-audio bridge at all; happy to take that route if
preferred.)*

## Verified

Live on a **FLEX-6700**:

- All four rates (24/48/96/192 k) switch and persist across enable →
disable → enable.
- Meter tracks signal across the real dynamic range: noise floor ~9%,
strong AM carrier ~19%, super-strong broadcast AM ~74% — no pegging.
(Calibration: −66 dBFS noise, −25.6 dBFS broadcast, confirming int16
full-scale.)
- Mixed on/off state (e.g. ch1+ch3 on at 96k/24k, ch2+ch4 off) restores
**exactly** on boot.

## Scope

- 4 commits, `src/gui/DaxIqApplet.{cpp,h}` + one call added to
`src/gui/MainWindow.cpp::startDax()`. All portable Qt/STL — no platform
guards needed.

## Test plan

- [x] Each commit builds standalone; full build clean (Linux).
- [x] **Live A/B on a FLEX-6700:** rate switching (24/48/96/192 k
persist across enable), dBFS meter response (noise ~9% → strong AM ~19%
→ super-strong broadcast ~74%, no peg), freeze-on-disable/unbind,
startup restore of mixed on/off state.
- [x] **CI green on all three platforms:** Linux build · Windows (MSVC)
· macOS (clang) · CodeQL · accessibility.
- [x] **Runtime-verified on Linux, Windows, and macOS** native builds
against the same FLEX-6700: meter, rate switching, and startup state all
work. The auto-start fix (commit 5) was confirmed live on macOS — a
DAX-IQ channel now brings the meter alive on the first enable with no
prior DAX-audio step.

---

73, Ozy **K6OZY**
AI compute partnership: [cloaked.agency](https://cloaked.agency) —
drafted with Don, our VP of Engineering agent, on Linux dev stack
(`nobara-dell`), live-verified against a FLEX-6700.
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…r#3521)

## 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 **aethersdr#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 `ENXIO`s 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 aethersdr#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

- [x] Each commit builds standalone; full build clean (Linux, gcc
15.2.1, Qt 6.10.3).
- [x] **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 aethersdr#2529 "zero payload").
- [x] **CI green on all three platforms:** Linux build · **Windows
(MSVC)** · **macOS (clang)** · CodeQL · accessibility.
- [x] **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 aethersdr#2529

---

73, Ozy **K6OZY**
AI compute partnership: [cloaked.agency](https://cloaked.agency) —
drafted with Don, our VP of Engineering agent, on Linux dev stack
(`nobara-dell`), live-verified against a FLEX-6700.
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…ethersdr#3522)

## Summary

**Stacked on aethersdr#3521** (the core fix that makes DAX-IQ actually deliver
samples). Because both PRs target `main`, this one currently shows **all
8 commits** — the first 3 belong to aethersdr#3521; the **5 commits unique to
this PR** are the UX/state fixes listed below. Once aethersdr#3521 merges, this
diff auto-reduces to just those 5. **Please review/merge aethersdr#3521 first.**

With IQ finally flowing, the DAX-IQ applet's UX/state handling — never
exercised because the feature never worked — needed fixing: the level
meter, sample-rate switching, and enable/disable/startup state. Four
focused commits.

> Review note for the meter: DAX-IQ is the radio's **native int16-scale
`float32`** (raw amplitude, ~32768 full-scale), not normalized `[-1, 1]`
— so the meter is scaled accordingly (see commit 1). This is a radio
data convention, not a wire-type guarantee.

## Fixes

**1. `fix(dax-iq): scale the level meter in dBFS for raw int16 IQ`** —
the meter did `rms * 200` assuming normalized `[0, 0.5]` IQ, so it
**pegged at 100% on the noise floor**. It now maps RMS to **dBFS over a
`[-70, -10]` window** (a level/overload meter: noise sits low, the bar
fills toward digital overload) with attack/decay ballistics matching the
DAX-audio meter.

*Before (pegged `×200`) vs after (responsive dBFS):*

| Before — `rms × 200` pegs on the noise floor | After — dBFS, tracking
signal across rates |
|---|---|
|
![before](https://raw.githubusercontent.com/Ozy311/AetherSDR/pr-assets/broken.png)
|
![after](https://raw.githubusercontent.com/Ozy311/AetherSDR/pr-assets/fixed.png)
|

**2. `fix(dax-iq): apply and persist the selected sample rate across
enable`** — picking 24/96/192 k reverted to 48 k: `createStream` sent no
rate (radio defaults 48 k) and `setSampleRate` dropped the selection
when no stream existed yet. The chosen rate is now remembered and
re-applied (`stream set … daxiq_rate=`) once the stream exists; the
combo retains the user's choice across disable; and the transient 48k UI
flip on enable is suppressed.

**3. `fix(dax-iq): zero the level meter when a channel is disabled or
unbound`** — a level update queued just before a channel was switched
off (or unbound from its panadapter, `pan=0x0`) could re-freeze the bar
at its last value. The meter now drops to 0 whenever a channel isn't
actively bound and receiving.

**4. `fix(dax-iq): restore persisted-enabled IQ channels and rates on
startup`** — enabled channels showed "On" at launch but had no stream
(dead). On Linux/macOS the IQ↔model wiring is established lazily inside
`MainWindow::startDax()`, so the applet's connect-time restore fired
before the wiring existed and was dropped. The restore is now triggered
from `startDax()` (idempotent), and saved **rates** restore too.

**5. `fix(dax-iq): auto-start DAX so a DAX-IQ channel works without
first enabling DAX audio`** — on PipeWire/macOS the DAX-IQ `enable →
stream create` wiring is established lazily inside `startDax()` (Windows
wires it at construction). So enabling a DAX-IQ channel *before* DAX
audio was started silently dropped the request — nothing reached the
radio and the meter never moved. Enabling a DAX-IQ channel now
auto-starts DAX and creates the triggering channel directly; once the
bridge is up the guard is inert and `startDax()`'s own connection
handles later enables (no double-create). *(Open design note for the
maintainer: this auto-starts DAX **audio** as a side effect — the small,
additive fix. The alternative is to un-lazy the DAX-IQ connections so
they need no DAX-audio bridge at all; happy to take that route if
preferred.)*

## Verified

Live on a **FLEX-6700**:

- All four rates (24/48/96/192 k) switch and persist across enable →
disable → enable.
- Meter tracks signal across the real dynamic range: noise floor ~9%,
strong AM carrier ~19%, super-strong broadcast AM ~74% — no pegging.
(Calibration: −66 dBFS noise, −25.6 dBFS broadcast, confirming int16
full-scale.)
- Mixed on/off state (e.g. ch1+ch3 on at 96k/24k, ch2+ch4 off) restores
**exactly** on boot.

## Scope

- 4 commits, `src/gui/DaxIqApplet.{cpp,h}` + one call added to
`src/gui/MainWindow.cpp::startDax()`. All portable Qt/STL — no platform
guards needed.

## Test plan

- [x] Each commit builds standalone; full build clean (Linux).
- [x] **Live A/B on a FLEX-6700:** rate switching (24/48/96/192 k
persist across enable), dBFS meter response (noise ~9% → strong AM ~19%
→ super-strong broadcast ~74%, no peg), freeze-on-disable/unbind,
startup restore of mixed on/off state.
- [x] **CI green on all three platforms:** Linux build · Windows (MSVC)
· macOS (clang) · CodeQL · accessibility.
- [x] **Runtime-verified on Linux, Windows, and macOS** native builds
against the same FLEX-6700: meter, rate switching, and startup state all
work. The auto-start fix (commit 5) was confirmed live on macOS — a
DAX-IQ channel now brings the meter alive on the first enable with no
prior DAX-audio step.

---

73, Ozy **K6OZY**
AI compute partnership: [cloaked.agency](https://cloaked.agency) —
drafted with Don, our VP of Engineering agent, on Linux dev stack
(`nobara-dell`), live-verified against a FLEX-6700.
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?

2 participants