Skip to content

fix(rade): renormalize V1 phase accumulators. Principle XI. - #6399

Merged
rfoust merged 2 commits into
aethersdr:mainfrom
NF0T:fix/rade-v1-phase-drift
Oct 12, 2026
Merged

rfoust merged 2 commits into
aethersdr:mainfrom
NF0T:fix/rade-v1-phase-drift

Conversation

@NF0T

@NF0T NF0T commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes #6398

Two complex phase accumulators in the vendored RADE V1 receiver are advanced by repeated complex multiplication and never renormalized, so float32 rounding error shrinks their magnitude without bound. The BPF mixes down to baseband and back up with the same drifting phase, so its output is attenuated by roughly |phase|². After a day of continuous receive the BPF output falls to about 0.003% of its starting level (≈ −90 dB). Pilot correlation and the SNR estimator keep running on the near-zero signal, so SNR reads far too low while rade_sync() still reports sync.

Both fixes are taken verbatim from upstream freedv/rade_c:

  • third_party/radae/src/rade_bpf.c: renormalize the mixer phase at the end of rade_bpf_process() (upstream 5cb24369).
  • third_party/radae/src/rade_rx.c: enable the renormalization that was already present but commented out (upstream a2d77e85).

+4 / −2 across two files. No API change, and no behaviour change on short sessions. The BPF is unconditionally enabled for V1 (rade_api_nopy.c passes bpf_en = 1) and both files are compiled into aethercore, so every ENABLE_RADE=ON build is affected.

Alongside the fix: a registered socket-free regression test for both sites (rade_phase_renorm_test), a record of the two cherry-picks next to the vendored pin (third_party/radae/LOCAL_PATCHES.md), and one current-behavior sentence on the RADE user page. Everything else is third_party/radae V1 code, independent of the RADE V2 work.

Constitution principle honored

Principle XI, Fixes Are Demonstrated. The harness below is my evidence, not verification. The gate is CI on the squash-merge commit and maintainer reproduction on pre- and post-merge builds (see Test plan).

Test plan

rade_phase_renorm_test (tests/rade_phase_renorm_test.cpp, declared in tests/tests.cmake under RADE_FOUND) seeds each accumulator off the unit circle, standing in for hours of float32 drift, and asserts one call returns it to unit magnitude. No socket, device or peer. It runs in the full suite on every push to main and in the weekly sanitizer lane; the frozen per-PR gate is unchanged.

Case Asserts
BPF, phase seeded at 0.5, one sample saved phase magnitude is 1
BPF, 50 modem frames from a clean start magnitude stays 1 (fails unpatched with no seeding)
BPF, empty call magnitude stays 1
BPF, output level two blocks after a 0.5 seed, output RMS matches an undrifted filter within 1%
RX, RADE_STATE_SYNC injected, rx_phase seeded at 0.5, production bottleneck=3/auxdata=1, BPF off one rade_rx_process() call saves magnitude 1
RX, rx_phase at 1.0 stays 1

The RX case proves the normalization from injected internal state. It does not claim acquisition or SNR convergence.

Mutation check (MinGW build, patched tree as control):

Tree Result
Patched 9 checks, 0 failures
BPF normalization removed 5 of 9 fail
RX normalization commented out 1 of 9 fails (the drifted-phase case; the unit-phase control correctly still passes)
Both restored 9 checks, 0 failures

Long-run harness (BPF only, constant on-centre tone, 8 kHz, 960-sample chunks), control is this tree unpatched. Output RMS is relative to the first block, which still carries the 101-tap filter's startup transient. That is why the patched column settles at 1.0276 rather than 1.0; rfoust's independent run against a settled reference reads 0.9999995.

