Skip to content

fix(tci): re-arm DAX RX when radio or slice arrives after audio_start (#3270). Principle I.#3282

Merged
aethersdr-agent[bot] merged 3 commits into
mainfrom
aetherclaude/issue-3270
May 31, 2026
Merged

fix(tci): re-arm DAX RX when radio or slice arrives after audio_start (#3270). Principle I.#3282
aethersdr-agent[bot] merged 3 commits into
mainfrom
aetherclaude/issue-3270

Conversation

@aethersdr-agent

Copy link
Copy Markdown
Contributor

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.cpp
 src/core/TciServer.cpp | 34 ++++++++++++++++++++++++++++++++++
 1 file changed, 34 insertions(+)

Generated by AetherClaude (automated agent for AetherSDR)


🤖 aethersdr-agent · cost: $33.7041 · model: claude-opus-4-7

…#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.
@aethersdr-agent aethersdr-agent Bot requested a review from a team as a code owner May 29, 2026 21:56
@aethersdr-agent aethersdr-agent Bot enabled auto-merge (squash) May 29, 2026 21:56
…#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.

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, so m_model is non-null.
  • RadioModel::connectionStateChanged(bool) and sliceAdded(SliceModel*) match the lambda signatures, and both are emitted after the relevant state is committed (isConnected() is already true; the new slice is appended to m_slices at RadioModel.cpp:4716 before emit sliceAdded at 4719), so ensureDaxForTci() observes correct state.
  • ensureDaxForTci() is idempotent — it skips channels already present in m_tciDaxStreamIds — so repeated sliceAdded/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:

  1. Radio connected, client audio_startensureDaxForTci() creates a stream → m_tciDaxStreamIds = {1: 0x…}.
  2. Radio drops. connectionStateChanged(false) fires, but releaseDaxForTci() isn't called (audio client still present), so the stale entry survives. An abrupt transport drop also won't deliver the stream … removed status that the line 130 handler relies on to zero it.
  3. 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 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.

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-agent aethersdr-agent Bot merged commit 1d06f2d into main May 31, 2026
4 checks passed
@aethersdr-agent aethersdr-agent Bot deleted the aetherclaude/issue-3270 branch May 31, 2026 05:02
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…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>
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.

No TCI audio going to WSJTX MS-Win TCI issues.

1 participant