Skip to content

feat(anan): apply the receiver's mute, AF gain and balance, and send RX audio to the radio's own speaker. Principle II. - #5992

Merged
jensenpat merged 1 commit into
aethersdr:mainfrom
tropo1234:anan-rx-audio
Oct 1, 2026
Merged

jensenpat merged 1 commit into
aethersdr:mainfrom
tropo1234:anan-rx-audio

Conversation

@tropo1234

Copy link
Copy Markdown
Contributor

Summary

Two things the ANAN backend could not do, in one PR because they are the same audio stage seen from two ends:

  • The operator's mute, AF fader and balance were dead. RadioModel wires every slice's audio signals to the backend, but ANAN overrode none of the three setters, so they landed on IRadioBackend'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."
  • The radio's own speaker and headphone jack were silent. The G2 has a codec and a DDC Audio port for it; nothing was ever sent there, so a G2 operator had to listen on the computer.

Verified on a G2 (Saturn, gateware 27). Measurements below.

The receiver's audio stage

AnanSliceAudio.h applies mute, gain and balance to the demodulated block before it leaves the backend, which is where docs/architecture/audio-pipeline.md says it belongs: audio reaches the engine with per-receiver balance already applied, and AudioEngine'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. ModelReceiveControlTarget refuses an audio operation whose observation is absent, so declaring receiveAudioControl without echoing the applied values would offer the control and then reject every use of it with capability.unavailable. Balance is applied but deliberately not published — SliceDelta carries 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:

Level Route Reaches
Per-slice RxApplet AF gain → SliceModel::setAudioGain → the stage above PC and radio
Client master slider/mute → AudioEngine::setRxVolume()/setMuted() (sink volume) PC sink only, by construction
Radio output RadioModel::setLineoutGain()/setLineoutMute() radio only

