feat: add spectrogram zoom#15
Merged
Merged
Conversation
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.
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.
Owner
Author
|
@codex please review. |
|
To use Codex here, create an environment for this repo. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
SpectrogramItemValidation
./scripts/run-tests.shMerge note
This branch contains the full feature work as incremental commits. It is intended to be squash-merged.