Skip to content

fix(modem): AetherModem TX never keys on a backend that takes audio over the seam - #4812

Merged
jensenpat merged 1 commit into
aethersdr:mainfrom
nigelfenton:fix/modem-tx-seam-audio-gate
Aug 7, 2026
Merged

fix(modem): AetherModem TX never keys on a backend that takes audio over the seam#4812
jensenpat merged 1 commit into
aethersdr:mainfrom
nigelfenton:fix/modem-tx-seam-audio-gate

Conversation

@nigelfenton

Copy link
Copy Markdown
Contributor

Live on main. The built-in AX.25 modem cannot transmit at all on a backend that declares takesTxAudioOverSeam — Icom today. PTT never keys, no audio is sent, and nothing logs an error.

Found operating packet on an IC-9700 this evening.

What happens

The modem gates its transmit path on hostModulates, which asks who runs the modulator. The question it needs to ask is whether transmit audio needs a DAX stream — and those came apart when takesTxAudioOverSeam was added.

On an Icom hostModulates is false, and correctly so: the radio modulates. So:

  1. The modem takes the DAX branch and asks for a stream a networked Icom has never had
  2. RadioModel::ensureDaxTxStream() returns true for a seam backend — "there IS a route for transmit audio, it just isn't DAX"
  3. So the modem's failure path never fires either. m_txPendingStream stays set, waiting on a stream that cannot arrive, until the wait times out

Every layer behaves correctly by its own lights, which is why it is silent. From the operator's side the modem simply does nothing.

Why it looks familiar

The call site already documents this outage for the HL2 (2026-07-31: "PTT never keyed, 181 audio chunks never sent"). This is the same failure reached from the opposite direction — the HL2 was excluded because it host-modulates, and a seam backend needs excluding because its audio does not go through DAX at all.

The change

hostModulatesTx()txAudioBypassesDax(), returning hostModulates || takesTxAudioOverSeam.

Renamed because the name no longer described what it answers, and both of its callers are asking the DAX question rather than the modulator one. It is private to this dialog — nothing else reads it.

Verified on hardware — IC-9700 over LAN, 2026-08-06

Before After
DAX stream requests 1 per frame, never satisfied 0
PTT never keyed keys
AX.25 T1 retry ladder never started runs 8/8 — frames going out and being timed
TX meters (FWDPWR / SWR / ALC) stale LIVE at 0.9 s

Confirmed modulating, not a bare carrier — a beacon and a connect attempt to a live station both went out with audio.

The T1 timeouts are expected here: the station did not answer. What matters is that the ladder runs at all, which it could not do before.

Scope

Affects any backend declaring takesTxAudioOverSeam, so Icom today and the same shape for anything added later. No change for Flex or the sim, which declare it false, or for the HL2, which was already excluded by hostModulates.

Related: #4799 introduces takesTxAudioOverSeam and wires it into ensureDaxTxStream; this caller was left on the old flag. The capability and the Icom's declaration are already on main, so this stands alone and does not depend on that PR.

…ver the seam

The built-in AX.25 modem gated its transmit path on `hostModulates`, which asks
who runs the MODULATOR. The question it actually needed to ask is whether
transmit audio needs a DAX stream — and those two came apart when
`takesTxAudioOverSeam` was added.

On an Icom, `hostModulates` is FALSE and correctly so: the RADIO modulates. So
the modem took the DAX branch and asked for a stream a networked Icom has never
had. `RadioModel::ensureDaxTxStream()` answers TRUE for a seam backend — "there
IS a route for transmit audio, it just isn't DAX" — so the modem's failure path
never fired either. `m_txPendingStream` stayed set, waiting on a stream that
could not arrive, until the wait timed out.

The result is a silent outage: PTT never keys, no audio chunks are sent, and
every queued frame dies with the timeout. Nothing logs an error, because from
each layer's own point of view nothing went wrong.

This is the same outage the call site already documents for the HL2 on
2026-07-31 ("PTT never keyed, 181 audio chunks never sent"), reached from the
opposite direction: the HL2 was excluded because it host-modulates, and a seam
backend needs excluding because its audio does not go through DAX at all.

