fix(spectrum): reproject only the active waterfall stream while panning (#3700)#3701
Conversation
…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>
There was a problem hiding this comment.
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 throughbeginWaterfallStreamWrite/endWaterfallStreamWrite, whosesave/restoreCurrentWaterfallStreamStateassignstate.waterfall = m_waterfall(and back) — leavingm_waterfallimplicitly shared, so the nextfill(Qt::black)inrebuildWaterfallViewportForFramedetaches the full image. Reprojecting only the active stream takes thevisibleStreamearly-return in bothbegin/end, so no share, no per-step detach. Sound. - Lazy remap is exact. Each history row carries its own
m_wfHistoryRowCenterMhz/BwMhz(#3578), andrebuildWaterfallViewportForFrameremaps per-row, so deferring the inactive stream's reprojection tosetKiwiSdrWaterfallActiveproduces the same result. The new call is null-safe (guards onm_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
left a comment
There was a problem hiding this comment.
Approving. Verified the diagnosis and fix against the code:
- Regression origin confirmed —
git 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, leavingm_waterfallimplicitly shared so the nextfill()detaches the full image per pan step. Reprojecting only the active stream hits thevisibleStreamearly-return inbegin/endWaterfallStreamWrite— no share, no detach. - Lazy remap is exact — each history row carries its own capture frame (#3578) and
rebuildWaterfallViewportForFrameremaps per-row, so deferring the inactive stream's reprojection tosetKiwiSdrWaterfallActiveis 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.
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_waterfallcopy-on-write–shared, so the nextfill()inrebuildWaterfallViewportForFrame()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=1restores 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:
aether.perfshowed the pan stall is client-side:wfUpdateP950.1 ms idle → ~68 ms during pan,panAgeP95~160 ms, 200+ main-thread stalls of 60–83 ms — whilerenderP95stayed ~7 ms andfftRestarts/wfRestarts/loss were all 0. So not the GPU render, and not the radio/retune (our first hypothesis — the data disproved it).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 logon 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 singlereprojectWaterfall()withreprojectStream(false) + reprojectStream(true)(merged the same day the choppiness appeared).fill()— a full deep-copy of the (large) waterfall image, per pan step.AETHER_WF_KIWI_ALWAYStoggling only the second pass:wfUpdateP95MspanAgeP95MsinputP95MsuiLagMaxMsPerfStallThe 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=1keeps the old unconditional double pass available for verification.Constitution principle honored
Principle XI — Fixes Are Demonstrated. Root cause and fix both confirmed by
aether.perfbefore/after (table above), same binary, one variable changed.Test plan
aether.perftelemetry (two runs each)rebuildWaterfallViewportForFrame; a KiwiSDR-equipped user toggling streams mid-pan would confirm the switch stays aligned. Flagging for maintainer / KiwiSDR review.Checklist
AppSettingscalls (N/A)MeterSmoother(N/A)