Skip to content

fix: show older data instead of black while zooming - #566

Merged
Tiaxi merged 4 commits into
mainfrom
fix/no-black-flash-on-zoom
Nov 10, 2026
Merged

Tiaxi merged 4 commits into
mainfrom
fix/no-black-flash-on-zoom

Conversation

@Tiaxi

@Tiaxi Tiaxi commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

Closes #542.

While zooming, both plots briefly drew the background ("no data") where older data could have stood in until the new zoom's data arrived. Frame sequences recorded from a GL window under a headless KWin (each rendered frame read back) showed where the black came from.

Where the black came from

Spectrogram

  • Missing columns drawn black. When the new zoom's first columns arrived, the canvas was rasterized again from the ring, and every column not decoded yet was filled with the background. A centered zoom out (and Reset Zoom, which is one) ends the zoom preview as soon as data comes, so the view collapsed to a sliver at the playhead and filled in over a few hundred milliseconds.
  • Rolling history thrown away. A rolling zoom restart clears the history, and a rolling session never decodes it again. So the whole view went black and refilled from the right edge as playback went on.
  • Zoom-out margins. The zoom preview only scales the canvas, so a zoom out showed the background beside it. The ring usually holds the columns there.

Waveform

  • In the per-channel view (the default), the overview fallback draws nothing, because the overview is one envelope with no channel lanes. While playing, the view is drawn in playback tiles, which leave no cache, so a zoom out (and Reset Zoom) could not be deferred. It showed at once over the overview fallback, and the lanes went empty until the new detail arrived (about 110 ms, 8 frames in the recording).

What changed

Spectrogram (SpectrogramItem)

  • The canvas is premultiplied ARGB. Missing columns are transparent instead of black, and the canvas textures carry alpha.
  • Two layers are drawn under the canvas, each placed by the time of its columns (the mapping staleCanvasAlignmentLocked already uses), so they follow the preview, scrolling and playback:
    • Backdrop: the canvas that a canvas with missing columns replaced. It keeps the view before the zoom at full detail until the new zoom's data covers the view, then it is dropped.
    • Reserve: a worker rasterization of the ring's columns around the view. It covers the edges a zoom out reveals from its first frame. It is kept up to date as playback moves on; a zoom out draws it again at the zoom asked for; and it captures the ring just before a zoom's session clears it. Each new reserve is composited over the one before on the worker, so older data is never dropped.
  • Both layers are dropped when their times stop being the view's: seeks, track changes, a mode change, a new projection (height, dB range, log scale), an epoch re-anchor, or released storage.
  • No timers. The UI and render threads only place textures; the rasterizations and compositing run on the existing raster pool, with serials and a validity counter so stale results are discarded.

Waveform (WaveformEditorItem)

  • A zoom out is held while playback tiles can still draw the old view, as it already was while a cache could. It shows once the wider view's detail arrives.
  • Where the overview cannot stand in (the per-channel view), the detail the view has stands in for the part it reaches. Detail sparser than a pixel holds each point until the next one, instead of leaving gaps.

Docs: docs/SHORTCUTS.md describes what the plots now show while zooming.

