Skip to content

feat: add spectrogram zoom#15

Merged
Tiaxi merged 93 commits into
mainfrom
feat/spectrogram-zoom
Apr 18, 2026
Merged

feat: add spectrogram zoom#15
Tiaxi merged 93 commits into
mainfrom
feat/spectrogram-zoom

Conversation

@Tiaxi

@Tiaxi Tiaxi commented Apr 18, 2026

Copy link
Copy Markdown
Owner

Summary

This branch implements the spectrogram zoom feature end to end and folds in the bug fixes that were needed to make it shippable.

What’s included

  • add interactive spectrogram zoom support across the backend analysis pipeline, bridge, QML, and SpectrogramItem
  • switch spectrogram zoom sampling/rendering to fractional peak-hold behavior so zoom levels stay visually distinct and stable
  • stabilize rolling and centered mode behavior across seeks, gapless transitions, zoom changes, and mode switches
  • add a backend-owned playback clock so spectrogram scrolling stays smooth while remaining tightly synced to playback
  • fix follow-up regressions found during testing, including:
    • seek sync mismatches
    • centered-to-rolling freeze at max zoom
    • centered repeated backward-seek blank-region regression after ring eviction
    • rolling gapless zoom reset edge cases
  • expand Rust and Qt coverage for zoom behavior, playback clocking, seek handling, and spectrogram rendering edge cases

Validation

  • ./scripts/run-tests.sh

Merge note

This branch contains the full feature work as incremental commits. It is intended to be squash-merged.

Tiaxi added 30 commits April 11, 2026 11:57
Add the spectrogram_zoom_enabled boolean setting through the full
settings pipeline: struct field, config parse/serialize, binary FFI
command (ID 56) and encode, command handler, and tests.
Adds zoomLevel/zoomEnabled Q_PROPERTYs, wheelEvent for scroll-to-zoom,
and middle-click to reset zoom. Signals zoomRequested/zoomResetRequested
are emitted upward for QML to handle across spectrogram panes.
Add _sharedZoomLevel property to SpectrogramSurface that synchronizes
zoom across all channel panes (same pattern as _crosshairSharedX). Wire
each SpectrogramItem's zoomEnabled/zoomLevel to the bridge setting and
shared property, and add an "Allow Spectrogram Zoom" checkbox with
description label to SpectrogramPage. Also update the fallback bridge
objects in Main.qml and tst_qml_smoke.cpp with spectrogramZoomEnabled
property and setSpectrogramZoomEnabled() to satisfy the smoke tests.
Scale display range by zoomLevel so centered/rolling modes show
fewer source columns when zoomed in and more when zoomed out.
Rebuild canvas with zoom-aware column-to-pixel mapping, guard
incremental advance to 1:1 zoom only, and adjust rolling ring
capacity for zoom-out.
Add zoomLevel parameter to pixelToTimeSeconds and timeToPixelX free
functions, and update all callers (mousePressEvent, updateCrosshairOverlayLocked,
updateTimeGridOverlayLocked) to pass m_zoomLevel. Also correct secondsPerPixel
in updateTimeGridOverlayLocked to account for zoom level so grid intervals
adapt correctly at non-1.0 zoom.
Compute the minimum zoom level as pixelWidth / totalColumns so the user
can zoom out until the entire track fits in the viewport. Falls back to
0.05 when no track data is available. Both wheelEvent and setZoomLevel
now use the dynamic minimum, and the QML onZoomRequested handler queries
minimumZoomLevel() instead of the hardcoded 0.05.
- Scale scrollOffset by m_zoomLevel for correct sub-pixel strip width
  at non-1.0 zoom levels
- Fix mock bridge defaults: spectrogramZoomEnabled should be true to
  match the Rust/C++ production default
Three fixes:

1. Bilinear interpolation between adjacent columns when zoomed in,
   replacing nearest-neighbor sampling that caused blocky vertical
   bands at high zoom levels.

2. Enable clipping on SpectrogramItem (setClip) to prevent canvas
   overflow from drawX sub-pixel offset bleeding beyond widget bounds
   into adjacent UI elements at high zoom.

