[codex] Fix FFT edge smoothing during pan/zoom#3984
Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses FFT trace edge artifacts during pan/zoom by carrying forward the nearest already-smoothed edge values into newly exposed bins and then “seeding” those placeholder bins from the next real FFT data, avoiding a visible rise-from-floor effect. It applies the same strategy to KiwiSDR traces, with an added coverage mask so zoomed-out (partially covered) server rows don’t incorrectly drive edge bins toward the floor before those bins are actually covered by incoming rows.
Changes:
- Add fallback “seed masks” for Flex FFT smoothing and KiwiSDR trace smoothing to control when placeholder bins are replaced by the next real frame/row.
- Extend the nearest reprojected edge bins into uncovered regions during reprojection to prevent transient floor-level edges.
- Add
mapRowCoverageMask()+ a regression test to ensure narrow Kiwi rows inside a wider viewport only mark covered bins.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| tests/kiwi_sdr_trace_math_test.cpp | Adds a regression test validating Kiwi row coverage masking for narrow rows within a wider view. |
| src/gui/SpectrumWidget.h | Introduces fallback seed mask state for Flex FFT and KiwiSDR trace paths (including per-stream saved state). |
| src/gui/SpectrumWidget.cpp | Implements edge extension + seed-mask-driven smoothing for Flex FFT and coverage-aware seeding for KiwiSDR. |
| src/gui/KiwiSdrTraceMath.h | Adds mapRowCoverageMask() utility (and required Qt type include) to identify which viewport bins are covered by a given Kiwi server row. |
…andle Found during the aethersdr#3984 defensive review: pan status parsing (client_handle, added in this PR) can legitimately reassign a staged pan's owner to another live client before we disconnect (ownership transfer between two sessions sharing a client_id). Reclaim eviction then read that handle as "our predecessor" and force-disconnected a healthy session — observed live as a forced=1 bounce loop between this Mac and the test Mac's instance. Stash our own handle at stage time and evict only when the staged pan still records exactly that handle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
04d96b8 to
f512bb9
Compare
jensenpat
left a comment
There was a problem hiding this comment.
Reviewed defensively against the in-flight panadapter work (#3975, #3981) — approving.
Verification performed:
- Patch applies cleanly on top of the #3975 branch (
git apply --check+ full build of the combined tree, zero warnings in the touched files). - No overlap with #3981 (different subsystems; reprojection sends nothing to the radio, so ownership gating and session handling are untouched).
- Seed-mask lifecycle traced through reproject → updateSpectrum → clear/save/restore paths: size-guarded at every consumer, correctly propagated across repeated reprojections, cleared on every reset path. No steady-state per-frame allocation.
- Live test on a FLEX-8400M (combined #3975 + this PR build, 57 fps pan): drag exposes the new edge as an extended trace instead of the old rise-from-floor wall, and the next real FFT frame replaces the seeded bins cleanly — crisp within one frame, no artifacts.
Nice fix — this removes the last visible pan/zoom edge artifact on the 2D trace.
There was a problem hiding this comment.
Thanks @rfoust — this is a carefully constructed fix. I traced the fallback-seed-mask lifecycle across all three sites (reprojection, Flex updateSpectrum, Kiwi row update) and the logic holds together well. A few notes:
Design looks correct
- Reprojection edge extension (
reprojectBins): the seed mask is initialized to all-1, cleared to0on any bin that receives a projected value, and left1on the non-overlap edge bins that hit thefreqMhz < overlapStartMhzguard.extendFallbackEdgesthen fills those edges from the nearest projected bin. So newly-exposed edges show the extended trace rather than rising from the floor, and are flagged for one-shot seeding. Clean. - Flex vs Kiwi asymmetry is well-justified: Flex FFT always spans the full view, so
updateSpectrumcan seed all pending bins from the first real frame and clear the mask unconditionally. Kiwi rows can be narrower than the view, so gating the seed onmapRowCoverageMaskand keeping uncovered bins pending until a row actually covers them is the right call — it avoids seeding edge placeholders from server data that doesn't cover that span. - The
nextFallbackSeedMaskcopy-and-prune in the Kiwi loop correctly keeps a bin pending when it's uncovered (continue), and drops the whole mask once nothing is pending. Mask state is bounded bybinCount, so no unbounded growth even if a far-zoomed edge never gets covered. - Gesture throttle (
kKiwiGestureViewUpdateMs = 100) via a sharedQElapsedTimerruns entirely on the GUI thread through thesethis-context connections, so the shared_ptr is fine — no concurrency concern. Invalidating on settle so the next gesture fires immediately is a nice touch.
Copilot's perf finding is valid (minor)
Copilot is correct that mapRowCoverageMask() at SpectrumWidget.cpp:5639 is computed unconditionally for every Kiwi row, but its result is only consulted when pendingFallback is true — which requires seedFallbackBins (a non-empty, size-matched m_kiwiSdrFftFallbackSeedMask). In steady state the mask is empty, so this is an avoidable per-row QVector allocation + O(destWidth) pass on the hot waterfall path. Worth gating:
QVector<quint8> kiwiTraceCoverage;
const bool seedFallbackBins =
m_kiwiSdrFftFallbackSeedMask.size() == kiwiTrace.size();
if (seedFallbackBins) {
kiwiTraceCoverage = KiwiSdrTraceMath::mapRowCoverageMask(
smoothedBins.size(), destWidth, rowCenterMhz, rowBandwidthMhz,
m_centerMhz, m_bandwidthMhz);
}(You'd hoist the seedFallbackBins computation above stabilizeKiwiSdrFftTrace / the size check, or just recompute — it's cheap.) Not a blocker, but an easy win given how often this runs.
Other checks
- Conventions: no settings touched, so no AppSettings concern; RAII/shared_ptr usage and C++20 idioms are consistent with the surrounding code.
- Scope: all five files map directly to FFT reprojection, Kiwi trace mapping, and gesture-cadence wiring — nothing out of scope.
- Boundaries:
mapRowCoverageMaskandreprojectBinsboth guard degenerate inputs (<= 0sizes/bandwidths) and return/clear cleanly. - The new
mapRowCoverageMaskregression test exercises the narrow-row-in-wider-view case and the assertions line up with the geometry.
Nice work — just the optional Copilot perf tweak from me.
🤖 aethersdr-agent · cost: $3.9842 · model: claude-opus-4-8
There was a problem hiding this comment.
Thanks for this, @rfoust — I traced the reprojection and seed-mask logic end to end and it holds together well. The edge-extension + "seed only the placeholder bins from the next real frame" approach is a clean way to kill the floor-rise artifact without smearing genuine data, and the coverage-mask gating for narrow Kiwi rows in a zoomed-out view is the right call. Nice touch clearing both masks on the zero-bandwidth / no-overlap early returns so a stale mask can't apply to a mismatched frame.
A couple of notes:
1. Confirmed Copilot's finding — valid, minor perf. In updateKiwiSdrWaterfallRow, mapRowCoverageMask() runs unconditionally for every row, but its result is only consumed inside the m_kiwiSdrFftTrace.size() == kiwiTrace.size() branch, and only when seedFallbackBins is true (pending edge bins exist). In the common steady state the seed mask is empty, so the mask is a wasted destWidth allocation plus a per-pixel FP pass every row. Worth hoisting the computation into the else branch, gated on seedFallbackBins:
} else {
const bool seedFallbackBins =
m_kiwiSdrFftFallbackSeedMask.size() == kiwiTrace.size();
QVector<quint8> kiwiTraceCoverage;
if (seedFallbackBins) {
kiwiTraceCoverage = KiwiSdrTraceMath::mapRowCoverageMask(
smoothedBins.size(), destWidth, rowCenterMhz, rowBandwidthMhz,
m_centerMhz, m_bandwidthMhz);
}
const bool hasCoverageMask = kiwiTraceCoverage.size() == kiwiTrace.size();
...Since hasCoverageMask is only ever true alongside pendingFallback (which requires seedFallbackBins), the guarded computation preserves behavior exactly while skipping the per-row cost when nothing is pending.
2. Verification question (not a blocker). The reproduction is described as user-tested on live KiwiSDR pan/zoom, and the regression test covers mapRowCoverageMask for the narrow-row case — good. But the core Flex-path seed-mask behavior (m_fftFallbackSeedMask extend + seed in reprojectSpectrum/updateSpectrum) has no unit coverage. If it's practical to add a focused test that reprojects a known trace across a shifted view and asserts the exposed edge bins carry the extended value (not floor) and then get seeded from the next frame, it'd lock in the Flex half of the fix the same way the Kiwi half is covered.
Conventions, RAII, and boundaries all look fine — the std::make_shared<QElapsedTimer> gesture-cadence state is captured cleanly, the sw QPointer guards are in place, and no QSettings/flat-key usage. The 100 ms bounded cadence for in-gesture Kiwi view updates is a reasonable throttle. Nothing blocking here.
🤖 aethersdr-agent · cost: $2.7647 · 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
cmake --build build --target AetherSDR kiwi_sdr_trace_math_test --parallel)./build/kiwi_sdr_trace_math_test)Additional validation:
git diff --checkpython3 tools/check_a11y.pyexits 0 with existing unrelated warning-only findingsChecklist
docs/COMMIT-SIGNING.md)AppSettingscalls — use nested-JSON-under-one-key(Principle V)
reverse-engineered from a proprietary binary (Principle IV)
MeterSmoother(AGENTS.md convention)(No docs needed for this transient rendering fix.)
(Not security-sensitive.)