Skip to content

fix(dax-iq): make DAX-IQ fully usable — meter, rate switching, state#3522

Merged
NF0T merged 7 commits into
aethersdr:mainfrom
Ozy311:fix/dax-iq-end-to-end
Jun 14, 2026
Merged

fix(dax-iq): make DAX-IQ fully usable — meter, rate switching, state#3522
NF0T merged 7 commits into
aethersdr:mainfrom
Ozy311:fix/dax-iq-end-to-end

Conversation

@Ozy311

@Ozy311 Ozy311 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

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 after

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

  • Each commit builds standalone; full build clean (Linux).
  • 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.
  • CI green on all three platforms: Linux build · Windows (MSVC) · macOS (clang) · CodeQL · accessibility.
  • 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 — drafted with Don, our VP of Engineering agent, on Linux dev stack (nobara-dell), live-verified against a FLEX-6700.

@Ozy311 Ozy311 marked this pull request as ready for review June 11, 2026 06:41
@Ozy311 Ozy311 requested a review from a team as a code owner June 11, 2026 06:41

@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 well-documented series, and the meter rework is a clear improvement (the dBFS window with attack/decay ballistics is the right model for a wideband IQ level meter, and the int16-scale convention is well-evidenced). The FIFO/state work follows repo conventions (AppSettings, owner-only XDG runtime dir mirroring the DAX-audio hardening). I reviewed the 5 commits unique to this PR; the DaxIqModel plumbing commits are #3521's and should get their review there.

A few real issues to address, all in the new auto-start / restore paths:

1. Double stream create after a DAX stop → IQ enable (the !m_daxBridge guard is not equivalent to "wiring absent"). stopDax() (MainWindow.cpp:15568) tears down the bridge but does not disconnect the IQ connections startDax() wires (statusReceivedapplyStreamStatus, iqEnableRequestedcreateStream, iqRateChangedsetSampleRate — they're connected to this/the model, not to m_daxBridge). So after a start→stop cycle, an IQ enable click fires the surviving iqEnableRequestedcreateStream connection and your new lambda (which sees m_daxBridge == nullptr, calls startDax() again — wiring a duplicate set of connections — then calls createStream(ch) directly): two stream create type=dax_iq commands for one click, and every subsequent enable double-creates via the duplicated connections. The connection accumulation across start/stop cycles is pre-existing, but this PR makes it user-visible. Options, best first:

  • Take the alternative you offered in the description: un-lazy the DAX-IQ wiring so it's established once at construction on all platforms (the IQ path doesn't actually need the audio bridge). That deletes the auto-start guard entirely and fixes the pre-existing duplication too. I'd prefer this route.
  • Or: disconnect the IQ connections in stopDax() and track "IQ wiring up" with a member flag instead of inferring it from m_daxBridge.

2. The first-enable-before-DAX-start path loses a non-default rate. In buildUI() the enable handler emits iqRateChanged then iqEnableRequested — but on PipeWire/macOS before startDax(), iqRateChanged isn't connected to anything yet, so the rate emit is dropped, m_desiredRate stays at default, and the stream your lambda creates comes up at 48 k (and the exists combo-sync then flips the combo back to 48 k, clobbering the visible selection). This is the exact flow commit 5 fixes for the meter, so the rate should survive it too: in the auto-start lambda, call daxIqModel().setSampleRate(ch, rate) (rate read from the applet) before createStream(ch) — or moot the whole thing via option 1 above.

3. Please confirm m_desiredRate is initialized to {48000, …} in DaxIqModel.h (the header hunk was cut off in my diff view). If it zero-initializes, the path in issue 2 — createStream with no prior setSampleRate call — makes applyStreamStatus see isNew && m_desiredRate(0) != 48000 and send stream set … daxiq_rate=0 to the radio.