The third had no implementation outside a Flex: it sent mixer lineout gain and nothing else, a command-plane string. Since MainWindow::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.h exposes SetSpkrMute(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 IRadioBackend gains setLineoutGain(int)/setLineoutMute(bool), and RadioModel makes the typed call when the backend has no command plane — guarded on usesFlexCommandPlane() so a Flex is not told twice, and with no new sendCommand(), 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's IncomingSpkrAudio() 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. P2Client owns 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:

  1. Cover one source block. WdspChannel::computeOutputBlockSize() makes a block 1024 * 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 yields floor(240/64) = 3 packets per tick against 3.75 consumed.
  2. Allow release to exceed consumption. With the target met, releases only replace what elapsed time consumed — correct for the rate, and it means a backlog would otherwise be permanent, since nominal rate is break-even by definition. So above a quarter of the queue's capacity the pacer ignores the target until the queue is down to a sixteenth, bounded to one target's worth per tick so a recovery cannot become the overrun it exists to prevent.

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.p2 is also a curated LogManager category now. It had none, and applyFilterRules() emits aether.*.debug=false before calling setFilterRules(), which replaces whatever QT_LOGGING_RULES set — so the environment variable could not reach it and the only route was the bridge's log 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 — *2 speaker, *4 mic, *4/3 DUC — 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:85 sets *Current to the raw register value, and p2app passes &FIFOCount as that parameter; only the return value converts to free locations, only for a write channel, and p2app never 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.py delta is zero (the new checkbox uses ThemeManager::applyStyleSheet, not a new setStyleSheet call 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):

Measurement Observed
Idle speaker FIFO level, stream off 0
Active level 53–165 locations (capacity 1024)
Underflow reports settle at 1 — the connect transient, before the first packet reaches an empty FIFO — then held for 591 consecutive status packets
Queue depth peaks 22–28 of 64, sawtoothing with the block size
Audio clean and intelligible
Controls mute, AF gain and balance all act at the radio

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

  • Pacing under load is untested. A busy machine, a saturated network or a competing DSP build is where a credit scheme is actually tested; a quiet bench proves only that it is not obviously wrong.
  • There is no per-slice mute in the GUI, so that half of the audio stage is reachable only through the automation bridge or the control API. Where such a control belongs is a placement call I would rather leave to a maintainer than guess at.
  • Live toggling of the stream is not implemented — it is connect-time, like the other ANAN options. The slice mute is live and reaches the radio, so there is live control of the audio without a reconnect.
  • Muting RX audio while transmitting needs G2 TX, which does not exist.

Notes for review

  • IRadioBackend, RadioModel and MainWindow are the only files outside the ANAN backend. The seam addition is two virtuals with no-op defaults, so every other backend is unaffected; the RadioModel change is guarded on usesFlexCommandPlane() and adds no sendCommand().
  • anan_speaker_audio_test is a new target. It reads the datagrams P2Client actually sends over loopback with no radio, and skips itself with a notice if it cannot bind the speaker port rather than passing silently.
  • Protocol facts are grounded in the openHPSDR Ethernet Protocol v4.4, the Saturn FPGA RTL and p2app, per the RFC's provenance model.

🤖 Generated with Claude Code

@tropo1234
tropo1234 requested a review from a team as a code owner September 28, 2026 00:37

@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

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 passed is 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 at 64/4 = 16 packets; 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 sees queued >= 16, latches m_catchingUp, releases min(16, 16) — the whole block, target ignored — and the next tick drops back below 64/16 = 4 and 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 with setBacklog(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:102 still reads "not yet qualified" in the Gain/mute column for ANAN. docs/architecture/radio-capabilities-map.md:66-68 names that table as where the per-backend receive-control matrix lives, and this PR flips receiveAudioControl from std::nullopt to 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_discoveryInfoSent is set. P2Client.cpp:441 only tries parseDiscoveryReply while !m_discoveryInfoSent; after that, any further 60-byte Discovery reply (a duplicated datagram, a late retransmit) falls straight into parseHighPriorityStatus at 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:2114 re-apply changes Flex behavior too, and the body does not say so. Turning PC Audio off on a Flex now sends mixer 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:330 writes PcAudioEnabled before emitting pcAudioToggled at 332, so applyMasterVolume() 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 whatever mixer lineout gain 50 means 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) and tests.cmake comment placement — read the files at HEAD rather than trusting the diff hunks, which is what surfaced both. Confirmed the fprintf/return pairing and the add_test ordering directly.
  • Stale-setting race on the PC Audio toggle. applyMasterVolume() reads PcAudioEnabled from AppSettings; if the toggle lambda ran before the write, the volume would route to the wrong destination. It does not — TitleBar.cpp:330 writes, 332 emits. Held.
  • Rate-change lifecycle. resetSpeakerResamplers() reads m_pendingDspConfig.audioSampleRateHz and is called from startP2ClientSession(), 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.speakerAudioEnabled survives a rate change as intended.
  • Resampler bounds. Resampler's default maxBlockSamples is 4096; the largest audio block is 1024 * 24000/48000 = 512 frames at the lowest DDC rate, so the per-channel calls cannot exceed it. The std::min(nL, nR) guard and the n <= 0 early return handle the filter-fill case. No overrun found.
  • Thread affinity of the new state. m_dsp and m_client are both moved to m_ioThread (AnanBackend.cpp:185-188); the pcmReady lambda is context-bound to this, so the audio stage and the three setters do share a thread as the comment claims. enqueueSpeakerAudio is Q_INVOKABLE and invoked Qt::QueuedConnection with a deep-copied QByteArray — 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 failed writeDatagram is 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() sets d.audioGain/d.audioMute unconditionally, so the observation ModelReceiveControlTarget needs exists from the first publish rather than only after a setter runs. control_receive_test was 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

Comment thread tests/anan_backend_test.cpp
Comment thread tests/anan_speaker_audio_test.cpp Outdated
Comment thread tests/tests.cmake
Comment thread src/core/backends/anan/AnanSpeakerPacing.h Outdated
Comment thread src/core/backends/anan/P2Client.cpp
Comment thread src/gui/MainWindow.cpp Outdated
@aethersdr-agent

Copy link
Copy Markdown
Contributor

Cisco CodeGuard — static analysis of this PR (5 finding(s))

  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5992/src/gui/MainWindow.cpp:9552
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5992/src/gui/MainWindow.cpp:9664
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5992/src/gui/MainWindow.cpp:9783
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5992/src/gui/MainWindow.cpp:9784
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-5992/src/gui/MainWindow.cpp:9785

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

tropo1234 added a commit to tropo1234/AetherSDR that referenced this pull request Sep 28, 2026
…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>
@tropo1234

Copy link
Copy Markdown
Contributor Author

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

Blockers

1. The swallowed if (g_failures == 0). Confirmed exactly as described. A brace map of the file showed the bare if at 423 gating the lineout block at 431-460, with the block at 468-505 and the fprintf both unconditional. The guard is back immediately above the fprintf, where origin/main had it. I checked the other two test files for the same insertion class; they are clean.

2. The green pass on an unbindable port. return 77, paired with set_tests_properties(anan_speaker_audio_test PROPERTIES SKIP_RETURN_CODE 77). Proved rather than asserted — with 127.0.0.1:1028 held by another process:

69: anan_speaker_audio_test: SKIP -- cannot bind 127.0.0.1:1028 (The bound address is already in use)
1/1 Test #69: anan_speaker_audio_test ..........***Skipped   0.03 sec

The tests.cmake block also moved below anan_noise_blanker_readback_test, so the "Socket-free NB publication/readback" comment describes its own target again — that nit and this blocker were the same misplaced insertion.

The catch-up threshold

This 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 capacityPackets / 4 and / 16, and one putting entry at a single block, all turn the suite red on the intended check.

The Discovery/status tie-break

Unconditional now. The shape test runs on every datagram and only the notification is gated on m_discoveryInfoSent, so a duplicate or retransmitted reply can no longer be decoded as a speaker-FIFO fault. parseHighPriorityStatus()'s header states that as a caller contract rather than as an unqualified claim.

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

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

Re-benched, so the pacing change is measured rather than assumed

G2, Saturn, gateware 27. Procedure and full numbers in BENCH.md §9.10.

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 me

The 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:

Queue when the underflow was reported Count Reading
0 288 (10%) nothing to send — the DSP was starved too
1–16 ~1870 audio in hand, one block or less
17–63 ~620 audio in hand, deep
64 (the cap) 25 holding the maximum and dropping new audio 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 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

  1. The RFC question, raised rather than assumed. The IRadioBackend virtual pair and the new outbound kSpeakerAudioPort stream are the two parts of this that reach outside the ANAN backend. Happy to take a ruling either way; if an RFC should precede them, this PR waits on it.
  2. An undisclosed cross-family effect, now disclosed. The PC Audio re-apply in MainWindow runs on every backend, so turning PC Audio off on a Flex writes MasterVolume to the radio's lineout where previously nothing was written, overwriting a level set at the radio. Deliberate rather than overlooked: gating it to backends without a command plane would keep the original bug alive everywhere else, and with PC Audio off the thing being listened to is the radio. Called out because the title does not imply a Flex change — if a maintainer would rather it were scoped, say so and it will be.

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>

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.cpp speaker conversion: std::clamp passes NaN through, and the cast to int16_t is undefined. Only matters if WDSP ever emits NaN, but an std::isfinite guard is cheap.

@jensenpat
jensenpat merged commit 0817cec into aethersdr:main Oct 1, 2026
5 checks passed
on8st added a commit to on8st/AetherSDR that referenced this pull request Oct 1, 2026
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>
tropo1234 added a commit to tropo1234/AetherSDR that referenced this pull request Oct 2, 2026
…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>
@tropo1234

Copy link
Copy Markdown
Contributor Author

@jensenpat Both follow-ups from your approval were implemented. One is up as #6089; the
other is withdrawn, because the G2 says it makes things worse — and finding out
why turned up something bigger than the follow-up.

Quotes below are against main at f10aa757, after #6072's comment pass.

The int16 cast guard: done — #6089

You were right, and the guard does have to be separate from the clamp: a clamp
cannot test a value that fails every comparison. The quantise moved into
AnanSliceAudio::sliceAudioToInt16() so both halves of the law sit where they
can be unit-tested. Infinity is folded in with NaN deliberately — it clamps
correctly, but it is the same class of fault and clamping it emits a full-scale
click, so one predicate covers both and silence is the right value for a sample
that has no value.

Capping catch-up at the estimated FIFO room: withdrawn

Implemented as byRoom + 1 packet under the existing ceiling, with the
arithmetic for why one packet rather than a free parameter. 571/571 locally, nine
mutation proofs, all ten static ratchets. Then it ran on an ANAN-G2 (Saturn,
gateware 27), same bench procedure as the original, with a probe logging the
pacer's own estimate beside the radio's reported level:

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_fifoFrames is negative, and room <= 0 on
    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 packet main sends
    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 <= 0 for 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:

  1. 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.
  2. 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.

@tropo1234
tropo1234 deleted the anan-rx-audio branch October 2, 2026 21:29
ten9876 added a commit that referenced this pull request Oct 4, 2026
…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>
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.

2 participants