Skip to content

feat(icom): transmit-audio taps that bracket the RS-BA1 seam, and the fixes they needed (#5011) - #5058

Closed
nigelfenton wants to merge 14 commits into
aethersdr:mainfrom
nigelfenton:fix/ax25-tx-chain-probe
Closed

feat(icom): transmit-audio taps that bracket the RS-BA1 seam, and the fixes they needed (#5011)#5058
nigelfenton wants to merge 14 commits into
aethersdr:mainfrom
nigelfenton:fix/ax25-tx-chain-probe

Conversation

@nigelfenton

@nigelfenton nigelfenton commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Closes nothing — this is the instrumentation that made #5011 measurable, plus the fixes those instruments needed in order not to lie. The taps have done their job on my bench; what I am asking for is a maintainer's read on whether this shape belongs in the tree, and a direction on the one architecture question below (submitTxAudio() as a shared funnel) that I have deliberately not answered myself.

Re-scoped 2026-08-26. The original body framed this around a suspected audio-seam fault inside AE. That framing needs correcting in two directions: AE's client TX path is clean and should stop being re-tested, but the fault is not the PTT-timing defect either — that was tested live on 08-24 with the fix in place and decode still failed. The live candidate is a radio-side deviation shortfall. The instruments in this PR are what established all three of those things.

Why taps and fixes are one PR

They cannot be split. Three commits repair the taps corrupting each other's captures, and the code they repair is not on main — cherry-picking the fixes alone fails to compile (m_txPostResampleTapEnabled: undeclared identifier).

What this is for

#5011: AX.25 transmit over RS-BA1 LAN audio is loud, on frequency, with correct tones, and undecodable by any TNC. Nothing in the tree could say why, because every existing instrument reports levels, and a level cannot distinguish a correct burst from silence — peak and RMS read −9.1 dBFS for both. These commits add the ability to record samples, at two points that bracket the seam.

What the instruments found

1. AE's client TX path is clean, end to end.

stage measured
resample 24k→48k ratio 1.0000 (16240 → 32480 samples)
LPCM round trip worst sample error 0.000026, quantum 0.000031
packetiser no drops on a normal burst
tap fidelity mark 1200 Hz 0.350, space 2200 Hz 0.050 preserved

Direwolf's atest decodes AE's own transmit audio 3/3 frames when captured at the tap. This is settled and should not be re-tested.

2. The fault is radio-side. The 08-20 off-air bench (RTL-SDR V4 as an independent receiver, IC-9700 into a dummy load) found three front-panel faults invisible to AE — DATA MOD input on ACC (radio radiated a dead carrier: RF present, 0 Hz content), TX bandwidth on DX+, and drive level wrong in both directions. AE's own post-resample tap decoded 3/3 in that same session. Note AE deliberately never writes mod.input.data (radio-authoritative per its own controls map), so AE cannot detect or fix that class of fault.

3. The remaining fault is a deviation shortfall, and it is not PTT timing. After ten runs: tones correct (1175/2224 Hz), balance flat, four clean bursts per run, but deviation stuck at ~370 Hz RMS / 650 Hz p99.5 where 1200-baud packet wants ~2500–3000 Hz. Separately, the space tone reads 2224 Hz at the client tap and 1775 Hz off-air — a spectral discrepancy the taps are what surfaced.

Pat's PTT/CI-V timing defect is real and worth landing on its own merits, but it is excluded as the cause here: re-tested live 08-24 with the fix in place, decode still fails.

4. submitTxAudio() is a shared funnel. In MainWindow.cpp, the mic-driven txFinalMonitorPcmReady path and AetherModem's AX.25 audio both reach the radio's single modulator input and sum there. A capture at the far end contains two interleaved sources in ~0.9 s blocks — the modem's AFSK (peak ~11476, energy 1180–1330 Hz) and the mic path (peak ~16000, broadband, dominant frequency wandering 770–1200 Hz every 100 ms). atest decodes the modem's audio alone perfectly and nothing from the mixture.

The comment there also records a wrong fix that was tried and measured: gating the feed on isDaxTxMode() silences the modem, not the microphone, because the modem's AFSK reaches the same signal via sendModemTxAudio() → feedDaxTxAudioInternal() → txFinalMonitorPcmReady. On air that presents as "RF power but no modulation". I have deliberately not applied a separation fix — it has to happen upstream of the shared tap where the two sources are still distinguishable, and that is the architecture decision I would rather a maintainer direct than guess at.

Using these taps: they no-op silently on a non-native backend

The taps arm only behind a qobject_cast<IcomCivBackend*>. Connecting via Aether-gate — which presents as a FLEX-6700 — makes them silently no-op: three real-RF trigger attempts recorded zero samples with no error of any kind. Confirm get radio reports the native icom:<ip> before trusting a "nothing recorded" result. This has cost time twice.

Commits

  • feat(modem) — a non-keying transmit-chain probe for AetherModem
  • feat(modem) — record TX-audio samples, not just their level
  • fix(automation) — register the TX final monitor so txmonitor has a source
  • fix(ax25) — name the transmit route that actually fired
  • feat(icom) — tap transmit audio after the resampler, to bracket the seam
  • feat(icom) — count and report TX packetiser drops instead of losing audio silently
  • fix(icom) — round the post-resample tap's copy to a whole stereo pair
  • docs(ax25) — warn against gating the TX seam feed on isDaxTxMode (code comments only — no docs/ file)
  • fix(icom) — stop txwave's taps corrupting each other's captures, and add a wire tap

The packetiser drop counter is the one behaviour change beyond instrumentation: submit() drops the oldest bytes past its 250 ms cap — right for voice, destructive for a digital burst where the oldest bytes are the preamble and opening flag. Previously silent: no error, no gap in the audio. It now warns on the edge with a running total.

That counter work is split out as #5162 and is awaiting review; this branch keeps the production counters it needs and no longer carries their tests.

Test suite split out too. The four test(icom) commits that were here are now a standalone branch (test/icom-tx-resample-ax25), opening as its own PR once #5162 lands — the test file includes only Resampler.h and IcomAudio.h, references no tap symbol, and depends on #5162's droppedBytes()/dropEvents(). That takes this PR from 1938 lines to 1431, leaving the taps and the fixes they needed — the part that actually needs the architecture call.

Verification

Rebased onto current main (56edc21b) and built clean on Windows/MSVC — AetherSDR.exe links, exit 0.

Icom and AX.25 suites on this branch, run rather than assumed — 12/12 pass:

icom_civ_test              PASS    icom_civ_scheduler_test    PASS
icom_protocol_test         PASS    icom_family_test           PASS
icom_audio_test            PASS    icom_meters_test           PASS
icom_settings_test         PASS    icom_scope_test            PASS
ax25_frame_formatter_test  PASS    ax25_libmodem_shim_test    PASS
ax25_link_timing_test      PASS    hdlc_codec_test            PASS

The end-to-end TX-chain measurements this PR's instruments produced (resample ratio 1.0000, LPCM worst error 0.000026 against a 0.000031 quantum, AFSK tones preserved, drop counters 40960 bytes / 1 event) now live in the split-out test branch, not here — see the split note above.

icom_backend_test and icom_session_test are declared in tests/tests.cmake but do not reach the generated build on this configuration, so they were not exercised. That is a pre-existing property of the tree on Windows, not something this branch changes — flagging it rather than listing them as passing.

What is not done

  • No fix for the funnel mixing. This PR makes it visible and documents where separation has to happen; it does not implement it. That is the maintainer-review question.
  • The deciding experiment has not been run — a matched same-day triple capture (post-resample tap + wire tap + RTL IQ). Everything above compares captures up to 8 days apart, which is a real caveat on the 2224-vs-1775 Hz discrepancy.
  • Windows/MSVC only. Not built on Linux or macOS.
  • One radio. IC-9700 over RS-BA1 LAN. Not tried on USB, nor on another Icom.
  • 34 commits behind main as of this edit; will rebase before marking ready.

@nigelfenton

Copy link
Copy Markdown
Contributor Author

Update — three more commits, and the headline finding has changed

Since this was opened I put the taps on a real IC-9700 and they disagreed with each other. Chasing that turned up three separate defects in the instrumentation itself, and the conclusion the taps were pointing at turned out not to be supported.

feb747b8 — the wire tap truncated at a point that moved

It gated on the instantaneous key state. Unkey races the packetiser drain: setKeying(false) clears m_keyed and then calls flushTxAudio(), while onTxPump() fires every 10 ms in between. Two identical AX.25 bursts:

run first frame differing from the post tap wire length vs post
1 frame 8 (160 ms) −828 samples
2 frame 24 (480 ms) +3264 samples

— while the post-resample capture was 86016 samples both times. A capture whose length varies for a transmission of constant length is an instrument fault. Now latched from key-down until the packetiser has drained.

a9f59663 — the tap was recording retransmits

sendRaw() is also the replay path: a retransmit is served by sendRaw(*it) on the retained copy, and sendRawTwice() writes the same bytes twice. So every packet the radio asked for again was recorded again, out of order, interleaved with the live stream.

isAudioData() correctly rejects keepalives, pings and retransmit requests — but the replay of a genuine audio payload is audio and passes. Moved into sendTrackedImpl() after the first sendRaw(), gated on isPayload. Byte-identical run grew 8 → 29 frames; inverted frames 4 → 0; length error +11244 → −2496.

3035877f — the packetiser test was measuring its own shortcut

icom_tx_resample_ax25_test handed the whole 64960-byte burst over in one submit(), so the 24000-byte cap discarded the front immediately and the test reported 40960 bytes lost — 63% of the burst. That number is an artefact. Fed at the real cadence (30 chunks × 20 ms against the 10 ms pump):

bulk  submit-then-drain   23040 bytes out, 40960 dropped (63%)
paced 20 ms in / 10 ms out 63360 bytes out, 1600 residue, none dropped

The 250 ms cap never bites on a real transmission. The residue is the tail of a burst that is not a whole number of frames (64960 / 1920 = 33.83), and refusing to emit a short frame is deliberate.

This commit also adds live-path counters — txSubmitCalls, txSubmitSamples, txFramesSent, txPumpTicks, txPumpEmpty — so feed-vs-drain can be measured on a real burst instead of inferred.

What the counters then showed

submitted 26 call(s)/34432 samples (717.3 ms), frames sent 35 (700 ms), pending=0

97.6% of submitted audio left as whole frames, nothing dropped, nothing pending. The 832-sample shortfall is exactly the partial tail frame the offline test predicts.

So: the modem hands over the whole burst and the packetiser sends all of it. If 35 whole frames reached sendTracked(), the datagrams carried the whole transmission — which means the wire capture's "preamble repeated, data frames missing" is not what went on the air.

Correcting the PR body above

The original body says the taps bracket a real fault. On the evidence now, they do not — every "the wire is corrupt" reading has dissolved under inspection, and AE's client TX chain measures clean by two independent methods:

  • resample ratio 1.0000
  • LPCM worst error 0.000026 (quantum 0.000031)
  • packetiser loses nothing when paced
  • feed and drain agree on live hardware
  • the post-resample capture decodes in Direwolf every single run (G0JKN>K3CAL-1, level 70, 19–20/20)

I am not claiming AE corrupts transmit audio, and I would not want this merged as if it demonstrated that. What it demonstrates is the opposite, which is still worth having: the chain is exonerated, and the remaining suspects for #5011 are downstream — the radio's own handling of the RS-BA1 LAN audio, or the deviation path, which is where the issue's original observation (deviation pinned, LAN MOD Level has no effect) already pointed.

⚠ Still Windows/MSVC only, still one radio. And the wire tap, while much better, has now been wrong three ways — I would treat its output as indicative rather than authoritative until someone reproduces it on other hardware.

@nigelfenton

Copy link
Copy Markdown
Contributor Author

Filed #5060 for the part of this that is not fixed here: after both tap fixes in this PR, txwave save2 still captures 87 frames for a burst in which the session counters say 35 were sent. The two fixes in this branch are real and help measurably, but they do not close that gap, and I would rather that be tracked separately than left implied by a PR that appears to fix the tap.

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Thanks for this — and especially for the follow-up comment correcting your own headline finding. Retracting a conclusion because your instruments disagreed with each other is the hard part, and you did it with the measurements attached. That's worth more to the project than the fix would have been.

On the CI failure at 66901a7b:

What CI actually shows

job result failed step
Static checks ✅ pass
build (Linux) ❌ fail Build
check-windows ❌ fail Build
check-macos ❌ fail Build

On macOS, Configure succeeded and Build failed, so this is a compile/link error, not a CMake problem. Note that check-windows fails too — that's the same MSVC path you built clean on your bench, which is the most informative thing in the table.

I could not retrieve the raw compiler output — my tooling only exposes step-level status for this run, not the log text — so I'm not going to invent an error message for you. What I can do is narrow it, and I've spent the effort to rule things out rather than speculate.

What I ruled out (so you don't have to)

I merged your branch into current main locally (merges clean, no conflicts) and checked the merged tree:

  • Every changed TU passes clang++ -fsyntax-only -std=c++20: RadioModel.cpp, AutomationServer.cpp, IcomCivBackend.cpp, IcomSession.cpp, IcomStream.cpp, IcomAudio.cpp, Ax25HfPacketDecodeDialog.cpp.
  • moc runs clean on all six changed headers, including the new signals: block in IcomCivBackend.h and txAudioPayload in IcomStream.h.
  • Every new declaration has a definition — I checked all 24, including the ones most likely to be link errors (setTxPayloadTapEnabled, resetTxFlowCounters, IcomCivBackend::setTxWireTapEnabled, noteTxAudioSubmission, recordTxAudioSamples, takeTxWireRecording).
  • m_finalMonitor exists on MainWindow as ClientPuduMonitor* (MainWindow.h:1013), so setTxFinalMonitor(m_finalMonitor) is fine.
  • icom_tx_resample_ax25_test compiles, links and passes — I built it standalone from your tests.cmake source list and ran it:
    duration: in=16240 out=32480 expected=32480 ratio=1.0000
    lpcm round-trip: worst sample error 0.000026 (quantum 0.000031)
    drop counters: droppedBytes=40960 dropEvents=1
    icom_tx_resample_ax25_test: all checks passed
    
    Your #ifndef M_PI guard in that file is also exactly right — that's the usual MSVC trap for new DSP tests, and you avoided it.

So the break isn't any of the obvious candidates. My local Qt is 6.11.1; CI pins 6.8.3 on macOS and Windows (ci.yml:452, ci.yml:687), so a version gap is possible — but I scanned every Qt API you added and they're all long-standing (QDir::temp, QMetaObject::invokeMethod with a functor + context, QString::fromLatin1), so I don't think that's it either.

The thing I'd fix first: your base is 23 commits stale

You verified against v26.8.3 (3e467fa8). main is now at 80e05c14, 23 commits ahead, and several of them land in files you touch:

Git merges all of that without a textual conflict, which is precisely how a semantic break gets in unnoticed. Rebase onto main and rebuild — that reproduces what CI is compiling, which your v26.8.3 build does not.

A real defect regardless: tests/tests.cmake is encoding-corrupted

This one I did confirm concretely. 114 lines of that file are mojibake — UTF-8 round-tripped through a Windows codepage more than once. Line 1 in your branch:

$ head -c 32 tests/tests.cmake | xxd
00000000: 2320 c383 c2a2 c3a2 e282 acc2 9dc3 a2e2  # ..............

Those bytes render as ââ€\x9d… where the file should have . It shows up as  wherever an em-dash used to be:

-# ── AetherSDR test registration ───────────────────
+# ââ€â‚¬Ã¢â€â‚¬ AetherSDR test registration ââ€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬…

That's 114 of the 244 changed lines in that file — pure encoding damage, no content change. 104 are comments and one is a quoted message string; none of it breaks CMake parsing, which is consistent with Configure passing. So this is almost certainly not your build failure — but it will swamp review and it needs to go before merge.

Cleanest fix, since none of the corruption is yours to keep:

git checkout main -- tests/tests.cmake
# then re-add just your block (the merged tree has it at ~line 418):
#   add_executable(icom_tx_resample_ax25_test ...)

Then set your editor to write UTF-8 without a BOM. If it's Visual Studio, File → Advanced Save Options → Unicode (UTF-8 without signature); VS Code, the encoding indicator in the status bar. Worth a git diff --stat before pushing — a comment-only file showing 100+ changed lines is the tell.

What would unblock this fastest

If a rebase onto main doesn't clear it, paste the first compiler error from any of the three logs (they should all be the same error) and I'll take it from there. The first one is the one that matters — the rest are usually cascade.

On reviewer comments: there are none on this PR yet, from Copilot or anyone else, so there's nothing there pointing at the failure.

On the PR itself

Two notes, separate from CI, since you asked for a maintainer read on the shape:

  • Splitting is worth reconsidering. The TxPacketizer drop counters (IcomAudio.h/.cpp) are self-contained, behaviour-complete, and have a passing standalone test. That could land on its own today. The taps are the part needing an architecture call, and they'd review much better without the counters riding along. You're right that the three tap fixes can't be separated from the taps — but the packetiser accounting isn't in that knot.
  • The AutomationServer.cpp +330 concern you raised is a fair one to raise, and I'd wait for a maintainer rather than pre-emptively trimming it — you've documented why each arm/drain pair exists, which is the argument that has to be answered either way.

And for what it's worth: #5060 being filed for the 87-vs-35 frame gap instead of letting this PR imply the tap was fixed is the right instinct. Thanks for volunteering the bench time on this — hardware-in-the-loop diagnosis of a seam like RS-BA1 is not work many people can do.


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

@nigelfenton

Copy link
Copy Markdown
Contributor Author

Rebased onto main (4c8bdb9f) and force-pushed — here is what changed, and the CI answer you asked for.

The first compiler error, and the root cause

You were right that the version gap mattered, but the mechanism turned out to be an include, not semantic drift:

IcomCivBackend.h(319): error C2039: 'set': is not a member of 'std'
RadioModel.h(1845): error C2039: 'set': is not a member of 'std'

Both headers declared a std::set<std::uint64_t> member with no #include <set>. My local Qt (6.10.x) drags <set> in transitively; CI's 6.8.3 does not. Confirmed rather than assumed: stripping the include back out on this box still compiles clean, so the local build genuinely cannot reproduce the failure — while a standalone TU declaring the same member without <set> emits exactly the four errors CI reported, in order (C2039, C7568, C2062, C2238). Fixed in 8b8310e5.

The rebase

  • Resolved against the session-generation work (9a31bb55) — the onCivFrame(frame, sessionGeneration) signature change merged cleanly alongside the taps; the one real conflict in IcomCivBackend.h was additive-vs-additive, both kept.
  • tests/tests.cmake is now a +16-line diff with zero encoding damage. Root cause of the mojibake: mid-history commits carried the corruption even after the tip-level cleanup, so it kept resurfacing. Each conflicted hunk was resolved by taking main's clean text and re-splicing only the real registrations.
  • Full Windows/MSVC build clean; icom_tx_resample_ax25_test passes (mark=0.34999 space=0.05004, 33 frames / 7 distinct, droppedBytes=40960 as designed).

The split you suggested

Done: the TxPacketizer drop counters are now #5162, standalone with their own falsifiability-proven test. Once that lands I'll drop the counter commits from this branch, which shrinks this PR to the taps and their fixes — the part that needs the architecture call.

One heads-up on framing: per the PTT-timing analysis in #5011, the audio fault these taps were built to find likely does not exist — the failure is in when audio starts relative to the radio keying, not in the samples. I'll re-scope this PR's description around timing instrumentation once that discussion settles, rather than let it keep implying an audio-seam cause.

nigelfenton and others added 14 commits August 27, 2026 21:03
`modem txprobe [text]` builds what WOULD be transmitted and reports each seam
of the chain separately — build, frame, modulate, txgate, route — without
keying the radio.

The failure it exists for: AE keys the transmitter, real RF goes out, and the
receiving modem sees no AX.25 frame structure at all (rejectBadFcs stays 0
against thousands of HDLC candidates) while AE's own txBytes and txAttempts
report 0. Those three symptoms are equally consistent with "the modulator emits
nothing coherent" and with "no bytes ever reached the modulator". A measurement
of the whole chain cannot tell those apart; a measurement of each seam can.

It reports peak/RMS dBFS of the modulated PCM, so digital silence is named
rather than left to be inferred from a sample count; the queue gate's inputs
individually, so a deferred transmit says WHICH condition held it; and the
audio route to the radio (hostModulates / takesTxAudioOverSeam / bypassesDax /
txStreamId), which is what differs between backends.

Deliberately does not transmit and is not TX-gated: a diagnostic should not
need the permission whose absence it may be diagnosing, and it stays usable
with no radio connected and off the air.

Proven on hardware 2026-08-13. It killed three standing hypotheses about an
open AX.25 transmit bug:
  - "the HL2 waits for a DAX stream that never arrives" — route reports
    bypassesDax=true; it never waits.
  - "the TX FIFO is not fed" — underflow is true only at idle.
  - "the modulator produces silence" — modulate reports peak -9.1 dBFS, and
    the HL2's own txMicPeakDbfs reads -9.119 under key, i.e. the audio
    arriving at the radio is bit-for-bit the modulator's output.

Run against an IC-9700 (seam route) and an HL2 (direct route) it reports the
two different transmit routes explicitly, which is how the remaining fault was
narrowed to shared code above the backend.
`txaudio` keeps a running peak/RMS and deliberately discards the samples.
That answered "did audio reach the backend" but could not answer the two
questions that actually mattered while chasing an AX.25 transmission that
modulated correctly yet decoded nowhere:

  - is the waveform clipped?
  - does the burst ever change from preamble flags into data?

Both are shapes. A level is an average, and it hid the difference: the tap
read a healthy -9.1 dBFS for a keyed burst AND for a stream of digital
silence. Same number, entirely different content.

txwave arm | save <path> | off records the int16 samples handed to
RadioModel::submitTxAudio and writes a stereo WAV. Capacity is reserved when
armed and capture stops at the cap, so the audio path never allocates. It
records the FIRST samples of a burst rather than the last, because the
preamble-to-data transition is at the start and that transition is the thing
under test.

Like txaudio and txmonitor it is an observer: it keys nothing, so it is not
TX-gated. Gating an observer behind the permission whose effects it observes
would make the failure it exists for unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
txAudioBypassesDax() is an OR of two different reasons — the HOST runs the
modulator (HL2: hostModulates=true), or the audio crosses the SEAM to a
radio that modulates it (Icom: hostModulates=false, takesTxAudioOverSeam=
true). The operator-facing log line reported both as "host-modulated (no
DAX stream)", which describes the HL2's case on a radio where the flag is
false.

Not cosmetic in practice. Chasing an AX.25 transmit fault on an IC-9700,
this line said "host-modulated (no DAX stream)" while `modem txprobe`
reported hostModulates:false for the same transmission. Read together they
look like a contradiction in the transmit path, and it cost a diversion
looking for a missing DAX stream on a radio that has never had one.

The internal qCInfo on the next line was already correct (route=seam/host);
only the line the operator reads was wrong. The HL2 keeps its existing text
because hostModulates is tested first.

Routing behaviour is unchanged — this touches the label only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Direwolf's atest decodes a `txwave` recording of AE's transmit audio
perfectly — 3 of 3 AX.25 frames, ideal level, mark/space 20/20 — while the
same transmission off the air decodes nowhere: audible on a Kenwood, and a
known-good decoder on an independent radio and interface gets nothing. So
the fault lives between RadioModel::submitTxAudio and the antenna.

`txwave arm post` captures at the far end of the client's half of that
stretch: after IcomCivBackend's 24k->48k resample, on the line before
IcomSession::sendAudio, which is the last point AE sees its own audio.
Everything past it is the LPCM codec, UDP and the radio.

Both taps feed the same recorder, so the run is unchanged —
`txwave arm post` -> transmit -> `txwave save <path>` -> `atest -B 1200` —
and the two captures answer one question between them: decodable after the
resampler clears the client entirely, undecodable indicts the resampler
directly above the tap.

Icom-only, because it is the one backend that resamples on the way out. A
no-op on every other family rather than an error: asking a Flex for its
post-resample audio is a question with no referent.

Off unless armed, read with a relaxed atomic, and it keys nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dio silently

TxPacketizer::submit() discards the OLDEST bytes past its 250 ms cap. For
voice that is correct and the comment at the drop site argues it well:
latency must not grow, and the freshest audio is what matters. For a digital
burst it is destructive in a way nothing reports — the oldest bytes of an
AX.25 transmission are the preamble and the opening flag, so what survives
keys the radio, sounds exactly like packet on a receiver, and syncs on
nothing.

Nothing surfaced that. No error, no gap, no counter: a transmission that
lost 63 % of itself looked identical to one that did not.

submit() now counts dropped bytes and overflow events; IcomSession::Stats
carries them; onLinkTick reports new drops at WARNING with the running total
and the pending/cap ratio, on the edge rather than per drop (one oversized
submit sheds thousands of bytes in a single loop).

Counting rather than logging inside IcomAudio.cpp keeps that file pure
protocol — no QObject, no Qt logging — which is also what lets the unit test
link it standalone.

The test asserts the counter against the arithmetic (droppedBytes == the
bytes actually missing, 40960 of 64960 submitted) rather than trusting it,
because an instrument that under-reports here would turn a lost preamble
back into silence.

This is diagnosis, not a fix: the drop rule is unchanged and a burst that
overflows still loses its front. Whether production reaches it depends on
pacing — onTxPaceTick ships a larger chunk when a tick lands late — and this
is what will answer that question from a real transmission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`room` is a raw capacity difference and can be odd. An odd `take` writes
take/2 pairs into take slots — one sample left uninitialised, and the next
block starting at an odd offset, which swaps left and right for the rest of
the capture and makes the WAV undecodable however good the audio was.

Not observed: the capacity and the arriving block sizes are both even, so
`room` stayed even and both existing captures have L == R on every frame.
Fixed anyway because this is a diagnostic whose entire value is that its
output can be trusted — verified separately by pushing known-good AFSK
through the tap's exact conversion (float -> clamp -> lround(v*32767) ->
duplicate) and confirming atest still decodes 3/3 at level 70, 20/20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tried 2026-08-15 and it is wrong: the modem's own AFSK reaches
txFinalMonitorPcmReady too, via sendModemTxAudio() ->
feedDaxTxAudioInternal(), so the gate silences the MODEM rather than the
microphone. Measured on air as "RF power but no modulation".

The comment stays so the next reader does not repeat it. If the two sources
ever do need separating, it has to happen upstream of this shared tap, where
they are still distinguishable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dd a wire tap

`txwave` exists so that "is the transmitted audio right?" can be answered with
samples rather than levels. Three defects meant its own captures could not be
trusted, and all three are the same shape: several taps at DIFFERENT SAMPLE
RATES sharing one recording buffer and one rate stamp.

1. The post-resample tap and the pre-resample path both wrote
   `m_txAudioRecordBuffer` and `m_txAudioTapSampleRate`. `txwave arm post` also
   arms the level tap, which runs the pre-resample path, so a "post" capture
   interleaved 24 kHz and 48 kHz samples and was stamped with whichever wrote
   last. The resulting WAV decodes as nothing no matter how good the audio was.

2. `setTxPostResampleTapEnabled()` and `setTxWireTapEnabled()` both wrote one
   boolean, and AutomationServer calls both on every arm — so `arm post` set
   the claim and the very next line cleared it. Symptom: a `post` capture came
   back at 24000 Hz / 16 s instead of 48000 Hz / ~1.8 s.

Replaced with a three-state owner (`PreResample` / `PostResample` / `Wire`):
one variable, one owner, each setter releasing only its own claim, and each
recording lambda checking ownership rather than assuming its connection implies
it.

3. The new wire tap (below) emitted from the socket write path ungated, at
   ~100 packets/s, allocating per datagram. Now gated on `m_keyed || m_tuning`,
   exactly as `submitTxAudio` is.

Also adds two taps that the AX.25 investigation needed and could not get:

* `txwave arm wire` — the payload of each outbound UDP datagram, captured after
  the socket write, so it reports what was handed to the socket rather than
  what was about to be. Reuses `isAudioData()`/`audioPayload()` (structural,
  not a size whitelist, so keepalives and the serial stream fail it) and
  decodes with the same `decodeAudio()` the receive path uses — any difference
  from the post-resample tap is then a difference in the BYTES, not in how they
  were interpreted.

* `txwave arm dual` — post-resample AND wire from ONE transmission, into
  separate buffers (`save` writes post, `save2` writes wire). Comparing two
  keyings cannot distinguish "the wire alters the audio" from "those were
  different bursts"; this can.

`save`/`save2` share one WAV writer deliberately: a second copy of the header
code is how the two files would drift into being not-comparable, which is the
whole point of capturing them together. `save` does not disarm during a dual
capture, or the first save would discard the wire buffer before `save2` could
read it.

Verified on hardware (IC-9700 over LAN, 145.070 DFM): `arm dual` on a single
transmission gives post-resample and wire captures that are byte-identical —
4096/4096 samples exact, max diff 0 — which is the result the collisions had
been hiding. Before the ownership fix the same command returned a 24 kHz 16 s
capture; after it, 48 kHz 1.9 s as intended.

The one-line comment on `kTxAfskAmplitude` records a measurement made with
these taps and changes no behaviour: raising it 0.35 -> 0.90 moves the
modulator by the expected +8.2 dB and does not change the transmitted
deviation at all on that path. See aethersdr#5011.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wire tap gated on the INSTANTANEOUS key state, which truncated the
capture at a point that moved from run to run — so the instrument, not
the transmission, was what changed.

Unkey races the packetiser drain: setKeying(false) clears m_keyed and
only then calls flushTxAudio(), while onTxPump() fires every 10 ms in
between, so a variable number of frames escape after the tap has already
stopped recording.

Measured on an IC-9700, two identical AX.25 bursts at 145.070 DFM:

  run  first frame differing from the post-resample tap   wire length
   1   frame  8  (160 ms)                                 -828 samples
   2   frame 24  (480 ms)                                +3264 samples

while the post-resample capture was 86016 samples both times. A capture
whose LENGTH varies for a transmission of constant length is an
instrument fault — and it reads exactly like "the wire is corrupt",
which is the conclusion it nearly produced.

Fix: latch on at key-down and hold until the packetiser has actually
drained (txPendingBytes == 0). The burst is captured whole, and the
inter-burst stream still never reaches the recording — which is what the
original gate existed to prevent, and why an ungated tap is not the
answer: measured 96.7 % non-zero over a 2.1 s capture, which atest
cannot sync on.

icom_backend_test, icom_civ_test, icom_civ_scheduler_test and
icom_tx_resample_ax25_test all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ere double-recorded

sendRaw() is also the REPLAY path. A retransmit request is served by
sendRaw(*it) on the retained copy, and sendRawTwice() deliberately writes
the same bytes twice. The wire tap sat in sendRaw(), so every packet the
radio asked for again was recorded again — out of order and interleaved
with the live stream.

In a capture that is indistinguishable from corrupted audio, and it is
not what went on the air once.

Measured on an IC-9700 over LAN, one AX.25 connect attempt (3 retries):

  post-resample capture   185152 samples   Direwolf decodes 4 of 5 bursts
  wire capture            196396 samples   Direwolf decodes 0 of 3 bursts
                          +11244 (+234 ms)

with duplicated and reordered 20 ms frames, and 4 frames that were the
exact sample-wise NEGATION of the post capture. The burst splitter used
to measure that was validated on the post capture first, where it
recovers 4 decodable bursts, so the wire failures were not an artefact
of how the audio was cut.

Moved into sendTrackedImpl() after the first sendRaw(), gated on
isPayload, so each payload is recorded exactly once in send order.
Retransmits, keepalives, pings and the serial stream still never reach
it — isAudioData() rejected those already, but the replay of a genuine
audio payload passed that test, which is precisely the case this misses
when the tap sits lower down.

icom_backend_test, icom_civ_test, icom_protocol_test, icom_session_test,
icom_civ_scheduler_test and icom_tx_resample_ax25_test all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…isible

The packetiser can be starved as well as overrun, and starvation is the
quieter failure: onTxPump() simply sends nothing on a tick where
takeFrame() has less than a whole 20 ms frame, so the radio's jitter
buffer sees a gap rather than an error. On an AX.25 burst that gap lands
in the middle of a frame whose preamble has already gone out.

Nothing reported it, because an empty takeFrame() is indistinguishable
from "nothing to send". These counters make the two separable:

  txSubmitCalls / txSubmitSamples  what the modem HANDED US
  txFramesSent                     whole 20 ms frames that reached the wire
  txPumpTicks / txPumpEmpty        pump ticks while keyed, and how many
                                   found less than a frame ready

A burst whose audio is 597 ms should yield ~30 frames; markedly fewer,
with txPumpEmpty climbing, is starvation rather than loss.
resetTxFlowCounters() zeroes them so one transmission can be measured
alone.

Counting only — no behaviour change on the transmit path.
…plicates

A count of blocks cannot detect the same block arriving twice, and that
is the failure under investigation. Captured off the wire with tshark on
2026-08-17, one AX.25 burst left the machine as 35 frames of which only
ELEVEN were distinct: the two preamble frames repeated 24 times in place
of the information field. Every level, tone and block-count measurement
called that healthy, because the blocks really were submitted — they
were duplicates.

Two hash points, deliberately bracketing the resampler:

  RadioModel::noteTxAudioSubmission()  what the backend was GIVEN
  IcomCivBackend before sendAudio()    what it HANDS ON, after 24k->48k

Each reports total blocks, DISTINCT blocks, and consecutive repeats.
Consecutive repeats are the signature of a stalled producer re-offering
its last buffer; a low distinct count is the signature of a burst whose
payload never arrived at all. Different faults, so both are counted.

Comparing the two distinct-counts localises a duplication to one side of
the resampler with no radio involved. It also settles a contradiction:
the post-resample tap shows all 89 frames the modulator built while the
wire shows 11, and the packetiser between them measures clean offline —
so one of those observations is not what it appears.

FNV-1a over the PCM: cheap, allocation-free, adequate for "is this
byte-for-byte the block I just saw". This runs on the audio path, so it
does not allocate — the set insert is the one exception and only grows
to the number of distinct blocks in a burst.

Surfaced as distinctBlocks/repeatedBlocks in txAudioTapSnapshot (so
`txaudio status` reports them) and appended to the Icom TX flow log line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both headers declare a std::set member with no <set> include:

  IcomCivBackend.h  std::set<std::uint64_t> m_txOutHashes
  RadioModel.h      std::set<std::uint64_t> m_txAudioTapHashes

They compile locally and fail on CI, which is the whole tell. Local Qt
here is 6.10.3 and CI pins 6.8.3 (ci.yml); 6.10.3's headers happen to
drag <set> in transitively and 6.8.3's do not, so the declaration was
resting on a Qt implementation detail rather than on an include.

Verified rather than assumed: stripping the include again on this box
still compiles clean, so the local build genuinely cannot reproduce the
failure. What does reproduce it is a standalone TU declaring the same
member without <set> -- MSVC emits exactly the four errors CI reported,
in order:

  error C2039: 'set': is not a member of 'std'
  error C7568: argument list missing after assumed function template 'set'
  error C2062: type '<error>' unexpected
  error C2238: unexpected token(s) preceding ';'

That is the reported break in full, so this is the cause and not merely
a plausible candidate. Both headers now also compile standalone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nigelfenton
nigelfenton force-pushed the fix/ax25-tx-chain-probe branch from 4d4ea4b to 97436d3 Compare August 28, 2026 01:15
@nigelfenton

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (c23bc103) after #5274 landed. One file conflicted — IcomStream.cpp — in two of this branch's commits, and the resolution is worth recording because it touches telemetry #5274 has only just added.

Both conflicts were in sendRaw(), where #5274 added packet counting and an activity timestamp alongside the existing byte counter:

m_counters.txBytes += static_cast<quint64>(n);
++m_counters.txPackets;          // #5274
m_lastTxAtMs = m_activityClock.elapsed();   // #5274

This branch has two commits that move through the same lines in sequence — 95256d5f adds a wire tap there, then 74e248fd takes it back out again ("tap the wire on first send, not in sendRaw — retransmits were double-recorded"). Each conflicted separately, and the end state is what matters: #5274's telemetry stays exactly as main has it, and no tap is in sendRaw().

Verified rather than assumed: the branch's diff against main for this file is now 39 insertions and 0 deletions — it purely adds, and takes nothing away from what #5274 introduced.

I left a comment at the site explaining why the two are treated differently, since the distinction is easy to lose next time someone edits this function:

The byte/packet counters DO belong here: they count writes to the socket, and a retransmit really is another write. The tap does not, because a retransmit puts the same audio on the air once but through sendRaw() twice.

A question for @jensenpat, not a finding. That distinction is exactly why the tap moved. txPackets now counts in the place the tap was moved out of, so on a lossy RS-BA1 link it will count retransmits as separate packets. That may well be intended — for transport telemetry, "how many writes did we make" is arguably the more useful number, and it is certainly the honest one for a packet counter. Flagging it only because the neighbouring code carries the opposite lesson, and someone reading txPackets as "packets that reached the radio" would be misled.

Build and tests on the rebased branch: AetherSDR.exe links clean; icom_civ_test, icom_protocol_test, icom_family_test, icom_audio_test, ax25_frame_formatter_test and hdlc_codec_test all pass (6/6).

@nigelfenton

nigelfenton commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Bump for review — and an honest note on why this one has sat for eleven days with no human look.

It is big: 1,438 additions across 17 files, 14 commits. That is a lot to ask of a reviewer, so here is the shape of it, what is safe to skim, and where I actually want eyes.

What this is

Transmit-audio taps that bracket the RS-BA1 seam (#5011) — instrumentation to find where AX.25 TX audio was being corrupted between AetherSDR and an IC-9700.

The headline finding was retracted, by me, in this thread. The taps disagreed with each other on real hardware; chasing that turned up three defects in the instrumentation itself, and the conclusion they had been pointing at was not supported. The client TX path measured clean. What is left in this branch is the tap fixes and the taps themselves — not a claim that AetherSDR corrupts the wire.

The part that is still not fixed is filed separately as #5060 (txwave save2 captures 87 frames where the session counters say 35 were sent). I would rather that be tracked openly than left implied by a PR that looks like it fixes the tap.

Where to actually look

Commit What it is Review weight
a6519f30 4ee5b62d 7a9a5850 839c9724 the four tap-correctness fixes — capture window, mutual corruption, whole-stereo-pair rounding the real content
f2485690 5543ae9a hash both ends of submitTxAudio, count feed against drain the diagnostics that caught the above
b6bd97ee 27d0923e dd20448f post-resample tap, record SAMPLES not levels, non-keying probe new surface, worth a look
4367f107 b1f65d02 e3807c55 97436d36 monitor registration, route naming, a docs warning, an #include <set> skimmable
9d755d8f TX packetiser drop counters already reviewed standalone as #5162

It can be shrunk

9d755d8f here is the same work as #5162, which is 221 lines across 8 files, standalone, and has already been through a review round with @ten9876. If #5162 lands first I will rebase this branch and drop that commit, which takes a meaningful bite out of the diff. That is the main reason I have been bumping #5162 — not because it is urgent on its own.

If a reviewer would rather I split this further before spending time on it, say so and I will. It is instrumentation, so it divides fairly cleanly.

State

  • Rebased onto c23bc103 after fix(icom): stabilize WSJT-X TCI unkey handling and incident telemetry #5274 landed; IcomStream.cpp conflicted in two commits and the resolution is written up above (it touches telemetry fix(icom): stabilize WSJT-X TCI unkey handling and incident telemetry #5274 had only just added). main has since moved one commit — the GHE applet, whose only CMakeLists.txt change is three added source files, so there is no conflict and I have not re-rebased for it.
  • Four CI checks green; MERGEABLE.
  • Built and run on Windows/MSVC today at 97436d36: AetherSDR target clean, and the Icom/AX.25/automation slice 34/35.
  • Full local suite 312/320. The 8 failures are crdv_manifest_test, dss_renderer_test, vkamp_connection_test, aether_mcp_field_mapping, bridge_docs_check, hl2_state_restore_test, connection_panel_size_test, phone_tx_filter_numeric_entry_test — an identical set to what I get on an unrelated branch of mine today, and hl2_state_restore_test / phone_tx_filter_numeric_entry_test are the same two @skerker independently reports as pre-existing on feat(audio): compile the PortAudio CW sidetone sink on Windows (#5200) #5201. None of them touch this branch's files.

Not verified

No IC-9700 on the bench for this push, so the taps are not re-measured on hardware since the rebase; the measurements in this thread stand as originally reported. The txwave save2 frame-count gap (#5060) is open and this PR does not close it.

@jeremymturner @jensenpat @ten9876 — whenever there is a moment. Happy to split it if that makes it reviewable.

@jensenpat

Copy link
Copy Markdown
Collaborator

Superceded by #5311

@jensenpat jensenpat closed this Aug 29, 2026
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