fix(tci): re-arm DAX RX when radio or slice arrives after audio_start (#3270). Principle I.#3282
Conversation
…#3270). Principle I. When a TCI client sends `audio_start` before AetherSDR has finished its radio handshake — or while the radio is connected but no slice yet exists — `ensureDaxForTci()` silently returns on `!isConnected()` / empty slice list and never gets a second chance. The text channel still works (CAT) and the TX binary path still flows (TX_CHRONO + radio-DAX TX route is set up on `trx:` not on `audio_start`), but the DAX RX stream is never created, so the radio sends no RX frames, `onDaxAudioReady` never fires, and WSJT-X RX stays silent. Wire `RadioModel::connectionStateChanged(true)` and `RadioModel::sliceAdded` in the TciServer constructor: if any client still has `audioEnabled=true`, re-run `ensureDaxForTci()`. The function is idempotent for already-armed channels — borrowed streams short-circuit on the `m_tciDaxStreamIds.contains(ch)` check, so reconnect/slice-restart sequences cost an extra command echo at most. Per Principle I (FlexLib is the protocol authority): `slice set <id> dax=<ch>` + `stream create type=dax_rx dax_channel=<ch>` is the FlexLib-blessed sequence for arming RX audio; this fix only re-runs that sequence on the FlexLib-emitted events that signal the slice/radio state finally became eligible. Blast radius: risk_score=0.0393 on ensureDaxForTci itself, just under the 0.05 threshold; the broader edit footprint (TciServer constructor) lights up 6 high-risk callers (MainWindow::MainWindow, wirePanadapter, buildMenuBar, AppletPanel::AppletPanel, onConnectionStateChanged, +1) but the change adds two `connect()` calls inside the existing `if (m_model)` block — no public-API change, no behavior change to existing callers. ensureDaxForTci is unchanged.
…#3270). Principle I. When a TCI client sends `audio_start` before AetherSDR has finished its radio handshake — or while the radio is connected but no slice yet exists — `ensureDaxForTci()` silently returns on `!isConnected()` / empty slice list and never gets a second chance. The text channel still works (CAT) and the TX binary path still flows (TX_CHRONO + radio-DAX TX route is set up on `trx:` not on `audio_start`), but the DAX RX stream is never created, so the radio sends no RX frames, `onDaxAudioReady` never fires, and WSJT-X RX stays silent. Wire `RadioModel::connectionStateChanged(true)` and `RadioModel::sliceAdded` in the TciServer constructor: if any client still has `audioEnabled=true`, re-run `ensureDaxForTci()`. The function is idempotent for already-armed channels — borrowed streams short-circuit on the `m_tciDaxStreamIds.contains(ch)` check, so reconnect/slice-restart sequences cost an extra command echo at most. Per Principle I (FlexLib is the protocol authority): `slice set <id> dax=<ch>` + `stream create type=dax_rx dax_channel=<ch>` is the FlexLib-blessed sequence for arming RX audio; this fix only re-runs that sequence on the FlexLib-emitted events that signal the slice/radio state finally became eligible. Blast radius: risk_score=0.0393 on ensureDaxForTci itself, just under the 0.05 threshold; the broader edit footprint (TciServer constructor) lights up 6 high-risk callers (MainWindow::MainWindow, wirePanadapter, buildMenuBar, AppletPanel::AppletPanel, onConnectionStateChanged, +1) but the change adds two `connect()` calls inside the existing `if (m_model)` block — no public-API change, no behavior change to existing callers. ensureDaxForTci is unchanged.
There was a problem hiding this comment.
Thanks for this — the diagnosis is clear and the approach (re-running ensureDaxForTci() when the radio connect or a slice arrives after audio_start) is the right shape. I traced the surrounding code and it holds up well:
- Both new connects sit inside the existing
if (m_model)guard, som_modelis non-null. RadioModel::connectionStateChanged(bool)andsliceAdded(SliceModel*)match the lambda signatures, and both are emitted after the relevant state is committed (isConnected()is already true; the new slice is appended tom_slicesatRadioModel.cpp:4716beforeemit sliceAddedat 4719), soensureDaxForTci()observes correct state.ensureDaxForTci()is idempotent — it skips channels already present inm_tciDaxStreamIds— so repeatedsliceAdded/reconnect firings won't stack duplicate DAX subscriptions. Good.
One gap on the reconnect path worth a look. m_tciDaxStreamIds is only cleared by releaseDaxForTci(), which runs on stop() and when the last audio client disconnects/stops (TciServer.cpp:249,405,512) — not on radio disconnect. So consider the literal "radio reconnected" case the commit names:
- Radio connected, client
audio_start→ensureDaxForTci()creates a stream →m_tciDaxStreamIds = {1: 0x…}. - Radio drops.
connectionStateChanged(false)fires, butreleaseDaxForTci()isn't called (audio client still present), so the stale entry survives. An abrupt transport drop also won't deliver thestream … removedstatus that the line 130 handler relies on to zero it. - Radio reconnects → new handler →
ensureDaxForTci()→ for ch 1,m_tciDaxStreamIds.contains(1)is true →continue, no stream re-created. The old radio-assigned stream handle is now invalid, so no frames route and RX stays silent — the very symptom the PR targets.
The primary scenario you describe in the comment (WSJT-X started before AetherSDR finishes its handshake, so the radio was never connected and the map is still empty) is fixed correctly — continue doesn't trigger and streams get created. It's specifically the reconnect-after-streams-existed path that the current change may no-op on.
Suggestion: reset the stale TCI DAX state when the radio drops — e.g. in the connectionStateChanged lambda, on !connected clear m_tciDaxStreamIds/m_tciDaxBorrowedChannels (without sending stream remove, since the radio is gone) before returning, so the subsequent reconnect re-arm actually re-creates the streams.
Minor: the two lambdas share the "scan m_clients for any audioEnabled, then ensureDaxForTci()" body — a tiny reArmDaxIfAudioPending() helper would de-dup it, but that's optional.
Nice, well-commented fix overall — just want to make sure the reconnect case named in the title is fully covered.
🤖 aethersdr-agent · cost: $18.1428 · model: claude-opus-4-8
The reconnect re-arm added in this PR could no-op: on an unexpected radio drop the radio sends no 'stream … removed' status, so m_tciDaxStreamIds retains dead stream IDs. ensureDaxForTci() on reconnect then hits its contains(ch) guard and skips 'stream create', leaving WSJT-X RX silent. Clear m_tciDaxStreamIds and m_tciDaxBorrowedChannels (and unregister the streams we own from PanadapterStream, skipping borrowed ones so the DAX bridge's own teardown is untouched) when the radio disconnects, so the reconnect re-arm recreates streams from a clean slate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jensenpat
left a comment
There was a problem hiding this comment.
Reviewed for risk. Added a connectionStateChanged(false) cleanup that clears stale DAX stream state on radio disconnect — without it, the reconnect re-arm would no-op on stale stream IDs and leave WSJT-X RX silent (the case this PR targets). Borrowed channels are skipped so the DAX bridge's teardown is untouched. Builds clean locally. Approving.
…aethersdr#3270). Principle I. (aethersdr#3282) ## Summary Fixes aethersdr#3270 ### What was changed fix(tci): re-arm DAX RX when radio or slice arrives after audio_start (aethersdr#3270). Principle I. ### Files modified - `src/core/TciServer.cpp` ``` src/core/TciServer.cpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) ``` --- Generated by AetherClaude (automated agent for AetherSDR) --- <sub>🤖 aethersdr-agent · cost: $33.7041 · model: claude-opus-4-7</sub> --------- Co-authored-by: aethersdr-agent[bot] <273844287+aethersdr-agent[bot]@users.noreply.github.com> Co-authored-by: jensenpat <patjensen@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Fixes #3270
What was changed
fix(tci): re-arm DAX RX when radio or slice arrives after audio_start (#3270). Principle I.
Files modified
src/core/TciServer.cppGenerated by AetherClaude (automated agent for AetherSDR)
🤖 aethersdr-agent · cost: $33.7041 · model: claude-opus-4-7