3. Use ceil() for visibleWindowCols calculation to ensure the display
   range covers enough source columns to fill the full widget width,
   fixing a gap at the right edge caused by integer truncation.

Also fixes: setAcceptedMouseButtons now includes Qt::MiddleButton
(was missing, preventing middle-click zoom reset from working).
…ctive_hop

Add effective_hop = hop_size * decimation_factor to SpectrogramSessionState
and StagingChunkState. Use it for the hop_size field in all PrecomputedSpectrogramChunk
emissions instead of the hardcoded REFERENCE_HOP constant. No behavior change
since effective_hop == REFERENCE_HOP with the current decimation system.
Add zoom_level to the analysis pipeline: a new AnalysisCommand variant
that adjusts the STFT hop size and decimation factor for finer temporal
resolution when zooming in. At zoom > 1.0, the hop is derived from
REFERENCE_HOP/zoom and decimation is bypassed so every STFT row becomes
an output column. At zoom <= 1.0, the normal FFT-derived hop with
decimation normalization is used.

Key changes:
- zoom_hop_size() helper computes zoom-appropriate hop, clamped [64, 1024]
- SetSpectrogramZoomLevel handler restarts the session with new parameters
- zoom_level propagated through NewTrack, session state, staging worker
- total_columns_estimate adjusted proportionally for non-REFERENCE_HOP hops
- Seek handler uses zoom-aware decimation factor
Replace m_zoomLevel in the rendering path with effectiveZoom computed
from the chunk's hop_size. When the backend provides matching
resolution (hop_size = REFERENCE_HOP / zoom), effectiveZoom becomes
1.0 and the incremental advance fast path runs automatically.

Also emits backendZoomRequested signal from setZoomLevel so QML can
forward zoom changes to the backend via setSpectrogramZoomLevel.
Replace full-track pre-decode with a bounded window (~3 screen widths +
lookahead) around the playhead. Seeking in centered mode now restarts
the decode session from 30s before the target position instead of just
sending a PositionUpdate, so the visible window fills around the new
position. The ring buffer retains overlapping data on seek
(clear_history=false) to avoid blank flashes.
Track the session start position and check whether a centered-mode
seek falls within the already-decoded window (~100s from session
start). If so, send a cheap PositionUpdate instead of restarting
the entire STFT session.

This avoids the visible re-decode delay for nearby seeks (clicking
the same spot, seeking a few seconds forward/backward, etc.) while
still restarting for far seeks outside the decoded window.
When seeking outside the decoded window in centered mode, old ring
data is from a completely different time region. With clear_history
= false, this stale data was displayed and then progressively
overwritten by new data, creating a visible "sweep" effect. Use
clear_history = true for far seeks so the display starts clean.
…ay range

With windowed decode, m_precomputedMaxColumnIndex starts at the
decode window position (e.g. column 13669) while the playhead may
be at column 15055. Using maxColumnIndex alone as totalCols caused
the display range to clamp to the rightmost decoded column, making
the spectrogram appear to "race in from the right" as data fills
toward the playhead.

Using the larger of maxColumnIndex and totalColumnsEstimate keeps
the display range centered on the playhead. Not-yet-decoded columns
render as black, which fills in progressively without positional
artifacts.
Replace the hardcoded 30s pre-decode margin with a dynamic
calculation based on sample rate and zoom level. The margin is
the visible half-screen time (assuming ~2560px max spectrogram
width) plus 2s for STFT warmup.

At zoom=1.0 (48kHz): ~29s margin (similar to before).
At zoom=4.0: ~9s margin (3x faster to fill the visible area).
At zoom=16.0: ~3.7s margin (much faster).

The seek window check also uses the dynamic margin instead of
hardcoded 100s.
When seeking in centered mode, the playback snapshot sends a
PositionUpdate that can reach the spectrogram worker BEFORE the
NewTrack command from the seek handler. The old session sees the
far-forward position, triggers its own seek, and briefly produces
content at the playhead before the new session replaces it — causing
a visible flash.

