Skip to content

fix(spectrum): reproject only the active waterfall stream while panning (#3700)#3701

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
svabi79:fix/wf-pan-active-stream-reproject
Jun 21, 2026
Merged

fix(spectrum): reproject only the active waterfall stream while panning (#3700)#3701
ten9876 merged 1 commit into
aethersdr:mainfrom
svabi79:fix/wf-pan-active-stream-reproject

Conversation

@svabi79

@svabi79 svabi79 commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #3700. Panning the waterfall regressed on wide / high-DPI displays after #3668 (KiwiSDR integration): every pan mouse-move ran an unconditional native + kiwi double reproject, even with no KiwiSDR receiver connected. The second pass is wasted work, and its waterfall-stream-state save/restore leaves m_waterfall copy-on-write–shared, so the next fill() in rebuildWaterfallViewportForFrame() deep-copies the whole waterfall image every pan step — 60–80 ms of UI-thread stall per step. This re-introduced, by a different path, the per-pan-step cost that #3578 (#3575) had removed.

This PR reprojects only the active stream per pan, and remaps the other stream to the current frequency frame when it next becomes visible (setKiwiSdrWaterfallActive) — exact, because each history row carries its own capture frame (#3578). AETHER_WF_KIWI_ALWAYS=1 restores the old double pass for A/B.

How we got here (approach)

Came at this from interest in the panadapter-perf topic — same lane as #3578/#3617. The path mattered, so here it is:

  1. Measured first, didn't guess. aether.perf showed the pan stall is client-side: wfUpdateP95 0.1 ms idle → ~68 ms during pan, panAgeP95 ~160 ms, 200+ main-thread stalls of 60–83 ms — while renderP95 stayed ~7 ms and fftRestarts/wfRestarts/loss were all 0. So not the GPU render, and not the radio/retune (our first hypothesis — the data disproved it).
  2. Bisected by inspection. The cost lands in rebuildWaterfallViewportForFrame (fill + remap every visible row) — but perf(spectrum): per-row waterfall-history frequency frames (no per-pan reproject) #3578 had made that acceptable. git log on the pan path since perf(spectrum): per-row waterfall-history frequency frames (no per-pan reproject) #3578 surfaced exactly one suspect: Add clean-room KiwiSDR receive integration #3668 replaced the single reprojectWaterfall() with reprojectStream(false) + reprojectStream(true) (merged the same day the choppiness appeared).
  3. Two mechanisms. The unconditional second (kiwi) pass; and the QImage COW detach the stream-state save/restore triggers on the next fill() — a full deep-copy of the (large) waterfall image, per pan step.
  4. Proved it with a one-variable A/B — same binary, AETHER_WF_KIWI_ALWAYS toggling only the second pass:
metric (hand pan) #3668 double pass this PR (active only)
wfUpdateP95Ms 63–68 ms 0.1 ms
panAgeP95Ms 150–174 ms 22–32 ms
inputP95Ms 19–24 ms 0.2–3.6 ms
uiLagMaxMs 75–85 ms 23–41 ms
total PerfStall 931 15

The single switch flips it — the second pass is the regression, reprojecting only the active stream is the fix.

The change

  • handleWaterfallFrequencyFrameChange() — reproject only the active stream (reprojectStream(m_kiwiSdrWaterfallActive)): no stream swap, so no COW-share and no per-step deep-copy.
  • setKiwiSdrWaterfallActive() — after restoring the switched-in stream's state, rebuildWaterfallViewportForFrame(m_centerMhz, m_bandwidthMhz) aligns it to the current frame the moment it's shown. This is the lazy counterpart to the per-pan reproject the inactive stream used to receive, so toggling streams stays seamless.
  • AETHER_WF_KIWI_ALWAYS=1 keeps the old unconditional double pass available for verification.

Constitution principle honored

Principle XI — Fixes Are Demonstrated. Root cause and fix both confirmed by aether.perf before/after (table above), same binary, one variable changed.

Test plan

  • Local build passes (Windows, Qt 6.8.3 msvc2022_64, GPU path on)
  • Before/after measured with the gated aether.perf telemetry (two runs each)
  • Existing tests pass (CI)
  • Reproduction steps documented in Regression (#3668): waterfall pan choppy on wide displays — unconditional kiwi reproject deep-copies the image each pan step #3700
  • ⚠️ KiwiSDR-active path not hardware-tested — I have no KiwiSDR receiver, so the measured path is FLEX (active = native). The stream-switch remap reuses the existing rebuildWaterfallViewportForFrame; a KiwiSDR-equipped user toggling streams mid-pan would confirm the switch stays aligned. Flagging for maintainer / KiwiSDR review.

Checklist

  • Commits are signed (SSH)
  • No new flat-key AppSettings calls (N/A)
  • Code is clean-room (Principle IV) — not decompiled / reverse-engineered
  • All meter UI uses MeterSmoother (N/A)
  • Documentation updated if user-visible behavior changed (N/A — pan is visually unchanged, just smooth again)
  • Security-sensitive changes reference a GHSA if applicable (N/A)

…ng (aethersdr#3700). Principle XI.

Fixes aethersdr#3700.

aethersdr#3668 (KiwiSDR integration) changed the per-pan waterfall reproject from
aethersdr#3578's single pass into an unconditional native + kiwi double pass, each
wrapped in a waterfall-stream-state save/restore. The inactive stream is not on
screen, so reprojecting it every pan step is wasted work -- and the save/restore
leaves m_waterfall COW-shared, so the next fill() in
rebuildWaterfallViewportForFrame deep-copies the whole waterfall image on every
pan step. On wide / high-DPI displays that stalled the UI thread 60-80 ms per
pan step, re-introducing (by a different path) the per-step cost aethersdr#3578 removed.

Reproject only the active stream; the inactive one is remapped to the current
frequency frame when it next becomes visible (setKiwiSdrWaterfallActive), which
is exact because each history row carries its own capture frame (aethersdr#3578).
AETHER_WF_KIWI_ALWAYS=1 restores the old double pass for A/B.

Measured (i9-13900K / RTX 4090, FLEX-6600, wide panadapter; same binary, env
toggling only the double pass; aether.perf), during a hand pan:
  wfUpdateP95      63-68 ms -> 0.1 ms
  panAgeP95Ms      150-174  -> 22-32 ms
  inputP95Ms       19-24    -> 0.2-3.6 ms
  total PerfStall  931      -> 15
Render, FFT/waterfall restarts and packet loss unchanged -- the cost was purely
the second pass + the deep-copy.

Clean-room (Principle IV); touches default review tier (SpectrumWidget).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@svabi79 svabi79 requested a review from a team as a code owner June 20, 2026 20:53

@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 — the diagnosis is excellent, and the write-up (measure → bisect to #3668 → one-variable A/B with AETHER_WF_KIWI_ALWAYS) makes the root cause easy to confirm. I traced it against the code and the reasoning holds up:

  • COW deep-copy mechanism is real. reprojectStream(true) for the inactive stream goes through beginWaterfallStreamWrite/endWaterfallStreamWrite, whose save/restoreCurrentWaterfallStreamState assign state.waterfall = m_waterfall (and back) — leaving m_waterfall implicitly shared, so the next fill(Qt::black) in rebuildWaterfallViewportForFrame detaches the full image. Reprojecting only the active stream takes the visibleStream early-return in both begin/end, so no share, no per-step detach. Sound.
  • Lazy remap is exact. Each history row carries its own m_wfHistoryRowCenterMhz/BwMhz (#3578), and rebuildWaterfallViewportForFrame remaps per-row, so deferring the inactive stream's reprojection to setKiwiSdrWaterfallActive produces the same result. The new call is null-safe (guards on m_waterfall.isNull() / m_waterfallHistory.isNull()).
  • CI is green on all 6 checks (build/macOS/Windows/CodeQL).

One thing I checked re: your "KiwiSDR-active path not hardware-tested" flag, in case it saves a reviewer the trace: the old inactive-kiwi pass also cleared m_kiwiSdrLastWaterfallBins, and that clear is now dropped for the inactive stream. It turns out not to matter — the temporal smoothing in smoothKiwiSdrWaterfallBins operates in fixed kiwi-aperture bin-index space (invariant to a display pan), and the ingest path in handleKiwiSdrWaterfallRow already clears those bins whenever the incoming row's center/bandwidth changes (!sameWaterfallFrame). The active-kiwi pan path still clears them via reprojectStream(m_kiwiSdrWaterfallActive). So no stale-smoothing regression on stream switch that I can see.

Nicely scoped, clean-room, env-gated A/B retained — looks good to me.


🤖 aethersdr-agent · cost: $4.6600 · model: claude-opus-4-8

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

Approving. Verified the diagnosis and fix against the code:

  • Regression origin confirmedgit log -S "reprojectStream(true)" pins the unconditional native+kiwi double pass to #3668. It runs on every pan step with no KiwiSDR guard, so it regressed the native panadapter for all users (the author measured it FLEX-only, active=native).
  • COW deep-copy mechanism is real — the inactive-stream reproject round-trips through save/restoreCurrentWaterfallStreamState, leaving m_waterfall implicitly shared so the next fill() detaches the full image per pan step. Reprojecting only the active stream hits the visibleStream early-return in begin/endWaterfallStreamWrite — no share, no detach.
  • Lazy remap is exact — each history row carries its own capture frame (#3578) and rebuildWaterfallViewportForFrame remaps per-row, so deferring the inactive stream's reprojection to setKiwiSdrWaterfallActive is equivalent. No stale-smoothing regression (kiwi smoothing is in fixed aperture bin-space, invariant to a display pan; ingest clears on frame change).
  • Minimal (+32/-6, one file), excellent Principle XI demonstration (measure → bisect → one-variable A/B), env-gated escape hatch retained, no UX change.

Known caveat (author-flagged, accepted): the kiwi-active / stream-switch-mid-pan branch is reasoned + traced but not hardware-tested. Mechanism is symmetric to the verified native path and env-reversible.

Thanks @svabi79 — exemplary write-up.

@ten9876 ten9876 merged commit f4e105d into aethersdr:main Jun 21, 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.

Regression (#3668): waterfall pan choppy on wide displays — unconditional kiwi reproject deep-copies the image each pan step

2 participants