Skip to content

fix(hl2): stop splicing TX audio that arrives in bursts. Principle VIII. - #6005

Merged
ten9876 merged 11 commits into
aethersdr:mainfrom
on8st:fix/tx-mic-rate
Oct 2, 2026
Merged

ten9876 merged 11 commits into
aethersdr:mainfrom
on8st:fix/tx-mic-rate

Conversation

@on8st

@on8st on8st commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Closes #6004.

The defect is TX audio reaching Hl2TxDsp::modulate() in bursts of two or more 512-sample blocks. The stage always runs at the fixed 24 kHz transport rate (TxVoiceProcessor::kTransportRate), whatever the device rate. modulate() exchanged the blocks of a burst back-to-back with the non-blocking TXA channel. The second underran, fexchange2 advanced its read index anyway, and WDSP's two-slot output ring was then read out of step, splicing every later block boundary. modulatorFaultBlocks only counted the first underrun.

Known routes to a burst:

Fix

  • Hl2TxDsp queues levelled audio and exchanges a block only when WdspChannel::outputReady() reports the previous output is ready. It retries from a 1 ms timer on its own thread, so nothing blocks the I/O thread. A head block whose worker has not answered within one block period of becoming due is dropped, not exchanged (an exchange would desync the ring), and counted in modulatorFaultBlocks() and modulatorStallDrops(). Queue depth above four blocks is warned once per excursion. Every over starts with an empty queue and a fresh deadline.
  • WDSP local patch 15 adds GetChannelOutputReady() and makes dexchange() publish a block into r2_havesamps only after copying it. The reorder applies to every channel; AETHERSDR-PATCHES.md documents its effect on RX (no latency change; the count becomes visible one memcpy later). It is numbered 15 because fix(wdsp): free the minimum-phase design workspace after each design, and disclose #5954's costs. Principle VIII. #5965 claims 14.

Evidence

Measured on hpsdrsim with a 24 kHz input:

before after
tone phase breaks (5.5 s) 252 2 (start and end)
speech envelope dips/s 3.58 0.0 (48 kHz baseline on macOS/hpsdrsim 0.13)

Verified by ear with a real AirPods Max microphone, on the simulator (before: chopped, after: clean) and on the air through a real HL2 by the operator.

Tests

New hl2_txdsp_capture_burst_test: a 1 kHz tone delivered as 48, 44.1, 24 and 16 kHz devices group it, plus two seam-driven stall cases (deadline from due, drop not exchange; fresh deadline per over). On main the 24 kHz case reports 1 fault and ~120 splices in 2 s; with the fix all cases pass. A rate case whose worker is stalled by machine load exits 77 (skipped); an underrun always fails. It does not pin the dexchange() reorder, which rests on the argument and measurement in the patch doc.

Not in this PR

A single 64–88 ms gap can occur right after key-up because the outgoing sample queue starts empty. It also happens at 48 kHz and on main. It will be a separate change, since the fix (a short pre-roll) changes transmit timing.

🤖 Generated with Claude Code

on8st and others added 2 commits September 28, 2026 11:42
…dsets)

Qt 6.8's macOS QAudioSource flushes captured audio in 4096-byte chunks
(1024 frames of 16-bit stereo) whatever the device rate. At 48 kHz that is
one 512-sample (24 kHz) Hl2TxDsp block per delivery; at 24 kHz -- a
Bluetooth headset microphone such as AirPods Max -- it is two, every time.
Hl2TxDsp::modulate() exchanged them back-to-back with the non-blocking TXA
channel. The second exchange underran before WDSP's worker had produced the
first block's output; fexchange2 zeroed it and advanced its read index, and
from then on the two-slot output ring was read out of step with the worker,
so every later block boundary was a splice. modulatorFaultBlocks counted the
one underrun (2 per over) and nothing for the ~250 splices after it.

Measured on hpsdrsim (hl2-lab d161), 1 kHz tone through BlackHole at 24 kHz:
252 phase discontinuities in 5.5 s, one every 21.3 ms, before; 0 after.
Speech: 5 ms envelope flutter 3.58/s -> 0.0/s (48 kHz baseline 0.13/s).

Hl2TxDsp now queues levelled audio and exchanges a block only when the
channel reports the previous block's output ready, retrying from a 1 ms
timer on its own thread; nothing blocks the I/O thread, a paced caller never
waits, and a stalled worker is exchanged anyway after one block period
(counted and logged as before). The readiness query is local WDSP patch 14
(GetChannelOutputReady, via WdspChannel::outputReady()), which also moves
dexchange()'s r2_havesamps increment after the copy into r2 so "ready" means
the block is in the ring. A fixed time spacing was tried first and failed
under ctest -j6: a loaded worker outlasted it.

hl2_txdsp_capture_burst_test feeds a tone as 48/24/16 kHz devices deliver
it, running the event loop between deliveries, and requires zero faults, the
full IQ count, no envelope hole and a phase-continuous tone. It fails on
origin/main (1 fault and 122 splices at 24 kHz) and passes with this change,
including 5 of 5 runs under 16 CPU hogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aethersdr-agent

Copy link
Copy Markdown
Contributor

Thanks for this one, @on8st — a two-slot ring being read out of step for the rest of an over, with the fault counter reporting 2 out of ~250, is exactly the kind of near-silent defect that's miserable to find. The 252 → 2 phase-break measurement and the "before: chopped, after: clean" A/B on hpsdrsim are the right shape of evidence, and numbering the WDSP patch 15 with the reason written down in AETHERSDR-PATCHES.md is appreciated.

What failed

da570c2a — check-windows, failing step Build (run 36425856547).

Everything else on that commit was either green or still running:

Check Result
check-windows failure — Build
build (Linux) in progress at time of writing
check-macos in progress at time of writing
Static checks success
Sanitizer option configures success

Being straight with you about one limit: the CI tooling I have returns per-step status only, not the compiler output, so I'm not going to quote you an error line I haven't actually read. Please open the Build step log and search for the first error C — MSVC's first error is the real one and the rest are usually cascade. If you paste it here I'll narrow this down properly.

What I can do is tell you where to look. All eight files in the diff are portable on inspection — no windows.h, no std::min/std::max near it, <algorithm> is present in Hl2TxDsp.cpp, and the patch-15 declaration in third_party/wdsp/include/aether_wdsp.h is correctly inside the extern "C" block, so it isn't a C++-mangling link failure. That leaves a short list, cheapest first:

1. M_PI in the new test. tests/hl2_txdsp_capture_burst_test.cpp:111 and :172 use M_PI, and MSVC's <cmath> doesn't define it without _USE_MATH_DEFINES set before the include. This is the repo's single most frequent Windows-only compile break (#4337, #2732 — and CHANGELOG.md:7413 records the same thing for M_PI_2).

Caveat worth stating: tests/hl2_txdsp_test.cpp uses M_PI with almost the same include set and is green on Windows, so M_PI is evidently arriving there via a Qt header. Your TU's include set differs (<QElapsedTimer>, <QEventLoop>), so it's plausible it no longer does — but it's not certain, which is why it's a "check this first", not a diagnosis. The repo's three established fixes, any of which is fine:

  • a file-local static constexpr double kPi = 3.14159265358979323846; — tests/hl2_cw_bfo_test.cpp:50
  • #include <numbers> and std::numbers::pi — src/core/WfmDsp.cpp:7, tests/wfm_dsp_test.cpp:16
  • #define _USE_MATH_DEFINES before the includes — tests/asr_segmenter_test.cpp:6-9