Suppress the next PositionUpdate after a centered-mode session
restart so the old session doesn't react to the new position.
When seeking far in centered mode, there's a 3-5ms gap between the
frontend position property jumping and the worker's reset chunk
arriving through the event pipeline. During this gap, old ring data
that happens to overlap with the new display window is briefly
visible at the wrong position.

Emit a zero-column reset chunk directly from the analysis thread
(bypassing the worker) so the ring clears before the next render
frame. The worker's reset chunk arrives later as a no-op since the
ring is already clean.
The synthetic reset chunk emitted on far seeks has bins=0, which
caused two problems:

1. The early return at `bins <= 0` discarded it entirely — the ring
   was never cleared. Fixed: allow bufferReset chunks through.

2. The deferred reset stored bins=0, but old queued chunks had
   bins=1025, so the deferred apply condition (bins match) never
   triggered — stale data refilled the ring after the deferred
   clear. Fixed: when clearHistoryOnReset=true with bins=0
   (synthetic reset), clear the ring immediately without deferring.
After the synthetic reset clears the ring, old chunks already queued
in the event pipeline would immediately refill it with stale data.
The previous fix cleared the ring but couldn't prevent these queued
chunks from being written.

Add m_awaitingWorkerReset flag: when set by the synthetic clear,
ALL data chunks are rejected until the worker's proper reset arrives
(bufferReset with valid bins). This creates a clean gate that blocks
the stale pipeline data regardless of how many chunks are queued.
…dChunk

The m_awaitingWorkerReset gate was positioned AFTER the implicit
reset check. When the synthetic clear set m_ringCapacity=0, old
stale data triggered the implicit reset (line 804: m_ringCapacity
== 0 → recreate ring), which accepted the data before the gate
check was reached.

Move the gate to be the FIRST check after the early returns so
stale data is rejected before any ring operations.
The synthetic reset used channel_count=1, which caused the QML
Repeater to destroy all 6 per-channel panes and rebuild with 1.
The gate was armed on this ephemeral single pane. When old 6-channel
data arrived, the Repeater rebuilt with 6 NEW panes (no gate armed),
and stale data flowed into all of them.

Use active_session_channel_count so the synthetic reset dispatches
to all existing panes without triggering a Repeater rebuild.
When the first post-reset data chunk arrived, the code forced the
position anchor to startIndex * hopSize / sampleRate — the session's
decode start position. This was correct for the old full-track decode
where start=0, but with windowed decode the session starts from a
margin (29s) BEFORE the playhead. This caused the display to briefly
jump backward to the margin position and show a "burst from the
playhead" as data appeared at the wrong position before the playback
engine's position update corrected it.

Only apply this anchor override in rolling mode where start_column
does represent the seek target.
Tiaxi added 25 commits April 15, 2026 16:13
The m_zoomFillActive flag was set in the 'if (columns > 0)' block
which runs AFTER applyPrecomputedResetLocked.  By the time the
flag was set, invalidateCanvas() had already nulled the canvas.

Move the hop change detection to BEFORE all reset processing so
the flag is active when applyPrecomputedResetLocked checks it.
The fill target was screenWidth (the widget width), but the display
is centered on the playhead which may be thousands of columns
into the track.  The ring fills from the session's start position
(often column 0), so the fill target must be playheadCol +
screenWidth/2 to cover the display's right edge.

Without this, the freeze cleared when the ring had 1535 columns
(>= screenWidth=1183) but the display needed columns up to 2928
(playhead 2337 + width/2 591).
The previous version used only playheadCol + screenWidth/2 as
fill target against ringFill, but this mixed absolute column
positions with ring fill counts — at high playhead positions the
target exceeded the ring capacity, freezing the canvas forever.

Using only screenWidth cleared too early (ring had 1535 columns
but display needed columns up to 2928).

