Skip to content

fix(dax): create DAX RX stream on per-slice channel (re)assignment (#2895)#3308

Merged
NF0T merged 2 commits into
aethersdr:mainfrom
jensenpat:fix/issue-2895-dax-multislice
Jun 4, 2026
Merged

fix(dax): create DAX RX stream on per-slice channel (re)assignment (#2895)#3308
NF0T merged 2 commits into
aethersdr:mainfrom
jensenpat:fix/issue-2895-dax-multislice

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

Fixes #2895

Problem

MainWindow::startDax() only sent stream create type=dax_rx dax_channel=N once at bridge startup, iterating slices that already had a DAX channel — in practice just slice 0 / DAX 1. When the user later assigned DAX 2–4 to another slice via the UI, SliceModel::setDaxChannel() only sends slice set <id> dax=<ch> and never stream create, so the radio never registers a DAX client (dax_clients stays 0) and sends silence. Slices 1–3 were mute on Linux PipeWire (the report) and macOS. The reporter confirmed slices 1–3 DAX assignments aren't persisted, so the "assign-all-then-toggle" workaround can't work — a code fix was the only path.

Fix

React to per-slice daxChannelChanged (and RadioModel::sliceAdded) while the bridge is up, mirroring the proven TCI path (TciServer::ensureDaxForTci, #1331/#1439) and FlexLib RequestDAXRXAudioStream(channel):

  • 0 → 1..4: create the stream if daxStreamIdForChannel(newCh) == 0, then re-assert slice set <id> dax=<newCh> (the Broken TCI audio tested on Win11 #1439 client-registration workaround). The status echo registers the stream in PanadapterStream.
  • release / move: remove the old channel's stream when no other slice references it.
  • stopDax() drops the new connections so late signals can't hit a torn-down bridge.

Cross-consumer ownership guard

The PanadapterStream DAX map is shared across the bridge, TCI (which borrows bridge-created streams) and RADE. The removal path now consults TciServer::ownsDaxChannel() and m_radeDaxStreamId, so de-assigning a slice's DAX channel never tears out a stream WSJT-X or RADE is still using — the mirror image of #3270. Full consolidation of this hand-rolled coordination is tracked in #3305.

Files

  • src/gui/MainWindow.cpp / .hstartDax()/stopDax(), new wireDaxSlice() / onDaxChannelChanged(), m_daxSliceConns / m_daxSliceLastCh
  • src/core/TciServer.h — new ownsDaxChannel(int) accessor

Testing

Builds clean (macOS). Manual:

  1. 2 slices (A on DAX 1, B on none); enable DAX bridge → A audio flows, dax_clients=1 on ch 1.
  2. Assign DAX 2 to slice B via UI → expect stream create dax_channel=2 + slice set B dax=2, dax_clients=1 on ch 2, audio on the previously-idle sink.
  3. Re-assign B back to none → stream remove sent, ch 2 quiet, others keep flowing.
  4. Ownership guard: with WSJT-X decoding on a DAX channel over TCI and the bridge up, de-assign that channel from the slice → WSJT-X audio keeps flowing; log shows keeping DAX RX stream … still used by TCI.

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

…ethersdr#2895)

startDax() only sent `stream create type=dax_rx dax_channel=N` once at
bridge startup, iterating slices that already had a DAX channel (in
practice just slice 0 / DAX 1). When a DAX channel was assigned to
another slice later via the UI, SliceModel only sent `slice set <id>
dax=<ch>` and never created the stream, so the radio never registered a
DAX client (dax_clients stayed 0) and sent silence — slices 1-3 were
mute on Linux PipeWire and macOS.

React to per-slice daxChannelChanged (and RadioModel::sliceAdded) while
the bridge is up: on 0->1..4 create the stream if not already registered
and re-assert slice set dax= (the aethersdr#1439 client-registration workaround,
mirrored from TciServer::ensureDaxForTci); on release/move, remove the
old channel's stream when no other slice references it.

Ownership guard: the PanadapterStream DAX map is shared across the
bridge, TCI (which borrows bridge-created streams) and RADE. The removal
path now consults TciServer::ownsDaxChannel() and m_radeDaxStreamId so it
never tears out a stream WSJT-X or RADE is still using (the mirror image
of aethersdr#3270). Tracked for full consolidation in aethersdr#3305.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jensenpat jensenpat marked this pull request as ready for review May 31, 2026 01:39
@jensenpat jensenpat requested a review from a team as a code owner May 31, 2026 01:39
@NF0T NF0T self-assigned this May 31, 2026

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

Reviewed independently as @NF0T (Tier 3 reviewer).

Summary

The overall architecture is sound and the diagnosis of #2895 is correct — startDax() only handled slices present at bridge startup, leaving channels 2–4 silent when DAX assignments arrive after startup. The wireDaxSlice + onDaxChannelChanged + sliceAdded approach mirrors the proven TciServer::ensureDaxForTci pattern and is the right design. However, there are two blocking issues that must be fixed before merge.


BLOCKING Issue #1sliceAdded lambda self-defeats its stream-creation path

In startDax(), the sliceAdded lambda:

m_daxSliceLastCh[s->sliceId()] = s->daxChannel();   // records current ch as "last known"
wireDaxSlice(s);
if (s->daxChannel() >= 1 && s->daxChannel() <= 4) {
    onDaxChannelChanged(s, s->daxChannel());          // always hits oldCh==newCh guard → no-op
}

The first line in onDaxChannelChanged is:

const int oldCh = m_daxSliceLastCh.value(sliceId, 0);
if (oldCh == newCh) return;

Because m_daxSliceLastCh was just set to s->daxChannel() on the line immediately before the call, oldCh == newCh is always true. The call is a permanent no-op.

The comment above that call reads "A slice can arrive already carrying a DAX channel (radio profile restore); make sure its stream exists too" — but this code never creates the stream. The primary case from #2895 (saved profile with DAX channels on slices 1–3, slices arriving via sliceAdded after bridge startup) will still not work.

Fix: Set m_daxSliceLastCh[s->sliceId()] = 0 before calling onDaxChannelChanged — or omit the pre-set entirely and let QHash::value(..., 0) default it — so onDaxChannelChanged sees the 0 → newCh transition and creates the stream:

// don't pre-set lastCh here; let onDaxChannelChanged see the 0→newCh transition
wireDaxSlice(s);
if (s->daxChannel() >= 1 && s->daxChannel() <= 4)
    onDaxChannelChanged(s, s->daxChannel());

BLOCKING Issue #2m_radeDaxStreamId referenced without #ifdef HAVE_RADE

onDaxChannelChanged lives inside #if defined(Q_OS_MAC) || defined(HAVE_PIPEWIRE) and unconditionally references:

const bool radeUsing = (id != 0 && id == m_radeDaxStreamId);

m_radeDaxStreamId is declared in MainWindow.h inside #ifdef HAVE_RADE. A Linux build with HAVE_PIPEWIRE=true but HAVE_RADE=false (any dev build without the RADE libraries installed, or the AppImage release build which does not install RADE) will fail to compile with "use of undeclared identifier 'm_radeDaxStreamId'".

This isn't caught by CI: the Windows job excludes the entire HAVE_PIPEWIRE block; the macOS CI job always has RADE; the AppImage release job only runs on tags, not PRs.

Fix — the exact pattern already used in deactivateRADE() at line ~16382:

#ifdef HAVE_RADE
const bool radeUsing = (id != 0 && id == m_radeDaxStreamId);
#else
const bool radeUsing = false;
#endif

Non-blocking observations

  • sliceRemoved gap (pre-existing): Dead entries accumulate in m_daxSliceConns and m_daxSliceLastCh when slices are removed. Harmless with max 4 slices; Qt auto-disconnects destroyed objects.
  • stopDax() removes streams without consulting TCI/RADE ownership (pre-existing): Correctly noted in the PR description as tracked separately in #3305.
  • Double-call risk after blocking fix #1: Once lastCh is initialised to 0 before wireDaxSlice, the direct onDaxChannelChanged call and any subsequent signal-driven call will both check m_finished/oldCh correctly. The daxChannelChanged signal only fires on actual value changes, so a second fire for the same transition is unlikely in practice.

Verdict: REQUEST CHANGES

Both blocking issues are small, targeted fixes — the PR needs no architectural changes. Once fixed this is ready to merge. All other project rules pass (no QSettings, no raw new/delete outside Qt parent ownership, correct naming conventions). CI is green on the checked configurations.

Tier 3 (src/ only) — within our review authority.

Force sliceAdded profile-restore paths through a 0-to-DAX-channel transition so onDaxChannelChanged creates or reasserts the DAX RX stream instead of returning early.

Guard the RADE ownership check behind HAVE_RADE so macOS/PipeWire DAX builds without RADE still compile.

Co-authored-by: Codex <noreply@openai.com>
@jensenpat

Copy link
Copy Markdown
Collaborator Author

Addressed the two blocking review items from @NF0T in 60a4bf0b.

Changes made:

  • Fixed the sliceAdded restore path in MainWindow::startDax() so slices that arrive with an existing DAX channel now seed m_daxSliceLastCh as 0 before calling onDaxChannelChanged(). That lets the handler see the intended 0 -> DAX channel transition and create/reassert the DAX RX stream instead of returning early on oldCh == newCh.
  • Guarded the RADE ownership check in onDaxChannelChanged() with #ifdef HAVE_RADE, falling back to radeUsing = false when RADE is disabled. This fixes the macOS/PipeWire DAX build path where m_radeDaxStreamId is not declared.

Verification performed locally:

  • git diff --check passed.
  • Full macOS app build passed with cmake --build build --parallel 22.
  • Targeted no-RADE compile passed: configured with -DENABLE_RADE=OFF, then built CMakeFiles/AetherSDR.dir/src/gui/MainWindow.cpp.o successfully. The first sandboxed attempt hit the known macOS iconutil false failure; rerunning outside the sandbox completed successfully.
  • Built bundle deployed to the test Mac desktop and quarantine cleared.

This should resolve both requested-change blockers; CI is queued/running on the pushed commit.

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

Both blockers addressed in 60a4bf0b, verified against the diff:

Blocker #1 (sliceAdded self-defeat): m_daxSliceLastCh[s->sliceId()] = 0 is now set before wireDaxSlice and before onDaxChannelChanged in the lambda. wireDaxSlice doesn't touch lastCh, so onDaxChannelChanged sees oldCh == 0, newCh >= 1 — the 0 → newCh transition proceeds and creates the stream. The startup loop correctly seeds lastCh to the actual current channel for slices already connected at bridge start, which is the right distinction between the two paths. ✅

Blocker #2 (m_radeDaxStreamId without #ifdef HAVE_RADE): Wrapped exactly as requested — #ifdef HAVE_RADE / #else radeUsing = false / #endif. ✅

CI green on all five checks (CodeQL, build, check-macos, check-windows, analyze). jensenpat's local no-RADE compile with -DENABLE_RADE=OFF confirms the platform-guard fix independently.

The pre-existing non-blocking items from the original review (sliceRemoved dead entries, stopDax ownership coordination) remain unchanged and are correctly tracked in #3305 — no issue.

✅ Approved.

@NF0T NF0T merged commit 07a5c40 into aethersdr:main Jun 4, 2026
5 checks passed
NF0T pushed a commit that referenced this pull request Jun 13, 2026
…ile-switch recreate, debounced teardown (#3363, #3364, #3476) (#3496)

## Summary

Closes #3363
Closes #3476

Hotfix bundle for the TCI/WSJT-X RX-audio-loss cluster (#3363, #3476,
and the already-closed duplicate #3364; materially helps #2886 and
#3366), plus the diagnostics that root-caused it. All failures share one
shape: a DAX RX stream that WSJT-X depends on is torn down (or never
recreated) by a path that doesn't know WSJT-X still needs it. Structural
refcounting is tracked in #3305; these are the contained fixes users
need now.

Root-caused with hardware in the loop (FLEX + PGXL + TGXL +
WSJT-X-Improved console capture). Full investigation notes are on #3305
(two comments, 2026-06-08).

## Fixes

### 1. `stopDax()` ownership guard (#3363, helps #2886)
`MainWindow::stopDax()` unconditionally `stream remove`d every
registered DAX RX stream, so toggling the DAX bridge (or slice mute) off
ripped out the stream TCI had borrowed — silencing WSJT-X RX instantly
(confirmed in #3363's support bundle: stream `0x04000009` removed at
14:38:19, ~50 s blackout until WSJT-X retried). The sibling teardown
paths (RADE at ~17520, per-slice de-assign from #3308) already guard via
`TciServer::ownsDaxChannel()` / `m_radeDaxStreamId`; `stopDax()` was the
one that didn't. Now iterates channels 1–4 and keeps any stream another
consumer still holds.

### 2. Liveness-keyed recreation after profile switch (#3476, #3364)
`profile global load` destroys/recreates slices **without** a radio
disconnect. The reactive "radio removed DAX stream" handler zeroed the
cached stream id but **kept the channel key**, so `ensureDaxForTci()`'s
`contains(ch)` guard skipped `stream create` on the re-arm — TCI RX
stayed silent until a full reconnect ("switched profile, never came
back"; only Reset Settings recovered). The handler now **erases** the
entry (and its borrowed flag), so the next re-arm recreates. Pending
creates (value 0) are never matched (`streamId != 0`), so in-flight
requests are safe.

### 3. Debounced DAX RX teardown (Tune/ATU + transient reconnects)
Field-reproduced: a band change landing during a TGXL ATU tune stalls
the main loop ~2 s → the `vfo:` echo misses WSJT-X's hard 2000 ms
`do_frequency` timeout → WSJT-X throws `TCI failed set rxfreq`, **closes
the socket**, and reconnects ~2–3.5 s later.
`onClientDisconnected`/`audio_stop` previously called
`releaseDaxForTci()` immediately, turning that blip into permanent
silence.

Now the teardown is deferred by `kDaxReleaseGraceMs` (10 s — measured
drop→`audio_start` gaps were 2.1/3.3/3.5 s); a (re)connecting client's
`audio_start` cancels it, so the stream survives and audio resumes with
no recreate. `stop()` still tears down immediately. Also clears
`m_tciDaxSlices` on radio disconnect so a deferred release after a quick
radio bounce can't `setDaxChannel(0)` on recreated slices.

The underlying ~2 s stall is **not** fixed here — it's the remaining
root bug, and the watchdog below is aimed at it.

## Diagnostics (kept deliberately)

- **Outbound TCI traffic logging** — `TCI tx→all:` / `tx→client:` /
`tx→init:` (`qCDebug(lcCat)`). We logged every inbound command but
nothing we sent; the `vfo:` echo WSJT-X times out on was invisible. This
is what made the Tune/ATU root cause provable.
- **Teardown logs elevated to `qCWarning`** — `releaseDaxForTci()`,
client TCP drop, `audio_stop`. These ran **completely invisibly** in the
26.6.2 field bundles because `qCInfo(lcCat)` sits below the default
warning threshold even with `aether.cat.debug` enabled. An audio-killing
teardown should never be silent.
- **Main-thread stall watchdog** (`main.cpp`) — 250 ms heartbeat; logs
`MainThreadWatchdog: event loop stalled ~N ms` (warning) for any
GUI-loop block > 600 ms. Stall start ≈ timestamp − gap. Converts the
next field stall into a single timestamped line.

## Validation

- Built clean on macOS (RelWithDebInfo, Ninja), rebased onto current
`main` and rebuilt.
- Live rig (FLEX + PGXL + TGXL + WSJT-X-Improved): 4 consecutive
`profile global load` switches with TCI audio flowing — `frames_sent`
climbed monotonically across all of them, zero teardowns; on client
quit, the debounce deferred 10 s and released cleanly (`0 stream(s), 0
slice assignment(s)`).
- Watchdog verified live (caught a benign ~1.1 s startup stall; silent
during all profile switches — confirming the 2 s stall is specific to
the Tune/ATU path).
- WSJT-X console + AetherSDR log correlation for the Tune/ATU repro:
throw at 05:05:40.715, our delayed `vfo:` processing at 05:05:40.726 —
2.0 s after WSJT-X sent it.

## Out of scope / follow-ups

- The ~2 s band-change+ATU main-thread stall (root cause of the WSJT-X
throws) — to be filed separately; the watchdog pinpoints it on next
occurrence.
- WSJT-X waterfall blanking for ~5 s when a profile load retunes the
radio to a band the client didn't request (cosmetic; client-side
reconciliation) — policy question, separate issue.
- Centralized DAX RX refcounting — #3305 (design requirements documented
there).

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

---------

Co-authored-by: Codex <noreply@openai.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…ethersdr#2895) (aethersdr#3308)

Fixes aethersdr#2895

## Problem
`MainWindow::startDax()` only sent `stream create type=dax_rx
dax_channel=N` once at bridge startup, iterating slices that *already*
had a DAX channel — in practice just slice 0 / DAX 1. When the user
later assigned DAX 2–4 to another slice via the UI,
`SliceModel::setDaxChannel()` only sends `slice set <id> dax=<ch>` and
never `stream create`, so the radio never registers a DAX client
(`dax_clients` stays 0) and sends silence. Slices 1–3 were mute on Linux
PipeWire (the report) and macOS. The reporter confirmed slices 1–3 DAX
assignments aren't persisted, so the "assign-all-then-toggle" workaround
can't work — a code fix was the only path.

## Fix
React to per-slice `daxChannelChanged` (and `RadioModel::sliceAdded`)
while the bridge is up, mirroring the proven TCI path
(`TciServer::ensureDaxForTci`, aethersdr#1331/aethersdr#1439) and FlexLib
`RequestDAXRXAudioStream(channel)`:
- **0 → 1..4**: create the stream if `daxStreamIdForChannel(newCh) ==
0`, then re-assert `slice set <id> dax=<newCh>` (the aethersdr#1439
client-registration workaround). The status echo registers the stream in
`PanadapterStream`.
- **release / move**: remove the old channel's stream when no other
slice references it.
- `stopDax()` drops the new connections so late signals can't hit a
torn-down bridge.

### Cross-consumer ownership guard
The `PanadapterStream` DAX map is shared across the bridge, TCI (which
*borrows* bridge-created streams) and RADE. The removal path now
consults `TciServer::ownsDaxChannel()` and `m_radeDaxStreamId`, so
de-assigning a slice's DAX channel never tears out a stream WSJT-X or
RADE is still using — the mirror image of aethersdr#3270. Full consolidation of
this hand-rolled coordination is tracked in aethersdr#3305.

## Files
- `src/gui/MainWindow.cpp` / `.h` — `startDax()`/`stopDax()`, new
`wireDaxSlice()` / `onDaxChannelChanged()`, `m_daxSliceConns` /
`m_daxSliceLastCh`
- `src/core/TciServer.h` — new `ownsDaxChannel(int)` accessor

## Testing
Builds clean (macOS). Manual:
1. 2 slices (A on DAX 1, B on none); enable DAX bridge → A audio flows,
`dax_clients=1` on ch 1.
2. Assign DAX 2 to slice B via UI → expect `stream create dax_channel=2`
+ `slice set B dax=2`, `dax_clients=1` on ch 2, audio on the
previously-idle sink.
3. Re-assign B back to none → `stream remove` sent, ch 2 quiet, others
keep flowing.
4. **Ownership guard:** with WSJT-X decoding on a DAX channel over TCI
*and* the bridge up, de-assign that channel from the slice → WSJT-X
audio keeps flowing; log shows `keeping DAX RX stream … still used by
TCI`.

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…ile-switch recreate, debounced teardown (aethersdr#3363, aethersdr#3364, aethersdr#3476) (aethersdr#3496)

## Summary

Closes aethersdr#3363
Closes aethersdr#3476

Hotfix bundle for the TCI/WSJT-X RX-audio-loss cluster (aethersdr#3363, aethersdr#3476,
and the already-closed duplicate aethersdr#3364; materially helps aethersdr#2886 and
aethersdr#3366), plus the diagnostics that root-caused it. All failures share one
shape: a DAX RX stream that WSJT-X depends on is torn down (or never
recreated) by a path that doesn't know WSJT-X still needs it. Structural
refcounting is tracked in aethersdr#3305; these are the contained fixes users
need now.

Root-caused with hardware in the loop (FLEX + PGXL + TGXL +
WSJT-X-Improved console capture). Full investigation notes are on aethersdr#3305
(two comments, 2026-06-08).

## Fixes

### 1. `stopDax()` ownership guard (aethersdr#3363, helps aethersdr#2886)
`MainWindow::stopDax()` unconditionally `stream remove`d every
registered DAX RX stream, so toggling the DAX bridge (or slice mute) off
ripped out the stream TCI had borrowed — silencing WSJT-X RX instantly
(confirmed in aethersdr#3363's support bundle: stream `0x04000009` removed at
14:38:19, ~50 s blackout until WSJT-X retried). The sibling teardown
paths (RADE at ~17520, per-slice de-assign from aethersdr#3308) already guard via
`TciServer::ownsDaxChannel()` / `m_radeDaxStreamId`; `stopDax()` was the
one that didn't. Now iterates channels 1–4 and keeps any stream another
consumer still holds.

### 2. Liveness-keyed recreation after profile switch (aethersdr#3476, aethersdr#3364)
`profile global load` destroys/recreates slices **without** a radio
disconnect. The reactive "radio removed DAX stream" handler zeroed the
cached stream id but **kept the channel key**, so `ensureDaxForTci()`'s
`contains(ch)` guard skipped `stream create` on the re-arm — TCI RX
stayed silent until a full reconnect ("switched profile, never came
back"; only Reset Settings recovered). The handler now **erases** the
entry (and its borrowed flag), so the next re-arm recreates. Pending
creates (value 0) are never matched (`streamId != 0`), so in-flight
requests are safe.

### 3. Debounced DAX RX teardown (Tune/ATU + transient reconnects)
Field-reproduced: a band change landing during a TGXL ATU tune stalls
the main loop ~2 s → the `vfo:` echo misses WSJT-X's hard 2000 ms
`do_frequency` timeout → WSJT-X throws `TCI failed set rxfreq`, **closes
the socket**, and reconnects ~2–3.5 s later.
`onClientDisconnected`/`audio_stop` previously called
`releaseDaxForTci()` immediately, turning that blip into permanent
silence.

Now the teardown is deferred by `kDaxReleaseGraceMs` (10 s — measured
drop→`audio_start` gaps were 2.1/3.3/3.5 s); a (re)connecting client's
`audio_start` cancels it, so the stream survives and audio resumes with
no recreate. `stop()` still tears down immediately. Also clears
`m_tciDaxSlices` on radio disconnect so a deferred release after a quick
radio bounce can't `setDaxChannel(0)` on recreated slices.

The underlying ~2 s stall is **not** fixed here — it's the remaining
root bug, and the watchdog below is aimed at it.

## Diagnostics (kept deliberately)

- **Outbound TCI traffic logging** — `TCI tx→all:` / `tx→client:` /
`tx→init:` (`qCDebug(lcCat)`). We logged every inbound command but
nothing we sent; the `vfo:` echo WSJT-X times out on was invisible. This
is what made the Tune/ATU root cause provable.
- **Teardown logs elevated to `qCWarning`** — `releaseDaxForTci()`,
client TCP drop, `audio_stop`. These ran **completely invisibly** in the
26.6.2 field bundles because `qCInfo(lcCat)` sits below the default
warning threshold even with `aether.cat.debug` enabled. An audio-killing
teardown should never be silent.
- **Main-thread stall watchdog** (`main.cpp`) — 250 ms heartbeat; logs
`MainThreadWatchdog: event loop stalled ~N ms` (warning) for any
GUI-loop block > 600 ms. Stall start ≈ timestamp − gap. Converts the
next field stall into a single timestamped line.

## Validation

- Built clean on macOS (RelWithDebInfo, Ninja), rebased onto current
`main` and rebuilt.
- Live rig (FLEX + PGXL + TGXL + WSJT-X-Improved): 4 consecutive
`profile global load` switches with TCI audio flowing — `frames_sent`
climbed monotonically across all of them, zero teardowns; on client
quit, the debounce deferred 10 s and released cleanly (`0 stream(s), 0
slice assignment(s)`).
- Watchdog verified live (caught a benign ~1.1 s startup stall; silent
during all profile switches — confirming the 2 s stall is specific to
the Tune/ATU path).
- WSJT-X console + AetherSDR log correlation for the Tune/ATU repro:
throw at 05:05:40.715, our delayed `vfo:` processing at 05:05:40.726 —
2.0 s after WSJT-X sent it.

## Out of scope / follow-ups

- The ~2 s band-change+ATU main-thread stall (root cause of the WSJT-X
throws) — to be filed separately; the watchdog pinpoints it on next
occurrence.
- WSJT-X waterfall blanking for ~5 s when a profile load retunes the
radio to a band the client didn't request (cosmetic; client-side
reconciliation) — policy question, separate issue.
- Centralized DAX RX refcounting — aethersdr#3305 (design requirements documented
there).

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

---------

Co-authored-by: Codex <noreply@openai.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: medium Medium priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DAX virtual audio: only slice 0 gets audio client on Linux PipeWire headless (RPi 5, Ubuntu 24.04, FLEX-8600)

2 participants