Tests

  • spectrogramZoomShowsOlderDataUntilTheNewArrives (centered and rolling): wheel steps out, data for them, a step in and back to 1. Each frame is checked through its scene-graph nodes (canvas, backdrop and reserve, with the canvas's alpha): no column that showed data shows the floor in the next frame, and the backdrop stands in wherever the canvas lacks columns. It fails without the reserve, and without the backdrop.
  • waveformEditorPerChannelZoomShowsOlderDetailUntilTheNewArrives: in the per-channel view, a reset and a zoom in past the detail's points keep the wave in both lanes. It fails without the fix.
  • waveformEditorZoomOutWhilePlayingHoldsTheTiledView: while playing in tiles, a zoom out is held with no empty lane columns, then shows when its detail arrives. It fails without the fix.
  • ./scripts/run-tests.sh --ui-only: 102 of 102 passed, with no warnings. Rust is unchanged.

Frame sequences

Recorded under a headless KWin (OpenGL), with each frame of the plot read back while zooming by wheel steps and Reset Zoom, in rolling and centered mode, against the fixture library. Counts are frames in which columns that showed data turned to the background, before → after:

Plot, mode Before After
Spectrogram, centered 6 frames, 2500 columns 0
Spectrogram, rolling 18 frames, 1233 columns 2 frames, 246 columns (*)
Waveform 5 to 6 frames, about 4600 to 5500 columns 0 to 3 frames, at most 9 columns (*)

(*) What remains is legitimately empty: zoomed out past where this run's rolling history begins (nothing was ever decoded there), the track's fade-out at its end, and single columns at the playhead.

Strips are not attached; they show only the fixture library.

Review round 1

  • A track change during a pending zoom no longer keeps the previous track. A reset counts as a zoom restart only if it is for the same track, the clock has reported no seek or track change since the zoom was requested, and in rolling mode it starts at the playhead. Any seek or track change the clock reports drops both layers and the reserve still being drawn. Layers now carry their track and validity in rolling mode too. Test: spectrogramTrackChangeDuringAZoomShowsNothingOfTheTrackBefore (the clock arriving first, and the data arriving first).
  • Sparse waveform envelopes cover their own frames. Each point now covers the pixels whose centres fall inside its frames, clipped to the slice and to the detail. Points narrower than a pixel are placed as before. Test: waveformEditorSparseDetailCoversEachPointsFrames (alternating loud and silent buckets, slices that start and end inside buckets).
  • Memory: the reserve is limited to the plot's logical width, so at a device pixel ratio of 2 it takes half the device-sized image (its rows stay at device height, which its frequency mapping needs). It is scaled up when drawn. The backdrop stays at full detail.

Review round 2

  • Zoom restarts are recognised by identity, with no distance thresholds. A reset counts as the zoom's restart when:

    • its hop is that of the zoom being requested, or of a zoom the debounce sent to the backend since the zoom began;
    • it is for the track the zoom was requested on;
    • the clock's epoch hasn't changed since then.

    A UI seek calls holdAt(), which always bumps the epoch, so any seek counts as a break, however small. The 1.5 s and 0.75 s checks are gone, and the clock no longer drops the layers.

  • Gapless and Repeat-One continuations keep the layers. Rolling layers follow the main canvas's rule: a data chunk with a new token and no reset continues the history. Only a reset that isn't the zoom's restart drops them. A continuation also carries a pending zoom's track (data side) and clock epoch (continueGaplessLocked) over to the successor. Layer times are counted from a stream origin, which absorbs m_gaplessPositionOffset whenever a reset clears it, so the layers stay correctly placed after a later zoom restart. Centered layers still check the track.

  • Tests:

    • spectrogramLateZoomDataKeepsTheRollingHistory: an advancing clock, with the zoom's data 2.5 s late.
    • spectrogramSmallSeekDuringAZoomBreaksTheHistory: a 0.2 s seek while a zoom is pending.
    • spectrogramGaplessContinuationKeepsTheRollingHistory: gapless and Repeat-One, with the clock first and the data first. History is kept, and placement is checked after a zoom restart of the successor.

    spectrogramTrackChangeDuringAZoomShowsNothingOfTheTrackBefore still passes. All the new tests fail against round 1, and the placement check also fails without the stream origin.

The spectrogram drew every column not decoded yet at a new zoom black: a
centered zoom out collapsed to the playhead and refilled, a rolling zoom
cleared its history, and a zoom out showed black beside the scaled canvas.
Its missing columns are now transparent, over the canvas the new one
replaced and a reserve of the ring's columns around the view, both placed
at the times of their columns and drawn on workers.

The waveform's per-channel lanes have no overview, so a zoom out while
playing, drawn in tiles without a cache to hold it, showed empty lanes until
the detail came. Tiles now hold the view as a cache does, and the detail the
view has stands in where the overview cannot.

Closes #542.
A session reset while a zoom was pending counted as the zoom's restart, so a
track change or seek then kept the canvas before it and the ring's columns
for the reserve, and a rolling view could show the track before under the
next one. A reset now counts only when it is of the same track, with no seek
or track change reported since the zoom was asked for, and in rolling mode
starting at the playhead. A seek or track change drops both layers and the
reserve being drawn, and the layers carry their track and validity in both
modes.

Sparse waveform envelopes were held from each point's middle to the next,
half a point late, and left the first and last points' ends empty. Each
point now covers the pixels whose middles are in its frames.

The reserve is drawn at no more than the plot's logical width.
…nuations

A zoom's restart was told from other resets by how far its start was from
the playhead, so a restart whose first columns came late dropped the rolling
history that only the backdrop and the reserve held. A reset now counts as
the zoom's when its hop is the zoom asked for or one sent since, of the
track the zoom was asked of, with the clock's epoch unchanged, so that any
seek, however small, breaks it.

The clock's track change dropped both layers, and their track was checked
in rolling mode too, so a gapless or Repeat-One continuation, which the
rolling canvas goes on across, lost the history they held. Rolling layers
now follow the canvas's rule: they go on across a continuation, which also
carries a pending zoom's track and epoch over, and only a reset that is not
the zoom's drops them. Their times are counted from a stream origin that
takes up the continuation's offset when a reset ends it, so that they stay
where their times are.
A zoom's restart of a gapless successor can land after the successor's
data and before the clock's new track. The reset cleared the pending
continuation without the previous track's length, so the history the
layers keep behind the playhead sat a track's length off.

The restart now keeps the continuation pending and counts the session's
times from the successor's start, at the previous track's end: its
length, or where the restart's first column is at the playhead. The
previous track plays on before that start until the clock changes
tracks, and the handoff then has the playhead follow the successor's
clock, as the restarted columns do.
@Tiaxi
Tiaxi merged commit 1e5fdca into main Nov 10, 2026
5 checks passed
@Tiaxi
Tiaxi deleted the fix/no-black-flash-on-zoom branch November 10, 2026 08:22
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.

fix: don't flash black in the spectrogram and waveform while zooming

1 participant