| Simulated hours | Before: |phase| | Before: out RMS vs start | After: |phase| | After: out RMS |
|---|---|---|---|---|
| 0.0 | 0.999993 | 1.0000 | 1.000000 | 1.0000 |
| 3.3 | 0.474835 | 0.2317 | 1.000000 | 1.0276 |
| 10.0 | 0.107226 | 0.0118 | 1.000000 | 1.0276 |
| 23.3 | 0.005479 | 0.0000 | 1.000000 | 1.0276 |

  • Local build passes: aethercore and the new test build clean on MinGW; the test target adds -Wno-cpp for Opus's nnet.h SSE #warning
  • Behavior verified on a real radio: not done. The SNR symptom has not been reproduced through the full app on air.
  • Existing tests pass (CI)
  • tools/check_test_registration.py --strict and tools/check_ci_test_gate.py pass locally
  • Reproduction steps documented: see the issue and the table above

For the maintainer: on a pre-merge build, run RADE V1 receive continuously (or loop a RADE recording) for many hours and watch SNR and BPF level. On the post-merge build both should stay flat.

Checklist

  • Commits are signed (SSH-signed)
  • No new flat-key AppSettings calls
  • Code is clean-room: taken from upstream freedv/rade_c, not decompiled or reverse-engineered
  • No meter UI touched
  • docs/user/docs/rade-digital-voice.md gets one current-behavior sentence (the SNR readout does not fall away over a long session), no incident history. No CHANGELOG.md entry, per AGENTS.md. npm run build for the docs site was not run locally.
  • third_party/radae/LOCAL_PATCHES.md records the two cherry-picks beside the COMMIT pin, which is left unchanged
  • No security-sensitive change

🤖 Generated with Claude Code

@NF0T
NF0T requested a review from a team as a code owner October 12, 2026 00:34

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Issue fit

Yes, on the mechanism. #6398 names two drift sites; the diff addresses both and nothing else. I traced the mechanism in the head checkout rather than taking the body for it, and it holds:

  • third_party/radae/src/rade_bpf.c:91-107 mixes down by phase and back up by rade_cconj(phase), so the output carries |phase|². phase only ever advances by phase = rade_cmul(phase, phase_inc) (:91) and before this PR was stored back unmodified. The attenuation claim is structurally right.
  • The exposure window is the whole RADE-enabled session, not uninterrupted sync. rade_rx_reset() (third_party/radae/src/rade_rx.c:100) is the only thing that would re-seed rx_phase and call rade_bpf_reset() — and it has no callers anywhere in third_party/radae/src/ or src/. RADEEngine holds one rade object from start() to stop() (src/core/RADEEngine.cpp:89, :164), so a sync loss, band change or re-acquire does not bound the drift. That makes the "day of continuous receive" framing conservative rather than optimistic.
  • The issue's "publishes confident wrong numbers" claim is also real, not rhetoric: rade_snrdB_3k_est → RADEEngine::snrChanged (src/core/RADEEngine.cpp:516) is connected to m_freedvClient at src/gui/MainWindow_DigitalModes.cpp:1088.

The one thing unaddressed is the one the body already declines to claim: the symptom has not been reproduced through the app. That is disclosed, and Principle XI is cited correctly — the body names the squash-merge re-run and maintainer reproduction as the gate rather than grading itself.

Scope

File What it changes Claimed by title/body Verdict
third_party/radae/src/rade_bpf.c:111-112 Renormalize the mixer phase at the end of rade_bpf_process() Yes In scope
third_party/radae/src/rade_rx.c:236-237 Enable the already-written, commented-out rx_phase renormalization Yes In scope

Everything in the diff is explained by the issue. No new public surface, no protocol verb, no settings or capability key, no default changed, no guard or symptom-naming comment removed (the /* Normalize phase to prevent drift */ comment at rade_rx.c:235 is retained, not replaced). No CHANGELOG.md entry — correct. One signed commit, no cherry-picked build workaround riding along.

Two scope claims I checked and can confirm:

  • "The BPF is unconditionally enabled for V1." True for RX: rade_api_nopy.c:84 passes a literal 1 to rade_rx_init. The TX side passes bpf_en = 0 (rade_api_nopy.c:76), so rade_tx.c:165/:228 never reach rade_bpf_process — the change is RX-only in effect, which is narrower than the body implies but not broader.
  • "Two sites." I went looking for a third. Every other phase-like vector in the vendored tree is recomputed from scratch rather than accumulated: rade_acq.c:77, :184, :186, :267 all build w_vec as rade_cexp(w * n) per index, and rade_ofdm.c:407 derives rx_phase from rade_cangle() of a measured pilot. rade_bpf.c:91 is the only x = rade_cmul(x, inc) accumulator in the tree. No sibling left unfixed.