4. Suppressed streamChanged can leave a restored channel stuck "Off". When reapplyPending suppresses the isNew emit, the On-button sync (which the startup restore deliberately relies on) only happens when the follow-up rate status arrives. If the radio rejects the stream set … daxiq_rate= (or never echoes it), the button stays "Off" while the stream exists — and since setDaxIqLevel now gates on the button text, the meter stays pinned at 0 even with IQ flowing; clicking the button then issues a second createStream. Consider emitting streamChanged unconditionally and suppressing only the combo sync for the transient 48 k state (a flag or skipping the combo update when reapplyPending), rather than suppressing the whole signal.

5. Minor: overlapping restore timers can double-create. restoreEnabledChannels() can be scheduled from both the connect handler and startDax() within the same 1.5 s window (e.g. user clicks DAX On right after connect). The exists idempotence guard lags the radio round-trip, so two timers firing close together both pass it. A simple "restore pending" flag (or a single reusable QTimer member) would close this.

None of these block the core value here — the meter, rate persistence, and unbind-zeroing logic are all sound, and the live verification across three platforms is appreciated. Issue 1 is the one I'd consider required before merge; if you take the un-lazy-wiring route it resolves 1 and 2 together. Agreed that #3521 should land first.


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

Ozy311 added a commit to Ozy311/AetherSDR that referenced this pull request Jun 11, 2026
…eview aethersdr#3522)

Address aethersdr-agent's review on aethersdr#3522:

- Un-lazy the DAX-IQ wiring (review aethersdr#1, aethersdr#2). The IQ-side connections
  (stream status, VITA-49 feed, level meter, enable/disable/rate) are now
  established once at construction on every platform instead of lazily in
  startDax() on Mac/PipeWire. They never needed the DAX audio bridge, so
  they survive a DAX-audio stop and stopDax() can no longer strand an
  iqEnableRequested->createStream connection. This removes the auto-start
  lambda and the per-start duplicate wiring: one DAX-IQ enable now issues
  exactly one `stream create`, regardless of DAX on/off cycles (was one
  duplicate per stranded cycle). Verified live on a FLEX-6700: 3 creates
  pre-patch across two DAX toggles -> exactly 1 post-patch.

- Always emit streamChanged; gate only the rate combo (review aethersdr#4). A
  restored channel no longer sticks at "Off" with the meter pinned at 0
  when the radio rejects or never echoes the daxiq_rate re-apply. A new
  IqStream::rateSettling flag suppresses only the rate-combo sync during
  the transient; it tracks the actual rate gap (m_desiredRate != sampleRate)
  so an interleaved non-rate status (e.g. a pan bind) can't clear it early
  and flicker the combo.

- Guard overlapping restoreEnabledChannels() timers with m_restorePending
  (review aethersdr#5).

- aethersdr#3: confirmed already safe -- m_desiredRate and IqStream::sampleRate both
  default to 48000, so daxiq_rate=0 is never sent.

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

Ozy311 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — addressed all five points (numbering below matches your review). Your preferred route (un-lazy the wiring) did collapse points 1 and 2 as you predicted.

What changed

Items 1 & 2 — un-lazy the DAX-IQ wiring. The IQ-side connections (stream-status routing, VITA-49 feed, level meter, and enable/disable/rate) are now wired once at construction on all platforms — the #if !defined(Q_OS_MAC) && !defined(HAVE_PIPEWIRE) guard is gone. They never depended on the audio bridge, so:

  • startDax() no longer wires any IQ connections (it keeps only the audio-bridge wiring), and the auto-start lambda is deleted.
  • A DAX-IQ channel works without first enabling DAX audio (the standalone case the lambda was papering over).
  • The enable/rate connections survive a DAX-audio stop because they were never tied to the bridge's lifetime — so stopDax() can't leave a half-wired path, and the surviving-connection + lambda double-fire is structurally impossible. Connection accumulation across start/stop cycles is also gone (wired once, not per-start).

This does mean enabling DAX-IQ no longer spins up the DAX audio bridge on Mac/PipeWire (it goes straight to createStream) — intentional, since IQ rides PanadapterStream's VITA-49 path independently of the bridge (matching the Windows precedent).

Item 3 — confirmed safe, no change. m_desiredRate already inits to {48000, 48000, 48000, 48000} and IqStream::sampleRate to 48000, so the createStream-with-no-prior-setSampleRate path can't send daxiq_rate=0.

Item 4 — emit streamChanged unconditionally; suppress only the combo sync. applyStreamStatus now always emits streamChanged, so the On/Off button and meter gating sync the instant the stream exists — a radio that rejects or never echoes the daxiq_rate re-apply no longer leaves the channel stuck "Off" with the meter pinned at 0. A new IqStream::rateSettling flag suppresses only the rate-combo sync during the transient, so the user's selection never visibly flips to 48k. I tracked the flag against the actual rate gap (m_desiredRate != s.sampleRate) rather than isNew, so an interleaved non-rate status (e.g. a pan= bind arriving between the 48k create-status and the rate echo) can't prematurely clear it and flicker the combo — it self-clears exactly on the status that reaches the rate.

Item 5 — m_restorePending guard. Collapses overlapping restoreEnabledChannels() 1.5 s timers (cleared the moment the timer fires) so two scheduled within the settle window can't both pass the lagging exists guard and double-create.

Verification

  • Builds clean on Linux + PipeWire (Nobara, Qt 6.10.3, gcc 15.2.1); the PipeWire path exercises the same #if defined(Q_OS_MAC) || defined(HAVE_PIPEWIRE) branch macOS compiles.
  • Live A/B on a FLEX-6700 (item 1): with two DAX-audio on/off cycles before a single channel-enable, the pre-patch build sent 3 stream create type=dax_iq for that one click (one duplicate per stranded cycle); the patched build sends exactly 1, and stays at 1 regardless of how many times DAX is toggled.
  • Items 4 and 5 verified by code review (their triggers — a radio rejecting a rate-set, two restore timers racing — aren't hand-reachable on the bench).

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

@jensenpat

Copy link
Copy Markdown
Collaborator

Review: DAX-stack stability, #3496 guard interactions, and audio-sink-factory impact

We took a close look at this PR specifically for (a) stability risk to the rest of the DAX stack, (b) interactions with the guards added in #3496, and (c) any impact on the audio sink factory work (#3306). Summary below.

Audio sink factory (#3306): no interaction ✅

Nothing here reaches AudioFormatNegotiator / AudioDeviceNegotiator or AudioEngine. The DAX-IQ pipe is a PipeWire module-pipe-source loaded via pactl — it never opens a Qt audio sink, never enters the format/rate negotiation ladder, and isn't part of device-following. The only shared resource is the pipewire-pulse daemon, where an extra pipe-source module can't affect sink negotiation. The staged factory migration is untouched.

DAX audio stack: net stability gain

The wiring relocation fixes a latent duplicate-connection bug rather than creating one. Previously (Mac/PipeWire), startDax() wired the IQ statusReceived handler, iqDataReady feed, meter, and enable/rate connections with receiver this (MainWindow), and stopDax() never disconnected them — it only tears down bridge-receiver connections or deletes m_daxBridge (which auto-drops only the dax_rx handler). So every DAX-audio off/on cycle stacked a duplicate set of IQ connections: duplicate applyStreamStatus, duplicate pipe destroy/create, duplicate stream set commands. Wiring once at construction eliminates that. The streamStatusBelongsToUs multi-client guard is preserved, and the worker's new synchronous pactl runs on the DaxIqWorker thread, not the GUI thread.

#3496 guard interactions: none unwound or circumvented ✅

One issue to fix before merge: stale exists blocks restore after reconnect ⚠️

restoreEnabledChannels() adds a guard the old restore path didn't have:

if (m_model->daxIqModel().stream(i + 1).exists) {
    continue;  // already restored
}

But nothing ever clears DaxIqModel::m_streams on radio disconnect — RadioModel's disconnect teardown clears panStream registrations etc. but never resets m_daxIqModel, and handleStreamRemoved only runs on a radio-sent removed status, which a hard disconnect never delivers. After an unexpected disconnect → auto-reconnect, exists is stale-true with a dead streamId, so the restore timer skips re-creating the persisted IQ channels — the applet shows "On" but no stream exists on the radio. (The old code re-enabled unconditionally on every connect, so it happened to recover; the stale-streamId in removeStream/setSampleRate is pre-existing, but the restore skip is new.)

Suggested fix (small, and the same shape as #3496's "erase, don't zero" cache fix on the TCI side): reset the four IqStreams (and destroy pipes) on connectionStateChanged(false) — or drop the exists skip and rely on the radio's idempotency, since the m_restorePending flag already covers the double-timer race the skip was added for.

Everything else probed — double-restore on connect (m_restorePending guard), rate re-apply loops (rateSettling tracks the rate gap and self-clears), TCI's parallel createStream/removeStream path — holds up.

💻 Generated with Claude Code (Fable 5.0) with architecture by @jensenpat

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

See change request above.

Ozy311 added a commit to Ozy311/AetherSDR that referenced this pull request Jun 13, 2026
aethersdr#3522)

Address @jensenpat's CHANGES_REQUESTED: stale `exists` blocked restore after
reconnect.

restoreEnabledChannels() skips a channel whose stream(i+1).exists is true, but
nothing cleared DaxIqModel::m_streams on a radio disconnect — RadioModel's
teardown cleared panStream registrations but never reset m_daxIqModel, and
handleStreamRemoved only fires on a radio-sent "removed" status, which a hard
disconnect never delivers. So after an unexpected disconnect -> auto-reconnect,
`exists` was stale-true with a dead streamId, the restore timer skipped the
persisted IQ channels, and the applet showed "On" with no stream on the radio.

Fix (the "erase, don't zero" shape, per the review): add
DaxIqModel::handleDisconnect() — reset all four IqStreams (preserving the
channel index, mirroring handleStreamRemoved) and destroy their pipes on the
worker thread — and call it from RadioModel::onDisconnected(), beside the
existing panStream teardown. m_desiredRate is intentionally preserved, so the
user's rate selection is re-applied by restoreEnabledChannels() on reconnect.
The `exists` skip is kept (it now reads correctly false after a disconnect), so
the double-timer idempotence guarded by m_restorePending + the skip is
unchanged.

Incremental ninja clean (Nobara 43, Qt 6.10.3, gcc 15.2.1).

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

Ozy311 commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @jensenpat — fixed the stale-exists reconnect-restore in 3dca722, taking the "erase, don't zero" route you listed first.

DaxIqModel::handleDisconnect() resets all four IqStreams (struct cleared, channel index preserved, pipes destroyed on the worker thread — mirroring handleStreamRemoved) and is called from RadioModel::onDisconnected() right beside the existing panStream teardown — the spot you flagged where the teardown cleared panStream registrations but never reset m_daxIqModel. m_desiredRate is intentionally preserved, so the user's rate selection is re-applied by restoreEnabledChannels() on reconnect. The exists skip stays (it now reads correctly false after a disconnect), so the double-timer idempotence guarded by m_restorePending is unchanged. Incremental build clean.

On the merge conflict: it's #3351's MainWindow.cpp split against this branch's in-place DAX edits. Since this PR is stacked on #3521 and you both want #3521 to land first, I'll rebase onto main once #3521 merges — that way the extraction conflict resolves against the final base in a single pass rather than twice. Happy to rebase onto current main now instead if you'd prefer.

73, Ozy K6OZY

@ten9876

ten9876 commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

Heads-up — main moved under the #3351 MainWindow decomposition while this PR was open, so it'll need a rebase before merge. Trial-merge against current main shows a conflict in src/gui/MainWindow.cpp and src/models/RadioModel.cpp (the RadioModel hunk is unrelated churn, not from the refactor).

Good news: this is a mechanical rebase, no code changes required. I checked the diff — it doesn't reference any of the members or methods that the decomposition relocated, so the conflict is pure line-churn from MainWindow.cpp shrinking (~19.5k → ~8.1k lines across #3508#3545).

One thing to watch when you resolve it: if your conflict hunk falls in a region that moved out of MainWindow.cpp, the new home is a sibling TU (MainWindow_Session.cpp, _Wiring.cpp, _DigitalModes.cpp, _Menus.cpp, _Controllers.cpp, _Shortcuts.cpp, _SwrSweep.cpp, _Spots.cpp, _DspApplets.cpp) — put the change there rather than back in MainWindow.cpp. Most likely your hunk is untouched and git rebase origin/main resolves cleanly.

Happy to take the rebase off your hands if you'd prefer — just say so.

Ozy311 added 7 commits June 13, 2026 11:01
DAX-IQ samples are the radio's raw baseband amplitude (int16 full-scale
~32768), not normalized [-1,1] like DAX audio, so the old rms * 200 pegged
the bar on real signals. Map RMS to dBFS against int16 full-scale over a
[-70,-10] dBFS window with attack-fast/decay-slow ballistics, turning the
bar into a level/overload meter that only fills near digital overload.

Co-authored-by: Don @ cloaked.agency <don@cloaked.agency> & K6OZY
Store the user-selected rate in m_desiredRate as soon as it is chosen, even
when no stream exists yet. When a stream first appears at the radio's 48k
default, re-apply the desired rate via "stream set ... daxiq_rate=" and
suppress the transient streamChanged so the combo never visibly flips to 48k.
On the applet side, push the combo's rate to the model just before enabling,
and sync the combo from radio state only while the stream exists so a disable
(which resets sampleRate to 48k) cannot clobber the user's selection.

Co-authored-by: Don @ cloaked.agency <don@cloaked.agency> & K6OZY
A levelReady queued just before a disable or pan-unbind would freeze the bar
at its last (random, timing-dependent) value. Force the bar to 0 and reset
the dBFS ballistics whenever the channel is not live — button != "On", no
model, the stream does not exist, or it is unbound from a pan (pan==0x0).
Mirror the same bound check in the streamChanged sync so an unbound-but-
existing stream also drops to 0 instead of freezing.

Co-authored-by: Don @ cloaked.agency <don@cloaked.agency> & K6OZY
Extract the connect-time restore into a public restoreEnabledChannels() that
re-creates persisted-enabled IQ streams ~1.5s after connect, idempotently
(skips channels whose stream already exists) and restores each saved rate via
iqRateChanged before iqEnableRequested. Trigger it from the connect handler,
from the already-connected path in setRadioModel (the initial
connectionStateChanged can fire before setRadioModel runs and be missed), and
from MainWindow::startDax once the lazy IQ enable->createStream wiring exists
on PipeWire/macOS. The On button is left to the streamChanged sync so a restore
that runs before the wiring is in place cannot show a dead "On".

Co-authored-by: Don @ cloaked.agency <don@cloaked.agency> & K6OZY
…nabling DAX audio

On PipeWire/macOS the DAX-IQ enable->createStream 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 — no stream
create reached the radio and the meter never moved. Auto-start DAX on the first
DAX-IQ enable and create the triggering channel directly; once the bridge is up
the guard is inert and startDax()s own connection handles later enables.

Co-authored-by: Don @ cloaked.agency <don@cloaked.agency> & K6OZY
…eview aethersdr#3522)

Address aethersdr-agent's review on aethersdr#3522:

- Un-lazy the DAX-IQ wiring (review aethersdr#1, aethersdr#2). The IQ-side connections
  (stream status, VITA-49 feed, level meter, enable/disable/rate) are now
  established once at construction on every platform instead of lazily in
  startDax() on Mac/PipeWire. They never needed the DAX audio bridge, so
  they survive a DAX-audio stop and stopDax() can no longer strand an
  iqEnableRequested->createStream connection. This removes the auto-start
  lambda and the per-start duplicate wiring: one DAX-IQ enable now issues
  exactly one `stream create`, regardless of DAX on/off cycles (was one
  duplicate per stranded cycle). Verified live on a FLEX-6700: 3 creates
  pre-patch across two DAX toggles -> exactly 1 post-patch.

- Always emit streamChanged; gate only the rate combo (review aethersdr#4). A
  restored channel no longer sticks at "Off" with the meter pinned at 0
  when the radio rejects or never echoes the daxiq_rate re-apply. A new
  IqStream::rateSettling flag suppresses only the rate-combo sync during
  the transient; it tracks the actual rate gap (m_desiredRate != sampleRate)
  so an interleaved non-rate status (e.g. a pan bind) can't clear it early
  and flicker the combo.

- Guard overlapping restoreEnabledChannels() timers with m_restorePending
  (review aethersdr#5).

- aethersdr#3: confirmed already safe -- m_desiredRate and IqStream::sampleRate both
  default to 48000, so daxiq_rate=0 is never sent.

Co-authored-by: Don @ cloaked.agency <don@cloaked.agency> & K6OZY
aethersdr#3522)

Address @jensenpat's CHANGES_REQUESTED: stale `exists` blocked restore after
reconnect.

restoreEnabledChannels() skips a channel whose stream(i+1).exists is true, but
nothing cleared DaxIqModel::m_streams on a radio disconnect — RadioModel's
teardown cleared panStream registrations but never reset m_daxIqModel, and
handleStreamRemoved only fires on a radio-sent "removed" status, which a hard
disconnect never delivers. So after an unexpected disconnect -> auto-reconnect,
`exists` was stale-true with a dead streamId, the restore timer skipped the
persisted IQ channels, and the applet showed "On" with no stream on the radio.

Fix (the "erase, don't zero" shape, per the review): add
DaxIqModel::handleDisconnect() — reset all four IqStreams (preserving the
channel index, mirroring handleStreamRemoved) and destroy their pipes on the
worker thread — and call it from RadioModel::onDisconnected(), beside the
existing panStream teardown. m_desiredRate is intentionally preserved, so the
user's rate selection is re-applied by restoreEnabledChannels() on reconnect.
The `exists` skip is kept (it now reads correctly false after a disconnect), so
the double-timer idempotence guarded by m_restorePending + the skip is
unchanged.

Incremental ninja clean (Nobara 43, Qt 6.10.3, gcc 15.2.1).

Co-authored-by: Don @ cloaked.agency <don@cloaked.agency> & K6OZY
@Ozy311 Ozy311 force-pushed the fix/dax-iq-end-to-end branch from 3dca722 to 53f2dfe Compare June 13, 2026 18:21
@Ozy311

Ozy311 commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

Rebased fix/dax-iq-end-to-end onto current main — done, force-pushed (head 53f2dfeb).

A heads-up on one wrinkle, since it was slightly more than pure line-churn: #3521 landed as a squash, so I rebased only this PR's own seven commits onto main (the three delivered-samples commits are already in main via #3521 and dropped out). Of those seven, the un-lazy-wiring change did fall inside regions the #3351 decomposition relocated, so it wasn't a no-op rebase — but no logic changed. Specifically:

  • The construction-time IQ wiring moved verbatim into MainWindow::wireDaxIq() (MainWindow_Session.cpp); the un-lazy edit (drop the #if !defined(Q_OS_MAC) && !defined(HAVE_PIPEWIRE) guard so the six IQ connects wire unconditionally, plus the comment refresh and removing the now-superseded auto-start lambda) was re-applied there.
  • The lazy IQ block in startDax() (MainWindow_DigitalModes.cpp) was removed and replaced with the explanatory comment, as before.
  • MainWindow.cpp itself ends up with zero net change — every IQ edit relocated into the two sibling TUs.

Verification after the rebase:

  • DaxIqApplet.{cpp,h} and DaxIqModel.{cpp,h} net diffs are byte-identical to the pre-rebase branch (those files weren't touched by the decomposition), so the meter/rate/restore/handleDisconnect logic is unchanged.
  • Each DAX-IQ connect() signature appears exactly once across all MainWindow*.cpp (in wireDaxIq()), startDax() carries no IQ connects, and there's no surviving auto-start lambda — i.e. no double-wire and no silently-dropped edit.
  • RadioModel::onDisconnected() still calls m_daxIqModel.handleDisconnect() right after clearRegisteredStreams() (the Fix FlexControl audio wheel actions #3499 churn you flagged was elsewhere in the file; that hunk applied clean).
  • Clean cmake/ninja build on Linux (Nobara 43, Qt 6.10.3, gcc 15.2.1).
  • Re-verified RX-only against a FLEX-6700 on the rebased binary (a PipeWire build, so it exercises the un-lazy path the decomposition relocated): a persisted-enabled DAX-IQ channel restores on connect to exactly one stream create type=dax_iq and the radio acks the stream — i.e. wireDaxIq() is reached and the construction-time iqEnableRequested → createStream connection fires, with no double-create.

All seven commits are GPG-signed. Thanks for the offer to take the rebase — appreciated, but happy to keep it on our side.

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

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

Reviewing the 7-commit rebase at head 53f2dfeb. @Ozy311 addressed all five items from the aethersdr-agent COMMENTED review (formally adopted by @jensenpat as the CHANGES_REQUESTED).

All five items resolved:

# Item Resolution
1 Double stream create after DAX stop → IQ enable ✅ Took Option A: wireDaxIq() wires IQ unconditionally at construction on all platforms; IQ wiring block removed from startDax() entirely. Auto-start lambda gone — no more connection accumulation across stop/start cycles.
2 Non-default rate lost on first-enable-before-DAX-start ✅ Moot via item 1 (lambda path eliminated); additionally m_desiredRate[] now persists the user's rate selection in setSampleRate() even before a stream exists.
3 m_desiredRate zero-init risk int m_desiredRate[NUM_CHANNELS]{48000, 48000, 48000, 48000} — explicitly initialized in DaxIqModel.h.
4 Suppressed streamChanged leaves restored channel stuck "Off" streamChanged now emitted unconditionally in applyStreamStatus(); rateSettling flag gates only the combo-sync path in the applet, not the signal itself.
5 Overlapping restore timers double-create m_restorePending flag in DaxIqApplet closes the race; handleDisconnect() in RadioModel::onDisconnected() resets m_streams[i] to IqStream{} (preserving m_desiredRate) so restoreEnabledChannels() correctly sees !exists and recreates on reconnect.

Additional observations:

  • dBFS meter scaling (20*log10(rms/32768), −70 to −10 dBFS, attack α=0.5 / decay α=0.15) replaces the old rms * 200 that saturated immediately on real signal levels. ✅
  • bound check (stream exists AND pan not 0×0) correctly gates meter zeroing — unbound streams don't show phantom signal. ✅
  • Removal of the #if !defined(Q_OS_MAC) && !defined(HAVE_PIPEWIRE) guard in wireDaxIq() is correct: the DAX IQ VITA-49 path is platform-agnostic and the old exclusion was the root cause of IQ never working on those platforms without a startDax() call first.

6/6 CI green. Approved.

@ten9876 ten9876 dismissed jensenpat’s stale review June 14, 2026 00:07

PR author resolved.

@NF0T NF0T merged commit c787348 into aethersdr:main Jun 14, 2026
6 checks passed
@Ozy311 Ozy311 deleted the fix/dax-iq-end-to-end branch June 14, 2026 00:31
ten9876 pushed a commit that referenced this pull request Jun 14, 2026
Comment-only: the wireDaxIq() call-site comment in the constructor still said '(no-audio-bridge platforms)', stale after #3522 made the helper unconditional/all-platforms. Updated to reflect once-at-construction wiring independent of the audio bridge. Closes #2530 (the extract-helper request was already resolved by #3541 + #3522). No code/behaviour change.

Co-authored-by: Ozy311 <ozy311@users.noreply.github.com>
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.
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
… (aethersdr#3566)

Comment-only: the wireDaxIq() call-site comment in the constructor still said '(no-audio-bridge platforms)', stale after aethersdr#3522 made the helper unconditional/all-platforms. Updated to reflect once-at-construction wiring independent of the audio bridge. Closes aethersdr#2530 (the extract-helper request was already resolved by aethersdr#3541 + aethersdr#3522). No code/behaviour change.

Co-authored-by: Ozy311 <ozy311@users.noreply.github.com>
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