2. std::size_t without <cstddef>. The new test uses std::size_t in struct Result and in run(), but doesn't include <cstddef>. It leaks in from <vector>/<string> on libstdc++ and libc++; MSVC's STL is stricter about what it re-exports. One-line fix, and worth adding regardless.

3. The PORT asymmetry on patch 15 — lower probability, but it is the one thing in the diff that is genuinely MSVC-shaped. PORT is __declspec(dllexport) (third_party/wdsp/upstream/comm.h:141). Your definition in iobuffs.c carries PORT, but neither declaration does — third_party/wdsp/upstream/iobuffs.h:99 and aether_wdsp.h:45 are both bare, whereas every other exported entry point in iobuffs.h (fexchange0, fexchange2) carries PORT. Harmless for a static lib; if WDSP is a DLL in the Windows build it's exactly the class of thing that compiles everywhere and only bites on MSVC. Matching the fexchange2 pattern costs nothing and removes the question.

Not a CI infrastructure failure

Worth ruling out explicitly, since that's the other common answer: all the fragile setup steps (Restore Qt, FFTW3, DeepFilterNet3, sccache, MSVC env, hidapi, PortAudio) succeeded, Configure succeeded, Build Opus succeeded, and both GPU/PortAudio enablement assertions passed. The failure is squarely at compile/link of the tree. No reviewer or Copilot comments have been posted on the PR yet, so there's nothing from that side pointing at a related code issue.

One thing to watch once it builds

Not a build problem, so separate from the above: m_exchangeTimer->start(1) with Qt::PreciseTimer (Hl2TxDsp.cpp:339, :347). You picked PreciseTimer, which is the right call — Windows' default timer resolution is ~15.6 ms and a CoarseTimer retry would have been far slower than a block period there, quietly turning your bounded stall path into the common path. Since check-windows died before hl2_txdsp_capture_burst_test could run, Windows hasn't yet exercised the 24 kHz burst case at all. It'd be worth confirming the fault count is still zero there once the build is green, because the fallback timing is the part of this design most likely to behave differently per-platform.

Docs, for reference: AETHERSDR-PATCHES.md · docs/architecture/audio-pipeline.md

Very likely a one-line portability fix on a genuinely good change. Drop the first error C line in here and I'll confirm it.


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

…e VIII.