Blockers

1. third_party/radae/src/rade_bpf.c:111-112 — no registered test, and the sibling site is proof that a code-only fix does not stick.

    float phase_mag = rade_cabs(phase);
    phase = rade_cscale(phase, 1.0/phase_mag);

docs/agents/tests-ci.md:58 is explicit about the layer: "Wire encoding, parser bounds, model tables, scheduling, DSP, capability/safety policy | a socket-free CTest in tests/". This is DSP with a fully deterministic seam and no Qt, no socket, no model — and the author has already written the harness that exercises it, it is just not registered.

The argument for making this merge-gating rather than a nit is in the diff itself: the rade_rx.c half of this PR is re-enabling a renormalization that was already written and then commented out. That is precisely the regression a registered test prevents, and nothing stops it happening again to either site. Today tests/tests.cmake registers 708 tests and zero of them reference radae or RADE_DIR, and the tree ships third_party/radae/src/rade_bpf_test.c which no CMake target compiles (CMakeLists.txt:522-545 lists rade_bpf.c but no test). So this would be the first RADE test — worth the maintainer knowing, since it is a new precedent rather than an extension.

It does not need the 23-hour harness. A mutation-killing test is microseconds: seed the accumulator off the unit circle, run one sample, assert it came back:

    rade_bpf bpf;
    rade_bpf_init(&bpf, 101, 8000.0f, 3000.0f, 1500.0f, 960);
    bpf.phase = (RADE_COMP){0.5f, 0.0f};   /* stand in for hours of drift */
    RADE_COMP x = rade_cone(), y;
    rade_bpf_process(&bpf, &y, &x, 1);
    assert(fabsf(rade_cabs(bpf.phase) - 1.0f) < 1e-5f);

Delete the two added lines and rade_cabs(bpf.phase) stays at ~0.5, so the test fails against the unfixed code — which the current evidence cannot demonstrate in CI.

I would scope this to the BPF site only. The rade_rx.c site needs a populated rade_rx_state in RADE_STATE_SYNC to reach :236, there is no cheap seam for that, and "the same one-line renormalization, upstream's own change" is a fair characterisation of it.

Needs maintainer decision

2. third_party/radae/COMMIT — the vendored tree now diverges from its recorded pin with nothing recording it.

COMMIT reads 4da110afd6de3bb2de8800d67c6d82a292a42e77. After this PR the tree is that pin plus two unrecorded upstream cherry-picks (5cb24369, a2d77e85). The next person who re-vendors from the pinned commit silently reverts both fixes, and nothing in the tree would tell them.

This tree already has a convention for exactly this, at third_party/radae/src/rade_text.c:5-11:

// Source:          FreeDV-GUI src/pipeline/rade_text.c (BSD-2-Clause)
//
// AetherSDR changes from upstream:
//   - #include "ofdm_internal.h" replaced with "mpdecode_core.h"

So the ask is either to move COMMIT forward (if the two commits are HEAD-adjacent and nothing else changed between), or to add the same AetherSDR changes from upstream: block naming these two hunks. I am calling this a maintainer decision rather than a blocker because I found no vendoring rule in canon — rade_text.c is a convention in the same directory, not a cited sentence, and §5 says a convention is a nit at most. But it is the kind of gap the next upstream sync trips over, and it belongs in the PR body either way.

