fix(gui): restore crisp 2D FFT trace — segment-clamped stroke, AA parity, plateau-gated smoothing (#3967, #3932)#3975
Conversation
… falloff, plateau-gated smoothing Fixes the two-release trace-quality regression chain behind aethersdr#3967 (and aethersdr#3932), root-caused with per-commit builds and live same-band A/B grabs: 1. aethersdr#3836 (shipped 26.6.5) applied buildFftDisplayTrace's 5-tap spatial smooth at an unconditional 0.75 blend. It exists to melt the radio's RBW stair-steps when zoomed in, but at wide spans it low-passes everything: narrow carriers lose height and the noise floor defocuses ("out of focus", aethersdr#3932). The blend is now gated on the measured plateau fraction (adjacent equal-pixel bins): zoomed-in plateau runs still get the full blend, busy wide spans get none, with a ramp between so zooming never visibly mode-flips. 2. aethersdr#3958 (shipped 26.7.1) drew the per-pixel stroke from the distance to the segment's INFINITE line (dFdx-derived slope). On steep segments that line passes near the whole pixel column, so the stroke shot full-height spikes above and below the trace, and the fixed ±0.75 px smoothstep AA band read as a soft "blurred" stroke at 1x DPI. The stroke now uses the true distance to the three adjacent polyline segments (endpoint-clamped, matching the old geometry quads' coverage) with the old falloff: solid to halfWidth−1, 1 px fade, 1 px feather annulus at low alpha. Also folds in PR aethersdr#3968's R16F-unconditional column format (float32 linear filtering is optional on GLES and undetectable via isTextureFormatSupported; half precision is ample for normalized 0..1 amplitudes) — though note that could not have caused aethersdr#3967: the reporter's renderer string shows D3D11 on Intel HD 520 (FL 12.1), where R32F filtering is mandatory and hardware-accelerated. Live-proven on FLEX-8400M against the AM broadcast band: 4-way same-band comparison (pre-aethersdr#3836 / post-aethersdr#3836 / 26.7.1 / this fix) shows the fix restores the pre-aethersdr#3836 look; panstats confirms frame-prep cost unchanged (~120 µs/frame at 58 presents/s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for the exceptionally thorough root-cause writeup, @jensenpat — the two-cause regression chain (unconditional smoothing from #3836, infinite-line stroke distance from #3958) is well isolated, and the 4-way same-band capture is exactly the right evidence. All six CI checks are green. The core of the fix is sound: replacing the dFdx-derived infinite-line distance with true endpoint-clamped distance to the adjacent polyline segments is the correct fix for the full-height needles, and gating the binomial blend on the measured plateau fraction is a clean way to preserve the zoomed-in stair-step melt without defocusing wide spans. A few notes, none blocking:
1. Shader: stroke polyline uses a different horizontal column convention than the fill (panscope.frag). The unchanged t/yTrace sampling at line 68 places column i at v_uv.x = i/(N-1) (endpoints inclusive: column 0 at x=0, column N-1 at x=wPx). The new stroke code places column i at (i+0.5)*colW = (i+0.5)/N. These two conventions disagree by up to half a column (~0.5 device px at one-column-per-pixel), sign-flipping across the width and zero-crossing near center. So the stroke is horizontally registered slightly off from the fill it sits on, and the offset varies across the panadapter. It's sub-pixel and both layers are AA'd, so likely imperceptible — but since it's a genuine internal inconsistency, consider deriving the polyline x-positions from the same mapping the fill/color already use (i/(N-1)) so stroke and fill share one registration. The implicit-LOD texture() calls inside the if (coreHalf > 0.0) block are fine, since stroke.x is uniform → the branch is uniform control flow.
2. Stale header comment. The file header (lines 3, 13–15) still describes an "R32F column texture" and "slope-corrected stroke width via screen-space derivatives." Both are now inaccurate after this PR (R16F unconditional; slope correction removed). Worth a one-line refresh so the next reader isn't misled.
3. Plateau threshold is a unit-dependent magic number (SpectrumWidget.cpp). std::abs(bins[i] - bins[i-1]) < 0.01f — since displaySpectrumBins() is in dBm, RBW plateaus are exact repeats (diff ≈ 0), so 0.01 dB catches them robustly and busy noise bins rarely fall under it; the logic is correct. But the 0.01f threshold and the 0.35f/0.30f ramp are unnamed constants sitting next to the already-named kFftDisplaySpatialSmoothBlend. Promoting them to named k… constants (with the dBm-units note) would make the gate easier to tune later. The srcCount < 2 early-return upstream makes the srcCount - 1 divisor safe.
Minor: forcing m_fftColFormat = R16F unconditionally leaves the if (m_fftColFormat == QRhiTexture::R32F) upload branch (~line 9059) as dead-but-harmless code — fine to leave, just noting it. The R16F rationale (filterable everywhere; half precision ample for normalized 0..1) is well-justified and matches #3968.
Nice work — the fix is correct and the perf claim (plateau gate skipping the smoothing copy on wide spans) is consistent with the COW displayBins = bins fast path.
🤖 aethersdr-agent · cost: $2.6981 · model: claude-opus-4-8
There was a problem hiding this comment.
Excellent root-causing — the four-way same-band capture and the two-releases/two-causes breakdown make this very easy to review. The plateau-gated smoothing and the true endpoint-clamped stroke distance are both well-reasoned, and the failure mode of the old dFdx-slope approximation (distance to the infinite line → full-height needles on steep flanks) is clearly the right diagnosis. CI is green across all six checks.
One substantive question before this lands, plus a couple of minor notes.
1. Column-center convention mismatch between the stroke and the fill (panscope.frag).
The fill path and the yTrace used everywhere else sample columns with an endpoint convention:
float texX = (v_uv.x * (plot.z - 1.0) + 0.5) / plot.z; // column i → device-x = i/(nCols-1) * wPxi.e. the trace spans the full width with sample 0 at the left edge and sample n-1 at the right edge (matching buildFftDisplayTrace producing one point per device column across specRect.width()).
The new stroke reconstruction uses a pixel-center convention instead:
float colW = plot.x / nCols;
pt[k] = vec2((ci + 0.5) * colW, (1.0 - ty) * hPx); // column i → device-x = (i+0.5)/nCols * wPxThese two mappings disagree by up to ~½ a column width (largest at the left/right edges, crossing zero mid-plot). At typical column counts colW ≈ 1 device px so the offset is sub-pixel — but the whole point of this PR is pixel-crisp stroke/fill registration, and on a steep peak flank a ~0.5 px horizontal shift of the stroke polyline relative to the fill's top edge is exactly the "stroke floating off the trace" class of artifact. Could you confirm this is intended, or align the stroke to the same endpoint mapping the fill uses, e.g.:
float denom = max(nCols - 1.0, 1.0);
pt[k] = vec2(ci / denom * plot.x, (1.0 - ty) * hPx);and sample columns at the matching (ci + 0.5)/nCols texel center (or texelFetch) so the reconstructed polyline is bit-identical to what the fill/yTrace path sees. On Retina (2×) the current offset is half the logical size, which is likely why it looked clean in the parity grabs but might not be at 1× DPI — the same DPI asymmetry this PR is calling out for the old spikes.
2. R16F-unconditional — reasonable, but two stale references. Folding in #3968 is fine and the half-precision argument holds for normalized 0..1 amplitude (worst-case ~0.5 px quantization near the top of the range). Two doc leftovers: the shader header still says "width×1 R32F column texture" (line 3) and describes "slope-corrected stroke width via screen-space derivatives" (lines 13–14), both now superseded by this change. The renderGpuFrame upload still branches on m_fftColFormat == QRhiTexture::R32F, which is now dead (the R32F arm can never be taken) — harmless, but worth collapsing to just the R16F path for clarity since the format is no longer conditional.
3. Scope. Bundling the #3968 R16F hardening into a #3967/#3932 trace-crispness fix is defensible given you touch the same pipeline, and you documented why #3968 couldn't have caused the reported bug — no objection, just flagging it's technically a third issue riding along.
Nothing here blocks the approach; the smoothing gate and stroke-distance rewrite are solid. Mainly want the column-center conventions unified (or a note confirming the sub-pixel offset is deliberate) before merge. Thanks for the thorough writeup and the hardware validation.
🤖 aethersdr-agent · cost: $2.2101 · model: claude-opus-4-8
## Summary Fixes a spectrum trace artifact during pan/zoom where newly exposed FFT bins at the left or right edge could appear from the display floor and rise into place. Flex FFT reprojection now extends the nearest existing smoothed trace into temporary edge bins, then seeds only those placeholders from the next real FFT frame. KiwiSDR uses the same idea with a separate trace mask, plus coverage-aware row updates so zoomed-out views do not treat uncovered server-row edges as real floor-level data before Kiwi sends rows for the wider span. KiwiSDR waterfall view requests are also sent during active pan/zoom gestures at a bounded cadence, so the server can start filling newly exposed edges before the mouse/trackpad gesture settles. ## Constitution principle honored Principle XI — Fixes Are Demonstrated. The change is limited to FFT trace reprojection/smoothing, KiwiSDR trace mapping, and bounded KiwiSDR waterfall-view updates during active pan/zoom gestures, with a focused regression test for narrow Kiwi rows inside a wider zoomed-out view. ## Test plan - [x] Local build passes (`cmake --build build --target AetherSDR kiwi_sdr_trace_math_test --parallel`) - [x] Behavior verified on KiwiSDR pan/zoom by user testing - [x] Existing tests pass (`./build/kiwi_sdr_trace_math_test`) - [x] Reproduction steps documented if user-reported bug Additional validation: - [x] `git diff --check` - [x] `python3 tools/check_a11y.py` exits 0 with existing unrelated warning-only findings - [x] Patch applies cleanly on top of PR #3975 merge checkout - [x] GitHub reports the updated commit signature as verified ## Checklist - [x] Commits are signed (`docs/COMMIT-SIGNING.md`) - [x] No new flat-key `AppSettings` calls — use nested-JSON-under-one-key (Principle V) - [x] Code is clean-room — not decompiled, disassembled, or reverse-engineered from a proprietary binary (Principle IV) - [x] All meter UI uses `MeterSmoother` (AGENTS.md convention) - [x] Documentation updated if user-visible behavior changed (No docs needed for this transient rendering fix.) - [x] Security-sensitive changes reference a GHSA if applicable (Not security-sensitive.)
|
Two-file fix, clean root-cause writeup. The two-release regression chain is well-isolated and the 4-way same-band comparison on FLEX-8400M is exactly the right hardware evidence for a rendering regression (Principle XI). Both root causes are correctly diagnosed and fixed. The #3836 spatial smoothing regression (#3932): the 5-tap binomial blend was always-on at 0.75, blurring the noise floor and rounding narrow carriers at wide spans. Gating the weight on On the column-center convention (aethersdr-agent's note): The stroke polyline x-positions use pixel-center convention CI 6/6 green, including |
NF0T
left a comment
There was a problem hiding this comment.
Traced the two-cause regression chain independently: infinite-line stroke distance (steep-flank spikes, #3967) and unconditional smoothing blend (wide-span defocus, #3932) are both correctly diagnosed and fixed. Plateau fraction gate is well-structured; endpoint-clamped polyline distance matches old geometry-quad coverage; R16F consolidation is sound. Hardware-validated on FLEX-8400M with 4-way same-band comparison (Principle XI). CI 6/6 green. Tier 3 only — merging.
…ction, get clients verb (#3977, #3951) (#3981) ## Summary Fixes #3977 (the root cause of #3951). Stacked on #3975 (first commit here is that PR's fix; review the second commit). A still-alive earlier AetherSDR session keeps running its auto-floor tracker against the pan a newer session has reclaimed, ratcheting `min_dbm`/`max_dbm` in 0.1 dB steps and visibly bouncing the new session's trace every ~1–1.5 s (evidence: #3951's support bundle — ~250 foreign-handle status echoes, zero outbound dBm commands from the victim). ## The three layers 1. **Auto-floor discipline (radio-authoritative ownership).** `PanadapterModel` now parses `client_handle` from pan status — previously unparsed; ownership was assumed at creation. The dBm-range sender drops-and-warns when the radio-reported owner isn't us, so a superseded session goes quiet the moment the radio broadcasts the new owner. No timers, no new state machine. 2. **Reclaim eviction.** When reconnect-reclaim adopts a staged pan, the predecessor handle recorded on it gets a `client disconnect 0x<handle>` (SmartSDR's takeover behavior, reusing the existing `disconnectClientHandlesThen`). Disconnecting a dead handle is a harmless error reply. 3. **Evidence-based zombie eviction.** The `S<handle>|` status source — parsed by `CommandParser` but discarded at the `statusReceived` signal boundary — is now plumbed through (`RadioConnection`/`WanConnection` signature; RadioModel's public relay unchanged). Foreign writes to **our** pans' dBm range are tallied per handle and warned (first + every 25th). Three strikes **and** a radio-roster identity match (program `AetherSDR` + our own radio-reported station — not local settings, which can differ) → evict. Anything else — SmartSDR, a different station, an unidentifiable non-GUI writer — is logged forensics only, never touched. Per review discussion: **no display-side smoothing of foreign echoes** — a foreign write to our pan is an anomaly to eliminate, not render gracefully, and smoothing would have masked #3951 instead of surfacing it. ## `get clients` bridge verb (+ pan snapshot fields) `get pans` could only show the *symptom* (`minDbm` drifting between polls). The new verb shows the *culprit*: radio client roster (handle/station/program/`isUs`), per-handle `foreignPanWrites` counters with last pan + timestamp, and `evictedHandles`. `get pan`/`pans` snapshots now carry `clientHandle` + `ownedByUs`. Documented in `docs/automation-bridge.md`, with `tools/zombie_session_sim.py` (raw-TCP zombie: registers as AetherSDR with the victim's station, replays `display pan set … min_dbm=…`) as the repro/regression harness. ## Live proofs (FLEX-8400M, fw 4.2.18) - **Detection**: every simulated zombie was counted, attributed, and warned in the log; counters visible via `get clients`. - **Reclaim eviction**: a radio-initiated bounce mid-test (see below) forced a real reconnect → pan reclaim → the prior handle was evicted (`evictedHandles` populated). The victim recovered fully — new handle, pan reclaimed, clean roster. - **Firmware finding worth recording**: registering a second connection with the victim's own `client gui` UUID made 4.2.18 fire its own `duplicate_client_id` disconnects — modern firmware self-heals the exact #3951 topology. The client-side eviction is the backstop for pre-4.2 firmware (the reporter's 8600 runs 4.1.3, where the zombie demonstrably survived). - **Honest limitation**: the full 3-strike eviction path (roster-identified GUI zombie surviving long enough) couldn't be provoked on this firmware — the radio kills duplicates first, and the radio's GUI slots were occupied so a distinct-UUID GUI zombie couldn't join. That path shares its identity check and disconnect helper with the live-proven reclaim eviction. ## TX safety No TX-path changes; the simulator and all tests are RX/display-only (`display pan set min_dbm` exclusively). 💻 Generated with Claude Code (claude-fable-5 7/2/26) with architecture by @jensenpat 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Summary
Fixes #3967. Also fixes #3932. Root-caused with per-commit builds (pre-#3836, post-#3836, 26.7.1-main, this fix) captured minutes apart against the same live AM broadcast band on a FLEX-8400M:
The regression chain (two releases, two causes)
26.6.5 — #3836's unconditional spatial smoothing ("out of focus", #3932).
buildFftDisplayTrace's 5-tap binomial smooth at a fixed 0.75 blend exists to melt the radio's RBW stair-steps when zoomed in. Applied to every frame at every span, it rounds narrow carriers (visible height loss on AM carriers, panel 2) and defocuses the noise floor. Fix: gate the blend on the measured plateau fraction (adjacent equal-pixel bins). Zoomed-in plateau runs (frac ≳0.65) keep the full blend — #3836's stair-step fix is preserved; busy wide spans (frac ≲0.35) get zero — the pre-#3836 crispness returns; the ramp between prevents a visible mode flip while zooming.26.7.1 — #3958's per-pixel stroke ("spiky above and below the line", the rest of #3967). The stroke distance was approximated as vertical distance scaled by a
dFdx-derived slope — i.e. distance to the segment's infinite line. On steep segments that line passes near the entire pixel column, so the stroke drew full-height needles above and below the trace (panel 3), and the fixed ±0.75 px smoothstep band read as a soft fat stroke at 1× DPI. Fix: true endpoint-clamped distance to the three adjacent polyline segments (4 column samples — matches the coverage the old geometry quads had), plus falloff parity with the oldspectrum.frag: solid tohalfWidth−1, 1 px fade, 1 px feather annulus at α 0.22.Also folds in #3968's R16F-unconditional column format as hardening (float32 linear filtering is optional on GLES and
isTextureFormatSupported()can't detect filterability). Note it could not have caused #3967: the reporter's own About screenshot showsD3D11; Intel(R) HD Graphics 520— feature level 12.1 hardware where R32F linear filtering is mandatory. With this folded in, #3968 can be closed.Not implicated (left untouched, documented for the record): #3836's
decodeFFTover-range renormalization only fires on radio/clienty_pixelsmismatch (transient), and the DPR-scaledx_pixels/y_pixelsrequests are orthogonal to trace sharpness.Why our platforms missed it
The #3958 parity checks ran on 2× Retina, where the extra AA softness is half the logical size and the spike needles read as band activity in static grabs. The 4-way same-band comparison above is exactly the test that should have been run: panel 4 (this fix) restores panel 1 (pre-#3836).
Verification
get panstats 0: frame prep unchanged at ~121 µs/frame, 58 presents/s,fftBuildMsPerSec0.28 (plateau gate skips the smoothing copy on wide spans).buildFftDisplayTraceand picks up the plateau gate automatically.Also on the #3967 thread (separate follow-ups, not in this PR)
💻 Generated with Claude Code (claude-fable-5 7/2/26) with architecture by @jensenpat
🤖 Generated with Claude Code