check-windows failed on this PR with C2375 ("'GetChannelOutputReady':
redefinition; different linkage", iobuffs.c): the definition carries
PORT (__declspec(dllexport)) and the iobuffs.h declaration did not.
MSVC rejects adding dllexport on a redeclaration; clang only warns
(-Wdll-attribute-on-redeclaration), which is why macOS and Linux built.

Declare it the way fexchange2 is declared one line above. No change
on platforms where the attribute is not an error.

Refs: aethersdr#6004

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

Issue fit

Yes, for the mechanism it names. #6004's cause section and this diff line up exactly: modulate() exchanged every block a delivery carried back-to-back against a bfo = 0 channel, the second call took fexchange2's *error += -2 branch, and that branch advances r2_outidx without consuming (third_party/wdsp/upstream/iobuffs.c:645) so the two-slot ring (r2_active_buffsize = DSP_MULT * r2_size, iobuffs.c:406) is read out of step for the rest of the over. The gate + patch 15 is the right shape of fix, and the reorder in dexchange() genuinely is required by it rather than decoration — I traced r2_havesamps and confirmed upstream publishes the count at iobuffs.c:711 before the data was in r2, which only matters to a host that exchanges the instant the count says ready.

Two requirements from the issue are not covered: 44.1 kHz (18 splices in the issue's own table, the second-worst rate) is not exercised by the new test, and the 6 splices the issue measured at 48 kHz have no explanation in this diff — the new control case asserts splices == 0 at 48 kHz, which is in tension with the issue's own 48 kHz row. Neither blocks; say what the 48 kHz residue is or that it's out of scope.

Scope

File / group What it changes Claimed? Verdict
Hl2TxDsp.cpp/.h queue + readiness gate + 1 ms retry timer yes in scope
WdspChannel.cpp/.h outputReady(), same fence as processIq() yes in scope
third_party/wdsp/.../iobuffs.c/.h, aether_wdsp.h new GetChannelOutputReady() export yes in scope; new vendored-API surface, documented
iobuffs.c dexchange() reorder publish-after-copy, applies to every channel, RX included yes, in the patch doc in scope but global — see below
AETHERSDR-PATCHES.md patch 15 entry + numbering note vs #5965 yes in scope, canon-compliant
tests/hl2_txdsp_capture_burst_test.cpp, tests.cmake new registered target yes in scope

Nothing in the diff is unexplained by #6004. No CHANGELOG.md edit — correct. No settings, no flat keys, no new UI, no preference change. Two things for the maintainer rather than the author:

  • The dexchange() reorder changes the vendored DSP for all channels, not just HL2 TX. I convinced myself it's safe (blocking hosts are released by Sem_OutReady, which is signalled after both the copy and the count either way — iobuffs.c:713; for a non-blocking host the credit only becomes visible later, never earlier), but a global change to the shared WDSP core riding a single-backend bug fix is a maintainer call.
  • Patch numbering is coordinated with open #5965 in its own commit. Fine, just noting it's a cross-PR dependency on a number.

Sibling call site, checked: Hl2RxDsp::processIqBlock (Hl2RxDsp.cpp:712) has the identical while (… >= block) back-to-back processIq loop against the same non-blocking channel, and its comment even calls Underrun "normal". It is not affected in practice: RX dspBlockSize is 1024 (Hl2RxDsp.h:88) while EP6 deliveries are 126 samples per DDC, so the loop runs at most once per call. Worth knowing that the guard is absent there if RX block sizing ever drops.

Socket tests: none. The new test binds nothing — no QTcpServer/QUdpSocket/bind/listen/connectToHost, no fake peer. It is a socket-free CTest driving real DSP, which is the correct layer per AGENTS.md.

Blockers

1. The stall fallback force-exchanges, which is precisely the corruption this PR exists to prevent — and at the targeted rates it is the normal path, not a rare one. (Hl2TxDsp.cpp:277, Hl2TxDsp.cpp:256, inline)

The gate is bypassed when m_lastExchange.nsecsElapsed() >= exchangeStallNs(), and exchangeStallNs() is one block period — 21.33 ms at 512/24 kHz. But m_lastExchange is restarted on every exchange, so at 24 kHz (42.7 ms deliveries) the gap between the last exchange of one delivery and the first block of the next is ~41 ms, and at 16 kHz ~63 ms. Both exceed 21.33 ms, so the first block of every delivery is exchanged with no readiness check at all — the gate only ever governs the second and later blocks inside a burst.

Usually harmless, because after 41 ms idle the worker has finished. It is not harmless when the worker happens to be behind at that instant — preempted under load, or mid-beginControlOperation() from a setMode/setFilter during the over, where outputReady() returns false by design. Then processIq underruns, and by the PR's own argument (Hl2TxDsp.cpp:270-274, and the patch doc's "every later block boundary is a splice") that one forced underrun ruins the rest of the over. The PR states a fixed time spacing already lost to a loaded worker under ctest -j6; this fallback reintroduces a time-based decision at the one moment the readiness answer is being ignored.

Two things would fix it, and I'd take both:

  • Time the wait from when the block became due, not from the last exchange. A block that has waited a whole block period has genuinely been starved; "no exchange happened for a block period" is trivially true whenever the caller is idle.
  • On a real stall, drop the block rather than exchange it. Dropping loses 21 ms of audio and keeps the ring in step; exchanging loses the same 21 ms (the output is zeroed) and desyncs the ring permanently. The worker already has input from the previous exchange, so dropping cannot starve it into never producing output. This is strictly better by the PR's own reasoning, and m_txFaultBlocks can still count it.

2. m_lastExchange is never invalidated, so every over opens on the bypass path. (Hl2TxDsp.cpp:216, inline)

resetModulatorState() and buildModulator() clear m_exchangePending and stop the timer but leave m_lastExchange valid and holding a timestamp from the previous over — minutes old. So the first block of every over takes the branch in finding 1 unconditionally. It is probably benign because DiscardTXAChannelData reprimes r2_havesamps (iobuffs.c:509), so outputReady() is true anyway at key-down; but the guard is off at the one moment the channel state has just changed, and that's the wrong place for it to be off. m_lastExchange.invalidate() alongside the two existing clears.

Nits (non-blocking)

  • m_exchangePending is unbounded and now silent. (Hl2TxDsp.cpp:245) Before this PR, a caller faster than the channel got counted, logged underruns — the fault counter was the overrun signal. Now the overrun turns into queue depth and transmit latency instead, with modulatorFaultBlocks() at zero. The drain is CPU-bound rather than real-time-bound so a burst does clear, but there is no cap and no warning if it doesn't. A depth watermark with a rate-limited qCWarning would keep the diagnostic the old behaviour gave for free.
  • The timer-thread comment states the wrong reason. (Hl2TxDsp.cpp:335-337) A QObject's thread affinity comes from the thread that constructs it, not from its parent — new QTimer(this) from a foreign thread would log "Cannot create children for a parent that is in a different thread" and the timer would never fire. It is correct here only because exchangeDueBlocks() is reachable only from processAudioBlock (queued to m_ioThread, Hl2Backend.cpp:416/5659) and from the timer itself. Worth restating as "only ever called on the I/O thread" so the invariant a future caller must not break is the one written down.
  • The new test is wall-clock paced and likely to be flaky in the sanitizer lane. (tests/hl2_txdsp_capture_burst_test.cpp:194) It runs ~6.3 s of real time across three cases, and its own comment concedes the control case fails "if the machine is too loaded to judge the rest" — it fails rather than skips. Under TSan/ASan the WDSP worker and the 1 ms timer both slow down by a large factor while the test's delivery cadence stays pinned to real time; with exchangeStallNs() at 21.33 ms, a slowed worker produces exactly the forced underruns of finding 1 and check(r.faults == 0, …) goes red. It's a registered add_test, so full-suite.yml and sanitizers.yml pick it up. Reasoned from the code, not observed — but it's the kind of failure that gets a target quarantined.
  • The patch doc over-claims what the test pins. AETHERSDR-PATCHES.md says hl2_txdsp_capture_burst_test "pins the behaviour". It pins the gate — neuter outputReady() to return true and the test goes red. It does not pin the dexchange() reorder, which is a narrow data race and probably untestable deterministically. Saying so is more useful than implying coverage that isn't there.

What I tried to break

  • Startup deadlock. My first suspicion was that outputReady() must be false before any input has been fed — no input, no dexchange(), no r2_havesamps — which would make the gate break on the very first block, arm the timer, and spin forever with zero IQ. It doesn't: create_iobuffs primes r2_havesamps = (DSP_MULT - 1) * r2_size (iobuffs.c:416), so there is exactly one block of credit at channel open and the first exchange proceeds. flush_iobuffs reprimes the same way (iobuffs.c:509), so a discard-and-restart over is also fine. Survived.
  • Off-by-one credit. Checked whether the primed credit could permit two back-to-back exchanges: it can only if r2_size > out_size, i.e. dsp_outsize > out_size. For this TX geometry dspSampleRate == outputSampleRate == 48 kHz so dsp_outsize == out_size == 1024 and the credit is exactly one block. Survived — but it is geometry-dependent, not structural.
  • Fence and lifetime on the new query. WdspChannel::outputReady() reproduces processIq's m_controlOperation/m_callbacksInFlight sequence verbatim, including the recheck after the increment, so the destructor's drain (WdspChannel.cpp:456) covers it. GetChannelOutputReady takes r2_ControlSection — the same lock fexchange2 takes to read the same counter — so the read is not torn and the CS gives the acquire fence that makes the worker's memcpy visible. Survived.
  • The reordered dexchange() against blocking hosts. Traced Sem_OutReady: released at iobuffs.c:713, after both the copy and the count in either ordering, so bfo channels are unaffected and wdsp_channel_test's offline path sees no change. Survived.
  • Sibling call site. Hl2RxDsp::processIqBlock loops the same way; cleared on block-size arithmetic (1024-sample block vs 126-sample deliveries), not on assumption.
  • Timer lifetime and over boundaries. The timer is parented to this, single-shot, re-armed only while a whole block is pending, stopped by both reset() and buildModulator(), and onExchangeTimer() re-checks permitsDispatch() before emitting — so a lapsed grant drops the tail instead of transmitting it. Principle VI holds on this path. Survived.
  • M_PI on MSVC. Flagged it, then cleared it: hl2_txdsp_test.cpp uses M_PI with only <cmath> and builds on check-windows today, because the Qt headers these tests pull in set _USE_MATH_DEFINES. Not a finding.
  • CI. All five checks green on e3d9c10 (build, check-windows, check-macos, static checks, sanitizer-configures). Those are compile gates; the per-PR ctest is -R-filtered and does not include the new target, so green says nothing about whether hl2_txdsp_capture_burst_test passes anywhere.

What I could not check: nothing here was run. No build, no test execution, no radio, no macOS QAudioSource. Every finding above is reasoned from the code in /tmp/aetherclaude/pr-6005 and from the vendored WDSP source, and the two claims I most want settled empirically are (a) hl2_txdsp_capture_burst_test under -DENABLE_SANITIZERS, and (b) whether an over with a mid-over setMode/setFilter survives the bypass in finding 1.

Recommendation

Request changes — narrowly, on findings 1 and 2. The diagnosis is excellent and the seam is in the right place: asking the worker instead of guessing at a spacing is the correct call, the WDSP patch is minimal and properly documented, and the underlying trace (advance-without-consume in a two-slot ring) checks out line by line. But the stall fallback's threshold is shorter than the delivery period at exactly the rates this PR targets, which leaves the readiness check switched off for the first block of every delivery, and the action it takes on stall is the one action the PR argues is unrecoverable. Timing the wait per pending block and dropping rather than force-exchanging should be a small change, and it would make the guard hold in the loaded case the PR itself identifies as untested. The nits can ride along or follow.

Thanks for the measurements in #6004 — the per-rate splice table and the "326 of 326 deliveries" figure are what made this reviewable at all.


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

Comment thread src/core/backends/hl2/Hl2TxDsp.cpp Outdated
Comment thread src/core/backends/hl2/Hl2TxDsp.cpp
Comment thread src/core/backends/hl2/Hl2TxDsp.cpp
Comment thread src/core/backends/hl2/Hl2TxDsp.cpp
Comment thread src/core/backends/hl2/Hl2TxDsp.cpp Outdated
Comment thread tests/hl2_txdsp_capture_burst_test.cpp Outdated
@on8st

on8st commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

It was your third candidate. The first MSVC error in that Build step:

third_party\wdsp\upstream\iobuffs.c(580): error C2375: 'GetChannelOutputReady': redefinition; different linkage

The definition carries PORT (__declspec(dllexport)) and the iobuffs.h declaration did not. MSVC rejects adding dllexport on a redeclaration; clang only warns (-Wdll-attribute-on-redeclaration), which is why macOS and Linux built. Reproduced with clang's MSVC target before the change (warning) and after it (clean). Fixed in e3d9c10 by declaring it the way fexchange2 is declared one line above. All five checks are green on that head.

The other two did not bite: the burst test compiled under MSVC with M_PI and without <cstddef>, so I left it as it is. Your last point stands, though: the Windows lane runs no ctest, so hl2_txdsp_capture_burst_test has still not run on Windows and the 24 kHz fault count there is unmeasured.

ON8ST

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Antares Detector — candidate vulnerable file(s)

  • src/core/backends/hl2/Hl2AdcPairing.h
  • src/core/backends/hl2/Hl2Backend.cpp

The Hl2AdcPairing implementation performs ADC slice sampling-rate selection and overload/pairing logic on hardware sample rates (Hz or kHz). It likely maps those values into DSP-usable rates and allocates buffers for processing; without validating that the supplied sampleRate is within an expected range before conversion, it can allocate memory based on untrusted input. The Hl2Backend is implicated because it calls adcPairing to decide slice selection, making this a source of unsafe rate conversion.

Localized by Cisco Foundation AI Antares-1B running locally in the AetherClaude sandbox, seeded by the Cartographer security map. Advisory only — please verify before acting.


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

…nciple VIII.

main() skipped under `#if !AETHER_HL2_TX_TXA`. aethersdr#5906 removes that build
option and with it every definition of the macro, and an undefined macro
reads as 0 in #if -- so after aethersdr#5906 this test would skip itself, report
Passed, and run none of its checks. Found by a local integration build
of the open HL2 PRs: on aethersdr#5906 + this branch it printed the phasing-
modulator skip line.

Skip only when the macro is DEFINED and 0, i.e. an explicit phasing
build. Verified on aethersdr#5906 + this branch: the test runs and passes.
on8st and others added 2 commits September 30, 2026 07:54
Review of aethersdr#6005 found the stall fallback was the normal path at the
rates this PR targets: the deadline ran from the previous exchange, so
after a 24 kHz (42.7 ms) or 16 kHz (64 ms) delivery gap the first block
of every delivery skipped the readiness check. And on a real stall it
force-exchanged, which underruns and desyncs WDSP's two-slot output
ring for the rest of the over -- the corruption this PR exists to stop.

Now the deadline runs from when a block reached the head of the queue,
and a block whose worker has not answered within a block period is
dropped, not exchanged: both lose the block's audio, only the exchange
desyncs the ring. Drops are counted (modulatorStallDrops, also in
modulatorFaultBlocks) and logged. The deadline is invalidated at every
over boundary (reset and buildModulator), so an over never opens on
the previous one's clock.

Also from the review: a rate-limited warning when the queue passes four
blocks, since overrun now shows as latency rather than faults; the
timer comment now states the real invariant (I/O thread only); the
burst test gains a 44.1 kHz case, two seam-driven stall cases that
fail on the old gate, and exits 77 rather than failing when load stalls
the worker -- an underrun still fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The patch doc said hl2_txdsp_capture_burst_test pins patch 15. It pins
the output-ready query and the gate built on it; the dexchange()
reorder closes a cross-thread window no deterministic test here
reaches, and rests on the argument in the doc. Say so, as the aethersdr#6005
review asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Top-level reply (on the review)

Both blockers were right, and the first one is the bad news: as first posted, this PR force-exchanged in exactly the case it exists for. At 24 and 16 kHz the gap between deliveries (42.7 / 64 ms) is longer than the 21.33 ms deadline, and the deadline ran from the previous exchange, so the first block of every delivery skipped the readiness check. A stall there would have caused the ring desync this PR is meant to fix. Fixed in 423a8ca. Details are in the inline replies.

Two things I cannot close, stated up front:

  • The 48 kHz residue in HL2: TX audio spliced when it reaches the modulator in bursts (macOS 24/16 kHz mics, Linux 48 kHz) #6004 is not explained. That table was end to end: BlackHole, AudioEngine, EP2 over loopback to hpsdrsim, captured and demodulated. It shows 6 splices on speech and 5 phase breaks in 5.5 s on a 1 kHz tone at 48 kHz. The 48 kHz control in this test runs the stage alone, and there it is 0. So on this test's evidence the residue does not come from the exchange gate at that grouping. Where it does come from, I have not traced. It is out of scope for this PR, and I am saying so rather than guessing.
  • Still not run: the sanitizer lane and Windows ctest. I ran the full suite on macOS only. The change to the test (below) is meant to keep the sanitizer lane from failing on load, but I have not seen it run there.

44.1 kHz is now covered (tests/hl2_txdsp_capture_burst_test.cpp:358, 557 samples per delivery, so roughly every eleventh delivery carries two blocks).

Inline replies

Hl2TxDsp.cpp:278 — Blocker 1

Agreed on both points, and I made both changes (423a8ca):

  • The deadline now runs from when a block reached the head of the queue, not from the previous exchange. m_headWait starts when a whole block is at the head (Hl2TxDsp.cpp:320). It is invalidated when that block is exchanged or dropped (:341, :345), and when no whole block is waiting (:384). A block arriving after the caller was idle now waits for outputReady() like any other block.
  • On a real stall the block is dropped, not exchanged (Hl2TxDsp.cpp:334). It is counted in modulatorFaultBlocks() and in the new modulatorStallDrops(), and logged through the same rate-limited warning. The next block's wait starts at the drop, so one stall cannot drop a whole backlog. I kept one head-of-queue wait. The reason is the one you gave: "measured from the last exchange" was what made one block period the wrong scale (next thread).

Test: stallIsTimedFromDueAndDrops() (tests/hl2_txdsp_capture_burst_test.cpp:272) makes the worker look stalled through a new seam, setOutputReadyProbeForTest(). The seam can only narrow the channel's own answer, so a test can never exchange into a channel that is not ready. The test checks three things:

  • a block that arrives after 40 ms idle is not exchanged while the worker is not ready;
  • once the worker is ready, that block is exchanged, not dropped;
  • a worker that never answers gets its block dropped once, and nothing is exchanged into it.

Restoring the old gate (deadline from the last exchange, force-exchange) turns this red. Four checks fail: the idle-arrival block is exchanged while the worker is not ready, and the stalled worker's block is force-exchanged instead of being dropped and counted.

Hl2TxDsp.cpp:256 — threshold scale

Agreed. The problem was the reference point, not the length. Measured from when the block became due, one block period means what the comment always claimed: the worker has not turned a block round in a whole block period, and a TXA block normally takes it well under a millisecond. The comment at Hl2TxDsp.cpp:275 now says that.

Hl2TxDsp.cpp:216 — Blocker 2

Agreed, and fixed as you suggested (423a8ca): m_headWait.invalidate() next to the existing clears, in resetModulatorState() (Hl2TxDsp.cpp:219) and in buildModulator() (:104). The lapsed-grant path in onExchangeTimer() (:418) now does the same. With the new deadline, a stale clock would drop the first block of the next over outright rather than bypass the gate, which makes this worse than before, not harmless. So it now has a test of its own: newOverStartsAFreshDeadline() (tests/hl2_txdsp_capture_burst_test.cpp:310). The test unkeys while a block is waiting, keys again more than a block period later with the worker not ready, and checks that the first block is neither exchanged nor dropped until the worker answers. Removing the invalidate from resetModulatorState() turns it red. Three checks fail: the first block of the new over is dropped against the old deadline, is never exchanged, and is counted as a fault.

Hl2TxDsp.cpp:245 — unbounded, silent queue

Agreed on the diagnostic, done (423a8ca, Hl2TxDsp.cpp:257). When more than kPendingWarnBlocks (4 blocks, so more than 85 ms of latency at 24 kHz) are waiting, a qCWarning gives the depth and the latency it adds. It fires once per excursion and re-arms when the queue drains. I did not add a hard cap. With stalls now dropped, a stalled worker drains one block per block period, the same rate the caller adds them, so the queue only grows for a caller that is faster than real time on average. That case is now logged. A cap would drop audio silently in the other direction.

Hl2TxDsp.cpp:337 — timer comment

I have one correction to the review, and I made the change anyway. The old comment did not attribute the timer's affinity to its parent. It said the timer is "created on first use, so it is born on the thread this object lives on", which is the constructing-thread rule. What it left out is the part you named: that is true only because every caller is on the I/O thread. The comment (Hl2TxDsp.cpp:391) now states that as an invariant. exchangeDueBlocks() is only called on the I/O thread, from processAudioBlock() (queued there, Hl2Backend.cpp:5659, after moveToThread at :416) and from onExchangeTimer(). It also says what goes wrong if a new caller breaks that.

tests/hl2_txdsp_capture_burst_test.cpp:194 — sanitizer flake

Agreed, and the exit-77 route is taken (423a8ca). The rate cases now separate two outcomes:

  • An underrun always fails. faults == stallDrops (:210). The gate exists so that no exchange underruns, whatever the load.
  • A stall drop means the run is inconclusive. A worker held for a block period is load, not the defect, so the envelope, IQ-count and splice checks are skipped and the binary exits 77 (:373). SKIP_RETURN_CODE 77 is set in tests/tests.cmake. The skip is bounded: more than one drop in ten blocks still fails, so a gate that drops everything cannot pass as "load".

The two stall cases do not depend on load: they drive the stall through the seam. Both coverage notes are addressed: 44.1 kHz above, and the patch doc in the next item.

(review body) AETHERSDR-PATCHES.md over-claims

Agreed, corrected in ffdd1e7. The doc now says the test pins the query and the gate. It also says the test does not pin the dexchange() reorder, which closes a cross-thread window that no deterministic test here reaches and rests on the argument in the doc (third_party/wdsp/AETHERSDR-PATCHES.md:791).

73, ON8ST

@on8st on8st changed the title fix(hl2): stop chopping TX audio from 24/16 kHz mics (AirPods, Bluetooth headsets). Principle VIII. fix(hl2): stop splicing TX audio that arrives in bursts. Principle VIII. Sep 30, 2026
The comment on processAudioBlock() explained the capture-burst defect
through macOS's 1024-frame QAudioSource flush alone, which read as a
24/16 kHz microphone problem. The condition is any delivery of two or
more 512-sample blocks at the 24 kHz transport rate, whatever the
device rate. The pull path used on Linux and Windows hands over
everything TxCaptureBuffer holds on each read, and the queued hops
between the audio, GUI and I/O threads can run deliveries back to back;
both produce the same burst at 48 kHz. Name them so the next reader
does not scope the fix to one platform. Comment only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@on8st

on8st commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

I wrote earlier that I had not seen the changed test run in the sanitizer lane. It has now been run under the sanitizers, locally, at 5427ee35. Three limits first, because they bound what the result means:

  • Leaks were not checked. This was macOS with Apple clang 21, whose ASan refuses to start with detect_leaks=1, so the run used detect_leaks=0. The weekly lane uses detect_leaks=1. A leak in the queue or the timer would not have shown here.
  • TSan ran against a stock Qt, not the instrumented one the :tsan image carries. On a stock Qt every cross-thread queued call reports as a race. That affects hl2_tx_gate_test below; it does not affect the two tests that exercise this PR, which produce no report at all.
  • It is a subset on one machine, not the weekly lane: six tests, macOS arm64, five repetitions per sanitizer.

Flags, taken from .github/workflows/sanitizers.yml at 25da98de and passed through CFLAGS/CXXFLAGS/LDFLAGS as the workflow does, so WDSP's C sources are instrumented:

lane build type compile runtime
ASan + UBSan Debug -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer ASAN_OPTIONS=detect_leaks=0:abort_on_error=0:halt_on_error=0:strict_string_checks=1:detect_stack_use_after_return=1:print_summary=1, UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=0:print_summary=1
TSan RelWithDebInfo -fsanitize=thread -g3 -fno-omit-frame-pointer -O1 TSAN_OPTIONS=halt_on_error=0:second_deadlock_stack=1:history_size=7:print_summary=1:suppressions=tests/sanitizers/tsan.supp

Configured with -G Ninja -DAETHER_ENABLE_HL2_RECEIVER_CHURN_TEST=ON -DAETHER_ENABLE_HL2_TX_LOOPBACK_TEST=ON, Qt 6.8.3 (this head predates #6055), QT_QPA_PLATFORM=offscreen. Only the six test targets were built. The loopback and churn tests were not built or run.

Results, five runs each:

test ASan + UBSan TSan
hl2_txdsp_capture_burst_test 5/5 pass, 0 reports 5/5 pass, 0 reports
hl2_txdsp_test 5/5 pass, 0 reports 5/5 pass, 0 reports
wdsp_channel_reservation_test 5/5 pass, 0 reports 5/5 pass, 0 reports
hl2_tx_level_policy_test 5/5 pass, 0 reports 5/5 pass, 0 reports
wdsp_channel_test 5/5 pass, 0 reports own checks pass; 3 TSan reports per run, not from this PR (below)
hl2_tx_gate_test 5/5 pass, 0 reports TSan reports, not from this PR (below)

On the flake the review predicted for hl2_txdsp_capture_burst_test: it did not happen here. The test never took its exit-77 route. In all ten sanitized runs every rate case printed faults 0 (stall drops 0) ... splices 0 (40 of 40 case lines), at about 10 s per run under ASan and 12 s under TSan against the worker. That is one 12-core machine with up to six tests running at once; it does not show the lane's runner behaves the same.

The TSan reports, and why they are not this PR's:

  1. wdsp_channel_test: data race TXA.c:822 (and :823, :824) in TXASetupBPFilters, against xbandpass bandpass.c:321 on the WDSP worker, reached through WdspChannel::setFilter (WdspChannel.cpp:713). Neither TXA.c nor that line is in this diff. It reproduces at 25da98de with the same three SUMMARY lines, and it is the report the weekly lane already tracks in [sanitizer] TSan weekly run failure (fp 4fd7a70228fc) #5831 (and repeats in [sanitizer] TSan weekly run failure (fp 596465812892) #5959).
  2. hl2_tx_gate_test: 58 to 68 reports per run, 10 distinct signatures. In every one, one side is a functor being run or destroyed by QObject::event on the I/O thread and the other is the same functor being built in QMetaObject::invokeMethod on the caller (Hl2Backend::applyKeying, Hl2Backend.cpp:5000; the test's own packet() helper, hl2_tx_gate_test.cpp:86). That is the stock-Qt class the workflow file describes. All 10 signatures reproduce at 25da98de; none is in a file this PR touches. The weekly lane's failing list at 33c0032 ([sanitizer] TSan weekly run failure (fp 596465812892) #5959) does not include this test.

One extra check, because tests/sanitizers/tsan.supp suppresses fexchange2 and upslew2, and patch 15 edits iobuffs.c right beside them (GetChannelOutputReady, and dexchange publishing r2_havesamps after the copy): I ran the two tests that drive patch 15 under TSan once more with no suppression file. hl2_txdsp_capture_burst_test three runs, hl2_txdsp_test one run: 0 reports, all pass. So the suppressions hid nothing in those two tests.

Positive control for the ASan + UBSan lane: a small program with a signed overflow and a heap use-after-free, built and run with the same flags and options, reports both.

Still not run: the weekly lane itself (Linux, instrumented Qt, leak detection on, whole suite), and Windows ctest.

73, ON8ST

on8st and others added 2 commits October 2, 2026 09:01
main condensed the comment blocks in Hl2TxDsp.{h,cpp} (aethersdr#6072) that this
branch had edited. Resolution: main's condensed text is kept, and only
what this branch makes true is re-applied on top: a delivery can carry
two or more blocks, so each exchange waits for the channel's output.
The code of the exchange gate is this branch's, unchanged.

The comments this branch adds in src/ are brought under the comment
rule (what is true now, five lines or fewer). No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…15's RX effect. Principle VIII.

The two seam-driven stall cases held the worker "stalled" for ~3 ms of wall
clock against a 21.3 ms drop deadline and failed outright if the process was
descheduled past it. They now time the hold from the delivery, and a hold
that outlived the deadline marks the case inconclusive (exit 77), as the rate
cases already do. A drop inside the deadline still fails, so the mutations
the cases pin (no deadline reset per over, force-exchange on stall, gate
removed) still go red.

AETHERSDR-PATCHES.md patch 15 now says what the dexchange() reorder does to
RX channels (count visible one memcpy later; priming, indices and
Sem_OutReady untouched, so no latency change; an exchange inside that window
underruns where upstream's order handed back a torn block) and its upstream
status (TAPR 2.10 still counts before it copies). It also records the
spin-host measurement behind the reorder: torn blocks in 3 of 15 runs with
upstream's order, 0 of 20 with the reorder.

Hl2TxDsp.h no longer says a caller faster than real time gets Underrun: the
gate queues it and warns. Comments in the burst test and iobuffs.c are cut to
AGENTS.md's comment policy, with the review history and lab narrative removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ten9876 ten9876 self-assigned this Oct 2, 2026

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

Approving at 0fee91cd. That is on8st's fix, plus my 79f016e5, plus two GitHub update-branch merges of main (#6007, #6021). Neither merge overlaps the files this PR touches.

Fixed in this pass (79f016e5)

  1. The stall cases could fail under load. The automated review pass found that both seam-driven cases held a fake stall for about 3 ms of wall clock against the 21.3 ms drop deadline, and failed outright if the process was descheduled past it. They now time the hold from the delivery. A hold that outlives the deadline marks the case inconclusive (exit 77, as the rate cases already do), and a drop inside the deadline still fails. A forced 25 ms hold gives rc=77 with no FAIL lines.
  2. Hl2TxDsp.h was stale. The processAudioBlock() comment said a caller faster than real time "gets Underrun". With the gate it grows a warned queue, and the comment now says so.
  3. Patch 15's doc was missing two things. AETHERSDR-PATCHES.md now states what the dexchange() reorder does to RX and gives its upstream status. On RX, the count becomes visible one memcpy later. Priming, ring indices and Sem_OutReady are untouched, so no channel gains latency. An exchange that lands inside that window underruns, where upstream's order handed back a torn block. Upstream status: TAPR 2.10 still counts before it copies, and there is no query. The doc also records a spin-host measurement: torn blocks in 3 of 15 runs with upstream's order, and 0 of 20 with the reorder.
  4. Comments now follow AGENTS.md § Comments. The test header, the iobuffs.c patch comments and the review-history references are cut down.
  5. PR body. I removed a stray SHA line, described the stall path as it is now (drop, not exchange), and listed 44.1 kHz.

Verified (Linux, local)

  • Mutations. Each of these turned hl2_txdsp_capture_burst_test red:

    • gate disabled: 14 FAILs, 82 splices at 24 kHz;
    • patch 15 reverted (query returns 1, reorder undone): 9 FAILs;
    • query alone returns 1: 9 FAILs;
    • stall drop replaced by exchange: 3 FAILs;
    • resetModulatorState() invalidate removed: 3 FAILs.

    Reverting only the reorder stays green in this test, as the patch doc states. It is supported by the spin-host measurement instead.

  • Plain runs. The burst test passed 5/5, and 5/5 again under 16 CPU hogs, with every rate case at faults 0 (stall drops 0) ... splices 0.

  • ASan+UBSan, leaks on, C instrumented. The burst test passed 5/5 with zero ASan and zero UBSan errors. All 12 wdsp_*, hl2_rxdsp* and hl2_txdsp* tests pass. LSan reports only create_calcc (calcc.c:1079, the PureSignal semaphores never closed). That is upstream code, and the unchanged hl2_txdsp_test and wdsp_channel_test leak from the same frame, so it is not this PR's.

  • Full ctest. I ran 546 tests on the merged head, with socket-owning tests excluded. All pass except connection_panel_size_test, a known local failure (#5811).

  • Static gates. check_engine_boundary, gen_touchpoint_manifest --check, check_capability_records and check_command_plane give output identical to the merge base.

Not verified: Windows ctest, the weekly TSan lane, and a live HL2. No transmitter was keyed.

ten9876 added a commit that referenced this pull request Oct 3, 2026
…ciple VI. (#6104)

## Summary

On HL2 and Icom, a TUNE power change made while TUNE is keyed now
reaches the carrier. Before this, the value waited for the next
key-down, and the operator saw a "dropped" notice for a control that
looks live. This PR also fixes two TX-power defects in the same paths:

- **HL2 transmit tail.** At TUNE release the RF drive was restored right
after MOX-off, but the HL2 keeps radiating for its FIFO drain plus PTT
hang after the MOX-off frame. The restore landed under the
still-draining full-scale tune tone. The drive restore and the receive
filter switch now wait out that tail. (Found by ON8ST.)
- **Icom restore.** An RF power change made during TUNE was written over
the tune drive and then lost at the unkey.

## What changed

**Seam and capability**
- `IRadioBackend::setTunePower(int)` is a new verb with a default no-op,
next to `setTxPower`. It never keys. A backend that generates its own
carrier re-applies drive to a carrier in progress and otherwise does
nothing; the next `setTune()` carries the value. Owner-thread contract
as for `setTxPower`.
- `RadioCapabilities::TransmitDriveControl::tunePowerAppliesLive`
defaults to `false`. It is a field inside the existing record, not a
top-level bool, so `FROZEN_BOOL_COUNT` stays at 71
(`check_capability_records.py --strict` reports 71 on both base and
head). The contract text in `IRadioBackend.h` and
`docs/architecture/radio-capabilities-map.md` are updated to match.

**Models**
- New signal `TransmitModel::tunePowerCommandIssued(int)`. Only
`setTunePower()` emits it, after `commandReady`, mirroring
`rfPowerCommandIssued`. `applyChanges()` (status decode) never emits it.
- `RadioModel` connects to it in the constructor's model-lifetime block,
next to `rfPowerCommandIssued` (#4599). It forwards a change only when
all of these hold:
  - `isTuning()` is true
  - a live `TxActivity::Tune` contribution exists
  - the backend declares `tunePowerAppliesLive`
- The connect checks `tunePowerAppliesLive` before calling
`setTunePower`. The behaviour is the same for every real backend,
because the base verb is a no-op. The check makes the contract explicit:
the field promises the verb is real, and a backend that doesn't declare
it is never handed the call.
- Both the forward and the drop-notice gate use one predicate,
`RadioModel::tuneCarrierLive()`: `isTuning()` AND a live
`TxActivity::Tune`. The gate is `routed = canTransmit &&
transmitDriveControl && (!isTuning() || (tuneCarrierLive() &&
tunePowerAppliesLive))`. While tuning, a value that is not forwarded
live always keeps its notice: a backend without the field, or a tune
state decoded off the radio with no TUNE this client admitted.
- Every `TransmitDriveControl` is built with the field stated
explicitly, as the ADDING A FIELD rule requires: `true` on HL2 and Icom,
`false` on Flex (whose tune power travels as command-plane text) and
ANAN. Sim and RTL declare no record.

**HL2**
- `Hl2Backend::setTunePower` writes only the drive register, through
`applyDrive()`, so the clamp and the transmit gate still apply. It
doesn't touch `m_rfPowerPercent`, band memory or
`notifyOperatingStateChanged`, and it doesn't route through `setTxPower`
or re-call `setTune(true)`.
- It acts only while `m_tuning` is true. It also acts only while the
TUNE operation, stored as `m_tuneOperation` in `setTune(true)`, still
permits dispatch. That optional defence is included: a TUNE whose
admission was revoked or emergency-stopped takes no drive change. Every
unkey and the connection reset clear `m_tuneOperation`, so it never
outlives the carrier.
- `tunePowerAppliesLive = true` in the HL2 capabilities.

**Icom**
- `IcomCivBackend::setTunePower` writes CI-V `14 0A` (the tune drive)
while tuning and does nothing otherwise. It also requires the same
admission `setTune(true)` checked: the TUNE operation stored there, and
the backend's transmit context. The operation is cleared at every unkey
and session reset. A refusal is logged on `aether.icom.tx`, because the
verb returns nothing to `RadioModel`. `tunePowerAppliesLive = true` in
the Icom capabilities.
- **Restore fix.** `setTxPower` during TUNE now records the value in
`m_preTuneTxPowerPercent`, which the unkey (and the disconnect emergency
path) writes back. It no longer writes over the tune drive.
- The raw write is factored into `writeTxPowerLevel()`. The TUNE
re-request branch in `setTune(true)` and the `tx.power` controls scrub
both use it, so neither can stamp the tune drive into the restore slot.
- **Readback during TUNE.** The CI-V read that confirms the tune-drive
write (`14 0A`) is no longer published as `TransmitDelta::rfPower` while
tuning. The periodic poll already skipped that read during TUNE. Without
this, `TransmitModel::rfPower()` showed the tune level. A relative
control (the power wheel, or a CAT/TCI read-modify-write) would then
step from that level, and the result would become the value the unkey
restores. Example: 100% RF and 10% tune, then one wheel step, left the
radio at 11%. *Visible effect:* on Icom the RF power slider no longer
jumps to the tune level while TUNE is keyed. It keeps the operator's RF
power, matching Flex. (Found by code review of this branch.)

**Evidence for the readback leak, and its limits.** This comes from the
code path and a scripted-reply unit test. It is **not
hardware-verified**: no Icom was on the bench.
- Icom has no tune-carrier command, so TUNE writes the tune level into
`14 0A`.
- `sendUserCommand()` queues a confirming read of that level 60 ms later
(`queueRead(..., now + 60)`).
- The `kRfPower` reply handler published it as `TransmitDelta::rfPower`.
- `icom_backend_seam_test` scripts that CI-V reply. It does not observe
a real radio's answer.

**Still wanted:** a CI-V capture from a real Icom of TUNE plus one
RF-power wheel step. It should show the confirming `14 0A` read during
TUNE and confirm that the restore after the unkey is the operator's RF
power.

## The HL2 transmit tail (found by ON8ST)

ON8ST showed from the gateware RTL that the HL2 keeps transmitting after
the MOX-off frame:
- MOX travels with the samples, not as a register.
`dsopenhpsdr1.v:351-352` tags each sample with `ds_cmd_ptt`.
- In `PTTTX`, `radio.v` drains the keyed samples already queued in the
FIFO (`tx_buffer_latency`, 20 ms default, `radio.v:934`). AetherSDR
never writes 0x17, so the default applies.
- It then holds the last sample for `ptt_hang_time` (12 ms default,
`radio.v:935`, 1036-1047). `tx_on` stays high until the state machine
reaches `NOTX` (`radio.v:1002`).
- Drive is not buffered. `ad9866ctrl.v:134-140` loads the TX gain as
soon as the command arrives.

So restoring RF drive one frame after MOX-off still landed under roughly
20 + 12 ms of full-scale tune carrier. ON8ST's unit (gateware 74.2)
unkeyed itself 40-54 ms after the host stopped sending keyed frames. The
same applies to the receive filter switch at unkey.

**Why a timer, not `tx_on`.** `tx_on` is reported only in the discovery
/ alternate-port reply: `resp_control` (`control.v:899`) feeds
`usopenhpsdr1.v:292` (`6'h20`). The EP6 stream's responses carry
`ptt_resp = cw_on | ext_ptt` instead (`control.v:456`, 472-475), which a
host MOX never raises. `MetisProtocol.cpp:546` is in
`parseDiscoveryReply()`. Reading `tx_on` mid-stream would mean polling
the discovery port during TX, which the stream-free telemetry design
avoids because that reply preempts EP6 (`Hl2TelemetryCadence.h`).
Gateware without `EXTENDED_RESP` also sends zeros there, so a "`tx_on`
low" trigger would fail open.

**Fix (`Hl2Backend`).**
- Every key-up arms a transmit tail of `kTxTailHoldMs` = 70 ms, after
the MOX-off is queued.
- 70 ms is never below the existing RX unkey hold (a `static_assert`
pins it). It covers the gateware's 20 + 12 ms default tail and ON8ST's
40-54 ms measurement. The RX-hold measurement of the same tail on one
HL2 was 51.66-66.15 ms, n = 11.
- While the tail is pending, `applyBandFilter()` keeps the transmit
filter byte, and `setTxPower()` records an owed RF drive without writing
it.
- When the tail ends, the receive filter and the owed RF drive are
applied.
- A re-key cancels the tail, so a restore is never applied over a new
carrier, and the drive stays owed to the next unkey's tail.
- A disconnect or connection reset drops the tail.

Icom is unchanged: its PTT-off is queued before the power restore, and
the radio owns its own timing.

**Maintainer decisions (Jeremy):**
1. A fixed 70 ms timer ends the tail. There is no `tx_on` poll, and 0x17
is not written.
2. A voice key inside the tail of a TUNE release keeps the tune drive
for that transmission. The restore is cancelled, never applied over the
new carrier, and RF drive returns at the next unkey's tail.
3. The transmit filter byte is held for the tail on every unkey,
including CW break-in elements.

## Out of scope (unchanged)

- No changes to TCI `tune_drive` or MIDI power limits. Note that TCI
`tune_drive` and the automation `transmit tunepower` verb go through
`TransmitModel::setTunePower`, so on HL2/Icom they now also apply live
while TUNE is keyed, as they already did on Flex.
- No slider dimming or tooltip UX for backends that don't declare the
field.
- No tune-power ceiling tied to RF power. Flex keeps them independent,
and so does this PR.
- Drag flood is accepted as is, matching RF power: no coalescing and no
debounce.

## Tests (all socket-free; nothing keys real hardware)

- `transmit_seam_drop_notice_test`:
- Flag on: `setTunePower(30)` arrives exactly once, with no re-key and
no drop notice.
  - Flag off: no call, and the notice stays.
  - Not tuning: no call, and the next `startTune` carries the value.
- A decoded tune power while keyed (`applyChanges`, a different value)
sends no call.
  - After `stopTune`, no call.
- A tune state decoded off the radio (`applyChanges`, `tune=true`) with
no live Tune activity: no call, and the drop notice stands. This pins
the Tune-activity check on its own, in both the forward and the gate.
- `hl2_tx_gate_test` reads the 0x09 bank bytes off the real EP2 packet
builder.

  | Step | 0x09 bank result |
  |---|---|
  | RF power 40% | drive 102 |
  | TUNE at 10% | drive 25 |
  | live 60% | drive 153, PA on |
  | 150% | 255 |
  | −5% | 0, PA off |
  | release | 102 restored |
  | 80% after release | no bank, no keyed frame |
  | gate closed while tuning | 0, PA off |
  | TUNE operation emergency-stopped | no bank |
  | release | stored TUNE operation dropped |
  | stopped client | no bank |

The same file has the transmit-tail tests, with the hold parked so each
test ends the tail itself:
- The unkey's queued calls post no RF-drive restore; walked packet by
packet with an event filter on `MetisClient`.
  - The tail ending restores RF drive.
- A re-key inside the tail cancels it; no restore under the new carrier,
and the next unkey's tail pays it.
  - TUNE re-keyed inside the tail keeps its tune drive.
  - RF power set during the tail waits for it.
  - A live tune drive with no key-time tune power is still restored.
  - A disconnect drops the tail.
- On a TX-only filter board, the TX byte holds through the tail, and
through a retune inside it, until the tail ends.
  - The real timer ends a 20 ms tail.
- `icom_backend_seam_test` checks the CI-V `14 0A` writes:
  - The capability is declared.
  - Not tuning: no write.
  - While tuning: the live 25% is written, with no re-key.
  - RF 50% during TUNE: no write, and it is held for restore.
  - A TUNE re-request writes 15% and keeps 50% held.
- A `tx.power` scrub during TUNE re-asserts the live 15% and keeps 50%
held.
  - Release writes 50%.
  - After release: no write, and the stored TUNE operation is dropped.
  - A TUNE whose operation was emergency-stopped: no write.
- A lapsed transmit context with the operation still live: no write, and
one warning logged.
- An RF power readback of the tune level during TUNE publishes no
`rfPower`. The premise is checked first: the same readback outside TUNE
does publish.

### Mutation results (each mutation made its test fail, then the file
was restored and verified by sha256 against HEAD)

| Mutation | Result |
|---|---|
| RadioModel connect deleted | RED |
| gate reverted to `!tuneKeyed` | RED |
| flag ignored (gate + connect) | RED |
| wired from `tunePowerChanged` instead | RED |
| `isTuning`/Tune-activity check removed | RED |
| Tune-activity check alone removed | RED |
| HL2 `m_tuning` guard removed | RED |
| HL2 implemented via `setTxPower` | RED |
| HL2 `applyDrive` bypassed | RED |
| HL2 tune-operation check removed | RED |
| HL2 flag not declared | RED |
| HL2 tail deferral removed (restore at unkey) | RED |
| HL2 re-key cancel removed | RED |
| HL2 `setTxPower` tail check removed | RED |
| HL2 filter predicate ignores the tail | RED |
| HL2 disconnect cancel removed | RED |
| HL2 `setTunePower` not marking drive owed | RED |
| Icom restore fix reverted | RED |
| Icom `setTunePower` guard removed | RED |
| Icom `setTunePower` no-op | RED |
| Icom TUNE re-request via `setTxPower` | RED |
| Icom scrub via `setTxPower` | RED |
| Icom flag not declared | RED |
| Icom tune-drive readback published during TUNE | RED |
| Icom tune-operation check removed | RED |
| Icom tune operation not stored at `setTune(true)` | RED |
| Icom tune operation not cleared at unkey | RED |
| Icom transmit-context check removed | RED |
| Icom refusal warning removed | RED |
| Drop-notice gate ignores `tuneCarrierLive` | RED |
| Forward uses `isTuning()` alone | RED |
| HL2 tune operation not cleared at unkey | RED |

## Merge with main

The branch was cut from `2d5712946`. It now carries signed merges of
`origin/main` up to `c7513e125` (27 commits, including #6062, #6005,
#5979, #5982, #5976, #6011, #5980, #6019, #5981, #6012 and #6050 on HL2,
and #6049 in `RadioModel`). Every merge was clean. The files both sides
touched (`Hl2Backend.cpp/.h`, `IRadioBackend.h`, `RadioModel.cpp`,
`radio-capabilities-map.md`) needed no manual resolution, and none of
main's changes in them touch keying, drive or TUNE. Everything below was
re-run on the current head.

## Static gates (merge base `c7513e125` vs merged head, findings diffed
per file)

`check_engine_boundary.py --strict`, `gen_touchpoint_manifest.py
--check`, `check_capability_records.py --strict` and
`check_command_plane.py --strict` all produce identical findings on base
and head once line numbers are normalised:

- Engine boundary: 102 tracked findings, 0 blocking.
- Touchpoint manifest: up to date.
- Capability records: 71 bools.
- Command plane: 401 commands across 32 files.

No ratchet moved, and no new wire text was added.
`gen_seam_probe_table.py --check` is clean (51 signals): this PR adds a
seam verb but no seam signal.

## Not verified

- No live-radio test on HL2 or Icom; per the TX rules, nothing was
keyed. The Icom path, including the readback leak and its guard, is
proven only against the backend's CI-V output and scripted CI-V replies.
A real-Icom CI-V capture of TUNE plus one RF-power wheel step is still
wanted.
- Icom's RF-power readback is suppressed during TUNE. CI-V cannot tell
the confirming read of the tune drive from a front-panel change, so an
RF-power knob turned on the radio during TUNE is not seen and the unkey
restores the pre-TUNE value (the code comment says so).
- The HL2 transmit-tail fix has no hardware test. Its 70 ms rests on the
gateware source defaults and on bench measurements made before it
existed: ON8ST's 40-54 ms unkey, and the RX unkey-hold measurement of
the same tail. Nothing here observed `tx_on` or RF output with this
change.
- Full default ctest at -j8 on the merged head, with every test whose
source uses a socket API excluded (523 of 620 run; the pty-backed
`thumbdv_queue_test` and four new socket tests from main are excluded
too): all pass except `connection_panel_size_test`, which is a known
local failure (#5811). `transmit_seam_drop_notice_test`,
`hl2_tx_gate_test` and `icom_backend_seam_test` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <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.

HL2: TX audio spliced when it reaches the modulator in bursts (macOS 24/16 kHz mics, Linux 48 kHz)

2 participants