`hostModulatesTx()` is renamed `txAudioBypassesDax()` and now ORs both, because
its two callers are asking that question rather than the modulator one. Nothing
else reads it — it is private to this dialog.

VERIFIED ON HARDWARE (IC-9700 over LAN, 2026-08-06)

Before: AetherModem TX did nothing at all; the log shows no keying for a whole
session.

After: a beacon and an AX.25 connect attempt to a live station both transmit —
0 DAX stream requests (the deadlock is gone), the full T1 retry ladder runs
(8/8, so frames are going out and being timed), and the radio's TX meters go
LIVE at 0.9 s age with forward power, SWR and ALC all reporting. Confirmed
modulating, not a bare carrier.

Affects any backend declaring `takesTxAudioOverSeam` — Icom today, and the same
shape for anything added later.
@nigelfenton
nigelfenton requested a review from a team as a code owner August 7, 2026 02:49

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

The diagnosis holds up against the tree. IcomCivBackend declares takesTxAudioOverSeam = true with hostModulates false, RadioModel::ensureDaxTxStream() short-circuits to true for exactly that capability, and AudioEngine::m_hostModulation — the gate that decides whether modem chunks reach submitTxAudio — is driven from takesTxAudioOverSeam && canTransmit in MainWindow_Session.cpp:591, not from hostModulates. So this dialog was the one caller still asking the modulator question, and the OR brings it back in line with the flag its own audio path uses. The HL2 declares both flags true, so it's genuinely unaffected; Flex and sim declare the seam flag false. Rename is right — the old name had stopped describing the answer.

Two small things, neither blocking, plus one note.

Polish

  • The old two-line doc comment is left in place above the new one in the header, and they now contradict each other.
  • The operator-facing route string still reads "host-modulated" on an Icom, where the radio modulates. The qCInfo tag next to it was updated; this one was missed.

Non-blocking notes

  • canTransmit isn't ANDed here, unlike the two sibling gates (MainWindow_Session.cpp:591, MainWindow::hostModulatesTxAudio()). Harmless today — a backend that can't transmit fails fast at "PTT did not engage" instead of hanging — but it's the same shape of flag drift this PR is fixing.

Nice writeup on the before/after table, and thanks for chasing this down on real hardware.


🤖 aethersdr-agent · cost: $4.2420 · model: claude-opus-5

Comment thread src/gui/Ax25HfPacketDecodeDialog.h
Comment thread src/gui/Ax25HfPacketDecodeDialog.cpp
@nigelfenton

Copy link
Copy Markdown
Contributor Author

Correcting my own before/after evidence — the claim in the PR body is not supported, and I would rather say so than have a reviewer find it.

I have since re-read the logs from before the change. The operator attempted an AX.25 connect at 22:18, on a binary built before this fix (which went in at 22:38), and that session shows:

  • the full T1 retry ladder running (8/8), so frames were already going out
  • zero Requesting DAX TX stream lines

So the "before" column of my table is wrong. The DAX deadlock I described did not reproduce on this hardware, and the fix cannot be credited with making the modem start transmitting — it was already transmitting.

What I still believe is correct, on code reading rather than observation:

txAudioBypassesDax() returned hostModulates, which is false on an Icom, and the branch it guards is entered when txStreamId() == 0. On that reading a seam backend should take the DAX path and wait. Something evidently short-circuits that on this radio and I have not yet established what — the most likely candidate is that the branch is never reached because the TX stream is already considered up, in which case the condition is dormant rather than harmful.

So this PR should be treated as a correctness change with no demonstrated failure behind it, not as the hardware-verified fix I originally described. The reasoning for ORing the two capabilities still holds — hostModulates genuinely is the wrong question once takesTxAudioOverSeam exists, and the rename makes the helper mean what it says — but I have not reproduced the outage it is meant to prevent.

Happy for this to sit until I can either produce a failing case or confirm the branch is unreachable. I would rather it not merge on evidence I have retracted.