Fix: require BOTH conditions:
1. ringFill >= screenWidth (enough data to fill canvas)
2. maxColumnIndex covers displayRight (right edge won't be black)
Both capped at totalEstimate for max zoom-out.
1. Restore the renderZoomLevel = refHop/hop snap that was lost in
   the revert.  Without it, hop rounding at non-power-of-2 zoom
   levels (e.g. zoom=3.815 → hop=268 → effectiveZoom=0.998) causes
   the advance path to fail, forcing 42ms full rebuilds.

2. Invalidate the old canvas when the zoom freeze clears, forcing
   needsFullRebuild.  This prevents stale pixels from the frozen
   canvas leaking into the new display range (the "extra content
   at the right edge" artifact).
invalidateCanvas() nulls the canvas.  If the next zoom transition
starts before a paint rebuilds it (common during rapid scrolling),
the early hop detection sees a null canvas and doesn't activate
the freeze → black gaps return.

Just mark dirty instead — the rebuild replaces all canvas content
and the canvas stays non-null for the next transition's freeze.
At high zoom (hop=64 and nearby), the file-metadata
total_columns_estimate overshoots the actual decoded extent by
~1 s worth of columns.  The previous 128-col EOF tolerance was
far too narrow — 128 cols at hop=64 is 0.19 s — so the
centered-mode playhead stayed pinned at center through the end
of the track, blank padding was shown past the last real
column, and the crosshair at the right edge read a time past
the audible end.

Paint-time fix: whenever the decoder can't fill the right half
of the centered window (maxCol - 1 < nowCol + halfWindow), use
maxCol as the hard right edge.  Inert during steady mid-track
playback (the decoder parks thousands of columns ahead of the
playhead), activates at EOF regardless of zoom, and keeps the
time axis bounded by real content.

Supporting plumbing so total_columns_estimate itself also
converges to the true extent (used by minimum-zoom and any
consumer that reads the estimate):
- Worker emits a finalize chunk on natural EOF carrying
  columns_produced.
- Analysis thread emits a finalize for the outgoing track
  before draining staged chunks on centered gapless, using a
  new columns_produced atomic exposed on SpectrogramWorkerHandles.
- Qt handles complete=true chunks by shrinking the estimate
  only for the matching committed token.

Also rolls in pending uncommitted fixes on this branch:
- Skip session restart when SetSpectrogramZoomLevel fires with
  unchanged zoom (e.g. widthSettleTimer on fullscreen toggle).
- Preserve the canvas across a synthetic clear in centered
  mode so the ~100 ms decoder catch-up doesn't flash black.
- Bypass the zoom-fill readiness freeze once the decoder is
  within tail-slack of the estimate so the max-zoom-out view
  doesn't latch stale content at the right edge.

Regression tests on both sides.
When the unchanged-zoom skip added in 71e7179 bypasses the session
restart on a fullscreen toggle, the running session's widget_width
and lookahead_columns stay frozen at the windowed value.  At the
windowed lookahead the decoder parks with only 2 × old_width + 10 s
cols ahead of the playhead — not enough to fill a wider display —
so the right portion of the fullscreen spectrogram stays black as
the decode edge crawls along at 1× realtime (the rate playback
consumes cols).

Track the maximum widget width ever reported in the analysis
runtime and size the session-start lookahead against it.  On a
width increase past the previous max, dispatch a new
SpectrogramWorkerCommand::UpdateWidgetWidth to the running session
so its centered-mode lookahead_columns is recomputed in place;
the decoder then produces enough cols to fill the new window
without restarting the session or flashing black.

Shrinks are ignored (keeps already-decoded cols around; trivial
memory cost).  Rolling mode is unaffected — its lookahead formula
doesn't depend on widget_width.

At max zoom (hop=64), sizing lookahead for a 4K display costs
~30 MB vs ~17 MB for 1080p.  Well within budget for the UX win.

Regression tests on both sides of the plumbing.
After 83966dd made the Rust decoder's centered-mode lookahead
track the max widget width ever reported (so fullscreen toggles
don't under-size the park threshold), the Qt ring buffer fell
out of sync.  The ring is reallocated on every session reset
(including the zoom-change restart that fires on a fullscreen
toggle through a per-view zoom level) and re-sizes its capacity
from the CURRENT widget width, floored at 1920.

Repro: zoom max in, fullscreen on, fullscreen off.  At max zoom
and 4K fullscreen the decoder's lookahead becomes 3840·2 + 10·cps
≈ 14570 cols.  Back in windowed, the session reset (triggered by
the per-view zoom delta) wipes the ring and reallocates
1920·3 + 10·cps ≈ 12650 cols.  The decoder keeps producing past
playback + 14570; the ring keeps only the last 12650, evicting
the left-margin cols around the playhead.  Centered-mode paint
then shows a little spectrogram that "ends" at the oldest
retained col and black beyond.

Track m_maxWidgetWidthSeen on SpectrogramItem and include it in
the ring-cap calc (max(currentWidth, 1920, maxSeen)).  Memory
stays bounded by the primary screen size — same trade-off
accepted on the Rust side.

Regression test: fullscreen-width ring cap must persist across a
session-reset shrink to windowed.
71e7179 added a finalize chunk in the run_spectrogram_session None
arm so Qt could shrink its total_columns_estimate to the true
decoded extent at end-of-track.  That arm actually fires for TWO
reasons: real source EOF (decoder ran out of frames) AND a stale-
generation detection race (is_stale() check at the top of the
decode loop runs after the analysis thread fetch_adds the
generation counter but before the new NewTrack command is pulled
out of the channel).  In the stale-gen case `cols_produced` is
whatever the decoder happened to be at, NOT the track length —
emitting a finalize here tells Qt the track is N cols long when
it's actually several minutes long.

Repro seen in a long interactive session: zoom and fullscreen
juggling on 6ch, then start playback of a 2ch track.  The
zoom-driven session restart races with the is_stale check; the
predecessor session returns None with cols_produced ≈ 28 s of
audio; the finalize clobbers Qt's estimate for a 230 s track; the
paint-time maxCol clamp then pins the playhead and the rest of
the spectrogram paints black.  The log shows "SESSION END (EOF)"
at col counts far below the track length with no source-EOF
justification — that was the tell.

Add session.source_reached_eof, set to true only where the
decode loop breaks on source.next_frames() returning None, and
gate the finalize on it.  Rename the stale-gen log to
"(superseded)" so future diagnostics can tell them apart.

Regression tests cover the helper semantics and the default-
false initialization guard.
a2cb790 added m_maxWidgetWidthSeen to SpectrogramItem as a
per-instance field, mirroring the Rust decoder's singleton
spectrogram_max_widget_width in AnalysisRuntimeState.  But
SpectrogramItem instances are destroyed and recreated whenever the
channel count changes (e.g. a 6ch PerChannel → 2ch PerChannel
track change tears down 6 widgets and instantiates 2 fresh
widgets), so the Qt tracker resets to 0 on every channel-count
transition while the Rust singleton remembers the prior maximum
(e.g. fullscreen width during the 6ch session).

The new instances then allocate rings sized against the current
(smaller) widget width — under-sized relative to the still-large
Rust lookahead.  The decoder laps the ring, evicting left-margin
cols around the playhead, and the previous-frame canvas smears
through the blanked region, producing a narrow growing-edge of
live data with smeared pixels filling the rest of the view.

Repro: 6ch track at max zoom in fullscreen → switch to 2ch track.
Log shows gen=36 on the fresh 2ch widgets with writeSeq=21345
oldestSeq=8695 (ring cap 12650) while playback is at col 7517 —
the ring contains nothing in the centered display window around
the playhead.

Make s_maxWidgetWidthSeen a static member on SpectrogramItem so
the floor persists across instance destruction, matching the Rust
side's persistence in the analysis runtime.  Memory cost: one int
per process.

Regression test constructs a wide-view instance, lets it drop,
then verifies a fresh narrow instance still sizes its ring
against the previously-seen max.
The Qt-side zoom-reset guard on track change gates on
m_renderZoomLevel != 1.0 — i.e. Qt's knowledge of its own render
zoom.  That's wrong when the SpectrogramItem instance is fresh
(channel-count change — 6ch PerChannel → 2ch PerChannel — tears
down the previous widgets and creates new ones).  The fresh
instance has m_renderZoomLevel = 1.0 by default, but the
Rust-side analysis runtime keeps zoom_level from the prior track
(track changes don't reset the backend's zoom).  So the guard
fails, backendZoomRequested(1.0) is never emitted, and the
decoder keeps producing at hop=64 (max zoom from the old track).

Qt renders at effectiveZoom = renderZoom × hop / refHop
= 1.0 × 64 / 1024 = 0.0625, which inflates the centered visible
window to 19200 cols at a 1200 px widget.  The decoder has
produced only ~16000 cols so far; the right ~3000 cols of the
widget have no ring coverage, and the previous-frame canvas
smears through the gap.  (Repro: zoom max on 6ch track, switch
to 2ch track → spectrogram appears at "normal" zoom with a
growing right edge and smeared content beyond.)

Also use the arriving chunk's hop as the source of truth: if it
differs from the reference hop on a track change, the backend is
at a non-default zoom and needs to be told to reset, regardless
of what m_renderZoomLevel says Qt thinks.

The estimate-clear inside the reset path is now gated on
qtRenderNotAtDefault — it's there for the pre-reset estimate
from a different zoom's decimation becoming stale.  A fresh
instance only needs the backend to resync; the estimate that
just arrived is the correct one for the new track and must be
preserved (otherwise a subsequent session restart at zoom=1.0
inherits a zeroed estimate and has to wait for a fresh data
chunk to repopulate it).

Regression test covers a fresh instance receiving a track change
at hop=64 and asserts: backendZoomRequested(1.0) is emitted,
renderZoomLevel stays at 1.0, the arrived estimate is preserved.
8e78dfb made the track-change zoom reset fire when the arriving
chunk's hop != reference hop, emitting backendZoomRequested(1.0)
directly.  But the QML SpectrogramSurface retains
_widgetZoomLevel across channel-count-driven instance replacement
(it's a property of the surface root, not the Repeater
children).  When the Repeater spins up fresh SpectrogramItems
for the new channel count, the zoomLevel property binding pushes
the surface's existing _widgetZoomLevel (e.g. 16 from the prior
track's max zoom) into each new instance — setZoomLevel(16)
arms the 150 ms debounce timer with m_pendingBackendZoom = 16.

My needsZoomReset path then fires, sets m_zoomLevel = 1.0, and
emits backendZoomRequested(1.0) directly.  That restarts the
backend at zoom = 1.0 (hop = 256) — good.  But 150 ms later the
untouched debounce timer fires, emits
backendZoomRequested(m_pendingBackendZoom = 16), and the
backend restarts at hop = 64 again.  Qt's fresh-instance
m_renderZoomLevel is still 1.0 so effectiveZoom becomes 0.0625,
visibleWindowCols inflates to 19 200 on a 1 200 px widget, and
the right ~25 % of the view has no ring coverage — the previous
canvas smears through.

Cancel the debounce and null out m_pendingBackendZoom in
needsZoomReset so the stale request can't override the
just-issued backendZoomRequested(1.0).  Repro in the diagnostic
log: gen=16 at hop=256 (my reset's effect) lasts 142 ms, then
gen=17 at hop=64 — matches the 150 ms debounce interval.

Regression test arms the debounce with 16, feeds a track-change
chunk at hop=64, and asserts: the debounce is inactive,
m_pendingBackendZoom = 1.0, only ONE backendZoomRequested(1.0)
is emitted even after waiting past the debounce interval.
maybe_update_columns_estimate's GStreamer duration re-query
computed new_est from frames/REFERENCE_HOP and compared it
directly to session.total_columns_estimate — but the session's
estimate is stored in effective_hop cols, not reference-hop cols.
For zoom-out sessions (effective_hop > REFERENCE_HOP) the
reference-hop figure is always larger than the correctly-scaled
in-session estimate, so the comparison always fires and the
estimate gets inflated by effective_hop/REFERENCE_HOP.

Repro on an AC3 file at zoom ≈ 0.17 (effective_hop ≈ 6104):
initial open_audio_file returns total_columns = 14126 (ref hop),
session.total_columns_estimate is correctly scaled to
14126·1024/6104 ≈ 2370, then packet ≥ 20 triggers the re-query
which wrongly updates it to 16273 (ref-hop figure).  Qt then
computes minimumZoomLevel against an inflated track length,
allows a zoom-out below what Rust's [0.05, 16.0] clamp accepts,
and subsequent zoom-out attempts restart the same zoom over and
over — matching the user report "zoom level sticks".

Extract a duration_requery_estimate_for_session helper that
funnels new_est through scale_columns_estimate and call it from
the re-query branch.  Test locks in that the returned value is
strictly smaller than the reference-hop figure for zoom-out
sessions and equals scale_columns_estimate of the same input,
so a ref-hop estimate coming from the re-query can never
clobber a correctly-scaled in-session estimate.
Two root causes for "zoom-out sticks + post-seek blank spectrogram"
on AC3 files at max zoom-out:

1. Rust safety-net doubling fired at 75% of the estimate.
   For bounded sources (files) the estimate is already +64
   cols padded over the true track length, so a 75% produced
   threshold is crossed as part of NORMAL decode — the decoder
   finishes the file with produced ≈ estimate.  Doubling there
   inflates total_columns_estimate to 2× the real track (at
   effective_hop ≈ 20480 a 5-min AC3 goes from 706 to 1414),
   and the propagated value breaks Qt's displayRight clamp and
   minimumZoomLevel.

   Change the trigger to `produced > estimate` — strict
   overshoot.  Unbounded sources (the decoders.rs 300-second
   fallback for missing-duration metadata) still trip the
   safety net once the decoder blows past the fallback, but
   bounded sources no longer double at the mere approach.

2. Qt's minimumZoomLevel returned the track-fit floor
   (widget_width/referenceCols) without mirroring the Rust-side
   `.clamp(0.05, 16.0)` on SetSpectrogramZoomLevel.  Long
   tracks give a track-fit floor well below 0.05; Qt lets the
   user request that, sends it to Rust, Rust clamps to 0.05,
   the session restarts at zoom 0.05, widthSettle re-sends
   0.02 (unchanged in QML), Rust clamps again — the user sees
   the same effective zoom restart over and over even though
   the zoom slider position claims to be lower.

   Floor Qt's minZoom at 0.05 so the two sides agree on the
   allowed range.  When the track legitimately fits within
   0.05 (widget covers enough of the track), the track-fit
   floor is higher and wins — unchanged for short tracks.

Repro from diagnostics: gen=3 at effective_hop=12229, correctly
scaled est 1363, then "columns approaching estimate 1363 → 2726
(produced=1023)" at 75%.  Subsequent seek at max zoom-out shows
most of the spectrogram blank because Qt's displayRight extends
to col 1413 but the current session only has 236 cols of data.

Test updates:
- columns_estimate_doubles_when_approaching_limit →
  columns_estimate_holds_when_approaching_without_overshoot
  and columns_estimate_doubles_when_overshooting, covering
  both the new bounded and unbounded cases.
- spectrogramZoomProperty: the Qt-side clamp is now 0.05, not
  the track-fit 0.02 — mirror the new floor.
@Tiaxi Tiaxi changed the title fix: stabilize spectrogram zoom, seeks, and playback clock feat: add spectrogram zoom Apr 18, 2026
@Tiaxi

Tiaxi commented Apr 18, 2026

Copy link
Copy Markdown
Owner Author

@codex please review.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@Tiaxi Tiaxi merged commit e14bb09 into main Apr 18, 2026
2 checks passed
@Tiaxi Tiaxi deleted the feat/spectrogram-zoom branch April 18, 2026 15:35
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.

1 participant