Skip to content

[codex] Fix FFT edge smoothing during pan/zoom#3984

Merged
rfoust merged 1 commit into
aethersdr:mainfrom
rfoust:codex/fix-fft-edge-smoothing
Jul 3, 2026
Merged

[codex] Fix FFT edge smoothing during pan/zoom#3984
rfoust merged 1 commit into
aethersdr:mainfrom
rfoust:codex/fix-fft-edge-smoothing

Conversation

@rfoust

@rfoust rfoust commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

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

  • Local build passes (cmake --build build --target AetherSDR kiwi_sdr_trace_math_test --parallel)
  • Behavior verified on KiwiSDR pan/zoom by user testing
  • Existing tests pass (./build/kiwi_sdr_trace_math_test)
  • Reproduction steps documented if user-reported bug

Additional validation:

Checklist

  • Commits are signed (docs/COMMIT-SIGNING.md)
  • No new flat-key AppSettings calls — use nested-JSON-under-one-key
    (Principle V)
  • Code is clean-room — not decompiled, disassembled, or
    reverse-engineered from a proprietary binary (Principle IV)
  • All meter UI uses MeterSmoother (AGENTS.md convention)
  • Documentation updated if user-visible behavior changed
    (No docs needed for this transient rendering fix.)
  • Security-sensitive changes reference a GHSA if applicable
    (Not security-sensitive.)

@rfoust rfoust self-assigned this Jul 3, 2026
@rfoust rfoust marked this pull request as ready for review July 3, 2026 02:45
@rfoust rfoust requested review from a team as code owners July 3, 2026 02:45
Copilot AI review requested due to automatic review settings July 3, 2026 02:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/gui/SpectrumWidget.cpp
jensenpat added a commit to jensenpat/AetherSDR that referenced this pull request Jul 3, 2026
…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>
@rfoust rfoust force-pushed the codex/fix-fft-edge-smoothing branch from 04d96b8 to f512bb9 Compare July 3, 2026 03:17

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

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.

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

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 to 0 on any bin that receives a projected value, and left 1 on the non-overlap edge bins that hit the freqMhz < overlapStartMhz guard. extendFallbackEdges then 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 updateSpectrum can 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 on mapRowCoverageMask and 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 nextFallbackSeedMask copy-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 by binCount, so no unbounded growth even if a far-zoomed edge never gets covered.
  • Gesture throttle (kKiwiGestureViewUpdateMs = 100) via a shared QElapsedTimer runs entirely on the GUI thread through these this-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: mapRowCoverageMask and reprojectBins both guard degenerate inputs (<= 0 sizes/bandwidths) and return/clear cleanly.
  • The new mapRowCoverageMask regression 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

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

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

@rfoust rfoust merged commit edc05b2 into aethersdr:main Jul 3, 2026
6 checks passed
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.

3 participants