Nits

  • third_party/radae/src/rade_bpf.c:112 — rade_cscale(phase, 1.0/phase_mag) uses a double literal where the sibling at rade_rx.c:237 uses 1.0f / phase_mag. I checked the "no new warnings" claim before raising this and it holds: -Wconversion is applied only to aether_crdv (CMakeLists.txt:2250), not to the RADE sources, so the implicit double→float narrowing at the rade_cscale(RADE_COMP, float) call is silent, and one double division per 960-sample chunk is free. I am not asking for a change — the body claims both hunks are verbatim upstream, and "fixing" this would manufacture a local divergence if upstream has 1.0. If upstream has 1.0f, match it; otherwise leave it as vendored and the within-tree inconsistency is upstream's.
  • The evidence table's own after-column is unexplained: output RMS is 1.0000 at 0.0 h and 1.0276 from 3.3 h onward. A 2.76% (0.24 dB) step appearing once renormalization engages sits slightly oddly next to "no behaviour change on short sessions". Most likely an artifact of the harness's reference point, but it is a number in the proof that the proof does not account for — worth a sentence.
  • rade_bpf.c:111-112 divides by phase_mag with no guard, as does rade_rx.c:237. I convinced myself this is unreachable rather than latent: phase starts at rade_cone() (rade_bpf.c:74) and only ever multiplies by rade_cexp(-alpha) (:66), which is (cosf, sinf) and therefore unit-magnitude, so phase_mag cannot approach zero. Non-blocking, and matching upstream here is the right call.

Security audit

CodeGuard did not look at the changed files. The 180 findings are on src/gui/MainWindow.cpp, MainWindow_Wiring.cpp, RadioModel.{cpp,h}, tests/audio_device_preference_policy_test.cpp, tools/check_command_plane.py and docs/architecture/aetherd-touchpoint-tags.json — none of which this PR touches. The CMDI-AMP cluster at MainWindow.cpp:527-609 lands on isTransientAudioDeviceId/containsAudioDeviceId, which together with the audio-device test file is the surface of main's tip commit aa0dd0e4c (#6387), not of this branch. So please do not read a clean scan of this PR into it.

Every lead I could check against source is a false positive (credit: security-audit (CodeGuard)): the CMDI-AMP hits are C++ reference parameters and lambda captures ([&id](const QByteArray& candidate)), the CMDI-BACKTICK hits are Markdown-quoted Flex/TCI verbs inside // comments, CS-INJ-role_override at :11822 is the word "override" in a QRM-classifier comment, CS-VLN-PATH-PROC at :6373 is a fixed "/proc/self/status" literal, CS-PII-IP at :6986 is the WSJT-X multicast default 224.0.0.1, and CS-VLN-SSRF-HEX is a hex client handle in a comment. No hardcoded credential exists in the flagged files. Independently of the scan: this PR's change is float arithmetic with no I/O, no parsing, no allocation and no attacker-reachable input, so there is no security surface to audit in it.

What I tried to break

  • A third drift site left behind. Grepped every rade_cmul in the vendored tree for a self-accumulating phase. rade_acq.c:77, :184, :186, :267 and rade_ofdm.c:407 all recompute from rade_cexp/rade_cangle per index; rade_bpf.c:91 is the only accumulator. The "two sites" claim survives.
  • Renormalizing once per call rather than per sample. Within one 960-sample chunk the drift is bounded by ~960·ε ≈ 1e-4 in magnitude, so ~2e-4 in |phase|² — three orders below the symptom. Correct placement.
  • Division by zero and NaN poisoning of bpf->phase. Unreachable from a rade_cone() start times unit-magnitude rade_cexp increments; see the nit. Also walked n == 0 and negative n: the loop is skipped, phase == bpf->phase is already unit, and the renormalization is a no-op.
  • An uninitialized read at rade_rx.c:228-233. RADE_COMP rx_phase; is declared uninitialized and stored to rx->rx_phase after the loop, which would now be renormalized. Bounded by compile-time constants (Nmf + M + Ncp > 0), so the loop always runs at least once. Not a defect, but it is why the new 1.0f / phase_mag cannot be fed garbage.
  • Whether the rx site explains the day-scale symptom. It does not, and the body does not claim it does. rx->rx_phase is not updated inside the loop at :230 — only the final rx_phase is stored at :233 — so that accumulator advances once per frame while the BPF advances once per sample, roughly three orders of magnitude slower drift. Fixing it is right; the −45 dB collapse is the BPF's alone.
  • Whether PR CI actually compiled this. It did, unlike the ORT-gated paths: ENABLE_RADE defaults ON (CMakeLists.txt:41) and ci.yml does not override it, so build, check-macos and check-windows all built both files. All five checks are green on 60083b8. That proves compilation on three platforms and nothing about the behaviour — which is blocker 1.
  • Could not check: whether 5cb24369 and a2d77e85 are the real upstream commits, whether the two hunks are byte-verbatim, and the upstream literal (1.0 vs 1.0f). No network here. Settled by a maintainer opening the two upstream commits.

