Repository navigation
feat(anan): apply the receiver's mute, AF gain and balance, and send RX audio to the radio's own speaker. Principle II. - #5992
Conversation
f665e63 to
e1a93ec
Compare
There was a problem hiding this comment.
Issue fit
There is no linked issue — no fixes/closes/resolves #NNNN in the body — so I reviewed against the PR's own stated intent. GOVERNANCE.md wants an RFC for architectural changes; this one adds a new IRadioBackend virtual pair (setLineoutGain/setLineoutMute), a new outbound wire path, and a new connect-time settings key, which is more than "bug fix with a clear root cause". I'd call that needs a maintainer decision rather than a violation — the seam addition is small and well-argued, and AGENTS.md:43-48's "no new capability declared without a verb behind it" is satisfied (ANAN implements all four new entry points). Against its own stated intent the PR delivers: the three slice-audio setters are overridden and applied, the capability is declared and echoed, the speaker stream is encoded, paced and sent, and both status fields are decoded.
Scope
| File / group | What it changes | Claimed in body? | Verdict |
|---|---|---|---|
AnanSliceAudio.h (new) |
mute/gain/balance law, header-only | yes | in scope |
AnanSpeakerPacing.h (new) |
credit pacer | yes | in scope |
AnanBackend.{h,cpp} |
three setters, lineout pair, resamplers, send path, published observations | yes | in scope |
P2Protocol.{h,cpp} |
buildSpeakerAudio, parseHighPriorityStatus, kSpeakerAudioPort |
yes | in scope — new wire surface, maintainer call |
P2Client.{h,cpp} |
queue, drain timer, status decode, session reset | yes | in scope |
IRadioBackend.h |
two new virtuals, no-op defaults | yes | in scope — new backend seam, maintainer call |
RadioModel.cpp |
anan.speakerAudioEnabled param; typed lineout calls guarded on usesFlexCommandPlane() |
yes | in scope |
MainWindow.cpp:2114 |
re-apply master volume on PC Audio toggle | yes, but not that it changes Flex behavior too | see Nits |
ConnectionPanel.{h,cpp} |
one ANAN checkbox | yes | in scope |
AnanSettings.{h,cpp} |
one key, default false | yes | in scope |
LogManager.cpp |
curate aether.anan.p2 |
yes | in scope |
docs/architecture/audio-pipeline.md |
two table rows + pan paragraph | yes | in scope |
tests (4 files) + tests.cmake |
new/extended coverage | yes | in scope; see Blockers 1 and 2 |
Nothing in the diff is unexplained by the stated intent. No CHANGELOG.md edit — correct. No new sendCommand(), as claimed; I checked, the command-plane ratchet is untouched. No removed guards: the only - lines of consequence are c.receiveAudioControl = std::nullopt and the const on bytes, both intentional.
Socket-owning test disclosure (AGENTS.md §"our own server is the subject"). tests/anan_speaker_audio_test.cpp is new, binds 127.0.0.1:1028 (QUdpSocket, kSpeakerAudioPort) and drives a real P2Client over loopback with no peer emulation — no fake radio, nothing replies, it only reads what we send. It is registered in tests/tests.cmake:914-923, its block names the socket, and the PR body discloses it. It is not in any ctest -R filter in ci.yml (I grepped; no anan target appears there), so none of the four affected targets gate this merge — the green checks above are build/static only. See Blocker 2 for the skip-code half of the rule.
Blockers
1. anan_backend_test.cpp: the new blocks were inserted into the middle of the if (g_failures == 0) statement, which both gates the new coverage on nothing having failed and makes "all checks passed" print on a failing run. (inline on tests/anan_backend_test.cpp:424)
tests/anan_backend_test.cpp:423 is if (g_failures == 0) with no brace; its body used to be the fprintf at what is now line 507. The new lineout block at 424-445 is now that body, and the fprintf at 507 is a separate unconditional statement. Two consequences, both from reading the file at HEAD:
- If any earlier check in the file fails, the entire three-levels lineout block is skipped — coverage disappears exactly when the file is already unhappy.
anan_backend_test: all checks passedis printed unconditionally, including on runs with failures. The exit code (return g_failures == 0 ? 0 : 1) is still correct so CTest still goes red, but the log line contradicts it, and that line is what a bench run quotes.
Fix is to move if (g_failures == 0) back down to immediately precede the fprintf at 507.
2. The new socket test returns 0 on bind failure, and tests.cmake sets no SKIP_RETURN_CODE, so an unavailable port reports a green pass with zero checks. (inline on tests/anan_speaker_audio_test.cpp:155 and tests/tests.cmake:914)
AGENTS.md:316 is explicit: "any new socket-owning test is disclosed in the PR body, its tests.cmake block names the socket it binds, reviewers notify the operator before continuing, and the test fails fast (or skips, exit 77) when it cannot bind rather than consuming its timeout." Three of the four hold. The fourth does not: tests/anan_speaker_audio_test.cpp:155 returns 0, and the add_test at tests/tests.cmake:923 has no set_tests_properties(... SKIP_RETURN_CODE 77) — unlike the thirteen other skip-capable targets in that file (e.g. pgxl_direct_protocol_test at 3817, the automation-bridge block at 2288-2302). The test's own comment says a silent "0 checks, exit 0" is indistinguishable from a suite that ran; that is precisely what CTest records today. return 77 plus the property makes it a visible SKIP.
Nits (non-blocking)
- The catch-up hysteresis entry threshold is exactly one source block at the default DDC rate, so "catch-up" is the steady state rather than the exception. (inline on
src/core/backends/anan/AnanSpeakerPacing.h:159)kMaxQueuedSpeakerPackets = 64(P2Client.h:278) gives entry at64/4 = 16packets; the header's own arithmetic says one DSP block at the default 48 ksps DDC resamples to 1024 frames = 16 packets, arriving as one enqueue. So the first drain tick after every block seesqueued >= 16, latchesm_catchingUp, releasesmin(16, 16)— the whole block, target ignored — and the next tick drops back below64/16 = 4and unlatches. The hysteresis is documented as existing so the sawtooth "cannot flap on every block boundary", and by this arithmetic it flaps on every block boundary at the default rate; the credit target never governs a release there. The pacing test enters catch-up withsetBacklog(40, kCap)and never exercises the 16 boundary, so nothing pins this. Reasoned from the code, not observed running — and your bench numbers (level 53-165, underflows flat at 1 over 591 packets) say it is not audibly harming anything, which is why this is a nit and not a blocker. Worth either raising the entry threshold above one block, or amending the comment to say catch-up is the normal path at the default rate. docs/aetherd-local-receive-control.md:102still reads "not yet qualified" in the Gain/mute column for ANAN.docs/architecture/radio-capabilities-map.md:66-68names that table as where the per-backend receive-control matrix lives, and this PR flipsreceiveAudioControlfromstd::nulloptto engaged. I could not find a canon sentence making that doc update merge-gating, so it is a nit rather than a governance blocker — but the row is now wrong.- The Discovery-vs-status disambiguation only holds until
m_discoveryInfoSentis set.P2Client.cpp:441only triesparseDiscoveryReplywhile!m_discoveryInfoSent; after that, any further 60-byte Discovery reply (a duplicated datagram, a late retransmit) falls straight intoparseHighPriorityStatusat 453 and is decoded as status — byte 30 bit 3 of a reply could raise a spurious "speaker FIFO underflowed" warning.P2Protocol.h's comment states the tie-break unconditionally ("the caller tries the Discovery parse first"), which is true only on the first reply. Diagnostic-only data, so low impact; worth either the extra guard or an honest comment. - The
MainWindow.cpp:2114re-apply changes Flex behavior too, and the body does not say so. Turning PC Audio off on a Flex now sendsmixer lineout gain <MasterVolume>, which it did not before — clobbering whatever the operator had set at the radio. Arguably what the slider is meant to do, but it is an undisclosed user-visible change on a family the title does not mention. (I did check the ordering hazard:TitleBar.cpp:330writesPcAudioEnabledbefore emittingpcAudioToggledat 332, soapplyMasterVolume()reads the fresh value — no stale-route bug.) tests/tests.cmake: the new block landed between the "Socket-free NB publication/readback" comment (912-913) and the target it describes (anan_noise_blanker_readback_test, now at 925). Same misplaced-insertion class as Blocker 1.- The lineout percent runs through the same 40 dB
sliceAudioAmplitude()law, so slider 50 is −20 dB on ANAN against whatevermixer lineout gain 50means on a Flex. Defensible, just not the same loudness at the same slider position across families.
CodeGuard: all five CG-PATH-001 hits are in src/gui/MainWindow.cpp at 9552 / 9664 / 9783-9785 — the FFTW-wisdom dialog text and the macOS .app bundle-root walk. This PR's only MainWindow.cpp change is at line 2114. Pre-existing, not introduced here; refuted and dropped.
What I tried to break
- The
if (g_failures == 0)andtests.cmakecomment placement — read the files at HEAD rather than trusting the diff hunks, which is what surfaced both. Confirmed thefprintf/returnpairing and theadd_testordering directly. - Stale-setting race on the PC Audio toggle.
applyMasterVolume()readsPcAudioEnabledfromAppSettings; if the toggle lambda ran before the write, the volume would route to the wrong destination. It does not —TitleBar.cpp:330writes, 332 emits. Held. - Rate-change lifecycle.
resetSpeakerResamplers()readsm_pendingDspConfig.audioSampleRateHzand is called fromstartP2ClientSession(), which both connect and rate change pass through;m_client->start()is also re-invoked on a rate change, so the pacer, queue, sequence and counters all reset. The claim in the comment holds.m_pendingParams.speakerAudioEnabledsurvives a rate change as intended. - Resampler bounds.
Resampler's defaultmaxBlockSamplesis 4096; the largest audio block is1024 * 24000/48000 = 512frames at the lowest DDC rate, so the per-channel calls cannot exceed it. Thestd::min(nL, nR)guard and then <= 0early return handle the filter-fill case. No overrun found. - Thread affinity of the new state.
m_dspandm_clientare both moved tom_ioThread(AnanBackend.cpp:185-188); thepcmReadylambda is context-bound tothis, so the audio stage and the three setters do share a thread as the comment claims.enqueueSpeakerAudioisQ_INVOKABLEand invokedQt::QueuedConnectionwith a deep-copiedQByteArray— no shared buffer crosses. - Overflow and mute paths.
enqueueSpeakerAudio's drop-oldest arithmetic is bounded for any block ≤ the cap (16 packets max in practice against a 64 cap); a failedwriteDatagramis neither credited to the pacer nor consumed from the queue, which is the right way round;advance()floors at zero so a stall cannot bank negative depth and license a catch-up burst. - Capability gate coherence.
emitSliceState()setsd.audioGain/d.audioMuteunconditionally, so the observationModelReceiveControlTargetneeds exists from the first publish rather than only after a setter runs.control_receive_testwas updated in the same change. - What I could not check: nothing was built or run — every finding above is reasoned from the code at
/tmp/aetherclaude/pr-5992, not reproduced. The bench numbers (G2, gateware 27), the 33 mutation proofs, and pacing behavior under real load are unverifiable from here; the catch-up nit is arithmetic from two constants in the diff, not a measurement, and your hardware run is evidence against it mattering audibly.
Recommendation
Request changes — on Blockers 1 and 2 only, both of which are small and mechanical. The substance of this PR is strong: the audio stage is placed where audio-pipeline.md says it belongs, the pacing rationale is honest about what it is not, and the "three levels" argument is the right framing. But a test that silently skips itself green and a test file whose new coverage is gated behind if (g_failures == 0) while printing "all checks passed" on failure together mean the evidence for everything else is weaker than it reads. Fix those two, take or leave the nits, and route the IRadioBackend seam addition and the new kSpeakerAudioPort wire surface past a maintainer for the RFC question. Thanks for the unusually careful write-up of the pacing and the OutHighPriority.c dead-store finding — both are the kind of thing that saves the next person a bench session.
🤖 aethersdr-agent · cost: $10.4102 · model: claude-opus-5
|
Cisco CodeGuard — static analysis of this PR (5 finding(s))
Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them. 🤖 aethersdr-agent · cost: $10.5993 · model: claude-opus-5 |
…arrival test, and stop a repeated Discovery reply reading as a speaker fault. Principle II. Review findings on aethersdr#5992, with the pacing change re-benched on a G2. TWO TESTS THAT COULD NOT DO THEIR JOB tests/anan_backend_test.cpp had a bare `if (g_failures == 0)` sitting above the new lineout block instead of above the fprintf it had always guarded. So that block was skipped whenever any earlier check had already failed -- the coverage disappearing exactly when the file was unhappy -- and "all checks passed" printed on failing runs. The exit code was still correct so CTest went red, but the log line a bench run quotes said the opposite. anan_speaker_audio_test returned 0 when it could not bind 127.0.0.1:1028, which CTest records as a PASS with zero checks -- the exact outcome the code's own comment three lines above warns about. It returns 77 now, with SKIP_RETURN_CODE 77 set on the test, so an occupied port reads as SKIP. Verified by holding the port: "***Skipped" rather than "Passed". Its tests.cmake block also moved below anan_noise_blanker_readback_test, so the "Socket-free NB publication/readback" comment describes its own target again. THE CATCH-UP BAND WAS A BLOCK-ARRIVAL TEST Entry was a quarter of the queue's capacity, which with a 64-packet queue is sixteen packets -- exactly one source block at the default DDC rate. Audio arrives one whole block at a time, so every ordinary block latched catch-up, the credit target governed no release at all, and the hysteresis flapped on precisely the block boundary it was written to absorb. With release bounded only by what the queue holds, that also ran at four times nominal for as long as any backlog lasted, into a FIFO this target already fills completely. Both thresholds are in source blocks now: ENTER at two, LEAVE at one. One block queued is what "a block just arrived" looks like and cannot be evidence of a backlog; two is audio that arrived while we were not draining. A tie to capacity has to be re-derived every time either the queue size or the DDC rate moves, and a tie to the block survives both. A DISCOVERY REPLY IS NOT A FAULT REPORT The reply parse was gated on m_discoveryInfoSent, so once the first reply had landed, any further one -- a duplicate, a retransmit, a reply to another client's broadcast on this socket -- fell through to the status parse and had its byte 30 bit 3 read as a speaker-FIFO underflow that never happened. The SHAPE test runs on every datagram now; only the NOTIFICATION is once per session. What that costs is named rather than hidden: a status packet whose sequence is 0 AND whose byte 4 holds 0x02 or 0x03 -- PTT with a CW dot or dash down at the instant of a session's first status packet -- reads as a reply and is not decoded. One packet, once, of diagnostic-only data, against never inventing a fault out of a packet that carries none. MEASURED, G2 (Saturn, gateware 27) -- procedure and numbers in BENCH.md 9.10 Underflow reports settle at 1 and hold for 627 consecutive status packets. The queue's single largest mode is exactly one block, bounded at 50 of a 64 cap, no drops. Audio clean and intelligible on speech. Catch-up still runs about a quarter of the time and cannot fall, which is STRUCTURAL rather than a symptom: nominal release is break-even by definition, so the credit target never drains a backlog and catch-up is the only mechanism that does. Any backlog therefore ends in an episode. What the change fixed is the TRIGGER -- 41 of 628 readings at or above entry against 154 showing catch-up is the hysteresis holding across a genuine once-per-second sawtooth, where before it was every block arrival. The radio runs its speaker FIFO at 97-100%: p50 995, p90 1011, max 1024 against a 1024-location capacity. So the estimate is accurate and this target keeps the FIFO full. An earlier comment here claimed the estimate merely hovered near the target while the radio held 53-165, and explained the gap away as a constant offset that cancels; that reading came from the regime where catch-up bypassed the target, so it measured the wrong thing, and it is corrected. NOT tunable in either direction: one source block pins the target from below and the FIFO's size pins it from above, at the same 1024 frames. Under a saturated host -- a clean parallel rebuild -- the stream starves, ~2805 underflow reports, and the queue depth ON the reporting packets shows which end failed: only 10% had an empty queue, ~90% had audio in hand, and 25 were at the 64-packet cap while the radio ran dry. So it is the 5 ms drain timer being starved, not the DSP. Audibly it is slight crackle that clears itself before the build even finishes. The FIFO's 21 ms is the entire jitter budget of this path: a host stall longer than that empties it however much is queued here, because there is nowhere further ahead to send. That closes the long-standing "pacing under load is untested" item with an answer rather than a shrug. DOCS AND TWO CROSS-FAMILY NOTES docs/aetherd-local-receive-control.md's ANAN Gain/mute row read "not yet qualified" while this work engages receiveAudioControl. It reads "supported" now, with a paragraph on what applies it and why balance carries no record. MainWindow's PC Audio re-apply runs on EVERY family, so turning PC Audio off on a Flex writes MasterVolume to the radio's lineout where nothing was written before, overwriting a level set at the radio. Stated at the call site: gating it to backends without a command plane would keep the same bug alive everywhere else, and with PC Audio off the thing being listened to is the radio. setLineoutGain records that the percent runs through the same 40 dB law as the per-slice fader, so 50 is -20 dB here while a Flex's own lineout scale is its own. TESTS Three new assertions pin the boundary the old band sat on: one whole source block queued must not latch, two must, and the exit is one block. A new group in anan_speaker_audio_test feeds a Discovery reply three times and then a real status packet, so a repeated reply reporting no underflow is pinned rather than implied. Five mutation proofs, all killed by the intended check: both thresholds back to capacity fractions, entry at one block instead of two, the reply parse re-gated on the announce, and the status parse dropped entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both blockers fixed, five nits taken, the pacing change re-benched on the G2, and two questions for a maintainer. All of it is in Blockers1. The swallowed 2. The green pass on an unbindable port. The The catch-up thresholdThis was the valuable finding, and it is a code change rather than a comment change. Your arithmetic is right. Both thresholds are now expressed in source blocks instead of fractions of queue capacity — enter at two blocks, leave at one — on the reasoning that one block queued is what "a block just arrived" looks like and cannot be evidence of a backlog, while two is audio that arrived while we were not draining. A tie to capacity has to be re-derived every time either the queue size or the DDC rate moves. Three new assertions pin the boundary you identified as unpinned: one whole source block queued must not latch, two must, and the exit sits at one block. Mutations putting either threshold back to The Discovery/status tie-breakUnconditional now. The shape test runs on every datagram and only the notification is gated on What it costs is named rather than hidden: a status packet whose sequence is 0 and whose byte 4 holds 0x02/0x03 — PTT with a CW dot or dash down at the instant of a session's first status packet — reads as a reply and is not decoded. One packet, once, of diagnostic-only data, against never inventing a fault out of a packet that carries none. A new test group feeds a reply three times and then a real status packet. Also taken
Re-benched, so the pacing change is measured rather than assumedG2, Saturn, gateware 27. Procedure and full numbers in Quiet run, 628 status packets: underflow reports settle at 1 and hold for 627 consecutive packets; the queue's single largest mode is exactly one block, bounded at 50 of a 64 cap with no drops; audio clean and intelligible on speech. One prediction I wrote was falsified, and it needs stating because it looks like the nit is unfixed. I expected catch-up to fall to near zero. It stayed at 24.5%, and it cannot fall: nominal release is break-even by definition, so the credit target never drains a backlog and catch-up is the only mechanism that does — any backlog therefore ends in an episode. What the change fixed is the trigger. 41 of 628 readings are at or above entry while 154 show catch-up, which is the hysteresis holding across a genuine once-per-second sawtooth, where before it was every block arrival. Periodic catch-up is structural to a break-even credit scheme rather than a symptom, and the header says so now instead of leaving a percentage to imply otherwise. Two measurements for a maintainer to weigh, neither tunable by meThe radio runs its speaker FIFO at 97–100%: p50 995, p90 1011, max 1024 against a 1024-location capacity. One packet is 64 locations, so at p90 there are 13 free. That is the target doing exactly what it documents — the target is the whole FIFO — and it cannot move in either direction: one source block pins it from below at 1024 frames and the FIFO's size pins it from above at the same 1024. Lowering it reproduces the garbled audio of the first bench run. This also corrects a comment of mine that explained an apparent estimate/radio disagreement away as a cancelling offset; that reading came from the regime where catch-up bypassed the target, so it described the wrong thing. The standing "pacing under load is untested" item is now tested. Under a clean parallel rebuild the stream starves — ~2805 underflow reports — and the queue depth on the packets that reported an underflow says which end failed:
So it is the 5 ms drain timer being starved, not the DSP. Audibly it is slight crackle that clears itself before the build finishes, with no reconnect — the underflow count reads far worse than the experience, since a report means the bit latched once in a ~200 ms window, not that the window was lost. The FIFO holds 1024 frames = 21 ms, and that is the entire jitter budget of this path: a host stall longer than that empties it however much is queued here, because there is nowhere further ahead to send. No pacing scheme can change that and a larger target is not available. Two things for a maintainer
Four targets green, five new mutation proofs all killed by the intended check, nine local ratchets pass, colour delta zero. Thanks for reading the files at HEAD rather than the hunks — both blockers were invisible in the diff. 🤖 Generated with Claude Code |
…RX audio to the radio's own speaker. Principle II. The ANAN backend inherited IRadioBackend's no-op per-slice audio setters, so the operator's mute, AF fader and balance moved and did nothing, and the G2's own speaker/headphone codec was never fed. - AnanSliceAudio.h: per-receiver mute, dB-law AF gain (0 silent, 50 = -20 dB, 100 unity) and balance using applyRxPanInPlace()'s attenuate-never-boost law. Gain and mute are echoed in SliceDelta so receiveAudioControl is declared with Engine authority. - The per-slice tap (sliceAudioFrameReady, feeding TCI receiver channels and decoders) is published BEFORE that stage, per the seam contract: muting a slice must not stop WSJT-X decoding on it. Only the speaker feed and the radio-speaker feed carry mute/gain/balance. - Radio speaker: optional (off by default, ConnectionPanel checkbox, takes effect on next connect) DDC Audio stream to port 1028 -- 64-frame big-endian Int16 stereo packets at 48 kHz, credit-paced against the radio's reported speaker FIFO with a two-block/one-block catch-up hysteresis. - IRadioBackend gains setLineoutGain()/setLineoutMute() with no-op defaults; RadioModel forwards to them only when !usesFlexCommandPlane(), so a Flex is not told twice and Icom/HL2 hit the no-op. - MainWindow re-applies the master volume on a PC Audio toggle for ANAN only, so the radio-side level is set when the slider's destination changes. Other families keep their existing behavior (on a Flex a re-apply would overwrite a lineout level set at the radio). - aether.anan.p2 logging category for speaker FIFO/underflow diagnostics. Verified by the author on an ANAN-G2 (Saturn, gateware 27). Co-Authored-By: Pat Jensen <patjensen@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abc001c to
0b013f3
Compare
jensenpat
left a comment
There was a problem hiding this comment.
Thanks, Sotiris — nice work, and the G2 bench numbers are exactly what this needed. I folded two fixes in and force-pushed a single commit (authored to you) rebased on current main, so this can land today.
What I changed
1. Slice tap is now pre-mute / pre-gain / pre-balance (blocker). AnanBackend's pcmReady handler applied applySliceAudioInPlace() before publishLegacySliceAudio(). That contradicts the seam contract on IRadioBackend::sliceAudioFrameReady ("PRE-mute, PRE-gain and PRE-balance, deliberately … muting a slice must not stop WSJT-X decoding on it"), which HL2 follows. On ANAN it would have made a muted slice feed silence to TCI receiver channels and decoders, and AF at 50 feed them 20 dB low. The slice tap is now published first; the stage applies only to the speaker feed and the radio-speaker feed. Docs updated to match.
2. PC-Audio master-volume re-apply scoped to ANAN (your question 2). Taking you up on the offer: the MainWindow re-apply now runs only when the family is anan. On a Flex it would write mixer lineout gain on every PC Audio toggle and overwrite a level set at the radio — a Flex behavior change that shouldn't ride on an ANAN feature. If we want it for every family, that's its own reviewed PR.
Your question 1 (RFC): not needed. The setLineoutGain/setLineoutMute pair is additive with no-op defaults, RadioModel only calls it when !usesFlexCommandPlane(), and the speaker stream is ANAN-internal and off by default.
Full suite: 565/565 headless on macOS, plus a rebuild and targeted ANAN tests after the rebase.
Follow-ups for you (not blocking — ANAN-local, feature off by default)
- Catch-up ignores the FIFO estimate —
AnanSpeakerPacing.h(~L229): once catch-up latches, each 5 ms tick releases up to 16 packets (~4× real time) regardless of the estimated room. With the radio already sitting at 97–100% of 1024 locations and catch-up active on ~25% of status packets, that looks like it will routinely overrun the radio's FIFO. Capping catch-up at estimated room plus a small margin seems right. - NaN → int16 is UB —
AnanBackend.cppspeaker conversion:std::clamppasses NaN through, and the cast toint16_tis undefined. Only matters if WDSP ever emits NaN, but anstd::isfiniteguard is cheap.
main moved to 0817cec and the branch went CONFLICTING on one file, docs/architecture/aetherd-touchpoints.md. Both sides added a row to the generated touchpoint table and both rewrote its Totals line: main's c50a2d3 added core/QtAudioBackendGuard.h (194 core), this branch added models/ReceiverSlotCount.h (38 models). The table is generated, so it is not hand-merged: it is the output of tools/gen_touchpoint_manifest.py on the merged tree, which carries both rows and 232 headers (194 core, 38 models). The tag sidecar aetherd-touchpoint-tags.json merged cleanly and holds both entries. CMakeLists.txt, tests/tests.cmake and src/gui/MainWindow.cpp merged without conflict. aethersdr#5992's MainWindow hunk is in the PC Audio toggle and this branch's are in the slice-tab and CAT-letter paths; they share no symbol. Refs: aethersdr#5774, aethersdr#5775, aethersdr#5776 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Principle VIII. ## Summary The second of the two follow-ups `jensenpat` left on his aethersdr#5992 approval: > `std::clamp` returns `v` when neither `v < lo` nor `hi < v`, and every > comparison against NaN is false, so NaN passes straight through and > `static_cast<qint16>(NaN * 32767.0f)` is undefined. Guard with > `std::isfinite`. He is right, and the guard has to be separate from the clamp rather than part of it. Only reachable if the DSP emits a non-finite sample, which is why it was a follow-up and not a blocker — the radio-speaker feature ships off by default. **The first follow-up (capping catch-up at the estimated FIFO room) is NOT in this PR.** It was implemented, it passed 571/571 and nine mutation proofs, and then it failed on the radio: the ANAN-G2's speaker FIFO fell from a median of 995 locations to 117 and underflow reports went from 1 per 14 minutes to 782 per 11. The measurement found something larger than the follow-up, and it is written up as a separate comment on aethersdr#5992 rather than guessed at here. That item needs a maintainer's direction before it can land; this one does not depend on it. ## The change The interleave loop's two lines move into `AnanSliceAudio::sliceAudioToInt16()`, which carries both halves of the law where they can be unit-tested — the clamp before the scale, and the guard a clamp cannot provide. The call site in `AnanBackend.cpp` loses four lines and gains two. Written to aethersdr#6072's comment policy: five lines, stating the invariant and the constraint, with no review narrative in the code. The five comment lines dropped from the `AnanBackend.cpp` call site are the clamp rationale, which moves to the function **together with tests for it** — the shortening AGENTS.md permits, since the guard and its test are what protect the behaviour. An infinity *does* clamp correctly, so covering it is a choice rather than a consequence: it is the same class of fault and clamping it emits a full-scale click, so one predicate covers both. Silence is the right value for a sample that has no value, and it is what the mute path already sends — so a bad block degrades into something the radio's codec and the send pacer both already handle, rather than into a click or into undefined behaviour. ## Verification - **`ctest` 595/595, 0 failures** on `f10aa757`, one deliberate skip (`app_settings_safety_explicit-profile-path-isolation`). No new test target: the checks land in `anan_p2_protocol_test`, which is standalone with no Qt and no `aethercore`. - **4/4 mutation proofs caught** — the guard removed, the guard narrowed to NaN only (so the infinity case is proven, not incidental), the clamp removed, and full scale off by one. - All eleven `tools/check_*.py --strict` ratchets pass, `check_qsettings.py` included. The hardcoded-colour ratchet does not apply; nothing here touches `src/gui/`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
@jensenpat Both follow-ups from your approval were implemented. One is up as #6089; the Quotes below are against The
|
main (current law) |
with the cap | |
|---|---|---|
| status packets / span | 3626 / 839 s | 2863 / 663 s |
| radio FIFO level p10/50/90/max | 978/995/1011/1024 | 49/117/365/526 |
| pacer estimate p10/50/90/max | 3693/4919/6075/7134 | 1030/1056/1081/1087 |
| estimate − reported level, p50 | 3923 (max 6146) | 938 (max 1078) |
| outbound queue depth p10/50/90/max | 3/19/27/43 | 34/46/55/63 |
| catch-up share of status packets | 22.9% | 100.0% |
| underflow reports in window | 1 | 782 |
| release rate, frames/s (sink is 48000) | 47995 | 47987 |
| audio by ear | clean | clean |
Worth stating plainly: it sounded fine both times. Ear does not catch this and
the underflow counter does not track audibility, which is why the numbers are the
argument and not the listening. Your 995/1024 figure reproduces exactly on the
current law.
The reason it fails is not the cap
The credit estimate does not bind in production. On main, over that
839-second session:
- the estimate sits at a median of 4919 frames against a 1024-frame target —
4.8x its own setpoint; - so
room = kTargetFifoFrames - m_fifoFramesis negative, androom <= 0on
3625 of 3626 readings — 100.0% of the session; - it passed the target at t+0.2 s and never came back;
- all 830 catch-up readings also had
room <= 0, so every packetmainsends
comes from the catch-up path and none from the credit path.
What actually paces this stream is the catch-up hysteresis band, behaving as a
bang-bang controller: queue above two source blocks, release sixteen packets a
tick; below one block, stop. That holds the FIFO at 97-100% with one underflow in
fourteen minutes. It works — it is just not the mechanism the header describes.
Why the estimate runs away: during the connect transient the band is latched and
releases sixteen packets a tick with no room check, crediting 1024 frames against
the 240 a tick drains. A few dozen such ticks inflate the estimate by tens of
thousands of frames, and the floor-at-zero only clamps downward, so an over-count
is permanent by construction.
So the cap did not break the pacer — it handed control to an estimator that has
never been in range. Your finding stands exactly as written: the old cap is four
times too high to bind. The measurement adds that the 4x burst is currently the
only thing keeping the stream fed, so the cap cannot be made safe until the
estimator is.
Three comments in AnanSpeakerPacing.h that read as settled and are not
- "this integrates a CREDIT estimate … answers how many packets may go now
without passing the target" — it answers, but the answer is<= 0for the
whole session, so nothing is released on it. - "The estimate floors at zero, so the target alone bounds a burst." The band
bounds the burst. The target bounds nothing. - "catch-up is the only mechanism that removes one" — true, and it is also the
only mechanism that releases anything at all, which is the part that was not
known.
The 995/1024 measurement in that same comment is sound; only its attribution to
the credit target is wrong. That attribution came from our bench write-up behind
#5992, so the error is ours to correct.
The question for you
Two directions, and this is a maintainer call rather than ours:
- Make the target real — reconcile the estimate against the level the radio
already publishes (status bytes 37-38, five times a second), then re-apply
your cap on an estimator that tracks. The header rejects that field because a
200 ms-stale value is "useless for control", which is true of servoing the
rate and not of correcting a bias; source and sink share the radio's
crystal, so no rate correction is wanted. - Treat the band as the real control law — document it as such, demote or
delete the dead credit path, and re-derive the thresholds as what they are.
Smaller change and a more honest description, but it gives up the design the
header argues for.
One loose end we have not explained: on the capped branch the estimate stayed near
its target (1056) while the radio held 117, so there is a ~940-frame over-count
there too, with a different shape from main's. Candidates are packets the radio
drops during a burst, and a frames-versus-locations scaling error between
onPacketSent() and bytes 37-38. We did not want to guess further in a comment.
Happy to put the diagnostic up as its own small PR if it is useful: one
debug-gated log line adding the estimate, the target and the sent-packet counter
to the existing FIFO-level line. It is what made any of this visible, and the next
run on this path will want it.
…e. Principle VIII. (#6089) ## Summary `std::clamp` returns `v` unless `v < lo` or `hi < v`, and every comparison against NaN is false, so a NaN passes straight through the clamp and the following `static_cast` to a 16-bit integer is undefined behaviour. `jensenpat` raised it on his #5992 approval for the ANAN radio-speaker quantiser; the same clamp-then-cast shape sits at every float→int16 conversion that consumes DSP, resampler or capture audio. This guards all of them, each at its own site and at its own existing scale: | Site | Path | Direction | Test | |---|---|---|---| | `AnanSliceAudio.h` `sliceAudioToInt16()` (call site `AnanBackend.cpp`) | ANAN radio-speaker wire | RX | `anan_p2_protocol_test` | | `MetisProtocol.cpp` `ep2WriteTxIq` | HL2 TX IQ on the wire | TX | `hl2_metis_protocol_test` | | `TciIoWorker.cpp` `encodeRxAudio` (int16 format) | TCI audio to external clients | RX | `tci_rx_audio_test` | | `Hl2Backend.cpp` `forwardSpeakerAudioToCodec` `toI16` | HL2 radio-speaker codec | RX | — | | `ClientPuduMonitor.cpp` playback | monitor after the resampler | RX | — | | `AudioEngine.cpp` RADE capture float→int16 | RADE mic | TX | — | | `AudioEngine.cpp` RADE resampler output | RADE mic | TX | — | | `RADEEngine.cpp` LPCNet input | RADE TX | TX | — | The guard is separate from the clamp because it has to be: a clamp cannot test a value that fails every comparison. It covers infinity too. An infinity clamps correctly, but to a full-scale rail — a click on a speaker feed, a full-scale sample on a TX feed — so one `std::isfinite` predicate makes both silence, which is also what each path already sends when muted or idle. **Finite input is unchanged.** Every edit is a guard in front of the existing expression; no scale, rail or rounding changes, and the symmetric ×32767 sites (ANAN, HL2, Metis) and the ×32768 sites (AudioEngine, RADE, TCI, monitor) keep their own convention. A 1,000,000-bit-pattern probe of the ANAN conversion found zero mismatches between old and new for every finite input. **A non-finite sample becomes silence for that sample only**; finite samples in the same block are untouched. The ANAN speaker interleave moves into `sliceAudioToInt16()` so it can be unit-tested; the five comment lines the `AnanBackend.cpp` call site loses are #6072's clamp rationale, which moves into that function with tests for it — the shortening AGENTS.md sanctions ("as long as the guard or test it describes stays"). Not in scope, and why: - **Already guarded:** `AudioEngine.cpp` TX float→int16, `CwDecoder.cpp`, `TxMicChannelNormalizer.cpp`, `QsoPcmConverter.cpp` (whole block validated first). - **Not undefined:** `IcomAudio.cpp`, `TxVoiceProcessor.cpp`, `RemoteAsrBackend.cpp` round with `lround()` and then narrow an integer, which C++20 defines. - **Own tone generator as input:** `CwSidetoneQAudioSink.cpp`, the CW-sidetone record path in `AudioEngine.cpp`, `ClientQuindarTone.cpp`, `ClientTxTestTone.cpp`. - **Not a cast:** `asr/Fbank.cpp`, `RNNoiseFilter.cpp` scale float into float. This guards the conversion, not the source. A NaN that reaches a resampler or IIR filter can latch in its state; that would now produce steady silence rather than undefined behaviour, and nothing here detects or resets it. `jensenpat`'s other #5992 follow-up — capping catch-up at the estimated FIFO room — is deliberately not here. It was implemented and then failed on an ANAN-G2 (speaker FIFO median 995 → 117 locations, underflow reports 1 per 14 min → 782 per 11); the measurement is written up on #5992 for a maintainer's direction. ## Constitution principle honored **Principle VIII — Evidence Over Assertion.** The existing comments asserted that clamping before the cast made the conversion safe. It does for overshoot and not for NaN. Each tested guard is shown red without its fix below. ## Test plan - [x] Local build passes (`cmake --build build`) - [x] Existing tests pass — 651/653 locally at `44d7cb29` (Arch, Qt 6.12, `ctest -j16`). One deliberate skip (`app_settings_safety_explicit-profile-path-isolation`); one failure, `connection_panel_size_test` ("horizontal overflow is reachable, not clipped"), deterministic on this machine with ThemeManager reporting its resources failed to load. This PR touches no file under `src/gui/`. - [x] Behaviour on a real radio — the ANAN quantiser was in the audio path for two ANAN-G2 sessions (14 min and 11 min, Saturn gateware 27) with clean audio. A NaN cannot be induced from a radio, which is why the guards are covered by arithmetic tests instead. Mutation proofs — each check below goes red with its guard removed: | mutation | caught by | |---|---| | ANAN guard narrowed to `std::isnan` | `an infinity quantises to silence…` | | ANAN clamp removed | `an overshoot clamps, not wraps` | | ANAN full scale 32767 → 32768 | `full scale is the positive peak` | | ANAN guard removed, built with `-fsanitize=float-cast-overflow` | `runtime error: nan is outside the range of representable values of type 'short int'` | | Metis EP2 guard removed | `an infinite Q sample is silence, not a full-scale rail`, `a negative infinity is silence too`; NaN under the sanitizer as above | | TCI int16 guard removed | `non-finite samples encode as int16 silence` | Without the sanitizer, a NaN check alone cannot go red: the undefined cast lands on 0 on both x86-64 (`cvttss2si` → `0x80000000`, low 16 bits zero) and arm64 (`fcvtzs` → 0). The sanitizer build is the proof for NaN; the infinity checks are the deterministic ones. The HL2 speaker, monitor and three RADE sites have no test seam short of a connected backend or a running audio engine; each is the same one-line `std::isfinite` guard as the tested sites, verified by build and by reading. The four aetherd gates (`check_engine_boundary`, `gen_touchpoint_manifest --check`, `check_capability_records`, `check_command_plane`, all `--strict`) produce identical output on the merge base and this head. No new tests target; the new checks land in existing, socket-free targets. ## Checklist - [x] Commits are signed (`docs/COMMIT-SIGNING.md`) - [x] No new flat-key `AppSettings` calls — no settings touched - [x] Code is clean-room (Principle IV) - [x] All meter UI uses `MeterSmoother` — n/a, no UI - [x] Documentation updated if user-visible behavior changed — n/a: nothing changes for finite audio - [x] Security-sensitive changes reference a GHSA if applicable — n/a, local DSP values, not untrusted input 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com> Co-authored-by: Jeremy [KK7GWY] <105957118+ten9876@users.noreply.github.com>
Summary
Two things the ANAN backend could not do, in one PR because they are the same audio stage seen from two ends:
RadioModelwires every slice's audio signals to the backend, but ANAN overrode none of the three setters, so they landed onIRadioBackend's no-op defaults — the case that header's own comment describes: "a backend that demodulates on this host has to apply them in its own mixer, and without that the operator's mute moves the fader while the audio keeps playing."Verified on a G2 (Saturn, gateware 27). Measurements below.
The receiver's audio stage
AnanSliceAudio.happlies mute, gain and balance to the demodulated block before it leaves the backend, which is wheredocs/architecture/audio-pipeline.mdsays it belongs: audio reaches the engine with per-receiver balance already applied, andAudioEngine's own volume and mute are the master controls for everything the operator hears at once, not one receiver's.Gain is a dB law — 0 silent, 50 at −20 dB, 100 unity — because a fader is a perceptual control and a linear amplitude scale spends most of its travel on changes that are barely audible. Zero is exactly silent rather than −40 dB: a fader at its bottom stop that still makes noise reads as a broken control. Balance reuses the law
applyRxPanInPlace()uses, attenuating the opposite channel and never boosting either, so the same setting is the same loudness whichever kind of receiver it is applied to.Both gain and mute are published, not just stored.
ModelReceiveControlTargetrefuses an audio operation whose observation is absent, so declaringreceiveAudioControlwithout echoing the applied values would offer the control and then reject every use of it withcapability.unavailable. Balance is applied but deliberately not published —SliceDeltacarries no field for it, so there is no observation to echo, and a test pins that rather than leaving it implied.Three levels, not one — and this is the part worth reviewing
Three controls in this path answer three different questions, and each needs its own route:
RxAppletAF gain →SliceModel::setAudioGain→ the stage aboveAudioEngine::setRxVolume()/setMuted()(sink volume)RadioModel::setLineoutGain()/setLineoutMute()The third had no implementation outside a Flex: it sent
mixer lineout gainand nothing else, a command-plane string. SinceMainWindow::applyMasterVolume()routes the master slider there whenever PC Audio is off, the control an operator actually reaches for could not affect the radio on any backend without a Flex command plane — not just ANAN.The radio cannot make up the difference.
saturnregisters.hexposesSetSpkrMute(bool)and no speaker volume register whatsoever, so the G2's own knob cannot attenuate this path: every bit of level control has to be applied host-side, or the operator gets full scale and no way down.So
IRadioBackendgainssetLineoutGain(int)/setLineoutMute(bool), andRadioModelmakes the typed call when the backend has no command plane — guarded onusesFlexCommandPlane()so a Flex is not told twice, and with no newsendCommand(), so the per-file command-plane ratchet is untouched. ANAN applies it to the speaker stream only: the computer's speakers must not get quieter because the operator turned the radio down.The default is 50, matching
RadioModel's own reset value for the same setting rather than a number invented here. They are two halves of one value and a disagreement between them is audible.The PC Audio toggle now re-applies the master volume, because that toggle changes where the volume applies;
applyMasterVolume()is otherwise called only by the slider, so without this the newly-selected destination keeps whatever level it held.The speaker stream
64 stereo frames per packet, 16-bit big-endian, 48 ksps, behind a 4-byte sequence — 260 bytes to the radio's DDC Audio port, one packet every 1333 µs. The geometry is the openHPSDR Protocol 2 transfer-size table, and
p2app'sIncomingSpkrAudio()is the counterparty.The demodulated 24 kHz is converted with two independent resamplers, one per channel, never
Resampler::processStereoToStereo()— that averages L and R to mono and would silently undo the balance applied moments earlier and collapse a diversity pair to one ear's worth of information.P2Clientowns the queue, the pacing and the socket on its own thread, so audio crosses the thread boundary once per DSP block rather than 750 times a second.A mute sends silence rather than stopping. Stopping starves the codec, so the FIFO underflows for as long as the mute lasts — destroying the only honest fault signal this path has — and clicks on unmute as it refills. Overflow drops the oldest audio: discarding the newest to preserve a backlog the operator has already missed hearing keeps the latency and loses the content.
Pacing, and two things it is not
Not a servo on the radio's published Speaker FIFO depth, however obviously right that looks. The status packet arrives every 200 ms while receiving — about once per 150 speaker packets — so a servo reading it corrects on information up to 150 packets stale. It is the right field for diagnosis, and byte 30 bit 3 is the honest underrun signal, which is why both are decoded.
Not a sleep per packet. A sleep is a floor, not a promise, so every scheduler overrun accumulates one-directionally into an underrun, and 1333 µs is below what a Qt timer will honour anyway.
Instead the sender integrates the FIFO from what is known exactly — each packet adds 64 frames, elapsed time removes them at 48 kHz — and releases while that estimate is below a target. This holds because the source is clock-locked to the sink: the audio is demodulated from the radio's own IQ, produced at 48 kHz as measured by the same crystal that plays it back, so there is no long-run drift to servo out.
The target has to satisfy two things, and both are load-bearing:
WdspChannel::computeOutputBlockSize()makes a block1024 * 24000/48000= 512 frames at the default 48 ksps DDC, which resamples to exactly 1024 frames — sixteen packets. A target below one block cannot release a block before the next arrives, so the queue grows to its cap and drops. It must also exceed one drain tick: a 5 ms tick removes 240 frames and releases are whole packets, so a target near 240 yieldsfloor(240/64)= 3 packets per tick against 3.75 consumed.There is no headroom above the target either, and that is the radio's shape rather than a choice:
DMAFIFODepths[eSpkCodecDMA]is 1024 locations = 2048 samples = 1024 stereo frames on gateware ≥13, so the target is the whole speaker FIFO and one DSP block fills it.Instrumentation
The High Priority status packet is now decoded — the first thing in this backend to read it at all — for two fields only: byte 30 bit 3 (speaker FIFO underflow) and bytes 37-38 (its level). A healthy stream stops reporting underflows once running, and that is the verdict on the pacing. The first report warns; the rest are counted, because the bit latches over the ~200 ms between packets.
aether.anan.p2is also a curatedLogManagercategory now. It had none, andapplyFilterRules()emitsaether.*.debug=falsebefore callingsetFilterRules(), which replaces whateverQT_LOGGING_RULESset — so the environment variable could not reach it and the only route was the bridge'slog set. These two fields are the only instrumentation the radio gives us for audio sent to it.Spec discrepancy settled on the way: bytes 31-38 are documented as sample counts (p.47, and
OutHighPriority.c's own comments say "speaker samples"), but that file computes the conversion —*2speaker,*4mic,*4/3DUC — and then writes the unconverted count, the same dead store in all three places. They are occupied FIFO locations. Confirmed from the implementation rather than a comment:saturndrivers.c:85sets*Currentto the raw register value, andp2apppasses&FIFOCountas that parameter; only the return value converts to free locations, only for a write channel, andp2appnever uses it.Off by default
Unlike every other ANAN connect-time option, whose default is the hardware's shipped state, this one's default is "the operator has not asked for it". A radio that was silent before an upgrade should not start making noise because of one, and a host that was sending nothing should not start sending 260 kB/s. One checkbox in the existing ANAN manual-connect rows turns it on.
Verification
ctest: the four affected targets pass —
anan_p2_protocol_test,anan_backend_test,anan_speaker_audio_test(new),control_receive_test. Nine local ratchets pass;tools/audit_colours.pydelta is zero (the new checkbox usesThemeManager::applyStyleSheet, not a newsetStyleSheetcall site).33 mutation proofs, every one killed by its intended check — the wire encoding, the gain/balance law, the status parser, the send plumbing, the lineout seam, and the pacing. The pacing invariants are stated as throughput rather than self-consistency: the target holds more than one drain tick drains, with margin for three, and at least one whole source block, spelled out from the DSP arithmetic so it survives a change of block size or packet geometry. A mutation putting the target back below one block turns the suite red.
On the radio (G2, Saturn, gateware 27):
A non-zero underflow figure at connect is expected: the FIFO is empty before the first packet arrives. The verdict is that it stops increasing.
Not done, and deliberately
Notes for review
IRadioBackend,RadioModelandMainWindoware the only files outside the ANAN backend. The seam addition is two virtuals with no-op defaults, so every other backend is unaffected; theRadioModelchange is guarded onusesFlexCommandPlane()and adds nosendCommand().anan_speaker_audio_testis a new target. It reads the datagramsP2Clientactually sends over loopback with no radio, and skips itself with a notice if it cannot bind the speaker port rather than passing silently.p2app, per the RFC's provenance model.🤖 Generated with Claude Code