Apologies for the noise — the original table conflated two sessions.

@nigelfenton

Copy link
Copy Markdown
Contributor Author

Following up on my own retraction: I have now established what the code does, and the branch is not dormant. The correctness argument stands; the original table stays withdrawn.

I said above that the most likely explanation was that the guarded branch is never reached on this radio, "in which case the condition is dormant rather than harmful." I have now traced it on origin/main rather than guessing, and that explanation is wrong. Here is the chain, all on current main:

1. The guard is straight-line reachable. beginTransmission() has no early return between entry and the guard at Ax25HfPacketDecodeDialog.cpp:2139 — the packetize step and its appendSystemLine are unconditional, then:

if (!hostModulatesTx() && m_audio->txStreamId() == 0) {

2. On an Icom the first term is false. IcomCivBackend.cpp:106 sets takesTxAudioOverSeam = true, and hostModulates stays false — correctly, the radio's own firmware modulates.

3. On an Icom txStreamId() is always 0. The only nonzero write is m_audio->setTxStreamId(streamId) in MainWindow_Session.cpp:773, driven solely by RadioModel::txAudioStreamReady, which fires only from the Flex stream create type=dax_tx reply. An Icom never emits it. The seam wiring in the same file says so out loud at :571-574 — the Opus encoder and VITA-49 send are "gated on a stream id this backend never sets."

So both terms hold and the branch is taken on an Icom. It sets m_txPendingStream, calls ensureDaxTxStream() — which returns true at RadioModel.cpp:10006 for a seam backend, by design ("TRUE, not false... False would be the same outage with a tidier log") — so the finishTransmit failure path does not fire, and the TX sits on armTxStreamWaitTimeout().

Why my 22:18 log showed zero Requesting DAX TX stream lines. That line is emitted inside the taken branch, so its absence means beginTransmission() did not run for those frames — not that the branch was skipped. The T1 ladder I saw running is consistent with that: the ladder is driven by the retry timer, and I read "frames were going out" off a ladder that can advance on timeouts alone. That was the actual error in my before/after — I compared a session that never entered this path against one that did, and attributed the difference to the fix.

Where that leaves the PR. I am no longer describing this as dormant-and-tidy, but I am also not restoring the hardware table — I have a code-level demonstration that the branch is entered and waits, and I have not re-run the radio to show PTT failing to key with a clean before/after. Those are different strengths of claim and I would rather label it accurately:

  • Demonstrated by reading: on a seam backend the pre-fix guard enters the DAX wait, and nothing in the seam path can end that wait except the timeout.
  • Not demonstrated: a captured failing session with the deadlock in the log.

The fix itself is unchanged and I still believe it is right — hostModulates is the wrong question once takesTxAudioOverSeam exists, and MainWindow_Session.cpp:581-590 already learned exactly this lesson for the audio path ("TWO DIFFERENT QUESTIONS, and they were one flag until an Icom proved they are not"). This call site was left on the old flag.

Reviewer's call whether the code-level case is enough, or whether you want me to go back to the 9700 and capture the failing session first. I am happy to do the latter before this merges.

@NF0T NF0T self-assigned this Aug 7, 2026

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

Verified against main: takesTxAudioOverSeam is declared true for HL2 and Icom, false for Flex and the sim, so this is a no-op for Flex/sim, unchanged for the HL2 (already excluded via hostModulates), and the fix lands only on the seam backends. Both call sites are asking the DAX question, and hostModulatesTx() had no other readers — the rename is safe and the OR belongs in the helper. Hardware evidence (PTT keys, 0 DAX requests, T1 ladder runs, meters live) is convincing. CI green.

Nit, non-blocking: the header keeps the old three-line "True when the backend runs the modulator on this host (HL2)…" comment stacked above the new one, so the declaration now carries two "True when" descriptions and the first no longer matches the name. Worth folding into one on a follow-up.

@jensenpat
jensenpat merged commit a07d939 into aethersdr:main Aug 7, 2026
4 checks passed
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.

3 participants