Everything above is reasoned from the head checkout at /tmp/aetherclaude/pr-6399. I ran no build and no test and drove no radio; none of these findings were reproduced at runtime.

Recommendation

Request changes — narrowly, and only for the test. The diagnosis is correct, I verified it independently of the body, the fix is minimal and in the right place, and the two scope claims I could check both held up. Nothing here needs rethinking.

What is missing is the one artifact that keeps it fixed. Register a socket-free CTest on the BPF site using the shape above — seeded phase, one sample, assert the magnitude came back — and this is ready. The vendoring-provenance item (#2) is the maintainer's call on whether to move COMMIT or add the rade_text.c-style upstream-changes block; either is a two-line change to the same PR. Thanks for the clear mechanism write-up and for naming the unreproduced symptom instead of papering over it — that honesty is what made the rest of the review cheap.


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

@aethersdr-agent

Copy link
Copy Markdown
Contributor

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

  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in docs/architecture/aetherd-touchpoint-tags.json /tmp/aetherclaude/pr-6399/docs/architecture/aetherd-touchpoint-tags.json:10
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in docs/architecture/aetherd-touchpoint-tags.json /tmp/aetherclaude/pr-6399/docs/architecture/aetherd-touchpoint-tags.json:1151
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:9770
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:9882
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:10004
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:10005
  • [MEDIUM] CG-PATH-001 — Potential path traversal in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:10006
  • [HIGH] CS-VLN-PATH-PROC — Vulnerability: CS-VLN-PATH-PROC in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:6294
  • [HIGH] CS-VLN-PATH-PROC — Vulnerability: CS-VLN-PATH-PROC in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:6373
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:527
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:568
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:571
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:593
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:602
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:3673
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:600
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:1293
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:1817
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:1909
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:1986
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:2028
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:2063
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:2358
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:3615
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:3706
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:6236
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:8106
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:8180
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:8265
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:8446
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:8649
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:9871
  • [LOW] CS-PII-IP — PII detected: ip_address in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:6986
  • [CRITICAL] CS-INJ-role_override — Prompt injection: role override attempt in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:11822
  • [CRITICAL] CS-INJ-role_override — Prompt injection: role override attempt (detected after NFKC normalization) in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:11822
  • [HIGH] CS-INJ-homoglyph — Mixed Unicode scripts detected (possible homoglyph attack) in src/gui/MainWindow.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow.cpp:0
  • [MEDIUM] CS-VLN-SSRF-HEX — Vulnerability: CS-VLN-SSRF-HEX in src/gui/MainWindow_Wiring.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow_Wiring.cpp:1883
  • [CRITICAL] CS-VLN-CMDI-AMP — Vulnerability: CS-VLN-CMDI-AMP in src/gui/MainWindow_Wiring.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow_Wiring.cpp:2607
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow_Wiring.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow_Wiring.cpp:414
  • [CRITICAL] CS-VLN-CMDI-BACKTICK — Vulnerability: CS-VLN-CMDI-BACKTICK in src/gui/MainWindow_Wiring.cpp /tmp/aetherclaude/pr-6399/src/gui/MainWindow_Wiring.cpp:1176

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: $14.4306 · model: claude-opus-5

@rfoust rfoust self-assigned this Oct 12, 2026

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

Issue fit

The patch addresses both phase-normalization sites in #6398 and appears correct. Both hunks exactly match upstream freedv/rade_c commits 5cb24369 and a2d77e85, which I retrieved and compared independently. I incorporated the additional AetherClaude review and CodeGuard comment before posting this review.

Scope

File Change Claimed by title/body? Verdict
third_party/radae/src/rade_bpf.c Normalize saved mixer phase Yes In scope
third_party/radae/src/rade_rx.c Enable existing RX phase normalization Yes In scope

Everything in the diff is explained by the issue. No unrelated files, API additions, defaults, UI preferences, or CHANGELOG entry.

Blocker

  1. Deliver registered, socket-free regression coverage for both sites — see the inline comments. The PR provides numerical results but no runnable harness or registered test. This has a deterministic seam: I compiled a socket-free harness against the actual RADE implementation and bundled Opus, and it passes with the patch and fails with the fixes reverted. docs/agents/tests-ci.md routes DSP assertions to a socket-free CTest. The RX site is also reachable without a radio or synthetic peer: initialize with the production bottleneck/auxdata settings, inject a synchronized state and an off-unit phase, and invoke rade_rx_process(). Neither site requires a many-hour CI run to detect removal of normalization.

Verification and attacks that held up

  • Local ARM64 aethercore build passed with RADE enabled, using the prescribed ARM64 toolchain and build-codex-arm64 directory. Both changed objects are ARM64, and the Ninja graph contains no RNNoise x86 sources. This was a core build, not a full GUI build or broad CTest run.
  • A 100,001-block BPF comparison at 8 kHz, 960 samples/block, 1475 Hz centre and 1740 Hz bandwidth produced the following. The RMS reference is a settled block after 12 seconds, avoiding startup filter settling:
Unpatched, 3.333333 simulated hours:
phase=0.485761017  RMS relative to baseline=0.236303557  exit=1
Patched, 3.333333 simulated hours:
phase=1.000000000  RMS relative to baseline=0.999999522  exit=0
  • Actual RX processing with injected phase magnitude 0.5, BPF disabled to isolate the second site, returned:
Unpatched: RX phase after one real process call: 0.500000000  exit=1
Patched:   RX phase after one real process call: 1.000000000  exit=0

This proves the normalization behavior, not successful RF acquisition or SNR recovery. It uses injected internal state, with no socket, device, or firmware peer.

  • Reset, empty BPF blocks, and varying valid block sizes retained finite unit phase on the patched version. I also examined initialization and call-size bounds; I found no reachable zero-magnitude division regression for valid production state.
  • Reviewed head: 60083b8. GitHub merge ref: a35c948, based on f6cd464. Local build used a constructed snapshot of main 1b77f49 plus this patch. At posting, main is 8207725; the intervening change touches GUI availability and associated docs/tests, with no RADE, CMakeLists.txt, or tests/tests.cmake changes. The merge ref is stale, so the green PR CI is not evidence for latest-main integration. All reported checks are green and GitHub verifies the commit signature.
  • I did not drive the GUI: demo mode does not provide the Flex DAX path RADE requires. No live radio was accessed and no TX was performed. Full-app SNR recovery remains unverified, as the PR discloses. A model-invocable automated review pass was unavailable; I performed the independent failure-path pass locally. The CodeGuard comment names files outside this two-file diff and supplies no demonstrated defect introduced by these arithmetic edits.

Non-blocking notes

  • Record the two upstream patches alongside the vendored pin. third_party/radae/COMMIT still names 4da110afd6de3bb2de8800d67c6d82a292a42e77; a subsequent re-vendor should have an explicit record that these fixes must be retained. This is a provenance convention, not a cited governance blocker. Do not simply change the pin unless the full tree matches it.
  • Correct the attenuation units: 0.003% output RMS is approximately -90 dB (20 log10), not -45 dB. The latter treats the RMS ratio as a power ratio. Also explain the initial-reference transient behind the after-column's 1.0276 value.
  • Reconcile the stated docs exemption with AGENTS.md's explicit user-visible-change documentation requirement. A concise current-behavior note in the RADE page can satisfy it without adding incident history.

Recommendation

Request changes, narrowly for regression coverage. Independent before/after checks support the implementation. Add registered tests that fail when each normalization is removed; the full-app SNR claim should remain explicitly unverified until the disclosed maintainer reproduction is performed.

Comment thread third_party/radae/src/rade_bpf.c
Comment thread third_party/radae/src/rade_rx.c
Both complex phase accumulators in the vendored RADE V1 receiver are
advanced by repeated complex multiplication and never renormalized, so
float32 rounding error shrinks their magnitude without bound. The BPF
mixes down to baseband and back up with the same drifting phase, so its
output is attenuated by roughly |phase|^2. Over a long receive session
the BPF output collapses toward zero while pilot correlation and the SNR
estimator keep running on the near-zero signal, producing large negative
SNR readings while rade_sync() still reports sync.

Two sites, both taken verbatim from upstream freedv/rade_c:

- third_party/radae/src/rade_bpf.c  - renormalize the mixer phase at the
  end of rade_bpf_process() (upstream 5cb24369).
- third_party/radae/src/rade_rx.c   - enable the renormalization that was
  already present but commented out (upstream a2d77e85).

The BPF is unconditionally enabled for V1 (rade_api_nopy.c passes
bpf_en=1), and both files are compiled into aethercore, so every build
with ENABLE_RADE=ON is affected.

Measured with a standalone harness driving rade_bpf_process() with a
constant on-centre tone, comparing this tree before and after the patch
(simulated hours of continuous receive, 8 kHz, 960-sample chunks):

  sim hours | before: |phase|, out RMS vs start | after: |phase|, RMS
       0.0  | 0.999993   1.0000                | 1.000000   1.0000
       3.3  | 0.474835   0.2317                | 1.000000   1.0276
      10.0  | 0.107226   0.0118                | 1.000000   1.0276
      23.3  | 0.005479   0.0000                | 1.000000   1.0276

Before the patch the filter output falls to 0.003% of its starting level
(about -45 dB) after a day of receive; after it is flat. Upstream reached
the same result from a user report of freedv-gui reporting -18 to -20 dB
SNR while still in sync after 24-48 hours of runtime
(freedv/rade_c#14, freedv/freedv-gui#1355).

No API change and no behaviour change on short sessions. No docs/user
update: the page documents current behaviour, and the style guide's
"state current behavior, never history" rule means a defect being
removed here leaves nothing for the operator to do.

Per Principle XI this harness is the implementing agent's evidence, not
independent verification - the squash-merge CI re-run and maintainer
reproduction on pre- and post-merge builds are the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NF0T
NF0T force-pushed the fix/rade-v1-phase-drift branch from 60083b8 to d58b72c Compare October 12, 2026 13:31
…es. Principle XI.

Review of aethersdr#6399 asked for registered, socket-free regression coverage of both
renormalizations, the provenance of the two cherry-picks, and a docs note.

- tests/rade_phase_renorm_test.cpp (rade_phase_renorm_test): seeds the BPF
  mixer phase and the receiver's rx_phase off the unit circle, as hours of
  float32 drift would, and asserts one call returns each to unit magnitude.
  It also asserts the symptom on the BPF - output level two blocks after a 0.5
  seed matches an undrifted filter - and that a healthy phase stays unit over
  50 modem frames. The RX case injects RADE_STATE_SYNC into a receiver built
  with the production bottleneck/auxdata and the BPF off; it proves the
  normalization, not acquisition or SNR. No socket, device or peer.
- tests/tests.cmake: declared under RADE_FOUND, no CI gate change.
- third_party/radae/LOCAL_PATCHES.md: records the two cherry-picks next to the
  COMMIT pin so a re-vendor from the pin does not silently drop them.
- docs/user: one current-behavior sentence on the RADE page.

Mutation-checked against the patched tree: removing the BPF lines fails 5 of 9
checks, commenting out the RX lines fails 1 (the drifted-phase case; the
unit-phase control correctly still passes), restoring both passes 9 of 9.

Correction to the previous commit message and PR body: 0.003% output RMS is
about -90 dB, not -45 dB. -45 dB treated the amplitude ratio as a power ratio.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@NF0T
NF0T requested a review from a team as a code owner October 12, 2026 14:12
@NF0T

NF0T commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 5dcee7994 on top of the reviewed 60083b8 (the branch is also rebased onto current main). It addresses the blocker and the non-blocking notes from the reviews:

  • Regression coverage for both sites: rade_phase_renorm_test in tests/tests.cmake, socket-free, declared under RADE_FOUND, with no change to the frozen per-PR gate. Mutation-checked: removing the BPF lines fails 5 of 9 checks, commenting out the RX lines fails 1 of 9 (the drifted-phase case; the unit-phase control correctly still passes), and with both restored it passes 9 of 9. Details are in the two inline threads and the updated Test plan.
  • Provenance: third_party/radae/LOCAL_PATCHES.md records both upstream cherry-picks beside the COMMIT pin. The pin is left unchanged, since the full tree does not match a later upstream commit.
  • Docs: one current-behavior sentence on the RADE page, no incident history. I have not run the docs-site npm run build locally.
  • dB figure corrected: 0.003% of amplitude is about −90 dB, not −45 dB. I had treated an amplitude ratio as a power ratio. The PR body is updated, and the issue text still carries the old figure until I fix it.
  • The 1.0276 in the after column: my harness referenced the first block, which still holds the 101-tap filter's startup transient. A settled reference reads 0.9999995, matching rfoust's run.

Still outstanding, as before: the SNR symptom has not been reproduced through the full app on air. The maintainer reproduction on pre- and post-merge builds remains the gate. CI on the new commit has not run to completion yet.

73, Ryan NF0T & Claude (AI dev partner)


👨🏼‍💻 Co-authored by Claude Sonnet 5.5

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

Issue fit

Approved after re-review of 5dcee79. The two normalization fixes address #6398, and the registered socket-free test now covers both sites. The previous coverage blocker is resolved.

Scope

File/group Claimed in the body? Verdict
rade_bpf.c / rade_rx.c Yes: upstream normalization fixes In scope
rade_phase_renorm_test.cpp / tests.cmake Yes: registered regression coverage In scope
LOCAL_PATCHES.md Yes: upstream-patch provenance In scope
RADE user page Yes: current-behavior documentation In scope

No unrelated changes. The attenuation figure is corrected to -90 dB and the initial-reference transient is explained.

Verification

Built the prescribed ARM64 CMake target with RADE enabled, then ran ctest -R '^rade_phase_renorm_test$' --no-tests=error --output-on-failure: 1/1 passed, 9 checks, 0 failures. The test executable and host/system processors are ARM64; no RNNoise x86 sources appear in the Ninja graph. Test-registration and frozen-gate checks passed.

Independent mutation runs using the exact new test source and actual vendored DSP code reproduced the claimed results: patched 9/9 pass; removing BPF normalization causes 5 failures; removing RX normalization causes 1 failure. This tests seeded off-unit phase, healthy-phase behavior, empty BPF calls, and downstream BPF output recovery. No socket, radio, device peer, or TX was used. The standalone mutation binaries use no Qt; the registered local run passed despite an existing mixed-Qt installation warning.

Reviewed merge result 19deb82 against main bbded44. Main has subsequently advanced to 8b39af8 with workflow-only changes, leaving RADE, CMakeLists.txt, the registration file, and the regression test unchanged. Current PR CI and the docs build are green; both commits have verified signatures. Full-app SNR recovery remains unverified and requires the disclosed maintainer reproduction; demo mode cannot exercise Flex DAX receive. A separate model-invocable automated pass was unavailable.

Non-blocking nit

The inline documentation suggestion qualifies the SNR claim so it describes phase-rounding drift rather than implying actual channel conditions cannot lower SNR. This does not block merging.

No remaining blockers. Approval is based on the independent build, registered CTest, and mutation results above; the post-merge CI re-run and the disclosed full-app reproduction remain independent follow-up verification.

Comment thread docs/user/docs/rade-digital-voice.md
@rfoust
rfoust merged commit d7a0802 into aethersdr:main Oct 12, 2026
7 checks passed
@NF0T
NF0T deleted the fix/rade-v1-phase-drift branch October 13, 2026 17:04
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.

RADE V1 receive: SNR reads −18 to −20 dB while still in sync after a day or more of continuous receive

2 participants